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.
110 lines
5.2 KiB
JavaScript
110 lines
5.2 KiB
JavaScript
/**
|
|
* 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();
|