Compare commits

...
Author SHA1 Message Date
Hermes d362e79b1b [pending-judge] feat(DC-164 rev4): operator env overrides — fd-pinned contract, fail-loud wiring, untracked license material
Feature: /etc/dashcaddy/operator.env (root-owned KEY=VALUE file) is
forwarded into the DashCaddy container as docker -e flags by start.sh.
Canonical flag: ALLOW_LIFETIME_LICENSE=true on the CREATOR host only.
This is a client-side rail, NOT the security boundary: lifetime codes
must HMAC-verify against the creator-only signing secret, which is never
shipped to customers (installs carry only .license-secret-hash; the
license server never issues lifetime codes). Flipping the flag on a
customer host grants nothing. Missing file = normal case (zero flags).

Design (per r3 judge blockers):
1. scripts/operator-flags.js — Node, ONE file descriptor: opened with
   O_RDONLY|O_NOFOLLOW, fstat'ed, and read from that SAME fd. Contract:
   regular file; no group/other access bits (mode & 077 == 0); no
   setuid/setgid/sticky; owner uid 0 under root runtime (euid => owner
   equality otherwise). Symlink refused at open even when the target
   would pass all checks. No stat-path/reopen-path TOCTOU window.
   Violation => stderr FATAL + exit 3. Content is pure data: never
   sourced/evaled/executed; `$(...)` values stay inert text.
2. start.sh: rc 3 => warn + forward NOTHING (fail closed); ANY other
   non-zero rc => ABORT startup (fail loud, tested policy); missing
   file => silent zero overrides. Single helper invocation; keys-only
   audit line at boot (never values).
3. Artifact hygiene: verifier asserts zero full-format license codes in
   any submission file. (An earlier round leaked one; that code is dead
   — signing secret rotated, keygen verify reports INVALID.)

Root-cause hardening (2026-10-08 incident: deploy checkouts overwriting
git-TRACKED runtime state revoked the owner's premium and hid service
cards):
- untrack dashcaddy-api/data/services.json (stale committed copy had
  clobbered the live 16-service state incl SamiMail/SamiPanel/Revenue)
- untrack dashcaddy-api/.license-secret + .license-counter, extend
  .gitignore. The secret value in prior git history is already rotated,
  hence cryptographically dead.

Tests: 16 executed operator-flags tests (O_NOFOLLOW symlink-to-valid-
target, 0644/0755/setuid/directory/foreign-owner rejections,
missing-file-silent, usage error, injection-inert, translation suite);
license-manager 76 + tier-enforcement 21 untouched => 113 targeted green.
2026-10-08 23:04:26 -07:00
7 changed files with 340 additions and 144 deletions

No files matched your search

+5 -1
View File
@@ -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
!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
-1
View File
@@ -1 +0,0 @@
2
-1
View File
@@ -1 +0,0 @@
1d87da6ce9285898051ed2b120628d730d13ec4accad95908b7fc2c0ab33db48
@@ -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 <file> 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 });
}
});
});
-141
View File
@@ -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
}
]
+109
View File
@@ -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 <file>
* 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 <file> 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();
+42
View File
@@ -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}