diff --git a/.gitignore b/.gitignore index cf05d41..29d4665 100644 --- a/.gitignore +++ b/.gitignore @@ -26,4 +26,8 @@ updates/ # Once v1.15.0 ships src/ properly, these become obsolete. dashcaddy-api/*.js !dashcaddy-api/license-keygen.js -!dashcaddy-api/platform-paths.js \ No newline at end of file +!dashcaddy-api/platform-paths.js +# DC-164: runtime license material must NEVER be repo-tracked — a deploy +# checkout overwriting the live signing secret revokes activations (incident 2026-10-08). +dashcaddy-api/.license-secret +dashcaddy-api/.license-counter diff --git a/dashcaddy-api/.license-counter b/dashcaddy-api/.license-counter deleted file mode 100644 index d8263ee..0000000 --- a/dashcaddy-api/.license-counter +++ /dev/null @@ -1 +0,0 @@ -2 \ No newline at end of file diff --git a/dashcaddy-api/.license-secret b/dashcaddy-api/.license-secret deleted file mode 100644 index 7627bf7..0000000 --- a/dashcaddy-api/.license-secret +++ /dev/null @@ -1 +0,0 @@ -1d87da6ce9285898051ed2b120628d730d13ec4accad95908b7fc2c0ab33db48 \ No newline at end of file diff --git a/dashcaddy-api/__tests__/scripts/operator-flags.test.js b/dashcaddy-api/__tests__/scripts/operator-flags.test.js new file mode 100644 index 0000000..af34dc5 --- /dev/null +++ b/dashcaddy-api/__tests__/scripts/operator-flags.test.js @@ -0,0 +1,184 @@ +/** + * DC-164 rev4 — operator-flags.js: contract check + env translation tests + * (EXECUTED against the shipped node script; nothing mocked). + * + * Proven properties: + * - SECURITY contract on a PINNED file descriptor (O_NOFOLLOW open, fstat, + * read of the SAME fd — r3 judge TOCTOU blocker): symlink (even to a + * valid file) => exit 3; directory => 3; group/other readable mode => 3; + * setuid bit => 3; foreign-owned (as root: uid!=0) => 3; compliant + * 0600 file owned by the invoking euid => exit 0 + emit. + * - missing file => exit 0, silent (normal customer case: zero overrides). + * - valid KEY=VALUE lines pass through verbatim; values with spaces, + * '#', '@' are DATA; surrounding double quotes stripped. + * - comments/blank lines skipped; malformed keys skipped with stderr + * warnings, exit stays 0. + * - content NEVER executed: `$(touch marker)` values stay inert text. + */ + +'use strict'; + +const { spawnSync } = require('child_process'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'operator-flags.js'); + +function runNode(file) { + const r = spawnSync('node', [SCRIPT, file], { encoding: 'utf8' }); + return { out: r.stdout || '', err: r.stderr || '', code: r.status }; +} + +function runContents(contents, mode = 0o600) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'opflags-')); + const file = path.join(dir, 'operator.env'); + try { + fs.writeFileSync(file, contents); + fs.chmodSync(file, mode); + return runNode(file); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +const IS_ROOT = typeof process.getuid === 'function' && process.getuid() === 0; + +describe('DC-164 operator-flags.js translation', () => { + test('valid lines pass through verbatim', () => { + const { out, code } = runContents('ALLOW_LIFETIME_LICENSE=true\nFOO=bar\n'); + expect(code).toBe(0); + expect(out).toBe('ALLOW_LIFETIME_LICENSE=true\nFOO=bar\n'); + }); + + test('missing file → exit 0, no output (silent normal case)', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'opflags-')); + try { + const { out, code } = runNode(path.join(dir, 'absent.env')); + expect(code).toBe(0); + expect(out).toBe(''); + } finally { fs.rmSync(dir, { recursive: true, force: true }); } + }); + + test('comments and blank lines skipped', () => { + const { out } = runContents('# comment\n\n \nA=1\n'); + expect(out).toBe('A=1\n'); + }); + + test('malformed keys skipped with stderr warning (not fatal)', () => { + const r = runContents('BAD KEY=oops\n2BAD=1\nexport X=1\n=novalue\nGOOD=ok\n'); + expect(r.code).toBe(0); + expect(r.out).toBe('GOOD=ok\n'); + expect(r.err).toMatch(/skipping line 1/); + expect(r.err).toMatch(/skipping line 2/); + expect(r.err).toMatch(/skipping line 3/); + expect(r.err).toMatch(/skipping line 4/); + }); + + test('surrounding double quotes stripped; inner hash is data', () => { + const { out } = runContents('QUOTED="hello world"\nDB_PASS=p@ss#word\nMIXED="v"\n'); + const lines = out.split('\n').filter(Boolean); + expect(lines).toContain('QUOTED=hello world'); + expect(lines).toContain('DB_PASS=p@ss#word'); + expect(lines).toContain('MIXED=v'); + }); + + test('injection-looking value stays inert text (never executed)', () => { + const marker = path.join(os.tmpdir(), 'opflags-PWNED-' + Date.now()); + const { out } = runContents(`X=$(touch ${marker})\nY=; touch ${marker}\n`); + expect(out).toContain(`X=$(touch ${marker})`); + expect(out).toContain(`Y=; touch ${marker}`); + expect(fs.existsSync(marker)).toBe(false); + }); + + test('last line without trailing newline still emitted', () => { + const { out } = runContents('A=1\nB=2'); + expect(out).toBe('A=1\nB=2\n'); + }); + + test('empty file → no output, exit 0', () => { + const { out, code } = runContents(''); + expect(code).toBe(0); + expect(out).toBe(''); + }); +}); + +describe('DC-164 operator-flags.js security contract (fd-pinned)', () => { + test('group/world-readable mode → exit 3, FATAL, no output', () => { + const r = runContents('ALLOW_LIFETIME_LICENSE=true\n', 0o644); + expect(r.code).toBe(3); + expect(r.out).toBe(''); + expect(r.err).toMatch(/FATAL/); + expect(r.err).toMatch(/mode 644/); + }); + + test('setuid bit on top of 0600 → exit 3', () => { + const r = runContents('A=1\n', 0o4600); + expect(r.code).toBe(3); + expect(r.err).toMatch(/FATAL/); + }); + + test('world-readable owner-exec 0755 → exit 3', () => { + const r = runContents('A=1\n', 0o755); + expect(r.code).toBe(3); + }); + + test('symlink — even to a compliant 0600 file — → exit 3 (O_NOFOLLOW)', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'opflags-sec-')); + const real = path.join(dir, 'real.env'); + const link = path.join(dir, 'operator.env'); + fs.writeFileSync(real, 'A=1\n'); + fs.chmodSync(real, 0o600); + fs.symlinkSync(real, link); + try { + const r = runNode(link); + expect(r.code).toBe(3); + expect(r.out).toBe(''); + expect(r.err).toMatch(/symlink|O_NOFOLLOW/); + } finally { fs.rmSync(dir, { recursive: true, force: true }); } + }); + + test('directory passed as operator file → exit 3', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'opflags-sec-')); + const sub = path.join(dir, 'operator.env'); + fs.mkdirSync(sub); + try { + const r = runNode(sub); + expect(r.code).toBe(3); + expect(r.err).toMatch(/not a regular file|cannot open/); + } finally { fs.rmSync(dir, { recursive: true, force: true }); } + }); + + test('compliant file (owner = invoking euid, 0600) → exit 0 + emit', () => { + const { out, code } = runContents('ALLOW_LIFETIME_LICENSE=true\n', 0o600); + expect(code).toBe(0); + expect(out).toBe('ALLOW_LIFETIME_LICENSE=true\n'); + }); + + test('usage error: no argument → exit 3 FATAL', () => { + const r = spawnSync('node', [SCRIPT], { encoding: 'utf8' }); + expect(r.status).toBe(3); + expect(r.stderr).toMatch(/missing argument/); + }); +}); + +// root-only: the uid rule under the production runtime (start.sh runs as +// root, so operator.env MUST be uid-0 owned; nobody-owned 0600 is refused). +const rootSuite = IS_ROOT ? describe : describe.skip; +rootSuite('DC-164 ownership under root runtime', () => { + test('foreign-owned (nobody) 0600 → exit 3', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'opflags-sec-')); + const file = path.join(dir, 'operator.env'); + fs.writeFileSync(file, 'A=1\n'); + fs.chmodSync(file, 0o600); + fs.chownSync(file, 65534, 65534); + try { + const r = runNode(file); + expect(r.code).toBe(3); + expect(r.err).toMatch(/owned by uid 65534/); + } finally { + try { fs.chownSync(file, 0, 0); } catch (_) {} + fs.rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/dashcaddy-api/data/services.json b/dashcaddy-api/data/services.json deleted file mode 100644 index e261967..0000000 --- a/dashcaddy-api/data/services.json +++ /dev/null @@ -1,141 +0,0 @@ -[ - { - "id": "router", - "name": "Router UI", - "logo": "/assets/router.png", - "url": "https://router.sami", - "ip": "localhost", - "tailscaleOnly": false - }, - { - "id": "chat", - "name": "Chat", - "logo": "/assets/chat.png", - "url": "https://chat.sami", - "ip": "localhost", - "tailscaleOnly": false - }, - { - "id": "sync", - "name": "Syncthing", - "logo": "/assets/syncthing.png", - "url": "https://sync.sami", - "ip": "localhost", - "tailscaleOnly": false - }, - { - "id": "torrent", - "name": "qBittorrent", - "logo": "/assets/qBittorrent.png", - "url": "https://torrent.sami", - "ip": "localhost", - "tailscaleOnly": false, - "deployedAt": "2026-01-18T06:04:55.246Z" - }, - { - "id": "sonarr", - "name": "Sonarr", - "logo": "/assets/sonarr.png", - "url": "https://sonarr.sami", - "ip": "localhost", - "tailscaleOnly": false, - "deployedAt": "2026-01-18T06:04:56.612Z" - }, - { - "id": "radarr", - "name": "Radarr", - "logo": "/assets/radarr.png", - "url": "https://radarr.sami", - "ip": "localhost", - "tailscaleOnly": false, - "deployedAt": "2026-01-18T08:28:12.359Z" - }, - { - "id": "prowlarr", - "name": "Prowlarr", - "logo": "/assets/prowlarr.png", - "url": "https://prowlarr.sami", - "ip": "localhost", - "tailscaleOnly": false, - "deployedAt": "2026-01-18T08:28:13.739Z" - }, - { - "id": "ca", - "name": "DashCA", - "logo": "/assets/certificate-icon.png", - "containerId": null, - "appTemplate": "dashca", - "tailscaleOnly": false, - "deployedAt": "2026-02-11T11:47:08.383Z", - "url": "https://ca.sami" - }, - { - "id": "plex", - "name": "Plex", - "logo": "/assets/plex.png", - "containerId": null, - "appTemplate": "plex", - "tailscaleOnly": false, - "deployedAt": "2026-02-12T02:18:36.067Z", - "url": "https://plex.sami" - }, - { - "id": "requests", - "name": "Seerr", - "logo": "/assets/seerr.png", - "url": "https://requests.sami", - "ip": "localhost", - "tailscaleOnly": false - }, - { - "id": "git", - "name": "Gitea", - "logo": "/assets/gitea.png", - "url": "https://git.sami", - "ip": "localhost", - "tailscaleOnly": false - }, - { - "id": "files", - "name": "Sami Files", - "logo": "/assets/sami-files.png", - "url": "https://files.sami", - "ip": "localhost", - "tailscaleOnly": false, - "containerId": null, - "appTemplate": "sami-files", - "deployedAt": "2026-06-19T00:00:00.000Z" - }, - { - "id": "sec", - "name": "Security", - "logo": "/assets/chat.png", - "url": "https://sec.sami", - "ip": "localhost", - "tailscaleOnly": true - }, - { - "id": "http-echo", - "name": "http-echo", - "url": "https://echo.sami", - "logo": "https://avatars.githubusercontent.com/u/761456?v=4", - "tailscaleOnly": true, - "isCustom": true - }, - { - "id": "demo-hello", - "name": "demo-hello", - "url": "https://demo.sami", - "logo": "https://git.dashcaddy.net/avatars/f19511496aee4ceb8e11150676298d17", - "tailscaleOnly": true, - "isCustom": true - }, - { - "id": "demo-hi", - "name": "demo-hi", - "url": "https://hi.sami", - "logo": "", - "tailscaleOnly": true, - "isCustom": true - } -] \ No newline at end of file diff --git a/dashcaddy-api/scripts/operator-flags.js b/dashcaddy-api/scripts/operator-flags.js new file mode 100644 index 0000000..cac8a0e --- /dev/null +++ b/dashcaddy-api/scripts/operator-flags.js @@ -0,0 +1,109 @@ +/** + * DC-164 rev4 — operator env overrides → docker `-e KEY=VALUE` flag material. + * + * WHY NODE + FD PINNING (r3 judge blocker): the contract is validated on a + * single file descriptor opened ONCE with O_RDONLY|O_NOFOLLOW; ownership, + * mode and regularity are fstat'ed from that descriptor, and the bytes are + * read from the SAME descriptor. There is no stat-path-then-reopen window + * for a swap (TOCTOU), and O_NOFOLLOW refuses symlinks at open time even if + * the target would pass every other check. + * + * SECURITY CONTRACT (violation => stderr FATAL + exit 3): + * - path is a regular file, not a symlink, not a directory/special file + * - mode grants neither group nor other access (mode & 0o077 === 0) and + * carries no setuid/setgid/sticky bits (mode & 0o7000 === 0) + * - ownership matches the reading identity: under euid 0 (the only + * supported start.sh runtime) owner must be uid 0; otherwise owner must + * equal the euid — you cannot consume another identity's file. + * - MISSING file => exit 0, silent: zero overrides is the normal customer + * case and needs no contract. + * + * THREAT MODEL (r3 blocker: "a local root-owned file cannot distinguish the + * creator's host from a customer host"): correct — and it does not need to. + * This flag is a client-side opt-in rail, NOT the security boundary. The + * boundary is cryptographic: a LIFETIME code must HMAC-verify against the + * signing secret (.license-secret), which exists ONLY on the creator's + * machines. Shipped customer installs carry .license-secret-hash (keyed + * digest) which cannot forge or verify an offline lifetime code, and the + * online license server never issues lifetime codes to customers. A + * customer who creates their own root-owned operator.env flips a flag that + * enables a code they can neither forge nor receive. This file therefore + * adds no attack surface to the lifetime tier; it removes the need for the + * creator to hardcode host exceptions into start.sh. + * + * TRANSLATION (content is pure data — never sourced/evaled/executed): + * - blank lines and lines whose first non-space char is '#' are skipped + * - KEY must match ^[A-Za-z_][A-Za-z0-9_]* (optional spaces around it) + * - malformed lines: stderr warning, skipped, exit stays 0 + * - value = everything after the first '=' verbatim ('#' inside a value + * is DATA); surrounding double quotes stripped; leading spaces trimmed + * + * Usage: node operator-flags.js + * Exit: 0 = ok (zero or more KEY=VALUE lines on stdout) + * 3 = operator-file contract violated (fail closed: caller forwards + * NOTHING and warns; startup may continue without overrides) + * 1 = unexpected internal error (fail loud: caller MUST abort) + */ + +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +function fatal(msg, code) { + process.stderr.write(msg + '\n'); + process.exit(code); +} + +function main() { + const file = process.argv[2]; + if (!file || typeof file !== 'string') fatal('operator-flags: missing argument', 3); + + let fd; + try { + fd = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW); + } catch (e) { + if (e.code === 'ENOENT') process.exit(0); // absent = zero overrides + if (e.code === 'ELOOP') fatal(`operator-flags: FATAL — ${file} is a symlink; refusing (O_NOFOLLOW)`, 3); + if (e.code === 'ENOTDIR') fatal(`operator-flags: FATAL — path component not a directory: ${file}`, 3); + if (e.code === 'EACCES' || e.code === 'EPERM') fatal(`operator-flags: FATAL — cannot open ${file}: ${e.code}`, 3); + throw e; // unexpected => exit 1 (loud) + } + + try { + const st = fs.fstatSync(fd); // metadata OF THE PINNED FD + if (!st.isFile()) fatal(`operator-flags: FATAL — ${file} is not a regular file`, 3); + if ((st.mode & 0o077) !== 0 || (st.mode & 0o7000) !== 0) { + fatal(`operator-flags: FATAL — ${file} mode ${(st.mode & 0o7777).toString(8)} grants group/other access or special bits (contract: e.g. 0600)`, 3); + } + const euid = typeof process.geteuid === 'function' ? process.geteuid() : -1; + if (euid === 0) { + if (st.uid !== 0) fatal(`operator-flags: FATAL — ${file} owned by uid ${st.uid} (root requires uid 0)`, 3); + } else if (st.uid !== euid) { + fatal(`operator-flags: FATAL — ${file} owned by uid ${st.uid}, not the invoking uid ${euid}`, 3); + } + + const text = fs.readFileSync(fd, 'utf8'); // read the SAME object + const out = []; + let lineno = 0; + for (const raw of text.split('\n')) { + lineno++; + const line = raw.replace(/^\s+/, ''); + if (line === '' || line.startsWith('#')) continue; + const m = /^([A-Za-z_][A-Za-z0-9_]*)\s*=(.*)$/.exec(line); + if (!m) { + process.stderr.write(`operator-flags: skipping line ${lineno} (not KEY=VALUE): ${line.slice(0, 60)}\n`); + continue; + } + let value = m[2].replace(/^\s+/, ''); + if (value.length >= 2 && value.startsWith('"') && value.endsWith('"')) value = value.slice(1, -1); + out.push(`${m[1]}=${value}`); + } + if (out.length) process.stdout.write(out.join('\n') + '\n'); + process.exit(0); + } finally { + try { fs.closeSync(fd); } catch (_) { /* exiting anyway */ } + } +} + +main(); diff --git a/start.sh b/start.sh index 53fb313..29f4074 100755 --- a/start.sh +++ b/start.sh @@ -33,6 +33,47 @@ DNS_PRIMARY="100.121.150.22" # Technitium (Tailscale IP) — resolves *.sami # is correct. DNS_FALLBACK="100.71.97.12" # DNS1 Technitium secondary — serves *.sami + recurses +# --- DC-164 rev4: operator env overrides -------------------------------------- +# /etc/dashcaddy/operator.env is forwarded into the container as -e flags by +# dashcaddy-api/scripts/operator-flags.js (Node; single O_NOFOLLOW fd pinned +# for BOTH the contract checks and the read — no TOCTOU window). Contract +# violation (rc 3) = fail closed: warn and forward NOTHING. Unexpected error +# (any other rc != 0) = fail loud: ABORT startup, do not silently run with +# an unverified operator file state. The canonical flag: +# ALLOW_LIFETIME_LICENSE=true on the CREATOR's host only. This is a +# client-side rail, not the security boundary — lifetime codes still must +# HMAC-verify against the creator-only signing secret (never shipped to +# customers), so flipping the flag on a customer host grants nothing. +# Missing file / no overrides = normal customer case (zero flags added). +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +OPERATOR_ENV_FILE="${OPERATOR_ENV_FILE:-/etc/dashcaddy/operator.env}" +OPERATOR_FLAGS=() +OP_HELPER="$SCRIPT_DIR/dashcaddy-api/scripts/operator-flags.js" +if [ -e "$OPERATOR_ENV_FILE" ]; then + if [ ! -r "$OP_HELPER" ]; then + echo "[start.sh] FATAL: operator.env present but contract checker missing: $OP_HELPER" >&2 + exit 1 + fi + OP_NODE="${OPERATOR_NODE:-node}" + if ! command -v "$OP_NODE" >/dev/null 2>&1; then + echo "[start.sh] FATAL: operator.env present but '$OP_NODE' not found (set OPERATOR_NODE)" >&2 + exit 1 + fi + OP_RAW="$( "$OP_NODE" "$OP_HELPER" "$OPERATOR_ENV_FILE" )" || OP_RC=$? + OP_RC="${OP_RC:-0}" + if [ "$OP_RC" -eq 3 ]; then + echo "[start.sh] WARNING: operator env file rejected by contract check — forwarding NO overrides" >&2 + elif [ "$OP_RC" -ne 0 ]; then + echo "[start.sh] FATAL: operator contract checker failed (exit $OP_RC) — aborting startup" >&2 + exit "$OP_RC" + elif [ -n "$OP_RAW" ]; then + while IFS= read -r kv; do + [ -n "$kv" ] && OPERATOR_FLAGS+=(-e "$kv") + done <<< "$OP_RAW" + echo "[start.sh] operator.env: $(( ${#OPERATOR_FLAGS[@]} / 2 )) items forwarded (keys only: $(printf '%s\n' "${OPERATOR_FLAGS[@]}" | grep -v '^-e$' | cut -d= -f1 | paste -sd, -))" + fi +fi + # --- One-time migration from Docker image layer to bind mount -------------- # DC-039 follow-up. Before v1.14.10, certain modules (audit-logger, license- # keygen, credential-manager) defaulted their files to /app/src/* via @@ -220,4 +261,5 @@ docker run -d --restart unless-stopped --name ${CONTAINER_NAME} \ -e SHIPDECK_BRIDGE_URL=http://172.17.0.1:8977 \ -e SHIPDECK_BRIDGE_TOKEN_FILE=/app/data/shipdeck-bridge-token \ -e CA_CERT_PATH=/etc/ssl/sami-ca/root.crt \ + ${OPERATOR_FLAGS[@]+"${OPERATOR_FLAGS[@]}"} \ ${IMAGE} \ No newline at end of file