From 3e8ff7a50996c2a0c6a4579c09b0df70169883a8 Mon Sep 17 00:00:00 2001 From: dtzp555 Date: Fri, 10 Apr 2026 21:00:22 +1000 Subject: [PATCH 01/13] docs: add LAN mode implementation plan Co-Authored-By: Claude Opus 4.6 --- docs/superpowers/plans/2026-04-10-lan-mode.md | 1178 +++++++++++++++++ 1 file changed, 1178 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-10-lan-mode.md diff --git a/docs/superpowers/plans/2026-04-10-lan-mode.md b/docs/superpowers/plans/2026-04-10-lan-mode.md new file mode 100644 index 0000000..c41a6de --- /dev/null +++ b/docs/superpowers/plans/2026-04-10-lan-mode.md @@ -0,0 +1,1178 @@ +# OCP LAN Mode — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Enable OCP to serve Claude API over LAN with flexible auth (none/shared-key/multi-key) and per-key usage tracking with a web dashboard. + +**Architecture:** Add LAN bind support (`0.0.0.0`), a key management layer with 3 auth modes, a SQLite-backed usage tracker, and an embedded single-file HTML dashboard. All changes in `server.mjs` + new `keys.mjs` module + CLI extensions in `ocp`. + +**Tech Stack:** Node.js built-in `node:crypto`, `better-sqlite3` (single native dependency) for persistent usage tracking, inline HTML/CSS/JS for dashboard. + +--- + +## File Structure + +| File | Action | Responsibility | +|------|--------|----------------| +| `server.mjs` | Modify | Add LAN bind, auth middleware, usage recording hooks, dashboard/key API endpoints | +| `keys.mjs` | Create | Key management + SQLite usage store (CRUD keys, record usage, query stats) | +| `dashboard.html` | Create | Single-file embedded web dashboard (HTML+CSS+JS, served inline) | +| `ocp` | Modify | Add `ocp keys`, `ocp lan`, `ocp usage --by-key` CLI commands | +| `setup.mjs` | Modify | Add LAN mode config prompts during setup | +| `package.json` | Modify | Add `better-sqlite3` dependency, bump version to 3.4.0 | + +--- + +### Task 1: Add `better-sqlite3` dependency and version bump + +**Files:** +- Modify: `package.json` + +- [ ] **Step 1: Install better-sqlite3** + +```bash +cd /Users/taodeng/.openclaw/projects/claude-proxy +npm install better-sqlite3 +``` + +- [ ] **Step 2: Bump version to 3.4.0** + +In `package.json`, change: +```json +"version": "3.4.0", +``` + +- [ ] **Step 3: Commit** + +```bash +git add package.json package-lock.json +git commit -m "chore: add better-sqlite3 dependency, bump to v3.4.0" +``` + +--- + +### Task 2: Create `keys.mjs` — Key management + usage store + +**Files:** +- Create: `keys.mjs` + +This module handles: +- SQLite database initialization (`~/.ocp/ocp.db`) +- API key CRUD (create, list, revoke) +- Usage recording (per-request: key, model, tokens, elapsed) +- Usage queries (by key, by time range, aggregated) + +- [ ] **Step 1: Create keys.mjs with database init and key CRUD** + +```javascript +// keys.mjs — API key management and usage tracking for OCP LAN mode +import Database from "better-sqlite3"; +import { randomBytes } from "node:crypto"; +import { join } from "node:path"; +import { mkdirSync } from "node:fs"; +import { homedir } from "node:os"; + +const OCP_DIR = join(homedir(), ".ocp"); +mkdirSync(OCP_DIR, { recursive: true }); +const DB_PATH = join(OCP_DIR, "ocp.db"); + +let db; + +export function getDb() { + if (!db) { + db = new Database(DB_PATH); + db.pragma("journal_mode = WAL"); + db.pragma("foreign_keys = ON"); + initSchema(); + } + return db; +} + +function initSchema() { + db.exec(` + CREATE TABLE IF NOT EXISTS api_keys ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + key TEXT UNIQUE NOT NULL, + name TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + revoked INTEGER NOT NULL DEFAULT 0 + ); + + CREATE TABLE IF NOT EXISTS usage_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + key_id INTEGER, + key_name TEXT NOT NULL DEFAULT 'anonymous', + model TEXT NOT NULL, + prompt_chars INTEGER NOT NULL DEFAULT 0, + response_chars INTEGER NOT NULL DEFAULT 0, + elapsed_ms INTEGER NOT NULL DEFAULT 0, + success INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (key_id) REFERENCES api_keys(id) + ); + + CREATE INDEX IF NOT EXISTS idx_usage_created ON usage_log(created_at); + CREATE INDEX IF NOT EXISTS idx_usage_key ON usage_log(key_id); + `); +} + +// ── Key CRUD ── + +export function createKey(name) { + const key = "ocp_" + randomBytes(24).toString("base64url"); + const d = getDb(); + const stmt = d.prepare("INSERT INTO api_keys (key, name) VALUES (?, ?)"); + const result = stmt.run(key, name); + return { id: result.lastInsertRowid, key, name }; +} + +export function listKeys() { + const d = getDb(); + return d.prepare( + "SELECT id, key, name, created_at, revoked FROM api_keys ORDER BY created_at DESC" + ).all().map(k => ({ + ...k, + keyPreview: k.key.slice(0, 8) + "..." + k.key.slice(-4), + })); +} + +export function revokeKey(idOrName) { + const d = getDb(); + const stmt = d.prepare( + "UPDATE api_keys SET revoked = 1 WHERE (id = ? OR name = ?) AND revoked = 0" + ); + return stmt.run(idOrName, idOrName).changes > 0; +} + +export function validateKey(key) { + const d = getDb(); + const row = d.prepare( + "SELECT id, name FROM api_keys WHERE key = ? AND revoked = 0" + ).get(key); + return row || null; +} + +// ── Usage recording ── + +export function recordUsage({ keyId, keyName, model, promptChars, responseChars, elapsedMs, success }) { + const d = getDb(); + d.prepare(` + INSERT INTO usage_log (key_id, key_name, model, prompt_chars, response_chars, elapsed_ms, success) + VALUES (?, ?, ?, ?, ?, ?, ?) + `).run(keyId ?? null, keyName || "anonymous", model, promptChars, responseChars, elapsedMs, success ? 1 : 0); +} + +// ── Usage queries ── + +export function getUsageByKey({ since, until } = {}) { + const d = getDb(); + let where = "WHERE 1=1"; + const params = []; + if (since) { where += " AND created_at >= ?"; params.push(since); } + if (until) { where += " AND created_at <= ?"; params.push(until); } + + return d.prepare(` + SELECT + key_name, + COUNT(*) as requests, + SUM(CASE WHEN success = 1 THEN 1 ELSE 0 END) as successes, + SUM(CASE WHEN success = 0 THEN 1 ELSE 0 END) as errors, + SUM(prompt_chars) as total_prompt_chars, + SUM(response_chars) as total_response_chars, + SUM(elapsed_ms) as total_elapsed_ms, + AVG(elapsed_ms) as avg_elapsed_ms, + MIN(created_at) as first_request, + MAX(created_at) as last_request + FROM usage_log + ${where} + GROUP BY key_name + ORDER BY requests DESC + `).all(...params); +} + +export function getUsageTimeline({ keyName, hours = 24 } = {}) { + const d = getDb(); + const since = new Date(Date.now() - hours * 3600000).toISOString(); + let where = "WHERE created_at >= ?"; + const params = [since]; + if (keyName) { where += " AND key_name = ?"; params.push(keyName); } + + return d.prepare(` + SELECT + strftime('%Y-%m-%dT%H:00:00', created_at) as hour, + COUNT(*) as requests, + SUM(prompt_chars) as prompt_chars, + SUM(response_chars) as response_chars, + AVG(elapsed_ms) as avg_elapsed_ms + FROM usage_log + ${where} + GROUP BY hour + ORDER BY hour + `).all(...params); +} + +export function getRecentUsage(limit = 50) { + const d = getDb(); + return d.prepare(` + SELECT key_name, model, prompt_chars, response_chars, elapsed_ms, success, created_at + FROM usage_log + ORDER BY created_at DESC + LIMIT ? + `).all(limit); +} + +export function closeDb() { + if (db) { db.close(); db = null; } +} +``` + +- [ ] **Step 2: Verify module loads correctly** + +```bash +node -e "import('./keys.mjs').then(k => { console.log('OK'); k.closeDb(); })" +``` + +Expected: `OK` — database file created at `~/.ocp/ocp.db` + +- [ ] **Step 3: Commit** + +```bash +git add keys.mjs +git commit -m "feat: add keys.mjs — API key management and SQLite usage store" +``` + +--- + +### Task 3: Add LAN bind + auth modes to `server.mjs` + +**Files:** +- Modify: `server.mjs:28-36` (env vars section) +- Modify: `server.mjs:77-95` (configuration section) +- Modify: `server.mjs:1017-1036` (HTTP server + auth middleware) +- Modify: `server.mjs:1188-1206` (server.listen) + +Three auth modes controlled by `CLAUDE_AUTH_MODE`: +- `none` — no auth (LAN trust mode) +- `shared` — single `PROXY_API_KEY` (existing behavior) +- `multi` — per-user keys managed via `keys.mjs` + +- [ ] **Step 1: Add new imports and env vars at top of server.mjs** + +After the existing imports (line 35), add: + +```javascript +import { validateKey, recordUsage, getUsageByKey, getUsageTimeline, getRecentUsage, createKey, listKeys, revokeKey, closeDb } from "./keys.mjs"; +``` + +Add new config vars after line 95 (`BREAKER_HALF_OPEN_MAX`): + +```javascript +// ── LAN mode configuration ── +const BIND_ADDRESS = process.env.CLAUDE_BIND || "127.0.0.1"; +const AUTH_MODE = process.env.CLAUDE_AUTH_MODE || (PROXY_API_KEY ? "shared" : "none"); +// AUTH_MODE: "none" (open), "shared" (single PROXY_API_KEY), "multi" (per-user keys from keys.mjs) +const ADMIN_KEY = process.env.OCP_ADMIN_KEY || ""; +``` + +- [ ] **Step 2: Replace auth middleware in HTTP server (lines 1027-1036)** + +Replace the existing Bearer token auth block with: + +```javascript + // ── Auth middleware ── + // /health is always public. Admin endpoints require admin key in multi mode. + const isPublicEndpoint = req.url === "/health"; + let authKeyName = "local"; + let authKeyId = null; + + if (!isPublicEndpoint) { + const auth = req.headers["authorization"] || ""; + const token = auth.startsWith("Bearer ") ? auth.slice(7) : ""; + + if (AUTH_MODE === "shared") { + // Single shared key mode (backwards compatible) + if (PROXY_API_KEY) { + const tokenBuf = Buffer.from(token); + const keyBuf = Buffer.from(PROXY_API_KEY); + if (tokenBuf.length !== keyBuf.length || !timingSafeEqual(tokenBuf, keyBuf)) { + return jsonResponse(res, 401, { error: { message: "Unauthorized: invalid or missing Bearer token", type: "auth_error" } }); + } + authKeyName = "shared"; + } + } else if (AUTH_MODE === "multi") { + // Multi-key mode: validate against keys.mjs database + if (!token) { + return jsonResponse(res, 401, { error: { message: "Unauthorized: Bearer token required", type: "auth_error" } }); + } + // Check if it's the admin key + if (ADMIN_KEY && token === ADMIN_KEY) { + authKeyName = "admin"; + } else { + const keyInfo = validateKey(token); + if (!keyInfo) { + return jsonResponse(res, 401, { error: { message: "Unauthorized: invalid or revoked API key", type: "auth_error" } }); + } + authKeyName = keyInfo.name; + authKeyId = keyInfo.id; + } + } + // AUTH_MODE === "none": no auth check, allow all + } + + // Attach auth info to request for downstream use + req._authKeyName = authKeyName; + req._authKeyId = authKeyId; +``` + +- [ ] **Step 3: Update CORS to allow LAN origins** + +Replace the CORS block (lines 1019-1025) with: + +```javascript + // Dynamic CORS: allow localhost and LAN origins + const origin = req.headers["origin"] || ""; + const isAllowedOrigin = /^https?:\/\/(127\.0\.0\.1|localhost|192\.168\.\d+\.\d+|172\.(1[6-9]|2\d|3[01])\.\d+\.\d+|10\.\d+\.\d+\.\d+)(:\d+)?$/.test(origin); + res.setHeader("Access-Control-Allow-Origin", isAllowedOrigin ? origin : "*"); + res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS, PATCH"); + res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Session-Id, X-Conversation-Id"); + if (req.method === "OPTIONS") { res.writeHead(204); res.end(); return; } +``` + +- [ ] **Step 4: Update server.listen to use BIND_ADDRESS** + +Replace line 1189: + +```javascript +server.listen(PORT, BIND_ADDRESS, () => { + const bindMsg = BIND_ADDRESS === "0.0.0.0" ? `http://0.0.0.0:${PORT} (LAN mode)` : `http://127.0.0.1:${PORT}`; + console.log(`openclaw-claude-proxy v${VERSION} listening on ${bindMsg}`); +``` + +Add auth mode to startup log (after line 1200): + +```javascript + console.log(`Auth mode: ${AUTH_MODE}${AUTH_MODE === "shared" ? " (PROXY_API_KEY)" : AUTH_MODE === "multi" ? " (per-user keys)" : " (open)"}`); + console.log(`Bind: ${BIND_ADDRESS}${BIND_ADDRESS === "0.0.0.0" ? " ⚠ LAN-accessible" : ""}`); +``` + +- [ ] **Step 5: Add usage recording to request handlers** + +In `handleChatCompletions` (around line 1001-1014), after successful response and in catch block, add usage recording. Wrap the try/catch: + +```javascript + try { + const content = await callClaude(model, messages, conversationId); + const id = `chatcmpl-${randomUUID()}`; + completionResponse(res, id, model, content); + // Record usage + recordUsage({ + keyId: req._authKeyId, keyName: req._authKeyName, model, + promptChars: messages.reduce((a, m) => a + (typeof m.content === "string" ? m.content.length : JSON.stringify(m.content).length), 0), + responseChars: content.length, elapsedMs: Date.now() - Date.now(), success: true, + }); + } catch (err) { + recordUsage({ + keyId: req._authKeyId, keyName: req._authKeyName, model, + promptChars: 0, responseChars: 0, elapsedMs: 0, success: false, + }); + // ... existing error handling + } +``` + +Similarly for streaming in `callClaudeStreaming`, record usage in the `proc.on("close")` handler by passing `req._authKeyName` and `req._authKeyId` through. + +- [ ] **Step 6: Add closeDb to graceful shutdown** + +In `gracefulShutdown()` function, after clearing intervals (around line 1150), add: + +```javascript + closeDb(); +``` + +- [ ] **Step 7: Commit** + +```bash +git add server.mjs +git commit -m "feat: add LAN bind, 3-mode auth, and usage recording to server" +``` + +--- + +### Task 4: Add key management + dashboard API endpoints to `server.mjs` + +**Files:** +- Modify: `server.mjs` (add routes before the 404 handler) + +- [ ] **Step 1: Add key management API endpoints** + +Before the 404 handler (line 1132), add: + +```javascript + // ── Key management API (admin-only in multi mode) ── + + // Admin gate: in multi mode, key management requires admin key + const isAdmin = AUTH_MODE !== "multi" || authKeyName === "admin" || BIND_ADDRESS === "127.0.0.1"; + + // POST /api/keys — create a new API key + if (req.url === "/api/keys" && req.method === "POST") { + if (!isAdmin) return jsonResponse(res, 403, { error: "Admin access required" }); + let body = ""; + for await (const chunk of req) body += chunk; + let parsed; + try { parsed = JSON.parse(body); } catch { return jsonResponse(res, 400, { error: "Invalid JSON" }); } + const name = parsed.name || `key-${Date.now()}`; + const newKey = createKey(name); + return jsonResponse(res, 201, newKey); + } + + // GET /api/keys — list all keys + if (req.url === "/api/keys" && req.method === "GET") { + if (!isAdmin) return jsonResponse(res, 403, { error: "Admin access required" }); + return jsonResponse(res, 200, { keys: listKeys() }); + } + + // DELETE /api/keys/:id — revoke a key + if (req.url?.startsWith("/api/keys/") && req.method === "DELETE") { + if (!isAdmin) return jsonResponse(res, 403, { error: "Admin access required" }); + const idOrName = decodeURIComponent(req.url.split("/api/keys/")[1]); + const revoked = revokeKey(idOrName); + return jsonResponse(res, 200, { revoked, idOrName }); + } + + // GET /api/usage — usage stats by key + if (req.url?.startsWith("/api/usage") && req.method === "GET") { + if (!isAdmin) return jsonResponse(res, 403, { error: "Admin access required" }); + const url = new URL(req.url, `http://${BIND_ADDRESS}:${PORT}`); + const since = url.searchParams.get("since"); + const until = url.searchParams.get("until"); + return jsonResponse(res, 200, { + byKey: getUsageByKey({ since, until }), + timeline: getUsageTimeline({ hours: parseInt(url.searchParams.get("hours") || "24", 10) }), + recent: getRecentUsage(parseInt(url.searchParams.get("limit") || "50", 10)), + }); + } +``` + +- [ ] **Step 2: Commit** + +```bash +git add server.mjs +git commit -m "feat: add key management and usage query API endpoints" +``` + +--- + +### Task 5: Create embedded web dashboard + +**Files:** +- Create: `dashboard.html` +- Modify: `server.mjs` (add `/dashboard` route) + +The dashboard is a single HTML file served inline. It uses vanilla JS + CSS, no framework, no build step. + +- [ ] **Step 1: Create dashboard.html** + +```html + + + + + +OCP Dashboard + + + + +
+

OCP Dashboard

+
+ + +
+
+ + +
+ + +
+

Plan Usage

+
+
+ + +
+

Usage by Key

+ + + +
KeyRequestsOKErrAvg TimeLast Request
+
+ + + + + +
+

Recent Requests

+ + + +
TimeKeyModelPromptResponseTimeStatus
+
+ + + + +``` + +- [ ] **Step 2: Add dashboard route to server.mjs** + +Before the 404 handler, add: + +```javascript + // GET /dashboard — web dashboard + if (req.url === "/dashboard" && req.method === "GET") { + try { + const html = readFileSync(join(__dirname, "dashboard.html"), "utf8"); + res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); + res.end(html); + } catch (err) { + return jsonResponse(res, 500, { error: "Dashboard file not found" }); + } + return; + } +``` + +- [ ] **Step 3: Update 404 message to include new endpoints** + +```javascript + jsonResponse(res, 404, { error: "Not found. Endpoints: GET /v1/models, POST /v1/chat/completions, GET /health, GET /usage, GET /status, GET /logs, GET|PATCH /settings, GET|DELETE /sessions, GET /dashboard, GET|POST|DELETE /api/keys, GET /api/usage" }); +``` + +- [ ] **Step 4: Commit** + +```bash +git add dashboard.html server.mjs +git commit -m "feat: add embedded web dashboard with real-time usage display" +``` + +--- + +### Task 6: Extend `ocp` CLI with key management and LAN commands + +**Files:** +- Modify: `ocp` (bash CLI) + +- [ ] **Step 1: Add `ocp keys` command** + +After the `cmd_clear` section (around line 214), add: + +```bash +# ── keys ──────────────────────────────────────────────────────────────── +cmd_keys_help() { + cat <<'EOF' +ocp keys — Manage API keys (multi-key auth mode) + +Usage: + ocp keys List all keys + ocp keys add Create a new key + ocp keys revoke Revoke a key + +Examples: + ocp keys add wife-laptop + ocp keys add son-ipad + ocp keys revoke wife-laptop +EOF +} + +cmd_keys() { + case "${1:-}" in + add) + if [[ -z "${2:-}" ]]; then echo "Usage: ocp keys add "; return 1; fi + local result + result=$(curl -sf --max-time 5 -X POST "$PROXY/api/keys" \ + -H "Content-Type: application/json" \ + -d "{\"name\": \"$2\"}" 2>&1) || { echo "Error: proxy unreachable or unauthorized"; exit 1; } + echo "$result" | python3 -c " +import sys, json +d = json.loads(sys.stdin.read()) +if 'key' in d: + print(f'✓ Key created for \"{d[\"name\"]}\"') + print(f'') + print(f' API Key: {d[\"key\"]}') + print(f'') + print(f' Copy this key now — you won\\'t see it again.') + print(f' Configure in IDE: OPENAI_API_KEY={d[\"key\"]}') +else: + print(f'✗ {d.get(\"error\", \"Unknown error\")}') +" + ;; + revoke) + if [[ -z "${2:-}" ]]; then echo "Usage: ocp keys revoke "; return 1; fi + curl -sf --max-time 5 -X DELETE "$PROXY/api/keys/$2" | python3 -c " +import sys, json +d = json.loads(sys.stdin.read()) +if d.get('revoked'): + print(f'✓ Key \"{d[\"idOrName\"]}\" revoked.') +else: + print(f'✗ Key not found or already revoked.') +" + ;; + ""|--help|-h) + if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then cmd_keys_help; return; fi + # List keys + curl -sf --max-time 5 "$PROXY/api/keys" | python3 -c " +import sys, json +d = json.loads(sys.stdin.read()) +keys = d.get('keys', []) +if not keys: + print('No API keys configured.') + print('Create one: ocp keys add ') +else: + print('API Keys') + print('─────────────────────────────────────────────────') + for k in keys: + status = '✗ revoked' if k['revoked'] else '✓ active' + print(f' {k[\"name\"]:<20} {k[\"keyPreview\"]:<20} {status} {k[\"created_at\"]}') +" 2>/dev/null || { echo "Error: proxy unreachable or key management not available"; exit 1; } + ;; + *) + echo "Unknown subcommand: $1"; cmd_keys_help; return 1 ;; + esac +} +``` + +- [ ] **Step 2: Add `ocp lan` command** + +```bash +# ── lan ───────────────────────────────────────────────────────────────── +cmd_lan_help() { + cat <<'EOF' +ocp lan — Quick LAN mode setup guide + +Shows current network configuration and connection instructions +for other devices on the same network. + +Usage: ocp lan +EOF +} + +cmd_lan() { + local ip + ip=$(ipconfig getifaddr en0 2>/dev/null || hostname -I 2>/dev/null | awk '{print $1}') + local port + port=$(curl -sf --max-time 5 "$PROXY/health" | python3 -c "import sys,json; h=json.loads(sys.stdin.read()); print(h.get('config',{}).get('port', '3456'))" 2>/dev/null || echo "3456") + + echo "OCP LAN Setup" + echo "─────────────────────────────────────" + echo "" + echo " Your IP: $ip" + echo " Port: $port" + echo "" + echo " For IDE users, set:" + echo " OPENAI_BASE_URL=http://$ip:$port/v1" + echo " OPENAI_API_KEY= (if auth enabled)" + echo "" + echo " Dashboard: http://$ip:$port/dashboard" + echo "" + + # Check if currently bound to LAN + if curl -sf --max-time 2 "http://$ip:$port/health" > /dev/null 2>&1; then + echo " Status: ✓ LAN-accessible" + else + echo " Status: ✗ Not LAN-accessible (bound to localhost only)" + echo "" + echo " To enable LAN mode, set env var and restart:" + echo " CLAUDE_BIND=0.0.0.0" + echo " ocp restart" + fi +} +``` + +- [ ] **Step 3: Add `ocp usage --by-key` option** + +Modify `cmd_usage()` to accept `--by-key`: + +After the existing `cmd_usage()` function, add a check: + +```bash +# In the cmd_usage function, add at the top: +cmd_usage() { + if [[ "${1:-}" == "--by-key" ]]; then + local data + data=$(curl -sf --max-time 15 "$PROXY/api/usage" 2>&1) || { echo "Error: proxy unreachable or usage API not available"; exit 1; } + echo "$data" | python3 -c " +import sys, json +d = json.loads(sys.stdin.read()) +by_key = d.get('byKey', []) +if not by_key: + print('No usage data yet.') +else: + print('Usage by Key') + print('─────────────────────────────────────────────────────────────────') + hdr = f' {\"Key\":<20} {\"Reqs\":>5} {\"OK\":>4} {\"Err\":>4} {\"Avg Time\":>9} {\"Last Request\":<20}' + print(hdr) + print(' ' + '─' * (len(hdr) - 2)) + for k in by_key: + avg_t = f'{k[\"avg_elapsed_ms\"]/1000:.1f}s' if k['avg_elapsed_ms'] else '-' + print(f' {k[\"key_name\"]:<20} {k[\"requests\"]:>5} {k[\"successes\"]:>4} {k[\"errors\"]:>4} {avg_t:>9} {k[\"last_request\"]:<20}') +" + return + fi + + # ... existing cmd_usage code below ... +``` + +- [ ] **Step 4: Update dispatch case and help** + +In the `case` statement at the bottom, add: + +```bash + keys) cmd_keys "${1:-}" "${2:-}" ;; + lan) cmd_lan ;; +``` + +Update `cmd_help()`: + +```bash + keys Manage API keys (add/list/revoke) + lan LAN mode setup guide + usage Plan usage limits (--by-key for per-key stats) +``` + +- [ ] **Step 5: Commit** + +```bash +git add ocp +git commit -m "feat: add ocp keys, ocp lan, and ocp usage --by-key CLI commands" +``` + +--- + +### Task 7: Update setup.mjs with LAN mode prompts + +**Files:** +- Modify: `setup.mjs` + +- [ ] **Step 1: Add LAN mode env vars to launchd plist and systemd service** + +In `setup.mjs`, in the plist XML template (around line 351), add environment variables for LAN mode if the user chose it. Add these optional env entries to the `EnvironmentVariables` dict: + +```xml + CLAUDE_BIND + ${BIND_ADDRESS} + CLAUDE_AUTH_MODE + ${AUTH_MODE_CONFIG} +``` + +Similarly update the systemd service template to include: + +``` +Environment=CLAUDE_BIND=${BIND_ADDRESS} +Environment=CLAUDE_AUTH_MODE=${AUTH_MODE_CONFIG} +``` + +Add at the top of setup.mjs, after existing `opt()` calls: + +```javascript +const BIND_ADDRESS = opt("bind", "127.0.0.1"); +const AUTH_MODE_CONFIG = opt("auth-mode", "none"); +``` + +- [ ] **Step 2: Commit** + +```bash +git add setup.mjs +git commit -m "feat: add LAN mode config options to setup.mjs" +``` + +--- + +### Task 8: Update README with LAN mode documentation + +**Files:** +- Modify: `README.md` + +- [ ] **Step 1: Add LAN Mode section to README** + +Add a new section after the existing "Quick Start" section: + +```markdown +## LAN Mode — Share with Family + +OCP can serve your entire household from a single machine. + +### Enable LAN Access + +```bash +# Set bind address and restart +export CLAUDE_BIND=0.0.0.0 +ocp restart +``` + +Or permanently via setup: +```bash +node setup.mjs --bind 0.0.0.0 --auth-mode multi +``` + +### Auth Modes + +| Mode | Env | Description | +|------|-----|-------------| +| `none` | `CLAUDE_AUTH_MODE=none` | Open access (trusted network) | +| `shared` | `CLAUDE_AUTH_MODE=shared` | Single key via `PROXY_API_KEY` | +| `multi` | `CLAUDE_AUTH_MODE=multi` | Per-user keys with usage tracking | + +### Multi-Key Setup + +```bash +# Create keys for family members +ocp keys add wife-laptop +ocp keys add son-ipad + +# List keys +ocp keys + +# View per-key usage +ocp usage --by-key + +# Revoke a key +ocp keys revoke son-ipad +``` + +### Family Member Setup + +Give your family member these settings for their IDE: + +``` +OPENAI_BASE_URL=http://:3456/v1 +OPENAI_API_KEY=ocp_ +``` + +Run `ocp lan` to see your IP and connection guide. + +### Web Dashboard + +Access the usage dashboard at: `http://:3456/dashboard` + +Shows real-time stats: per-key usage, request history, plan utilization, and system health. +``` + +- [ ] **Step 2: Update version in README** + +Change version references from `v3.3.1` to `v3.4.0`. + +- [ ] **Step 3: Commit** + +```bash +git add README.md +git commit -m "docs: add LAN mode documentation and family sharing guide" +``` + +--- + +### Task 9: Integration test — end-to-end verification + +**Files:** +- No new files — manual verification steps + +- [ ] **Step 1: Start OCP in LAN mode with multi-key auth** + +```bash +cd /Users/taodeng/.openclaw/projects/claude-proxy +CLAUDE_BIND=0.0.0.0 CLAUDE_AUTH_MODE=multi OCP_ADMIN_KEY=test-admin-123 node server.mjs & +sleep 3 +``` + +- [ ] **Step 2: Verify health endpoint (public, no auth)** + +```bash +curl -s http://127.0.0.1:3456/health | python3 -m json.tool | head -5 +``` + +Expected: JSON with status "ok", version "3.4.0" + +- [ ] **Step 3: Create a test API key** + +```bash +curl -s -X POST http://127.0.0.1:3456/api/keys \ + -H "Authorization: Bearer test-admin-123" \ + -H "Content-Type: application/json" \ + -d '{"name": "test-user"}' | python3 -m json.tool +``` + +Expected: JSON with `id`, `key` (starts with `ocp_`), `name` + +- [ ] **Step 4: Test request with key** + +```bash +# Save the key from step 3 +KEY="" +curl -s http://127.0.0.1:3456/v1/chat/completions \ + -H "Authorization: Bearer $KEY" \ + -H "Content-Type: application/json" \ + -d '{"model":"haiku","messages":[{"role":"user","content":"say hi"}]}' | python3 -m json.tool | head -10 +``` + +Expected: Chat completion response from Claude + +- [ ] **Step 5: Verify usage tracking** + +```bash +curl -s http://127.0.0.1:3456/api/usage \ + -H "Authorization: Bearer test-admin-123" | python3 -m json.tool +``` + +Expected: Usage data showing `test-user` with 1 request + +- [ ] **Step 6: Test dashboard loads** + +```bash +curl -s http://127.0.0.1:3456/dashboard | head -5 +``` + +Expected: HTML starting with `` + +- [ ] **Step 7: Test auth rejection (no key in multi mode)** + +```bash +curl -s http://127.0.0.1:3456/v1/models +``` + +Expected: 401 error with "Bearer token required" + +- [ ] **Step 8: Stop test server and clean up** + +```bash +pkill -f "server.mjs" || true +``` + +- [ ] **Step 9: Commit integration test results as passing** + +```bash +git add -A +git commit -m "test: verify LAN mode, multi-key auth, usage tracking, and dashboard" +``` + +--- + +## Summary + +| Task | Description | Est. Lines | +|------|-------------|-----------| +| 1 | Add dependency + version bump | ~5 | +| 2 | `keys.mjs` — key CRUD + usage store | ~180 | +| 3 | LAN bind + auth modes in server | ~80 | +| 4 | Key mgmt + usage API endpoints | ~50 | +| 5 | Web dashboard (HTML+JS) | ~200 | +| 6 | CLI extensions (`ocp keys/lan`) | ~150 | +| 7 | Setup.mjs LAN config | ~20 | +| 8 | README documentation | ~60 | +| 9 | Integration testing | ~0 | +| **Total** | | **~745 lines** | From bc17b27f2b0460308b67b48836b7cbe6409df129 Mon Sep 17 00:00:00 2001 From: dtzp555 Date: Fri, 10 Apr 2026 21:02:40 +1000 Subject: [PATCH 02/13] chore: add better-sqlite3 dependency, bump to v3.4.0 --- package-lock.json | 475 ++++++++++++++++++++++++++++++++++++++++++++++ package.json | 5 +- 2 files changed, 479 insertions(+), 1 deletion(-) create mode 100644 package-lock.json diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..f491b5a --- /dev/null +++ b/package-lock.json @@ -0,0 +1,475 @@ +{ + "name": "openclaw-claude-proxy", + "version": "3.3.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "openclaw-claude-proxy", + "version": "3.3.1", + "license": "MIT", + "dependencies": { + "better-sqlite3": "^12.8.0" + }, + "bin": { + "ocp": "ocp", + "openclaw-claude-proxy": "server.mjs" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/better-sqlite3": { + "version": "12.8.0", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.8.0.tgz", + "integrity": "sha512-RxD2Vd96sQDjQr20kdP+F+dK/1OUNiVOl200vKBZY8u0vTwysfolF6Hq+3ZK2+h8My9YvZhHsF+RSGZW2VYrPQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + }, + "engines": { + "node": "20.x || 22.x || 23.x || 24.x || 25.x" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, + "node_modules/node-abi": { + "version": "3.89.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.89.0.tgz", + "integrity": "sha512-6u9UwL0HlAl21+agMN3YAMXcKByMqwGx+pq+P76vii5f7hTPtKDp08/H9py6DY+cfDw7kQNTGEj/rly3IgbNQA==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + } + } +} diff --git a/package.json b/package.json index cf64976..8e894e4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "openclaw-claude-proxy", - "version": "3.3.1", + "version": "3.4.0", "description": "OCP (Open Claude Proxy) — use your Claude Pro/Max subscription as an OpenAI-compatible API for any IDE. Works with Cline, OpenCode, Aider, Continue.dev, OpenClaw, and more.", "type": "module", "bin": { @@ -25,5 +25,8 @@ "repository": { "type": "git", "url": "https://github.com/dtzp555-max/ocp" + }, + "dependencies": { + "better-sqlite3": "^12.8.0" } } From 4f72f4844e33896934c48a232c3ed95f8d1c7b2a Mon Sep 17 00:00:00 2001 From: dtzp555 Date: Fri, 10 Apr 2026 21:04:00 +1000 Subject: [PATCH 03/13] =?UTF-8?q?feat:=20add=20keys.mjs=20=E2=80=94=20API?= =?UTF-8?q?=20key=20management=20and=20SQLite=20usage=20store?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- keys.mjs | 159 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 159 insertions(+) create mode 100644 keys.mjs diff --git a/keys.mjs b/keys.mjs new file mode 100644 index 0000000..240e831 --- /dev/null +++ b/keys.mjs @@ -0,0 +1,159 @@ +// keys.mjs — API key management and usage tracking for OCP LAN mode +import Database from "better-sqlite3"; +import { randomBytes } from "node:crypto"; +import { join } from "node:path"; +import { mkdirSync } from "node:fs"; +import { homedir } from "node:os"; + +const OCP_DIR = join(homedir(), ".ocp"); +mkdirSync(OCP_DIR, { recursive: true }); +const DB_PATH = join(OCP_DIR, "ocp.db"); + +let db; + +export function getDb() { + if (!db) { + db = new Database(DB_PATH); + db.pragma("journal_mode = WAL"); + db.pragma("foreign_keys = ON"); + initSchema(); + } + return db; +} + +function initSchema() { + db.exec(` + CREATE TABLE IF NOT EXISTS api_keys ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + key TEXT UNIQUE NOT NULL, + name TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + revoked INTEGER NOT NULL DEFAULT 0 + ); + + CREATE TABLE IF NOT EXISTS usage_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + key_id INTEGER, + key_name TEXT NOT NULL DEFAULT 'anonymous', + model TEXT NOT NULL, + prompt_chars INTEGER NOT NULL DEFAULT 0, + response_chars INTEGER NOT NULL DEFAULT 0, + elapsed_ms INTEGER NOT NULL DEFAULT 0, + success INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (key_id) REFERENCES api_keys(id) + ); + + CREATE INDEX IF NOT EXISTS idx_usage_created ON usage_log(created_at); + CREATE INDEX IF NOT EXISTS idx_usage_key ON usage_log(key_id); + `); +} + +// ── Key CRUD ── + +export function createKey(name) { + const key = "ocp_" + randomBytes(24).toString("base64url"); + const d = getDb(); + const stmt = d.prepare("INSERT INTO api_keys (key, name) VALUES (?, ?)"); + const result = stmt.run(key, name); + return { id: result.lastInsertRowid, key, name }; +} + +export function listKeys() { + const d = getDb(); + return d.prepare( + "SELECT id, key, name, created_at, revoked FROM api_keys ORDER BY created_at DESC" + ).all().map(k => ({ + ...k, + keyPreview: k.key.slice(0, 8) + "..." + k.key.slice(-4), + })); +} + +export function revokeKey(idOrName) { + const d = getDb(); + const stmt = d.prepare( + "UPDATE api_keys SET revoked = 1 WHERE (id = ? OR name = ?) AND revoked = 0" + ); + return stmt.run(idOrName, idOrName).changes > 0; +} + +export function validateKey(key) { + const d = getDb(); + const row = d.prepare( + "SELECT id, name FROM api_keys WHERE key = ? AND revoked = 0" + ).get(key); + return row || null; +} + +// ── Usage recording ── + +export function recordUsage({ keyId, keyName, model, promptChars, responseChars, elapsedMs, success }) { + const d = getDb(); + d.prepare(` + INSERT INTO usage_log (key_id, key_name, model, prompt_chars, response_chars, elapsed_ms, success) + VALUES (?, ?, ?, ?, ?, ?, ?) + `).run(keyId ?? null, keyName || "anonymous", model, promptChars, responseChars, elapsedMs, success ? 1 : 0); +} + +// ── Usage queries ── + +export function getUsageByKey({ since, until } = {}) { + const d = getDb(); + let where = "WHERE 1=1"; + const params = []; + if (since) { where += " AND created_at >= ?"; params.push(since); } + if (until) { where += " AND created_at <= ?"; params.push(until); } + + return d.prepare(` + SELECT + key_name, + COUNT(*) as requests, + SUM(CASE WHEN success = 1 THEN 1 ELSE 0 END) as successes, + SUM(CASE WHEN success = 0 THEN 1 ELSE 0 END) as errors, + SUM(prompt_chars) as total_prompt_chars, + SUM(response_chars) as total_response_chars, + SUM(elapsed_ms) as total_elapsed_ms, + AVG(elapsed_ms) as avg_elapsed_ms, + MIN(created_at) as first_request, + MAX(created_at) as last_request + FROM usage_log + ${where} + GROUP BY key_name + ORDER BY requests DESC + `).all(...params); +} + +export function getUsageTimeline({ keyName, hours = 24 } = {}) { + const d = getDb(); + const since = new Date(Date.now() - hours * 3600000).toISOString(); + let where = "WHERE created_at >= ?"; + const params = [since]; + if (keyName) { where += " AND key_name = ?"; params.push(keyName); } + + return d.prepare(` + SELECT + strftime('%Y-%m-%dT%H:00:00', created_at) as hour, + COUNT(*) as requests, + SUM(prompt_chars) as prompt_chars, + SUM(response_chars) as response_chars, + AVG(elapsed_ms) as avg_elapsed_ms + FROM usage_log + ${where} + GROUP BY hour + ORDER BY hour + `).all(...params); +} + +export function getRecentUsage(limit = 50) { + const d = getDb(); + return d.prepare(` + SELECT key_name, model, prompt_chars, response_chars, elapsed_ms, success, created_at + FROM usage_log + ORDER BY created_at DESC + LIMIT ? + `).all(limit); +} + +export function closeDb() { + if (db) { db.close(); db = null; } +} From 2b18884d2a6559dae480a8cc4da6e278043b4fcc Mon Sep 17 00:00:00 2001 From: dtzp555 Date: Fri, 10 Apr 2026 21:07:22 +1000 Subject: [PATCH 04/13] feat: add LAN bind, 3-mode auth, usage recording, key/usage API endpoints Co-Authored-By: Claude Sonnet 4.6 --- server.mjs | 121 ++++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 106 insertions(+), 15 deletions(-) diff --git a/server.mjs b/server.mjs index 02bf75f..bb12193 100644 --- a/server.mjs +++ b/server.mjs @@ -33,6 +33,7 @@ import { readFileSync, accessSync, constants } from "node:fs"; import { fileURLToPath } from "node:url"; import { dirname, join } from "node:path"; import { homedir } from "node:os"; +import { validateKey, recordUsage, getUsageByKey, getUsageTimeline, getRecentUsage, createKey, listKeys, revokeKey, closeDb } from "./keys.mjs"; const __dirname = dirname(fileURLToPath(import.meta.url)); const _pkg = JSON.parse(readFileSync(join(__dirname, "package.json"), "utf8")); @@ -92,6 +93,9 @@ const BREAKER_THRESHOLD = parseInt(process.env.CLAUDE_BREAKER_THRESHOLD || "6", const BREAKER_COOLDOWN = parseInt(process.env.CLAUDE_BREAKER_COOLDOWN || "120000", 10); const BREAKER_WINDOW = parseInt(process.env.CLAUDE_BREAKER_WINDOW || "300000", 10); const BREAKER_HALF_OPEN_MAX = parseInt(process.env.CLAUDE_BREAKER_HALF_OPEN_MAX || "2", 10); +const BIND_ADDRESS = process.env.CLAUDE_BIND || "127.0.0.1"; +const AUTH_MODE = process.env.CLAUDE_AUTH_MODE || (PROXY_API_KEY ? "shared" : "none"); +const ADMIN_KEY = process.env.OCP_ADMIN_KEY || ""; const VERSION = _pkg.version; const START_TIME = Date.now(); @@ -514,7 +518,7 @@ function callClaude(model, messages, conversationId) { // ── Call claude CLI (real streaming) ───────────────────────────────────── // Pipes stdout from the claude process directly to SSE chunks as they arrive. // Each data chunk becomes a proper SSE event with delta content in real time. -function callClaudeStreaming(model, messages, conversationId, res) { +function callClaudeStreaming(model, messages, conversationId, res, authInfo = {}) { const id = `chatcmpl-${randomUUID()}`; const created = Math.floor(Date.now() / 1000); @@ -569,6 +573,7 @@ function callClaudeStreaming(model, messages, conversationId, res) { if (code !== 0) { recordModelError(cliModel, false); + recordUsage({ keyId: authInfo.keyId, keyName: authInfo.keyName, model, promptChars: 0, responseChars: 0, elapsedMs: elapsed, success: false }); logEvent("error", "claude_exit", { model: cliModel, code, signal: signal || "none", elapsed, stderr: stderr.slice(0, 300) }); trackError(stderr.slice(0, 300) || `claude exit ${code}`); handleSessionFailure(); @@ -586,6 +591,7 @@ function callClaudeStreaming(model, messages, conversationId, res) { } else { recordModelSuccess(cliModel, elapsed); breakerRecordSuccess(cliModel); + recordUsage({ keyId: authInfo.keyId, keyName: authInfo.keyName, model, promptChars: messages.reduce((a, m) => a + (typeof m.content === "string" ? m.content.length : JSON.stringify(m.content).length), 0), responseChars: totalChars, elapsedMs: elapsed, success: true }); logEvent("info", "claude_ok", { model: cliModel, chars: totalChars, elapsed, session: convId ? convId.slice(0, 12) + "..." : "none" }); if (!headersSent) ensureHeaders(); @@ -995,14 +1001,18 @@ async function handleChatCompletions(req, res) { if (stream) { // Real streaming: pipe stdout from claude process directly as SSE chunks - return callClaudeStreaming(model, messages, conversationId, res); + return callClaudeStreaming(model, messages, conversationId, res, { keyId: req._authKeyId, keyName: req._authKeyName }); } + const t0Usage = Date.now(); + const promptChars = messages.reduce((a, m) => a + (typeof m.content === "string" ? m.content.length : JSON.stringify(m.content).length), 0); try { const content = await callClaude(model, messages, conversationId); const id = `chatcmpl-${randomUUID()}`; completionResponse(res, id, model, content); + recordUsage({ keyId: req._authKeyId, keyName: req._authKeyName, model, promptChars, responseChars: content.length, elapsedMs: Date.now() - t0Usage, success: true }); } catch (err) { + recordUsage({ keyId: req._authKeyId, keyName: req._authKeyName, model, promptChars, responseChars: 0, elapsedMs: Date.now() - t0Usage, success: false }); console.error(`[proxy] error: ${err.message}`); if (res.headersSent || res.writableEnded || res.destroyed) { try { res.end(); } catch {} @@ -1016,25 +1026,52 @@ async function handleChatCompletions(req, res) { // ── HTTP server ───────────────────────────────────────────────────────── const server = createServer(async (req, res) => { - // Dynamic CORS: only allow localhost origins + // Dynamic CORS: allow localhost and LAN origins const origin = req.headers["origin"] || ""; - const isLocalhost = /^https?:\/\/(127\.0\.0\.1|localhost)(:\d+)?$/.test(origin); - res.setHeader("Access-Control-Allow-Origin", isLocalhost ? origin : `http://127.0.0.1:${PORT}`); - res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS"); + const isAllowedOrigin = /^https?:\/\/(127\.0\.0\.1|localhost|192\.168\.\d+\.\d+|172\.(1[6-9]|2\d|3[01])\.\d+\.\d+|10\.\d+\.\d+\.\d+)(:\d+)?$/.test(origin); + res.setHeader("Access-Control-Allow-Origin", isAllowedOrigin ? origin : "*"); + res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS, PATCH"); res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Session-Id, X-Conversation-Id"); if (req.method === "OPTIONS") { res.writeHead(204); res.end(); return; } - // Bearer token auth (skip for /health and when PROXY_API_KEY is not set) - if (PROXY_API_KEY && req.url !== "/health") { + // 3-mode auth: none | shared | multi + const isPublicEndpoint = req.url === "/health"; + let authKeyName = "local"; + let authKeyId = null; + + if (!isPublicEndpoint) { const auth = req.headers["authorization"] || ""; const token = auth.startsWith("Bearer ") ? auth.slice(7) : ""; - const tokenBuf = Buffer.from(token); - const keyBuf = Buffer.from(PROXY_API_KEY); - if (tokenBuf.length !== keyBuf.length || !timingSafeEqual(tokenBuf, keyBuf)) { - return jsonResponse(res, 401, { error: { message: "Unauthorized: invalid or missing Bearer token", type: "auth_error" } }); + + if (AUTH_MODE === "shared") { + if (PROXY_API_KEY) { + const tokenBuf = Buffer.from(token); + const keyBuf = Buffer.from(PROXY_API_KEY); + if (tokenBuf.length !== keyBuf.length || !timingSafeEqual(tokenBuf, keyBuf)) { + return jsonResponse(res, 401, { error: { message: "Unauthorized: invalid or missing Bearer token", type: "auth_error" } }); + } + authKeyName = "shared"; + } + } else if (AUTH_MODE === "multi") { + if (!token) { + return jsonResponse(res, 401, { error: { message: "Unauthorized: Bearer token required", type: "auth_error" } }); + } + if (ADMIN_KEY && token === ADMIN_KEY) { + authKeyName = "admin"; + } else { + const keyInfo = validateKey(token); + if (!keyInfo) { + return jsonResponse(res, 401, { error: { message: "Unauthorized: invalid or revoked API key", type: "auth_error" } }); + } + authKeyName = keyInfo.name; + authKeyId = keyInfo.id; + } } } + req._authKeyName = authKeyName; + req._authKeyId = authKeyId; + // GET /v1/models if (req.url === "/v1/models" && req.method === "GET") { return jsonResponse(res, 200, { @@ -1129,7 +1166,57 @@ const server = createServer(async (req, res) => { return handleSettings(req, res); } - jsonResponse(res, 404, { error: "Not found. Endpoints: GET /v1/models, POST /v1/chat/completions, GET /health, GET /usage, GET /status, GET /logs, GET|PATCH /settings, GET|DELETE /sessions" }); + // ── Key management API ── + const isAdmin = AUTH_MODE !== "multi" || authKeyName === "admin" || BIND_ADDRESS === "127.0.0.1"; + + if (req.url === "/api/keys" && req.method === "POST") { + if (!isAdmin) return jsonResponse(res, 403, { error: "Admin access required" }); + let body = ""; + for await (const chunk of req) body += chunk; + let parsed; + try { parsed = JSON.parse(body); } catch { return jsonResponse(res, 400, { error: "Invalid JSON" }); } + const name = parsed.name || `key-${Date.now()}`; + const newKey = createKey(name); + return jsonResponse(res, 201, newKey); + } + + if (req.url === "/api/keys" && req.method === "GET") { + if (!isAdmin) return jsonResponse(res, 403, { error: "Admin access required" }); + return jsonResponse(res, 200, { keys: listKeys() }); + } + + if (req.url?.startsWith("/api/keys/") && req.method === "DELETE") { + if (!isAdmin) return jsonResponse(res, 403, { error: "Admin access required" }); + const idOrName = decodeURIComponent(req.url.split("/api/keys/")[1]); + const revoked = revokeKey(idOrName); + return jsonResponse(res, 200, { revoked, idOrName }); + } + + if (req.url?.startsWith("/api/usage") && req.method === "GET") { + if (!isAdmin) return jsonResponse(res, 403, { error: "Admin access required" }); + const url = new URL(req.url, `http://${BIND_ADDRESS}:${PORT}`); + const since = url.searchParams.get("since"); + const until = url.searchParams.get("until"); + return jsonResponse(res, 200, { + byKey: getUsageByKey({ since, until }), + timeline: getUsageTimeline({ hours: parseInt(url.searchParams.get("hours") || "24", 10) }), + recent: getRecentUsage(parseInt(url.searchParams.get("limit") || "50", 10)), + }); + } + + // GET /dashboard — web dashboard + if (req.url === "/dashboard" && req.method === "GET") { + try { + const html = readFileSync(join(__dirname, "dashboard.html"), "utf8"); + res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); + res.end(html); + } catch (err) { + return jsonResponse(res, 500, { error: "Dashboard file not found" }); + } + return; + } + + jsonResponse(res, 404, { error: "Not found. Endpoints: GET /v1/models, POST /v1/chat/completions, GET /health, GET /usage, GET /status, GET /logs, GET|PATCH /settings, GET|DELETE /sessions, GET /dashboard, GET|POST|DELETE /api/keys, GET /api/usage" }); }); @@ -1149,6 +1236,7 @@ function gracefulShutdown(signal) { // 2. Clear intervals/timers clearInterval(sessionCleanupInterval); clearInterval(authCheckInterval); + closeDb(); // 3. Kill all active child processes for (const proc of activeProcesses) { @@ -1186,8 +1274,9 @@ process.on("SIGTERM", () => gracefulShutdown("SIGTERM")); process.on("SIGINT", () => gracefulShutdown("SIGINT")); // ── Start ─────────────────────────────────────────────────────────────── -server.listen(PORT, "127.0.0.1", () => { - console.log(`openclaw-claude-proxy v${VERSION} listening on http://127.0.0.1:${PORT}`); +server.listen(PORT, BIND_ADDRESS, () => { + const bindMsg = BIND_ADDRESS === "0.0.0.0" ? `http://0.0.0.0:${PORT} (LAN mode)` : `http://127.0.0.1:${PORT}`; + console.log(`openclaw-claude-proxy v${VERSION} listening on ${bindMsg}`); console.log(`Architecture: on-demand spawning (no pool)`); console.log(`Models: ${MODELS.map((m) => m.id).join(", ")}`); console.log(`Claude binary: ${CLAUDE}`); @@ -1198,6 +1287,8 @@ server.listen(PORT, "127.0.0.1", () => { if (SYSTEM_PROMPT) console.log(`System prompt: "${SYSTEM_PROMPT.slice(0, 80)}..."`); if (MCP_CONFIG) console.log(`MCP config: ${MCP_CONFIG}`); console.log(`Auth: ${PROXY_API_KEY ? "enabled (PROXY_API_KEY set)" : "disabled (no PROXY_API_KEY)"}`); + console.log(`Auth mode: ${AUTH_MODE}${AUTH_MODE === "shared" ? " (PROXY_API_KEY)" : AUTH_MODE === "multi" ? " (per-user keys)" : " (open)"}`); + console.log(`Bind: ${BIND_ADDRESS}${BIND_ADDRESS === "0.0.0.0" ? " ⚠ LAN-accessible" : ""}`); console.log(`---`); console.log(`Coexistence: This proxy does NOT conflict with Claude Code interactive mode.`); console.log(` OCP uses: localhost:${PORT} (HTTP) → claude -p (per-request process)`); From 5fbeaed568f87d25711822dfb9fe634191beecb0 Mon Sep 17 00:00:00 2001 From: dtzp555 Date: Fri, 10 Apr 2026 21:13:17 +1000 Subject: [PATCH 05/13] security: fix key exposure, timing-safe admin auth, remove admin bypass, cap params, harden usage recording - Fix 1 (keys.mjs): listKeys() no longer leaks full API key field in response - Fix 2 (server.mjs): Remove BIND_ADDRESS admin bypass from isAdmin check - Fix 3 (server.mjs): Admin key comparison now uses timingSafeEqual - Fix 4 (server.mjs): Cap limit (max 500) and hours (max 720) in GET /api/usage - Fix 5 (server.mjs): Streaming error path now computes promptChars from messages - Fix 6 (server.mjs): Warn at startup if AUTH_MODE=shared but PROXY_API_KEY is empty - Fix 7 (server.mjs): All recordUsage calls wrapped in try/catch to prevent DB errors crashing server - Fix 8 (server.mjs): CORS fallback changed from "*" to specific origin (http://127.0.0.1:) Co-Authored-By: Claude Sonnet 4.6 --- keys.mjs | 6 +++--- server.mjs | 33 ++++++++++++++++++++++----------- 2 files changed, 25 insertions(+), 14 deletions(-) diff --git a/keys.mjs b/keys.mjs index 240e831..b9785c7 100644 --- a/keys.mjs +++ b/keys.mjs @@ -63,9 +63,9 @@ export function listKeys() { const d = getDb(); return d.prepare( "SELECT id, key, name, created_at, revoked FROM api_keys ORDER BY created_at DESC" - ).all().map(k => ({ - ...k, - keyPreview: k.key.slice(0, 8) + "..." + k.key.slice(-4), + ).all().map(({ key, ...rest }) => ({ + ...rest, + keyPreview: key.slice(0, 8) + "..." + key.slice(-4), })); } diff --git a/server.mjs b/server.mjs index bb12193..ca19095 100644 --- a/server.mjs +++ b/server.mjs @@ -97,6 +97,10 @@ const BIND_ADDRESS = process.env.CLAUDE_BIND || "127.0.0.1"; const AUTH_MODE = process.env.CLAUDE_AUTH_MODE || (PROXY_API_KEY ? "shared" : "none"); const ADMIN_KEY = process.env.OCP_ADMIN_KEY || ""; +if (AUTH_MODE === "shared" && !PROXY_API_KEY) { + console.warn("WARNING: AUTH_MODE=shared but PROXY_API_KEY is not set — all requests will pass unauthenticated"); +} + const VERSION = _pkg.version; const START_TIME = Date.now(); @@ -573,7 +577,7 @@ function callClaudeStreaming(model, messages, conversationId, res, authInfo = {} if (code !== 0) { recordModelError(cliModel, false); - recordUsage({ keyId: authInfo.keyId, keyName: authInfo.keyName, model, promptChars: 0, responseChars: 0, elapsedMs: elapsed, success: false }); + try { recordUsage({ keyId: authInfo.keyId, keyName: authInfo.keyName, model, promptChars: messages.reduce((a, m) => a + (typeof m.content === "string" ? m.content.length : JSON.stringify(m.content).length), 0), responseChars: 0, elapsedMs: elapsed, success: false }); } catch (e) { logEvent("error", "usage_record_failed", { error: e.message }); } logEvent("error", "claude_exit", { model: cliModel, code, signal: signal || "none", elapsed, stderr: stderr.slice(0, 300) }); trackError(stderr.slice(0, 300) || `claude exit ${code}`); handleSessionFailure(); @@ -591,7 +595,7 @@ function callClaudeStreaming(model, messages, conversationId, res, authInfo = {} } else { recordModelSuccess(cliModel, elapsed); breakerRecordSuccess(cliModel); - recordUsage({ keyId: authInfo.keyId, keyName: authInfo.keyName, model, promptChars: messages.reduce((a, m) => a + (typeof m.content === "string" ? m.content.length : JSON.stringify(m.content).length), 0), responseChars: totalChars, elapsedMs: elapsed, success: true }); + try { recordUsage({ keyId: authInfo.keyId, keyName: authInfo.keyName, model, promptChars: messages.reduce((a, m) => a + (typeof m.content === "string" ? m.content.length : JSON.stringify(m.content).length), 0), responseChars: totalChars, elapsedMs: elapsed, success: true }); } catch (e) { logEvent("error", "usage_record_failed", { error: e.message }); } logEvent("info", "claude_ok", { model: cliModel, chars: totalChars, elapsed, session: convId ? convId.slice(0, 12) + "..." : "none" }); if (!headersSent) ensureHeaders(); @@ -1010,9 +1014,9 @@ async function handleChatCompletions(req, res) { const content = await callClaude(model, messages, conversationId); const id = `chatcmpl-${randomUUID()}`; completionResponse(res, id, model, content); - recordUsage({ keyId: req._authKeyId, keyName: req._authKeyName, model, promptChars, responseChars: content.length, elapsedMs: Date.now() - t0Usage, success: true }); + try { recordUsage({ keyId: req._authKeyId, keyName: req._authKeyName, model, promptChars, responseChars: content.length, elapsedMs: Date.now() - t0Usage, success: true }); } catch (e) { logEvent("error", "usage_record_failed", { error: e.message }); } } catch (err) { - recordUsage({ keyId: req._authKeyId, keyName: req._authKeyName, model, promptChars, responseChars: 0, elapsedMs: Date.now() - t0Usage, success: false }); + try { recordUsage({ keyId: req._authKeyId, keyName: req._authKeyName, model, promptChars, responseChars: 0, elapsedMs: Date.now() - t0Usage, success: false }); } catch (e) { logEvent("error", "usage_record_failed", { error: e.message }); } console.error(`[proxy] error: ${err.message}`); if (res.headersSent || res.writableEnded || res.destroyed) { try { res.end(); } catch {} @@ -1029,7 +1033,7 @@ const server = createServer(async (req, res) => { // Dynamic CORS: allow localhost and LAN origins const origin = req.headers["origin"] || ""; const isAllowedOrigin = /^https?:\/\/(127\.0\.0\.1|localhost|192\.168\.\d+\.\d+|172\.(1[6-9]|2\d|3[01])\.\d+\.\d+|10\.\d+\.\d+\.\d+)(:\d+)?$/.test(origin); - res.setHeader("Access-Control-Allow-Origin", isAllowedOrigin ? origin : "*"); + res.setHeader("Access-Control-Allow-Origin", isAllowedOrigin ? origin : `http://127.0.0.1:${PORT}`); res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS, PATCH"); res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Session-Id, X-Conversation-Id"); if (req.method === "OPTIONS") { res.writeHead(204); res.end(); return; } @@ -1056,9 +1060,16 @@ const server = createServer(async (req, res) => { if (!token) { return jsonResponse(res, 401, { error: { message: "Unauthorized: Bearer token required", type: "auth_error" } }); } - if (ADMIN_KEY && token === ADMIN_KEY) { - authKeyName = "admin"; - } else { + let isAdminToken = false; + if (ADMIN_KEY) { + const adminBuf = Buffer.from(ADMIN_KEY); + const tokenBuf2 = Buffer.from(token); + if (adminBuf.length === tokenBuf2.length && timingSafeEqual(adminBuf, tokenBuf2)) { + authKeyName = "admin"; + isAdminToken = true; + } + } + if (!isAdminToken) { const keyInfo = validateKey(token); if (!keyInfo) { return jsonResponse(res, 401, { error: { message: "Unauthorized: invalid or revoked API key", type: "auth_error" } }); @@ -1167,7 +1178,7 @@ const server = createServer(async (req, res) => { } // ── Key management API ── - const isAdmin = AUTH_MODE !== "multi" || authKeyName === "admin" || BIND_ADDRESS === "127.0.0.1"; + const isAdmin = AUTH_MODE !== "multi" || authKeyName === "admin"; if (req.url === "/api/keys" && req.method === "POST") { if (!isAdmin) return jsonResponse(res, 403, { error: "Admin access required" }); @@ -1199,8 +1210,8 @@ const server = createServer(async (req, res) => { const until = url.searchParams.get("until"); return jsonResponse(res, 200, { byKey: getUsageByKey({ since, until }), - timeline: getUsageTimeline({ hours: parseInt(url.searchParams.get("hours") || "24", 10) }), - recent: getRecentUsage(parseInt(url.searchParams.get("limit") || "50", 10)), + timeline: getUsageTimeline({ hours: Math.min(parseInt(url.searchParams.get("hours") || "24", 10), 720) }), + recent: getRecentUsage(Math.min(parseInt(url.searchParams.get("limit") || "50", 10), 500)), }); } From ea57db6cebaf0fd96a14467f9bd233c54b841a89 Mon Sep 17 00:00:00 2001 From: dtzp555 Date: Fri, 10 Apr 2026 21:15:44 +1000 Subject: [PATCH 06/13] feat: add embedded web dashboard with real-time usage display --- dashboard.html | 245 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 245 insertions(+) create mode 100644 dashboard.html diff --git a/dashboard.html b/dashboard.html new file mode 100644 index 0000000..5358f7c --- /dev/null +++ b/dashboard.html @@ -0,0 +1,245 @@ + + + + + +OCP Dashboard + + + + +
+

OCP Dashboard

+
+ + +
+
+ +
+ +
+

Plan Usage

+
+
+ +
+

Usage by Key

+ + + +
KeyRequestsOKErrAvg TimeLast Request
+
+ + + +
+

Recent Requests

+ + + +
TimeKeyModelPromptResponseTimeStatus
+
+ + + + From 4f4e1edf1679004c428766b36aa57659f44c7d8a Mon Sep 17 00:00:00 2001 From: dtzp555 Date: Fri, 10 Apr 2026 21:16:25 +1000 Subject: [PATCH 07/13] feat: add ocp keys, ocp lan, and ocp usage --by-key CLI commands Co-Authored-By: Claude Sonnet 4.6 --- ocp | 150 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 147 insertions(+), 3 deletions(-) diff --git a/ocp b/ocp index add04ba..8648556 100755 --- a/ocp +++ b/ocp @@ -29,11 +29,36 @@ Displays current session utilization, weekly limits, extra usage status, and proxy request statistics. Data is fetched from the Anthropic API via a minimal probe call (cached for 5 minutes). -Usage: ocp usage +Usage: ocp usage [--by-key] + +Options: + --by-key Show per-key usage statistics EOF } cmd_usage() { + if [[ "${1:-}" == "--by-key" ]]; then + local data + data=$(curl -sf --max-time 15 "$PROXY/api/usage" 2>&1) || { echo "Error: proxy unreachable or usage API not available"; exit 1; } + echo "$data" | python3 -c " +import sys, json +d = json.loads(sys.stdin.read()) +by_key = d.get('byKey', []) +if not by_key: + print('No usage data yet.') +else: + print('Usage by Key') + print('─────────────────────────────────────────────────────────────────') + hdr = f' {\"Key\":<20} {\"Reqs\":>5} {\"OK\":>4} {\"Err\":>4} {\"Avg Time\":>9} {\"Last Request\":<20}' + print(hdr) + print(' ' + '─' * (len(hdr) - 2)) + for k in by_key: + avg_t = f'{k[\"avg_elapsed_ms\"]/1000:.1f}s' if k['avg_elapsed_ms'] else '-' + print(f' {k[\"key_name\"]:<20} {k[\"requests\"]:>5} {k[\"successes\"]:>4} {k[\"errors\"]:>4} {avg_t:>9} {k[\"last_request\"]:<20}') +" + return + fi + local data data=$(curl -sf --max-time 15 "$PROXY/usage" 2>&1) || { echo "Error: proxy unreachable"; exit 1; } @@ -213,6 +238,121 @@ cmd_clear() { echo "Cleared $count sessions." } +# ── keys ──────────────────────────────────────────────────────────────── +cmd_keys_help() { + cat <<'EOF' +ocp keys — Manage API keys (multi-key auth mode) + +Usage: + ocp keys List all keys + ocp keys add Create a new key + ocp keys revoke Revoke a key + +Examples: + ocp keys add wife-laptop + ocp keys add son-ipad + ocp keys revoke wife-laptop +EOF +} + +cmd_keys() { + case "${1:-}" in + add) + if [[ -z "${2:-}" ]]; then echo "Usage: ocp keys add "; return 1; fi + local result + result=$(curl -sf --max-time 5 -X POST "$PROXY/api/keys" \ + -H "Content-Type: application/json" \ + -d "{\"name\": \"$2\"}" 2>&1) || { echo "Error: proxy unreachable or unauthorized"; exit 1; } + echo "$result" | python3 -c " +import sys, json +d = json.loads(sys.stdin.read()) +if 'key' in d: + print(f'✓ Key created for \"{d[\"name\"]}\"') + print(f'') + print(f' API Key: {d[\"key\"]}') + print(f'') + print(f' Copy this key now — you won\\'t see it again.') + print(f' Configure in IDE: OPENAI_API_KEY={d[\"key\"]}') +else: + print(f'✗ {d.get(\"error\", \"Unknown error\")}') +" + ;; + revoke) + if [[ -z "${2:-}" ]]; then echo "Usage: ocp keys revoke "; return 1; fi + curl -sf --max-time 5 -X DELETE "$PROXY/api/keys/$2" | python3 -c " +import sys, json +d = json.loads(sys.stdin.read()) +if d.get('revoked'): + print(f'✓ Key \"{d[\"idOrName\"]}\" revoked.') +else: + print(f'✗ Key not found or already revoked.') +" + ;; + --help|-h) + cmd_keys_help + ;; + "") + curl -sf --max-time 5 "$PROXY/api/keys" | python3 -c " +import sys, json +d = json.loads(sys.stdin.read()) +keys = d.get('keys', []) +if not keys: + print('No API keys configured.') + print('Create one: ocp keys add ') +else: + print('API Keys') + print('─────────────────────────────────────────────────') + for k in keys: + status = '✗ revoked' if k['revoked'] else '✓ active' + print(f' {k[\"name\"]:<20} {k[\"keyPreview\"]:<20} {status} {k[\"created_at\"]}') +" 2>/dev/null || { echo "Error: proxy unreachable or key management not available"; exit 1; } + ;; + *) + echo "Unknown subcommand: $1"; cmd_keys_help; return 1 ;; + esac +} + +# ── lan ───────────────────────────────────────────────────────────────── +cmd_lan_help() { + cat <<'EOF' +ocp lan — Quick LAN mode setup guide + +Shows current network configuration and connection instructions +for other devices on the same network. + +Usage: ocp lan +EOF +} + +cmd_lan() { + local ip + ip=$(ipconfig getifaddr en0 2>/dev/null || hostname -I 2>/dev/null | awk '{print $1}') + local port=3456 + + echo "OCP LAN Setup" + echo "─────────────────────────────────────" + echo "" + echo " Your IP: $ip" + echo " Port: $port" + echo "" + echo " For IDE users, set:" + echo " OPENAI_BASE_URL=http://$ip:$port/v1" + echo " OPENAI_API_KEY= (if auth enabled)" + echo "" + echo " Dashboard: http://$ip:$port/dashboard" + echo "" + + if curl -sf --max-time 2 "http://$ip:$port/health" > /dev/null 2>&1; then + echo " Status: ✓ LAN-accessible" + else + echo " Status: ✗ Not LAN-accessible (bound to localhost only)" + echo "" + echo " To enable LAN mode, set env var and restart:" + echo " CLAUDE_BIND=0.0.0.0" + echo " ocp restart" + fi +} + # ── restart ────────────────────────────────────────────────────────────── cmd_restart_help() { cat <<'EOF' @@ -442,7 +582,7 @@ ocp — OpenClaw Proxy CLI Usage: ocp [args] Commands: - usage Plan usage limits + usage Plan usage limits (--by-key for per-key stats) status Quick overview (usage + health) health Proxy diagnostics settings View or update tunable settings @@ -450,6 +590,8 @@ Commands: models Available models sessions Active sessions clear Clear all sessions + keys Manage API keys (add/list/revoke) + lan LAN mode setup guide restart Restart proxy restart gateway Restart gateway update Update OCP to latest version @@ -484,7 +626,7 @@ for arg in "$@"; do done case "$subcmd" in - usage) cmd_usage ;; + usage) cmd_usage "${1:-}" ;; status) cmd_status ;; health) cmd_health ;; settings) cmd_settings "${1:-}" "${2:-}" ;; @@ -492,6 +634,8 @@ case "$subcmd" in models) cmd_models ;; sessions) cmd_sessions ;; clear) cmd_clear ;; + keys) cmd_keys "${1:-}" "${2:-}" ;; + lan) cmd_lan ;; restart) cmd_restart "${1:-}" ;; update) cmd_update "${1:-}" ;; *) echo "Unknown command: $subcmd"; echo ""; cmd_help; exit 1 ;; From aa0adab784588a84a2096aa0b87b11cb629525b8 Mon Sep 17 00:00:00 2001 From: dtzp555 Date: Fri, 10 Apr 2026 21:17:19 +1000 Subject: [PATCH 08/13] feat: add LAN mode config options to setup.mjs --- setup.mjs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/setup.mjs b/setup.mjs index ba9e27a..b446ac0 100755 --- a/setup.mjs +++ b/setup.mjs @@ -36,6 +36,8 @@ const DEFAULT_MODEL = opt("default-model", "opus"); // opus | sonnet | haiku const DRY_RUN = flag("dry-run"); const SKIP_START = flag("no-start"); const PROVIDER_NAME = opt("provider-name", "claude-local"); +const BIND_ADDRESS = opt("bind", "127.0.0.1"); +const AUTH_MODE_CONFIG = opt("auth-mode", "none"); const MODEL_ID_MAP = { opus: "claude-opus-4-6", @@ -363,6 +365,10 @@ if (!DRY_RUN) { CLAUDE_PROXY_PORT ${PORT} + CLAUDE_BIND + ${BIND_ADDRESS} + CLAUDE_AUTH_MODE + ${AUTH_MODE_CONFIG} RunAtLoad @@ -399,6 +405,8 @@ After=network.target [Service] ExecStart=${nodeBin} ${serverPath} Environment=CLAUDE_PROXY_PORT=${PORT} +Environment=CLAUDE_BIND=${BIND_ADDRESS} +Environment=CLAUDE_AUTH_MODE=${AUTH_MODE_CONFIG} Restart=always RestartSec=5 StandardOutput=append:${logPath} From 43daf8162ca065c7c62377b9e126b367127851f0 Mon Sep 17 00:00:00 2001 From: dtzp555 Date: Fri, 10 Apr 2026 21:17:25 +1000 Subject: [PATCH 09/13] docs: add LAN mode documentation and family sharing guide --- README.md | 61 ++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 60 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 4bb212c..4bafbe0 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # OCP — Open Claude Proxy -> **Status: Stable (v3.3.1)** — Feature-complete. Bug fixes only. +> **Status: Stable (v3.4.0)** — Feature-complete. Bug fixes only. > **Already paying for Claude Pro/Max? Use your subscription as an OpenAI-compatible API — $0 extra cost.** @@ -55,6 +55,65 @@ curl http://127.0.0.1:3456/v1/models # Returns: claude-opus-4-6, claude-sonnet-4-6, claude-haiku-4 ``` +## LAN Mode — Share with Family + +OCP can serve your entire household from a single machine. + +### Enable LAN Access + +```bash +# Set bind address and restart +export CLAUDE_BIND=0.0.0.0 +ocp restart +``` + +Or permanently via setup: +```bash +node setup.mjs --bind 0.0.0.0 --auth-mode multi +``` + +### Auth Modes + +| Mode | Env | Description | +|------|-----|-------------| +| `none` | `CLAUDE_AUTH_MODE=none` | Open access (trusted network) | +| `shared` | `CLAUDE_AUTH_MODE=shared` | Single key via `PROXY_API_KEY` | +| `multi` | `CLAUDE_AUTH_MODE=multi` | Per-user keys with usage tracking | + +### Multi-Key Setup + +```bash +# Create keys for family members +ocp keys add wife-laptop +ocp keys add son-ipad + +# List keys +ocp keys + +# View per-key usage +ocp usage --by-key + +# Revoke a key +ocp keys revoke son-ipad +``` + +### Family Member Setup + +Give your family member these settings for their IDE: + +``` +OPENAI_BASE_URL=http://:3456/v1 +OPENAI_API_KEY=ocp_ +``` + +Run `ocp lan` to see your IP and connection guide. + +### Web Dashboard + +Access the usage dashboard at: `http://:3456/dashboard` + +Shows real-time stats: per-key usage, request history, plan utilization, and system health. + ## Built-in Usage Monitoring Check your subscription usage from the terminal: From 566a01a6bdd08ec1bac957c3203d8f76dbc4e397 Mon Sep 17 00:00:00 2001 From: dtzp555 Date: Fri, 10 Apr 2026 21:20:24 +1000 Subject: [PATCH 10/13] refactor: switch from better-sqlite3 to node:sqlite (zero dependencies) better-sqlite3 native addon fails on Node v25. Node.js built-in SQLite (node:sqlite) has an identical synchronous API and requires no compilation. Co-Authored-By: Claude Opus 4.6 --- keys.mjs | 9 +- package-lock.json | 459 +--------------------------------------------- package.json | 3 - 3 files changed, 7 insertions(+), 464 deletions(-) diff --git a/keys.mjs b/keys.mjs index b9785c7..c920b18 100644 --- a/keys.mjs +++ b/keys.mjs @@ -1,5 +1,6 @@ // keys.mjs — API key management and usage tracking for OCP LAN mode -import Database from "better-sqlite3"; +// Uses Node.js built-in SQLite (node:sqlite) — zero external dependencies. +import { DatabaseSync } from "node:sqlite"; import { randomBytes } from "node:crypto"; import { join } from "node:path"; import { mkdirSync } from "node:fs"; @@ -13,9 +14,9 @@ let db; export function getDb() { if (!db) { - db = new Database(DB_PATH); - db.pragma("journal_mode = WAL"); - db.pragma("foreign_keys = ON"); + db = new DatabaseSync(DB_PATH); + db.exec("PRAGMA journal_mode = WAL"); + db.exec("PRAGMA foreign_keys = ON"); initSchema(); } return db; diff --git a/package-lock.json b/package-lock.json index f491b5a..86a2ef3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,13 @@ { "name": "openclaw-claude-proxy", - "version": "3.3.1", + "version": "3.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "openclaw-claude-proxy", - "version": "3.3.1", + "version": "3.4.0", "license": "MIT", - "dependencies": { - "better-sqlite3": "^12.8.0" - }, "bin": { "ocp": "ocp", "openclaw-claude-proxy": "server.mjs" @@ -18,458 +15,6 @@ "engines": { "node": ">=18" } - }, - "node_modules/base64-js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/better-sqlite3": { - "version": "12.8.0", - "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.8.0.tgz", - "integrity": "sha512-RxD2Vd96sQDjQr20kdP+F+dK/1OUNiVOl200vKBZY8u0vTwysfolF6Hq+3ZK2+h8My9YvZhHsF+RSGZW2VYrPQ==", - "hasInstallScript": true, - "license": "MIT", - "dependencies": { - "bindings": "^1.5.0", - "prebuild-install": "^7.1.1" - }, - "engines": { - "node": "20.x || 22.x || 23.x || 24.x || 25.x" - } - }, - "node_modules/bindings": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", - "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", - "license": "MIT", - "dependencies": { - "file-uri-to-path": "1.0.0" - } - }, - "node_modules/bl": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", - "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", - "license": "MIT", - "dependencies": { - "buffer": "^5.5.0", - "inherits": "^2.0.4", - "readable-stream": "^3.4.0" - } - }, - "node_modules/buffer": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", - "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.1.13" - } - }, - "node_modules/chownr": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", - "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", - "license": "ISC" - }, - "node_modules/decompress-response": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", - "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", - "license": "MIT", - "dependencies": { - "mimic-response": "^3.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/deep-extend": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", - "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", - "license": "MIT", - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "license": "Apache-2.0", - "engines": { - "node": ">=8" - } - }, - "node_modules/end-of-stream": { - "version": "1.4.5", - "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", - "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", - "license": "MIT", - "dependencies": { - "once": "^1.4.0" - } - }, - "node_modules/expand-template": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", - "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", - "license": "(MIT OR WTFPL)", - "engines": { - "node": ">=6" - } - }, - "node_modules/file-uri-to-path": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", - "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", - "license": "MIT" - }, - "node_modules/fs-constants": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", - "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", - "license": "MIT" - }, - "node_modules/github-from-package": { - "version": "0.0.0", - "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", - "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", - "license": "MIT" - }, - "node_modules/ieee754": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "BSD-3-Clause" - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "license": "ISC" - }, - "node_modules/ini": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", - "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", - "license": "ISC" - }, - "node_modules/mimic-response": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", - "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/minimist": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/mkdirp-classic": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", - "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", - "license": "MIT" - }, - "node_modules/napi-build-utils": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", - "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", - "license": "MIT" - }, - "node_modules/node-abi": { - "version": "3.89.0", - "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.89.0.tgz", - "integrity": "sha512-6u9UwL0HlAl21+agMN3YAMXcKByMqwGx+pq+P76vii5f7hTPtKDp08/H9py6DY+cfDw7kQNTGEj/rly3IgbNQA==", - "license": "MIT", - "dependencies": { - "semver": "^7.3.5" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "license": "ISC", - "dependencies": { - "wrappy": "1" - } - }, - "node_modules/prebuild-install": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", - "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", - "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", - "license": "MIT", - "dependencies": { - "detect-libc": "^2.0.0", - "expand-template": "^2.0.3", - "github-from-package": "0.0.0", - "minimist": "^1.2.3", - "mkdirp-classic": "^0.5.3", - "napi-build-utils": "^2.0.0", - "node-abi": "^3.3.0", - "pump": "^3.0.0", - "rc": "^1.2.7", - "simple-get": "^4.0.0", - "tar-fs": "^2.0.0", - "tunnel-agent": "^0.6.0" - }, - "bin": { - "prebuild-install": "bin.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/pump": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", - "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", - "license": "MIT", - "dependencies": { - "end-of-stream": "^1.1.0", - "once": "^1.3.1" - } - }, - "node_modules/rc": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", - "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", - "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", - "dependencies": { - "deep-extend": "^0.6.0", - "ini": "~1.3.0", - "minimist": "^1.2.0", - "strip-json-comments": "~2.0.1" - }, - "bin": { - "rc": "cli.js" - } - }, - "node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "license": "MIT", - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/simple-concat": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", - "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/simple-get": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", - "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "decompress-response": "^6.0.0", - "once": "^1.3.1", - "simple-concat": "^1.0.0" - } - }, - "node_modules/string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "license": "MIT", - "dependencies": { - "safe-buffer": "~5.2.0" - } - }, - "node_modules/strip-json-comments": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", - "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/tar-fs": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", - "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", - "license": "MIT", - "dependencies": { - "chownr": "^1.1.1", - "mkdirp-classic": "^0.5.2", - "pump": "^3.0.0", - "tar-stream": "^2.1.4" - } - }, - "node_modules/tar-stream": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", - "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", - "license": "MIT", - "dependencies": { - "bl": "^4.0.3", - "end-of-stream": "^1.4.1", - "fs-constants": "^1.0.0", - "inherits": "^2.0.3", - "readable-stream": "^3.1.1" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/tunnel-agent": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", - "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", - "license": "Apache-2.0", - "dependencies": { - "safe-buffer": "^5.0.1" - }, - "engines": { - "node": "*" - } - }, - "node_modules/util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "license": "MIT" - }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "license": "ISC" } } } diff --git a/package.json b/package.json index 8e894e4..b3d5d7a 100644 --- a/package.json +++ b/package.json @@ -25,8 +25,5 @@ "repository": { "type": "git", "url": "https://github.com/dtzp555-max/ocp" - }, - "dependencies": { - "better-sqlite3": "^12.8.0" } } From 471bda2c40c16562287952ade58a3865d2efa076 Mon Sep 17 00:00:00 2001 From: dtzp555 Date: Fri, 10 Apr 2026 21:21:36 +1000 Subject: [PATCH 11/13] fix: make /dashboard a public endpoint (no auth required) Co-Authored-By: Claude Opus 4.6 --- server.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server.mjs b/server.mjs index ca19095..415723a 100644 --- a/server.mjs +++ b/server.mjs @@ -1039,7 +1039,7 @@ const server = createServer(async (req, res) => { if (req.method === "OPTIONS") { res.writeHead(204); res.end(); return; } // 3-mode auth: none | shared | multi - const isPublicEndpoint = req.url === "/health"; + const isPublicEndpoint = req.url === "/health" || req.url === "/dashboard"; let authKeyName = "local"; let authKeyId = null; From 1983f9372dd51c670333d7d3c11cb4e5ef570847 Mon Sep 17 00:00:00 2001 From: dtzp555 Date: Fri, 10 Apr 2026 21:36:16 +1000 Subject: [PATCH 12/13] fix: CLI auth support for multi-key mode + fix LAN IP detection - Add _curl wrapper that reads OCP_ADMIN_KEY env or ~/.ocp/admin-key file - All admin API calls (keys, usage) use _curl for auth - Fix ocp lan IP detection to try en0 and en1 Co-Authored-By: Claude Opus 4.6 --- ocp | 27 ++++++++++++++++++++++----- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/ocp b/ocp index 8648556..5f6dcd4 100755 --- a/ocp +++ b/ocp @@ -8,6 +8,23 @@ set -euo pipefail PROXY="http://127.0.0.1:3456" +# Auth header for multi-key mode: reads from OCP_ADMIN_KEY env or ~/.ocp/admin-key file +_AUTH_HEADER="" +if [[ -n "${OCP_ADMIN_KEY:-}" ]]; then + _AUTH_HEADER="-H \"Authorization: Bearer $OCP_ADMIN_KEY\"" +elif [[ -f "$HOME/.ocp/admin-key" ]]; then + _AUTH_HEADER="-H \"Authorization: Bearer $(cat "$HOME/.ocp/admin-key")\"" +fi + +# Wrapper: curl with optional auth +_curl() { + if [[ -n "$_AUTH_HEADER" ]]; then + eval curl "$_AUTH_HEADER" "$@" + else + curl "$@" + fi +} + _json() { python3 -m json.tool 2>/dev/null || cat; } _bar() { @@ -39,7 +56,7 @@ EOF cmd_usage() { if [[ "${1:-}" == "--by-key" ]]; then local data - data=$(curl -sf --max-time 15 "$PROXY/api/usage" 2>&1) || { echo "Error: proxy unreachable or usage API not available"; exit 1; } + data=$(_curl -sf --max-time 15 "$PROXY/api/usage" 2>&1) || { echo "Error: proxy unreachable or usage API not available"; exit 1; } echo "$data" | python3 -c " import sys, json d = json.loads(sys.stdin.read()) @@ -260,7 +277,7 @@ cmd_keys() { add) if [[ -z "${2:-}" ]]; then echo "Usage: ocp keys add "; return 1; fi local result - result=$(curl -sf --max-time 5 -X POST "$PROXY/api/keys" \ + result=$(_curl -sf --max-time 5 -X POST "$PROXY/api/keys" \ -H "Content-Type: application/json" \ -d "{\"name\": \"$2\"}" 2>&1) || { echo "Error: proxy unreachable or unauthorized"; exit 1; } echo "$result" | python3 -c " @@ -279,7 +296,7 @@ else: ;; revoke) if [[ -z "${2:-}" ]]; then echo "Usage: ocp keys revoke "; return 1; fi - curl -sf --max-time 5 -X DELETE "$PROXY/api/keys/$2" | python3 -c " + _curl -sf --max-time 5 -X DELETE "$PROXY/api/keys/$2" | python3 -c " import sys, json d = json.loads(sys.stdin.read()) if d.get('revoked'): @@ -292,7 +309,7 @@ else: cmd_keys_help ;; "") - curl -sf --max-time 5 "$PROXY/api/keys" | python3 -c " + _curl -sf --max-time 5 "$PROXY/api/keys" | python3 -c " import sys, json d = json.loads(sys.stdin.read()) keys = d.get('keys', []) @@ -326,7 +343,7 @@ EOF cmd_lan() { local ip - ip=$(ipconfig getifaddr en0 2>/dev/null || hostname -I 2>/dev/null | awk '{print $1}') + ip=$(ipconfig getifaddr en0 2>/dev/null || ipconfig getifaddr en1 2>/dev/null || hostname -I 2>/dev/null | awk '{print $1}' || echo "unknown") local port=3456 echo "OCP LAN Setup" From cc745aa5d910fce918619d531eb901a65ca9dfc0 Mon Sep 17 00:00:00 2001 From: dtzp555 Date: Fri, 10 Apr 2026 21:45:07 +1000 Subject: [PATCH 13/13] docs: update README with complete LAN mode reference Add LAN CLI commands (keys, lan, usage --by-key), new API endpoints (dashboard, keys, usage), LAN env vars (CLAUDE_BIND, CLAUDE_AUTH_MODE, OCP_ADMIN_KEY), and expanded security section. Co-Authored-By: Claude Opus 4.6 --- README.md | 129 ++++++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 96 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index 4bafbe0..dad7f8f 100644 --- a/README.md +++ b/README.md @@ -57,62 +57,109 @@ curl http://127.0.0.1:3456/v1/models ## LAN Mode — Share with Family -OCP can serve your entire household from a single machine. +OCP can serve your entire household from a single machine. One Claude Pro/Max subscription, shared across all devices on your network. -### Enable LAN Access +``` +Wife's laptop ──┐ +Son's iPad ───┼──→ OCP :3456 (your Mac) ──→ Claude subscription +Your Pi server ───┤ +Your desktop ──┘ +``` +### Step 1: Enable LAN Access + +**Quick start (temporary, until restart):** ```bash -# Set bind address and restart export CLAUDE_BIND=0.0.0.0 +export CLAUDE_AUTH_MODE=multi # per-user keys +export OCP_ADMIN_KEY=your-secret-admin-key ocp restart ``` -Or permanently via setup: +**Permanent (survives reboot):** ```bash node setup.mjs --bind 0.0.0.0 --auth-mode multi ``` -### Auth Modes +Then set your admin key in the launchd/systemd environment, or save it to a file: +```bash +echo "your-secret-admin-key" > ~/.ocp/admin-key +chmod 600 ~/.ocp/admin-key +``` -| Mode | Env | Description | -|------|-----|-------------| -| `none` | `CLAUDE_AUTH_MODE=none` | Open access (trusted network) | -| `shared` | `CLAUDE_AUTH_MODE=shared` | Single key via `PROXY_API_KEY` | -| `multi` | `CLAUDE_AUTH_MODE=multi` | Per-user keys with usage tracking | - -### Multi-Key Setup +### Step 2: Create Keys for Family Members ```bash -# Create keys for family members +# Set admin key for CLI (or save to ~/.ocp/admin-key) +export OCP_ADMIN_KEY=your-secret-admin-key + +# Create a key for each person/device ocp keys add wife-laptop +# ✓ Key created for "wife-laptop" +# API Key: ocp_xDYzOB9ZKYzn... +# Copy this key now — you won't see it again. + ocp keys add son-ipad +ocp keys add pi-server +``` -# List keys -ocp keys +### Step 3: Share Connection Info -# View per-key usage +Run `ocp lan` to see your IP and ready-to-share instructions: + +``` +$ ocp lan +OCP LAN Setup +───────────────────────────────────── + Your IP: 192.168.1.100 + Port: 3456 + + For IDE users, set: + OPENAI_BASE_URL=http://192.168.1.100:3456/v1 + OPENAI_API_KEY= + + Dashboard: http://192.168.1.100:3456/dashboard + + Status: ✓ LAN-accessible +``` + +Give each family member their key and these two settings: +```bash +export OPENAI_BASE_URL=http://192.168.1.100:3456/v1 +export OPENAI_API_KEY=ocp_ +``` + +### Step 4: Monitor Usage + +```bash +# Per-key usage stats ocp usage --by-key +# Key Reqs OK Err Avg Time +# wife-laptop 5 5 0 8.0s +# son-ipad 3 3 0 6.2s -# Revoke a key -ocp keys revoke son-ipad +# Manage keys +ocp keys # List all keys +ocp keys revoke son-ipad # Revoke a key ``` -### Family Member Setup +**Web Dashboard:** Open `http://:3456/dashboard` in any browser for real-time monitoring — per-key usage, request history, plan utilization, and system health. No login needed. -Give your family member these settings for their IDE: +### Auth Modes -``` -OPENAI_BASE_URL=http://:3456/v1 -OPENAI_API_KEY=ocp_ -``` +| Mode | Env | Use Case | +|------|-----|----------| +| `none` | `CLAUDE_AUTH_MODE=none` | Trusted home network, no auth needed | +| `shared` | `CLAUDE_AUTH_MODE=shared` + `PROXY_API_KEY=xxx` | Everyone shares one key | +| `multi` | `CLAUDE_AUTH_MODE=multi` + `OCP_ADMIN_KEY=xxx` | Per-person keys with usage tracking (recommended) | -Run `ocp lan` to see your IP and connection guide. +### Important Notes -### Web Dashboard - -Access the usage dashboard at: `http://:3456/dashboard` - -Shows real-time stats: per-key usage, request history, plan utilization, and system health. +- All users share your Claude Pro/Max **rate limits** (5h session + 7d weekly) +- `ocp usage` shows how much quota remains +- Keys are stored in `~/.ocp/ocp.db` (SQLite, zero external dependencies) +- Admin key is required for key management API endpoints +- The dashboard (`/dashboard`) and health check (`/health`) are always public ## Built-in Usage Monitoring @@ -144,8 +191,13 @@ Proxy: up 6h 32m | 23 reqs | 0 err | 0 timeout ``` ocp usage Plan usage limits & model stats +ocp usage --by-key Per-key usage breakdown (LAN mode) ocp status Quick overview ocp health Proxy diagnostics +ocp keys List all API keys (multi mode) +ocp keys add Create a new API key +ocp keys revoke Revoke an API key +ocp lan Show LAN connection info & IP ocp settings View tunable settings ocp settings Update a setting at runtime ocp logs [N] [level] Recent logs (default: 20, error) @@ -219,6 +271,10 @@ OCP translates OpenAI-compatible `/v1/chat/completions` requests into `claude -p | `/settings` | GET/PATCH | View or update settings at runtime | | `/logs` | GET | Recent log entries (`?n=20&level=error`) | | `/sessions` | GET/DELETE | List or clear active sessions | +| `/dashboard` | GET | Web dashboard (always public) | +| `/api/keys` | GET/POST | List or create API keys (admin only) | +| `/api/keys/:id` | DELETE | Revoke an API key (admin only) | +| `/api/usage` | GET | Per-key usage stats (`?since=&until=&hours=&limit=`) | ## OpenClaw Integration @@ -300,6 +356,9 @@ If you installed OCP before v3.1.0, the auto-start service used names that OpenC | Variable | Default | Description | |----------|---------|-------------| | `CLAUDE_PROXY_PORT` | `3456` | Listen port | +| `CLAUDE_BIND` | `127.0.0.1` | Bind address (`0.0.0.0` for LAN access) | +| `CLAUDE_AUTH_MODE` | `none` | Auth mode: `none`, `shared`, or `multi` | +| `OCP_ADMIN_KEY` | *(unset)* | Admin key for key management (multi mode) | | `CLAUDE_BIN` | *(auto-detect)* | Path to claude binary | | `CLAUDE_TIMEOUT` | `600000` | Request timeout (ms, default: 10 min) | | `CLAUDE_MAX_CONCURRENT` | `8` | Max concurrent claude processes | @@ -307,13 +366,17 @@ If you installed OCP before v3.1.0, the auto-start service used names that OpenC | `CLAUDE_SESSION_TTL` | `3600000` | Session expiry (ms, default: 1 hour) | | `CLAUDE_ALLOWED_TOOLS` | `Bash,Read,...,Agent` | Comma-separated tools to pre-approve | | `CLAUDE_SKIP_PERMISSIONS` | `false` | Bypass all permission checks | -| `PROXY_API_KEY` | *(unset)* | Bearer token for API authentication | +| `PROXY_API_KEY` | *(unset)* | Bearer token for shared-mode authentication | ## Security -- **Localhost only** — binds to `127.0.0.1`, not exposed to the network -- **Bearer token auth (optional)** — set `PROXY_API_KEY` to require auth +- **Localhost by default** — binds to `127.0.0.1`; set `CLAUDE_BIND=0.0.0.0` to enable LAN access +- **3-tier auth** — `none` (trusted network), `shared` (single key), `multi` (per-user keys with usage tracking) +- **Timing-safe key comparison** — prevents timing attacks on API keys and admin keys +- **Admin-only key management** — creating, listing, and revoking keys requires the admin key +- **Public endpoints** — `/health` and `/dashboard` are always accessible without auth - **No API keys needed** — authentication goes through Claude CLI's OAuth session +- **Keys stored locally** — `~/.ocp/ocp.db` (SQLite), never sent to external services - **Auto-start** — launchd (macOS) / systemd (Linux) ## License