| … | … | @@ -0,0 +1,137 @@ |
| 1 | # Strip HTTP Transport — Design Document | |
| 2 | ||
| 3 | **Branch**: `strip-http-transport` | |
| 4 | **Date**: 2026-02-17 | |
| 5 | **Context**: keet-red-team security audit found 44 vulnerabilities, most caused by HTTP transport | |
| 6 | ||
| 7 | ## Problem | |
| 8 | ||
| 9 | The HTTP transport in keet-daemon creates the entire attack surface identified in | |
| 10 | SECURITY_AUDIT_V2.md. The 8 CRITICAL findings all stem from exposing keet-core over | |
| 11 | unauthenticated HTTP with CORS wildcard: | |
| 12 | ||
| 13 | - Browser-based identity theft (any website can steal recovery phrase) | |
| 14 | - Arbitrary function execution via `raw`/`call.raw` | |
| 15 | - SSRF via image proxy (godot-keet) | |
| 16 | - Network exposure via 0.0.0.0 binding (godot-keet) | |
| 17 | ||
| 18 | ## Solution: Remove HTTP, UDS Only | |
| 19 | ||
| 20 | The daemon already has a UDS (Unix Domain Socket) transport that Godot uses. | |
| 21 | The CLI is the only thing still using HTTP. Move CLI to UDS and delete HTTP entirely. | |
| 22 | ||
| 23 | ## What Changes | |
| 24 | ||
| 25 | ### CLI Client (index.js) | |
| 26 | ||
| 27 | **Before**: Uses `bare-http1` to make HTTP requests to `localhost:3117` | |
| 28 | **After**: Uses `bare-pipe` + `framed-stream` to connect to `/tmp/keet-daemon.sock` | |
| 29 | ||
| 30 | Changes: | |
| 31 | - Replace `request()`, `get()`, `rpc()` HTTP helpers with UDS client | |
| 32 | - Replace SSE-based `clientWatch()` with UDS subscribe/push events | |
| 33 | - Remove `--port` flag (no HTTP port needed) | |
| 34 | - Remove `import http from 'bare-http1'` | |
| 35 | ||
| 36 | ### Daemon (daemon.js) | |
| 37 | ||
| 38 | **Before**: Starts both HttpTransport and UdsTransport | |
| 39 | **After**: Starts only UdsTransport | |
| 40 | ||
| 41 | Changes: | |
| 42 | - Remove `import { HttpTransport }` and HTTP startup | |
| 43 | - Remove HTTP endpoint logging | |
| 44 | - Remove `--port` option | |
| 45 | - Add socket permissions: `fs.chmodSync(socketPath, 0o600)` after listen | |
| 46 | ||
| 47 | ### Router (router.js) | |
| 48 | ||
| 49 | **Before**: Has `raw`, `call.raw`, `introspect`, `identity.recovery` exposed to any caller | |
| 50 | **After**: Remove `raw` and `call.raw` entirely. Keep other methods (now protected by UDS permissions) | |
| 51 | ||
| 52 | Changes: | |
| 53 | - Delete `_raw()` and `_callRaw()` methods | |
| 54 | - Delete their case entries in `handle()` | |
| 55 | - Keep `introspect` (useful for debugging, now UDS-only = owner-only) | |
| 56 | - Keep `identity.recovery` (now UDS-only = owner-only) | |
| 57 | - Keep `shutdown` (now UDS-only = owner-only) | |
| 58 | ||
| 59 | ### HTTP Transport (lib/http-transport.js) | |
| 60 | ||
| 61 | **Before**: 260+ lines of HTTP server with CORS, proxy, SSE, body parsing | |
| 62 | **After**: DELETED | |
| 63 | ||
| 64 | ### UDS Transport (lib/uds-transport.js) | |
| 65 | ||
| 66 | **Before**: Creates socket at `/tmp/keet-daemon.sock` with no permissions | |
| 67 | **After**: Same, but add `fs.chmodSync(socketPath, 0o600)` after listen | |
| 68 | ||
| 69 | ### Godot Image Proxy | |
| 70 | ||
| 71 | **Before**: Godot fetches images via `GET /rpc/proxy?url=<httpLink>` through HTTP | |
| 72 | **After**: Add `file.fetch` RPC method that fetches the image over keet-core's internal | |
| 73 | HTTP and returns base64 in the JSON-RPC response. Godot decodes in GDScript. | |
| 74 | ||
| 75 | Alternative: Pass keet-core's dynamic HTTP port to Godot so it can fetch directly. | |
| 76 | This is simpler but means Godot needs to discover the port. | |
| 77 | ||
| 78 | We'll implement the base64 approach first (simpler, no port discovery needed). | |
| 79 | ||
| 80 | ## What Gets Deleted | |
| 81 | ||
| 82 | | File/Feature | Lines | Why | | |
| 83 | |-------------|-------|-----| | |
| 84 | | `lib/http-transport.js` | 268 | Entire HTTP server — source of all CRITICAL findings | | |
| 85 | | `raw` / `call.raw` in router | ~30 | Arbitrary function execution | | |
| 86 | | CORS headers | 3 | Browser attack vector | | |
| 87 | | Image proxy endpoint | ~60 | SSRF vector | | |
| 88 | | SSE watch endpoint | ~50 | Replaced by UDS subscribe | | |
| 89 | | Convenience GET endpoints | ~80 | Replaced by UDS RPC | | |
| 90 | | `import http from 'bare-http1'` in index.js | 1 | No longer needed | | |
| 91 | ||
| 92 | ## What Gets Added | |
| 93 | ||
| 94 | | Feature | Lines (est) | Purpose | | |
| 95 | |---------|-------------|---------| | |
| 96 | | UDS client in index.js | ~60 | CLI connects via framed-stream | | |
| 97 | | UDS-based watch (subscribe) | ~40 | CLI watch uses push events | | |
| 98 | | `file.fetch` RPC method | ~30 | Returns base64 image data over UDS | | |
| 99 | | Socket chmod 0600 | 1 | Owner-only permissions | | |
| 100 | ||
| 101 | ## Security Impact | |
| 102 | ||
| 103 | | Finding | Before | After | | |
| 104 | |---------|--------|-------| | |
| 105 | | D-F1: No auth on HTTP | CRITICAL | ELIMINATED (no HTTP) | | |
| 106 | | D-F2: CORS wildcard | CRITICAL | ELIMINATED (no HTTP) | | |
| 107 | | D-F3: raw/call.raw | CRITICAL | ELIMINATED (removed) | | |
| 108 | | D-F4: Recovery phrase exposed | HIGH | MITIGATED (UDS = owner-only) | | |
| 109 | | D-F5: HTTP binds localhost via DNS | HIGH | ELIMINATED (no HTTP) | | |
| 110 | | D-F6: UDS world-accessible | HIGH | FIXED (chmod 0600) | | |
| 111 | | D-F7: Unauthenticated shutdown | MEDIUM | MITIGATED (UDS = owner-only) | | |
| 112 | | D-F8: No body size limit | MEDIUM | ELIMINATED (no HTTP) | | |
| 113 | | D-F9: Introspect leaks API | MEDIUM | MITIGATED (UDS = owner-only) | | |
| 114 | | G-F1: SSRF via proxy | CRITICAL | ELIMINATED (proxy removed) | | |
| 115 | | G-F2: HTTP binds 0.0.0.0 | CRITICAL | ELIMINATED (no HTTP) | | |
| 116 | | G-F7: No auth on RPC | HIGH | ELIMINATED (no HTTP) | | |
| 117 | | G-F8: Wildcard CORS | HIGH | ELIMINATED (no HTTP) | | |
| 118 | ||
| 119 | **Result**: 8 CRITICAL findings eliminated, 5 HIGH findings eliminated or mitigated. | |
| 120 | Remaining attack surface = UDS socket with 0600 permissions (owner-only). | |
| 121 | ||
| 122 | ## Rollback | |
| 123 | ||
| 124 | If anything breaks badly, `git checkout main` restores the HTTP transport. | |
| 125 | This is why we're on a separate branch. | |
| 126 | ||
| 127 | ## Implementation Order | |
| 128 | ||
| 129 | 1. Create UDS client helper for CLI | |
| 130 | 2. Rewrite CLI to use UDS client | |
| 131 | 3. Add `file.fetch` RPC method to router | |
| 132 | 4. Remove `raw`/`call.raw` from router | |
| 133 | 5. Remove `lib/http-transport.js` | |
| 134 | 6. Update `daemon.js` to UDS-only | |
| 135 | 7. Add socket permissions | |
| 136 | 8. Test CLI commands | |
| 137 | 9. Test Godot connectivity (separate PR for godot-keet changes) |
| … | … | @@ -3,16 +3,13 @@ import path from 'path' |
| 3 | 3 | import os from 'os' |
| 4 | 4 | import fs from 'fs' |
| 5 | 5 | import { Router } from './lib/router.js' |
| 6 | import { HttpTransport } from './lib/http-transport.js' | |
| 7 | 6 | import { UdsTransport } from './lib/uds-transport.js' |
| 8 | 7 | |
| 9 | 8 | export async function startDaemon (opts = {}) { |
| 10 | 9 | const storage = opts.storage || path.join(os.homedir(), '.config', 'keet-cli', 'default') |
| 11 | const port = opts.port || 3117 | |
| 12 | 10 | const pidFile = opts.pidFile || '/tmp/keet-daemon.pid' |
| 13 | 11 | |
| 14 | 12 | console.log('[keet-daemon] Starting... storage:', storage) |
| 15 | console.log('[keet-daemon] Port:', port) | |
| 16 | 13 | |
| 17 | 14 | // Init Keet core (holds exclusive RocksDB lock) |
| 18 | 15 | const KeetCore = KeetCoreHyperDB.default || KeetCoreHyperDB |
| … | … | @@ -29,18 +26,11 @@ export async function startDaemon (opts = {}) { |
| 29 | 26 | await backend.ready() |
| 30 | 27 | console.log('[keet-daemon] Backend ready') |
| 31 | 28 | |
| 32 | // Router + transports | |
| 29 | // Router + UDS transport (no HTTP — see STRIP_HTTP.md) | |
| 33 | 30 | const router = new Router(backend, core) |
| 34 | const http = new HttpTransport(router, { port }) | |
| 35 | await http.start() | |
| 36 | ||
| 37 | 31 | const socketPath = opts.socketPath || '/tmp/keet-daemon.sock' |
| 38 | 32 | const uds = new UdsTransport(router, { socketPath }) |
| 39 | try { | |
| 40 | await uds.start() | |
| 41 | } catch (err) { | |
| 42 | console.error('[keet-daemon] UDS transport failed (non-fatal):', err.message) | |
| 43 | } | |
| 33 | await uds.start() | |
| 44 | 34 | |
| 45 | 35 | // Write PID file |
| 46 | 36 | try { |
| … | … | @@ -49,17 +39,7 @@ export async function startDaemon (opts = {}) { |
| 49 | 39 | console.error('[keet-daemon] Warning: could not write PID file:', err.message) |
| 50 | 40 | } |
| 51 | 41 | |
| 52 | console.log(`[keet-daemon] Ready — HTTP :${port}, UDS ${socketPath}, PID ${Bare.pid}`) | |
| 53 | console.log('[keet-daemon] Endpoints:') | |
| 54 | console.log(` GET http://localhost:${port}/rpc/rooms`) | |
| 55 | console.log(` GET http://localhost:${port}/rpc/status`) | |
| 56 | console.log(` GET http://localhost:${port}/rpc/identity`) | |
| 57 | console.log(` GET http://localhost:${port}/rpc/log/:roomId?limit=50 (plain text)`) | |
| 58 | console.log(` GET http://localhost:${port}/rpc/messages/:roomId?limit=20`) | |
| 59 | console.log(` GET http://localhost:${port}/rpc/members/:roomId`) | |
| 60 | console.log(` GET http://localhost:${port}/rpc/watch/:roomId (SSE)`) | |
| 61 | console.log(` POST http://localhost:${port}/rpc {"method":"...","params":{...}}`) | |
| 62 | console.log(` UDS ${socketPath} (framed-stream JSON-RPC)`) | |
| 42 | console.log(`[keet-daemon] Ready — UDS ${socketPath}, PID ${Bare.pid}`) | |
| 63 | 43 | |
| 64 | return { router, http, uds, core, pidFile } | |
| 44 | return { router, uds, core, pidFile } | |
| 65 | 45 | } |
| … | … | @@ -1,4 +1,6 @@ |
| 1 | import http from 'bare-http1' | |
| 1 | import Pipe from 'bare-pipe' | |
| 2 | import FramedStream from 'framed-stream' | |
| 3 | import b4a from 'b4a' | |
| 2 | 4 | import path from 'path' |
| 3 | 5 | import os from 'os' |
| 4 | 6 | import { getGenericEventName } from './lib/format.js' |
| … | … | @@ -7,7 +9,7 @@ import { getGenericEventName } from './lib/format.js' |
| 7 | 9 | const rawArgs = Pear.config.args || [] |
| 8 | 10 | |
| 9 | 11 | let customStorage = null |
| 10 | let customPort = null | |
| 12 | let customSocket = null | |
| 11 | 13 | const args = [] |
| 12 | 14 | for (let i = 0; i < rawArgs.length; i++) { |
| 13 | 15 | if (rawArgs[i] === '--storage' && rawArgs[i + 1]) { |
| … | … | @@ -20,17 +22,17 @@ for (let i = 0; i < rawArgs.length; i++) { |
| 20 | 22 | } else if (rawArgs[i].startsWith('--profile=')) { |
| 21 | 23 | const profileName = rawArgs[i].split('=')[1] |
| 22 | 24 | customStorage = path.join(os.homedir(), '.config', 'keet-cli', profileName) |
| 23 | } else if (rawArgs[i] === '--port' && rawArgs[i + 1]) { | |
| 24 | customPort = parseInt(rawArgs[++i]) | |
| 25 | } else if (rawArgs[i].startsWith('--port=')) { | |
| 26 | customPort = parseInt(rawArgs[i].split('=')[1]) | |
| 25 | } else if (rawArgs[i] === '--socket' && rawArgs[i + 1]) { | |
| 26 | customSocket = rawArgs[++i] | |
| 27 | } else if (rawArgs[i].startsWith('--socket=')) { | |
| 28 | customSocket = rawArgs[i].split('=')[1] | |
| 27 | 29 | } else { |
| 28 | 30 | args.push(rawArgs[i]) |
| 29 | 31 | } |
| 30 | 32 | } |
| 31 | 33 | |
| 32 | 34 | const STORAGE = customStorage || path.join(os.homedir(), '.config', 'keet-cli', 'default') |
| 33 | const PORT = customPort || 3117 | |
| 35 | const SOCKET = customSocket || '/tmp/keet-daemon.sock' | |
| 34 | 36 | const command = args[0] |
| 35 | 37 | |
| 36 | 38 | // --- Help --- |
| … | … | @@ -43,7 +45,7 @@ Usage: pear run --dev . [options] <command> [args...] |
| 43 | 45 | Options: |
| 44 | 46 | --profile <name> Use named profile (each profile = separate identity) |
| 45 | 47 | --storage <path> Use custom storage path |
| 46 | --port <number> Daemon port (default: 3117) | |
| 48 | --socket <path> Daemon socket (default: /tmp/keet-daemon.sock) | |
| 47 | 49 | |
| 48 | 50 | Daemon Commands: |
| 49 | 51 | daemon Start daemon (foreground, use tmux for background) |
| … | … | @@ -75,7 +77,7 @@ Room Config: |
| 75 | 77 | Messages: |
| 76 | 78 | send <roomId> <message> Send a chat message |
| 77 | 79 | read <roomId> [count] Read recent messages (default: 20) |
| 78 | watch <roomId> Watch for new messages (SSE stream) | |
| 80 | watch <roomId> Watch for new messages (live stream) | |
| 79 | 81 | |
| 80 | 82 | Members: |
| 81 | 83 | members <roomId> List room members |
| … | … | @@ -103,10 +105,6 @@ Quick Start: |
| 103 | 105 | pear run --dev . rooms |
| 104 | 106 | pear run --dev . send abc123 "Hello!" |
| 105 | 107 | pear run --dev . watch abc123 |
| 106 | ||
| 107 | # Or use curl directly | |
| 108 | curl -s localhost:3117/rpc/rooms | jq . | |
| 109 | curl -sN localhost:3117/rpc/watch/abc123 | |
| 110 | 108 | `) |
| 111 | 109 | Bare.exit(0) |
| 112 | 110 | } |
| … | … | @@ -126,7 +124,7 @@ if (command === 'daemon') { |
| 126 | 124 | console.error('[keet-cli] Error:', result.error) |
| 127 | 125 | } |
| 128 | 126 | } catch (err) { |
| 129 | if (err.code === 'ECONNREFUSED') { | |
| 127 | if (err.code === 'ENOENT' || err.message.includes('ECONNREFUSED') || err.message.includes('ENOENT')) { | |
| 130 | 128 | console.log('[keet-cli] Daemon is not running') |
| 131 | 129 | } else { |
| 132 | 130 | console.error('[keet-cli] Error:', err.message) |
| … | … | @@ -137,7 +135,7 @@ if (command === 'daemon') { |
| 137 | 135 | |
| 138 | 136 | if (sub === 'status') { |
| 139 | 137 | try { |
| 140 | const result = await get('/rpc/status') | |
| 138 | const result = await rpc('status') | |
| 141 | 139 | if (result.ok) { |
| 142 | 140 | console.log('[keet-cli] Daemon is running') |
| 143 | 141 | console.log(` Uptime: ${result.data.uptime}s`) |
| … | … | @@ -146,10 +144,10 @@ if (command === 'daemon') { |
| 146 | 144 | console.log(` Identity: ${result.data.identity.name || result.data.identity.memberId}`) |
| 147 | 145 | } |
| 148 | 146 | } else { |
| 149 | console.log('[keet-cli] Daemon is not running') | |
| 147 | console.log('[keet-cli] Daemon responded with error:', result.error) | |
| 150 | 148 | } |
| 151 | 149 | } catch (err) { |
| 152 | if (err.code === 'ECONNREFUSED') { | |
| 150 | if (err.code === 'ENOENT' || err.message.includes('ENOENT')) { | |
| 153 | 151 | console.log('[keet-cli] Daemon is not running') |
| 154 | 152 | } else { |
| 155 | 153 | console.error('[keet-cli] Error:', err.message) |
| … | … | @@ -160,7 +158,7 @@ if (command === 'daemon') { |
| 160 | 158 | |
| 161 | 159 | // Start daemon (foreground) |
| 162 | 160 | const { startDaemon } = await import('./daemon.js') |
| 163 | await startDaemon({ storage: STORAGE, port: PORT }) | |
| 161 | await startDaemon({ storage: STORAGE, socketPath: SOCKET }) | |
| 164 | 162 | // Keep alive |
| 165 | 163 | await new Promise(() => {}) |
| 166 | 164 | } |
| … | … | @@ -230,7 +228,7 @@ try { |
| 230 | 228 | printHelp() |
| 231 | 229 | } |
| 232 | 230 | } catch (err) { |
| 233 | if (err.code === 'ECONNREFUSED') { | |
| 231 | if (err.code === 'ENOENT' || err.message.includes('ENOENT')) { | |
| 234 | 232 | console.error('[keet-cli] Daemon not running. Start with: pear run --dev . daemon') |
| 235 | 233 | } else { |
| 236 | 234 | console.error('[keet-cli] Error:', err.message || err) |
| … | … | @@ -336,7 +334,7 @@ async function clientIdentity (subArgs) { |
| 336 | 334 | return |
| 337 | 335 | } |
| 338 | 336 | // Default: show identity |
| 339 | const result = await get('/rpc/identity') | |
| 337 | const result = await rpc('identity') | |
| 340 | 338 | if (result.ok && result.data) { |
| 341 | 339 | console.log('[keet-cli] Current identity:') |
| 342 | 340 | console.log(JSON.stringify(result.data, null, 2)) |
| … | … | @@ -346,7 +344,7 @@ async function clientIdentity (subArgs) { |
| 346 | 344 | } |
| 347 | 345 | |
| 348 | 346 | async function clientRooms () { |
| 349 | const result = await get('/rpc/rooms') | |
| 347 | const result = await rpc('rooms') | |
| 350 | 348 | if (!result.ok) { |
| 351 | 349 | console.error('[keet-cli] Error:', result.error) |
| 352 | 350 | return |
| … | … | @@ -442,7 +440,7 @@ async function clientRead (roomId, count) { |
| 442 | 440 | console.error('Usage: read <roomId> [count]') |
| 443 | 441 | return |
| 444 | 442 | } |
| 445 | const result = await get(`/rpc/messages/${encodeURIComponent(roomId)}?limit=${count}`) | |
| 443 | const result = await rpc('read', { roomId, limit: count }) | |
| 446 | 444 | if (!result.ok) { |
| 447 | 445 | console.error('[keet-cli] Error:', result.error) |
| 448 | 446 | return |
| … | … | @@ -467,61 +465,51 @@ async function clientWatch (roomId) { |
| 467 | 465 | } |
| 468 | 466 | console.log(`[keet-cli] Watching ${roomId}... (Ctrl+C to stop)`) |
| 469 | 467 | |
| 470 | const req = http.request({ | |
| 471 | hostname: 'localhost', | |
| 472 | port: PORT, | |
| 473 | path: `/rpc/watch/${encodeURIComponent(roomId)}`, | |
| 474 | method: 'GET' | |
| 475 | }, res => { | |
| 476 | let buffer = '' | |
| 477 | res.on('data', chunk => { | |
| 478 | buffer += chunk.toString() | |
| 479 | // Parse SSE events (separated by double newline) | |
| 480 | const parts = buffer.split('\n\n') | |
| 481 | buffer = parts.pop() // keep incomplete tail | |
| 482 | for (const part of parts) { | |
| 483 | const lines = part.split('\n') | |
| 484 | let eventType = null | |
| 485 | let data = null | |
| 486 | for (const line of lines) { | |
| 487 | if (line.startsWith('event: ')) eventType = line.slice(7) | |
| 488 | if (line.startsWith('data: ')) data = line.slice(6) | |
| 489 | } | |
| 490 | if (eventType === 'ready') { | |
| 491 | console.log('--- live ---') | |
| 492 | continue | |
| 493 | } | |
| 494 | if (data) { | |
| 495 | try { | |
| 496 | const msg = JSON.parse(data) | |
| 497 | const time = msg.timestamp ? new Date(msg.timestamp).toLocaleTimeString() : '??:??' | |
| 498 | const deleted = msg.deleted ? ' [DELETED]' : '' | |
| 499 | const system = msg.system ? ` [${getGenericEventName(msg.system)}]` : '' | |
| 500 | if (msg.text || msg.system) { | |
| 501 | console.log(`[${time}] ${msg.memberId}: ${msg.text}${deleted}${system}`) | |
| 502 | } | |
| 503 | } catch { | |
| 504 | console.log('[event]', data) | |
| 505 | } | |
| 506 | } | |
| 507 | } | |
| 508 | }) | |
| 509 | res.on('end', () => { | |
| 510 | console.log('[keet-cli] Watch stream ended') | |
| 511 | Bare.exit(0) | |
| 512 | }) | |
| 513 | }) | |
| 468 | // Connect to UDS and subscribe | |
| 469 | const pipe = Pipe.connect(SOCKET) | |
| 470 | const framed = new FramedStream(pipe) | |
| 514 | 471 | |
| 515 | req.on('error', err => { | |
| 516 | if (err.code === 'ECONNREFUSED') { | |
| 472 | pipe.on('error', (err) => { | |
| 473 | if (err.code === 'ENOENT') { | |
| 517 | 474 | console.error('[keet-cli] Daemon not running. Start with: pear run --dev . daemon') |
| 518 | 475 | } else { |
| 519 | console.error('[keet-cli] Error:', err.message) | |
| 476 | console.error('[keet-cli] Connection error:', err.message) | |
| 520 | 477 | } |
| 521 | 478 | Bare.exit(1) |
| 522 | 479 | }) |
| 523 | 480 | |
| 524 | req.end() | |
| 481 | framed.on('data', (buf) => { | |
| 482 | try { | |
| 483 | const msg = JSON.parse(b4a.toString(buf, 'utf8')) | |
| 484 | ||
| 485 | // Response to our subscribe request | |
| 486 | if (msg.id === 1 && msg.ok) { | |
| 487 | console.log('--- live ---') | |
| 488 | return | |
| 489 | } | |
| 490 | ||
| 491 | // Push events | |
| 492 | if (msg.event === 'message' && msg.data) { | |
| 493 | const data = msg.data | |
| 494 | const time = data.timestamp ? new Date(data.timestamp).toLocaleTimeString() : '??:??' | |
| 495 | const deleted = data.deleted ? ' [DELETED]' : '' | |
| 496 | const system = data.system ? ` [${getGenericEventName(data.system)}]` : '' | |
| 497 | if (data.text || data.system) { | |
| 498 | console.log(`[${time}] ${data.name || data.memberId}: ${data.text}${deleted}${system}`) | |
| 499 | } | |
| 500 | } | |
| 501 | } catch {} | |
| 502 | }) | |
| 503 | ||
| 504 | framed.on('close', () => { | |
| 505 | console.log('[keet-cli] Watch stream ended') | |
| 506 | Bare.exit(0) | |
| 507 | }) | |
| 508 | ||
| 509 | // Send subscribe request | |
| 510 | const subscribeMsg = JSON.stringify({ id: 1, method: 'subscribe', params: { roomId } }) | |
| 511 | framed.write(b4a.from(subscribeMsg, 'utf8')) | |
| 512 | ||
| 525 | 513 | await new Promise(() => {}) |
| 526 | 514 | } |
| 527 | 515 | |
| … | … | @@ -530,7 +518,7 @@ async function clientMembers (roomId) { |
| 530 | 518 | console.error('Usage: members <roomId>') |
| 531 | 519 | return |
| 532 | 520 | } |
| 533 | const result = await get(`/rpc/members/${encodeURIComponent(roomId)}`) | |
| 521 | const result = await rpc('members', { roomId }) | |
| 534 | 522 | if (!result.ok) { |
| 535 | 523 | console.error('[keet-cli] Error:', result.error) |
| 536 | 524 | return |
| … | … | @@ -561,7 +549,7 @@ async function clientLeave (roomId) { |
| 561 | 549 | } |
| 562 | 550 | |
| 563 | 551 | async function clientStatus () { |
| 564 | const result = await get('/rpc/status') | |
| 552 | const result = await rpc('status') | |
| 565 | 553 | if (result.ok) { |
| 566 | 554 | console.log('[keet-cli] Daemon status:') |
| 567 | 555 | console.log(` Uptime: ${result.data.uptime}s`) |
| … | … | @@ -580,7 +568,6 @@ async function clientConfig (roomId, key, value) { |
| 580 | 568 | return |
| 581 | 569 | } |
| 582 | 570 | if (!key) { |
| 583 | // Show config | |
| 584 | 571 | const result = await rpc('room.config', { roomId }) |
| 585 | 572 | if (result.ok) { |
| 586 | 573 | console.log('[keet-cli] Room config:') |
| … | … | @@ -590,7 +577,6 @@ async function clientConfig (roomId, key, value) { |
| 590 | 577 | } |
| 591 | 578 | return |
| 592 | 579 | } |
| 593 | // Set config | |
| 594 | 580 | if (!value) { |
| 595 | 581 | console.error('Usage: config <roomId> <key> <value>') |
| 596 | 582 | return |
| … | … | @@ -702,47 +688,46 @@ async function clientIntrospect (api) { |
| 702 | 688 | } |
| 703 | 689 | |
| 704 | 690 | // ============================================================ |
| 705 | // HTTP helpers | |
| 691 | // UDS client helpers (framed-stream JSON-RPC over Unix socket) | |
| 706 | 692 | // ============================================================ |
| 707 | 693 | |
| 708 | function request (method, urlPath, body) { | |
| 694 | let _nextId = 1 | |
| 695 | const _pending = new Map() | |
| 696 | ||
| 697 | function rpc (method, params) { | |
| 709 | 698 | return new Promise((resolve, reject) => { |
| 710 | const headers = {} | |
| 711 | let payload = null | |
| 712 | if (body) { | |
| 713 | payload = JSON.stringify(body) | |
| 714 | headers['Content-Type'] = 'application/json' | |
| 715 | headers['Content-Length'] = Buffer.byteLength(payload) | |
| 716 | } | |
| 699 | const id = _nextId++ | |
| 700 | const msg = JSON.stringify({ id, method, params: params || {} }) | |
| 717 | 701 | |
| 718 | const req = http.request({ | |
| 719 | hostname: 'localhost', | |
| 720 | port: PORT, | |
| 721 | path: urlPath, | |
| 722 | method, | |
| 723 | headers | |
| 724 | }, res => { | |
| 725 | let data = '' | |
| 726 | res.on('data', chunk => { data += chunk.toString() }) | |
| 727 | res.on('end', () => { | |
| 728 | try { | |
| 729 | resolve(JSON.parse(data)) | |
| 730 | } catch { | |
| 731 | resolve({ ok: false, error: 'Invalid JSON response', raw: data }) | |
| 732 | } | |
| 733 | }) | |
| 702 | const pipe = Pipe.connect(SOCKET) | |
| 703 | const framed = new FramedStream(pipe) | |
| 704 | ||
| 705 | const timeout = setTimeout(() => { | |
| 706 | framed.destroy() | |
| 707 | reject(new Error('RPC timeout (5s)')) | |
| 708 | }, 5000) | |
| 709 | ||
| 710 | pipe.on('error', (err) => { | |
| 711 | clearTimeout(timeout) | |
| 712 | reject(err) | |
| 734 | 713 | }) |
| 735 | 714 | |
| 736 | req.on('error', reject) | |
| 737 | if (payload) req.write(payload) | |
| 738 | req.end() | |
| 739 | }) | |
| 740 | } | |
| 715 | framed.on('data', (buf) => { | |
| 716 | try { | |
| 717 | const response = JSON.parse(b4a.toString(buf, 'utf8')) | |
| 718 | if (response.id === id) { | |
| 719 | clearTimeout(timeout) | |
| 720 | framed.destroy() | |
| 721 | resolve(response) | |
| 722 | } | |
| 723 | } catch {} | |
| 724 | }) | |
| 741 | 725 | |
| 742 | function get (urlPath) { | |
| 743 | return request('GET', urlPath) | |
| 744 | } | |
| 726 | framed.on('error', (err) => { | |
| 727 | clearTimeout(timeout) | |
| 728 | reject(err) | |
| 729 | }) | |
| 745 | 730 | |
| 746 | function rpc (method, params) { | |
| 747 | return request('POST', '/rpc', { method, params: params || {} }) | |
| 731 | framed.write(b4a.from(msg, 'utf8')) | |
| 732 | }) | |
| 748 | 733 | } |
| … | … | @@ -1,287 +0,0 @@ |
| 1 | import http from 'bare-http1' | |
| 2 | ||
| 3 | export class HttpTransport { | |
| 4 | constructor (router, opts = {}) { | |
| 5 | this.router = router | |
| 6 | this.port = opts.port || 3117 | |
| 7 | this.server = null | |
| 8 | } | |
| 9 | ||
| 10 | start () { | |
| 11 | return new Promise((resolve, reject) => { | |
| 12 | this.server = http.createServer((req, res) => this._onRequest(req, res)) | |
| 13 | this.server.on('error', reject) | |
| 14 | this.server.listen(this.port, () => { | |
| 15 | console.log(`[keet-daemon] HTTP listening on :${this.port}`) | |
| 16 | resolve() | |
| 17 | }) | |
| 18 | }) | |
| 19 | } | |
| 20 | ||
| 21 | stop () { | |
| 22 | if (this.server) this.server.close() | |
| 23 | } | |
| 24 | ||
| 25 | async _onRequest (req, res) { | |
| 26 | res.setHeader('Access-Control-Allow-Origin', '*') | |
| 27 | res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS') | |
| 28 | res.setHeader('Access-Control-Allow-Headers', 'Content-Type') | |
| 29 | ||
| 30 | if (req.method === 'OPTIONS') { | |
| 31 | res.writeHead(204) | |
| 32 | res.end() | |
| 33 | return | |
| 34 | } | |
| 35 | ||
| 36 | const url = req.url | |
| 37 | const [path, queryStr] = url.split('?', 2) | |
| 38 | ||
| 39 | try { | |
| 40 | // SSE: GET /rpc/watch/:roomId | |
| 41 | if (req.method === 'GET' && path.startsWith('/rpc/watch/')) { | |
| 42 | const roomId = decodeURIComponent(path.slice('/rpc/watch/'.length)) | |
| 43 | return this._handleSSE(req, res, roomId) | |
| 44 | } | |
| 45 | ||
| 46 | // Convenience GETs | |
| 47 | if (req.method === 'GET' && path === '/rpc/rooms') { | |
| 48 | return this._json(res, await this.router.handle('rooms')) | |
| 49 | } | |
| 50 | ||
| 51 | if (req.method === 'GET' && path === '/rpc/status') { | |
| 52 | return this._json(res, await this.router.handle('status')) | |
| 53 | } | |
| 54 | ||
| 55 | if (req.method === 'GET' && path === '/rpc/identity') { | |
| 56 | return this._json(res, await this.router.handle('identity')) | |
| 57 | } | |
| 58 | ||
| 59 | if (req.method === 'GET' && path.startsWith('/rpc/log/')) { | |
| 60 | const roomId = decodeURIComponent(path.slice('/rpc/log/'.length)) | |
| 61 | const query = parseQuery(queryStr) | |
| 62 | const limit = parseInt(query.limit) || 50 | |
| 63 | const result = await this.router.handle('read', { roomId, limit }) | |
| 64 | if (!result.ok) return this._json(res, result) | |
| 65 | const lines = (result.data || []).map(m => { | |
| 66 | const ts = m.timestamp ? new Date(m.timestamp).toISOString().replace('T', ' ').slice(0, 19) : '????-??-?? ??:??:??' | |
| 67 | const who = m.name || m.memberId?.slice(0, 8) || '?' | |
| 68 | if (m.system) return `[${ts}] * ${formatSystemEvent(m.system)}` | |
| 69 | const del = m.deleted ? ' [DELETED]' : '' | |
| 70 | return `[${ts}] ${who}: ${m.text}${del}` | |
| 71 | }) | |
| 72 | const body = lines.join('\n') + '\n' | |
| 73 | res.writeHead(200, { 'Content-Type': 'text/plain', 'Content-Length': Buffer.byteLength(body) }) | |
| 74 | res.end(body) | |
| 75 | return | |
| 76 | } | |
| 77 | ||
| 78 | if (req.method === 'GET' && path.startsWith('/rpc/messages/')) { | |
| 79 | const roomId = decodeURIComponent(path.slice('/rpc/messages/'.length)) | |
| 80 | const query = parseQuery(queryStr) | |
| 81 | const limit = parseInt(query.limit) || 20 | |
| 82 | return this._json(res, await this.router.handle('read', { roomId, limit })) | |
| 83 | } | |
| 84 | ||
| 85 | if (req.method === 'GET' && path.startsWith('/rpc/members/')) { | |
| 86 | const roomId = decodeURIComponent(path.slice('/rpc/members/'.length)) | |
| 87 | return this._json(res, await this.router.handle('members', { roomId })) | |
| 88 | } | |
| 89 | ||
| 90 | // Generic RPC: POST /rpc | |
| 91 | if (req.method === 'POST' && path === '/rpc') { | |
| 92 | const body = await readBody(req) | |
| 93 | let parsed | |
| 94 | try { | |
| 95 | parsed = JSON.parse(body) | |
| 96 | } catch { | |
| 97 | return this._json(res, { ok: false, error: 'Invalid JSON' }, 400) | |
| 98 | } | |
| 99 | ||
| 100 | const { method, params } = parsed | |
| 101 | if (!method) { | |
| 102 | return this._json(res, { ok: false, error: 'Missing "method" in request body' }, 400) | |
| 103 | } | |
| 104 | ||
| 105 | const result = await this.router.handle(method, params || {}) | |
| 106 | return this._json(res, result) | |
| 107 | } | |
| 108 | ||
| 109 | // 404 | |
| 110 | this._json(res, { ok: false, error: 'Not found' }, 404) | |
| 111 | } catch (err) { | |
| 112 | this._json(res, { ok: false, error: err.message || String(err) }, 500) | |
| 113 | } | |
| 114 | } | |
| 115 | ||
| 116 | async _handleSSE (req, res, roomId) { | |
| 117 | res.writeHead(200, { | |
| 118 | 'Content-Type': 'text/event-stream', | |
| 119 | 'Cache-Control': 'no-cache', | |
| 120 | Connection: 'keep-alive' | |
| 121 | }) | |
| 122 | ||
| 123 | // Resolve member names for display | |
| 124 | const nameMap = await this.router._getMemberNames(roomId) | |
| 125 | ||
| 126 | // Send recent messages first | |
| 127 | const recent = await this.router.handle('read', { roomId, limit: 10 }) | |
| 128 | if (recent.ok && recent.data) { | |
| 129 | for (const msg of recent.data) { | |
| 130 | res.write(`event: message\ndata: ${JSON.stringify(msg)}\n\n`) | |
| 131 | } | |
| 132 | } | |
| 133 | res.write('event: ready\ndata: {}\n\n') | |
| 134 | ||
| 135 | // Subscribe to live messages | |
| 136 | // subscribeChatMessages yields arrays (full snapshot) on each change | |
| 137 | const stream = this.router.subscribe(roomId) | |
| 138 | ||
| 139 | // Track highest seq — skip first snapshot (it's the current state), | |
| 140 | // only emit messages from subsequent snapshots that are newer | |
| 141 | let highSeq = -1 | |
| 142 | let initialized = false | |
| 143 | ||
| 144 | if (stream && typeof stream[Symbol.asyncIterator] === 'function') { | |
| 145 | ;(async () => { | |
| 146 | try { | |
| 147 | for await (const snapshot of stream) { | |
| 148 | if (!snapshot) continue | |
| 149 | ||
| 150 | const messages = Array.isArray(snapshot) ? snapshot : [snapshot] | |
| 151 | ||
| 152 | if (!initialized) { | |
| 153 | // First snapshot = current state — just record the high water mark | |
| 154 | for (const event of messages) { | |
| 155 | const seq = event.seq ?? event.clock ?? -1 | |
| 156 | if (seq > highSeq) highSeq = seq | |
| 157 | } | |
| 158 | initialized = true | |
| 159 | continue | |
| 160 | } | |
| 161 | ||
| 162 | // Subsequent snapshots — emit only messages newer than highSeq | |
| 163 | for (const event of messages) { | |
| 164 | const seq = event.seq ?? event.clock ?? -1 | |
| 165 | if (seq <= highSeq) continue | |
| 166 | ||
| 167 | highSeq = seq | |
| 168 | const msg = normalizeEvent(event, nameMap) | |
| 169 | if (msg) res.write(`event: message\ndata: ${JSON.stringify(msg)}\n\n`) | |
| 170 | } | |
| 171 | } | |
| 172 | } catch (err) { | |
| 173 | if (err.message !== 'Stream was destroyed') { | |
| 174 | res.write(`event: error\ndata: ${JSON.stringify({ error: err.message })}\n\n`) | |
| 175 | } | |
| 176 | } | |
| 177 | res.end() | |
| 178 | })() | |
| 179 | } else if (stream && typeof stream.on === 'function') { | |
| 180 | stream.on('data', snapshot => { | |
| 181 | if (!snapshot) return | |
| 182 | const messages = Array.isArray(snapshot) ? snapshot : [snapshot] | |
| 183 | if (!initialized) { | |
| 184 | for (const event of messages) { | |
| 185 | const seq = event.seq ?? event.clock ?? -1 | |
| 186 | if (seq > highSeq) highSeq = seq | |
| 187 | } | |
| 188 | initialized = true | |
| 189 | return | |
| 190 | } | |
| 191 | for (const event of messages) { | |
| 192 | const seq = event.seq ?? event.clock ?? -1 | |
| 193 | if (seq <= highSeq) continue | |
| 194 | highSeq = seq | |
| 195 | const msg = normalizeEvent(event, nameMap) | |
| 196 | if (msg) res.write(`event: message\ndata: ${JSON.stringify(msg)}\n\n`) | |
| 197 | } | |
| 198 | }) | |
| 199 | stream.on('error', err => { | |
| 200 | res.write(`event: error\ndata: ${JSON.stringify({ error: err.message })}\n\n`) | |
| 201 | }) | |
| 202 | stream.on('close', () => res.end()) | |
| 203 | } | |
| 204 | ||
| 205 | // Cleanup on client disconnect | |
| 206 | res.on('close', () => { | |
| 207 | if (stream && typeof stream.destroy === 'function') stream.destroy() | |
| 208 | }) | |
| 209 | } | |
| 210 | ||
| 211 | _json (res, data, status) { | |
| 212 | const body = JSON.stringify(data) | |
| 213 | res.writeHead(status || 200, { | |
| 214 | 'Content-Type': 'application/json', | |
| 215 | 'Content-Length': Buffer.byteLength(body) | |
| 216 | }) | |
| 217 | res.end(body) | |
| 218 | } | |
| 219 | } | |
| 220 | ||
| 221 | function normalizeEvent (event, nameMap) { | |
| 222 | if (!event || typeof event !== 'object') return null | |
| 223 | ||
| 224 | const text = event.chat?.text || event.text || '' | |
| 225 | const generic = event.message?.generic || null | |
| 226 | ||
| 227 | // Normalize system events | |
| 228 | let system = null | |
| 229 | if (generic && typeof generic === 'object') { | |
| 230 | for (const v of Object.values(generic)) { | |
| 231 | if (v !== null && v !== false) { system = generic; break } | |
| 232 | } | |
| 233 | } else if (generic) { | |
| 234 | system = generic | |
| 235 | } | |
| 236 | ||
| 237 | if (!text && !system) return null | |
| 238 | ||
| 239 | const memberId = event.memberId || 'unknown' | |
| 240 | ||
| 241 | return { | |
| 242 | memberId, | |
| 243 | name: (nameMap && nameMap[memberId]) || memberId.slice(0, 8), | |
| 244 | text, | |
| 245 | timestamp: event.timestamp || Date.now(), | |
| 246 | deleted: event.deleted || false, | |
| 247 | system, | |
| 248 | seq: event.seq ?? event.clock ?? null, | |
| 249 | id: event.id || null | |
| 250 | } | |
| 251 | } | |
| 252 | ||
| 253 | function readBody (req) { | |
| 254 | return new Promise((resolve, reject) => { | |
| 255 | let body = '' | |
| 256 | req.on('data', chunk => { body += chunk.toString() }) | |
| 257 | req.on('end', () => resolve(body)) | |
| 258 | req.on('error', reject) | |
| 259 | }) | |
| 260 | } | |
| 261 | ||
| 262 | function formatSystemEvent (generic) { | |
| 263 | if (!generic) return 'SYSTEM_EVENT' | |
| 264 | if (typeof generic === 'number') { | |
| 265 | const names = { 1: 'MEMBER_JOINED', 2: 'CALL_STARTED', 3: 'AVATAR_CHANGED', 4: 'INVITATION_CREATED', 5: 'MEMBER_LEFT', 6: 'TITLE_CHANGED', 7: 'MEMBER_REMOVED', 8: 'CALL_HANGUP', 9: 'CALL_MISSED' } | |
| 266 | return names[generic] || `EVENT_${generic}` | |
| 267 | } | |
| 268 | // Object form — pick first truthy key | |
| 269 | for (const [k, v] of Object.entries(generic)) { | |
| 270 | if (v !== null && v !== false) return k.replace(/([A-Z])/g, '_$1').toUpperCase() | |
| 271 | } | |
| 272 | return 'SYSTEM_EVENT' | |
| 273 | } | |
| 274 | ||
| 275 | function parseQuery (str) { | |
| 276 | const params = {} | |
| 277 | if (!str) return params | |
| 278 | for (const pair of str.split('&')) { | |
| 279 | const idx = pair.indexOf('=') | |
| 280 | if (idx === -1) { | |
| 281 | params[decodeURIComponent(pair)] = '' | |
| 282 | } else { | |
| 283 | params[decodeURIComponent(pair.slice(0, idx))] = decodeURIComponent(pair.slice(idx + 1)) | |
| 284 | } | |
| 285 | } | |
| 286 | return params | |
| 287 | } |
| … | … | @@ -45,7 +45,7 @@ export class Router { |
| 45 | 45 | case 'call.shareTrack': return this._callShareTrack(params) |
| 46 | 46 | case 'call.unshareTrack': return this._callUnshareTrack(params) |
| 47 | 47 | case 'call.signal': return this._callSignalMedia(params) |
| 48 | case 'call.raw': return this._callRaw(params) | |
| 48 | // call.raw removed — arbitrary function execution (SECURITY_AUDIT_V2 D-F3) | |
| 49 | 49 | case 'msg.edit': return this._editMessage(params) |
| 50 | 50 | case 'msg.delete': return this._deleteMessage(params) |
| 51 | 51 | case 'msg.pin': return this._pinMessage(params) |
| … | … | @@ -61,7 +61,7 @@ export class Router { |
| 61 | 61 | case 'unblock': return this._unblockMember(params) |
| 62 | 62 | case 'kick': return this._removeMember(params) |
| 63 | 63 | case 'blocked': return this._getBlocked(params) |
| 64 | case 'raw': return this._raw(params) | |
| 64 | // raw removed — arbitrary function execution (SECURITY_AUDIT_V2 D-F3) | |
| 65 | 65 | case 'status': return this._status() |
| 66 | 66 | case 'shutdown': return this._shutdown() |
| 67 | 67 | |
| … | … | @@ -100,6 +100,7 @@ export class Router { |
| 100 | 100 | case 'file.cancel': return this._fileCancel(params) |
| 101 | 101 | case 'file.remove': return this._fileRemove(params) |
| 102 | 102 | case 'file.clear': return this._fileClear(params) |
| 103 | case 'file.fetch': return this._fileFetch(params) | |
| 103 | 104 | |
| 104 | 105 | // --- Advanced Members --- |
| 105 | 106 | case 'members.active': return this._membersActive(params) |
| … | … | @@ -526,24 +527,7 @@ export class Router { |
| 526 | 527 | return { ok: true } |
| 527 | 528 | } |
| 528 | 529 | |
| 529 | async _callRaw (params) { | |
| 530 | if (!params.fn) throw new Error('Missing required param: fn') | |
| 531 | const api = this._callApi() | |
| 532 | const fn = api[params.fn] | |
| 533 | if (typeof fn !== 'function') throw new Error(`Not a function: api.call.${params.fn}`) | |
| 534 | const args = params.args || [] | |
| 535 | return fn.apply(api, args) | |
| 536 | } | |
| 537 | ||
| 538 | // --- Raw API passthrough --- | |
| 539 | ||
| 540 | async _raw (params) { | |
| 541 | if (!params.fn) throw new Error('Missing required param: fn (e.g. "getMembers")') | |
| 542 | const fn = this.backend.api.core[params.fn] | |
| 543 | if (typeof fn !== 'function') throw new Error(`Not a function: api.core.${params.fn}`) | |
| 544 | const args = params.args || [] | |
| 545 | return fn.apply(this.backend.api.core, args) | |
| 546 | } | |
| 530 | // call.raw and raw removed — arbitrary function execution vectors (SECURITY_AUDIT_V2 D-F3) | |
| 547 | 531 | |
| 548 | 532 | // --- Streaming --- |
| 549 | 533 | |
| … | … | @@ -751,6 +735,16 @@ export class Router { |
| 751 | 735 | return { ok: true } |
| 752 | 736 | } |
| 753 | 737 | |
| 738 | async _fileFetch (params) { | |
| 739 | if (!params.url) throw new Error('Missing required param: url (httpLink URL from file preview)') | |
| 740 | const { default: fetch } = await import('bare-fetch') | |
| 741 | const resp = await fetch(params.url) | |
| 742 | if (!resp.ok) throw new Error(`Fetch failed: ${resp.status} ${resp.statusText}`) | |
| 743 | const buf = Buffer.from(await resp.arrayBuffer()) | |
| 744 | const contentType = resp.headers.get('content-type') || 'application/octet-stream' | |
| 745 | return { base64: buf.toString('base64'), contentType, size: buf.length } | |
| 746 | } | |
| 747 | ||
| 754 | 748 | // --- Advanced Members --- |
| 755 | 749 | |
| 756 | 750 | async _membersActive (params) { |
| … | … | @@ -26,7 +26,9 @@ export class UdsTransport { |
| 26 | 26 | }) |
| 27 | 27 | |
| 28 | 28 | this.server.listen(this.socketPath, () => { |
| 29 | console.log(`[keet-daemon] UDS listening on ${this.socketPath}`) | |
| 29 | // Owner-only permissions — prevents other users from connecting (SECURITY_AUDIT_V2 D-F6) | |
| 30 | try { fs.chmodSync(this.socketPath, 0o600) } catch {} | |
| 31 | console.log(`[keet-daemon] UDS listening on ${this.socketPath} (mode 0600)`) | |
| 30 | 32 | resolve() |
| 31 | 33 | }) |
| 32 | 34 | }) |