diff --git a/core/archipelago/src/api/rpc/system/handlers.rs b/core/archipelago/src/api/rpc/system/handlers.rs index c1ec24e6..36ba7dcb 100644 --- a/core/archipelago/src/api/rpc/system/handlers.rs +++ b/core/archipelago/src/api/rpc/system/handlers.rs @@ -780,6 +780,19 @@ impl TlsMaterial { ); } + // nginx and the app gate share the leaf. Validate the daemon's read + // permission on the staged key before replacing either live file. + if self.privileged { + let mut repair = self.cmd("/usr/bin/python3"); + repair.args([ + "-c", + include_str!("../../../../../../scripts/repair-app-gate-tls-permissions.py"), + "--key-name", + TLS_KEY_STAGING_NAME, + ]); + run_checked(repair, "repair staged app TLS key permissions").await?; + } + // rename(2) within one directory: a reader sees either the whole old // file or the whole new one, never a partial write. Two files cannot // be swapped in a single atomic step, so there is a sub-millisecond diff --git a/core/archipelago/src/bootstrap.rs b/core/archipelago/src/bootstrap.rs index 85c2e13c..84e96f7f 100644 --- a/core/archipelago/src/bootstrap.rs +++ b/core/archipelago/src/bootstrap.rs @@ -162,6 +162,20 @@ pub async fn ensure_runtime_assets_ready() { Ok(_) => debug!("No OTA runtime payload to synchronize"), Err(e) => warn!("Runtime asset bootstrap failed (non-fatal): {:#}", e), } + // Older provisioning/rotation wrote a root-only leaf key. nginx could + // read it while the unprivileged app gate silently lost HTTPS. Repair + // permissions without replacing node identity, including binary-only OTA. + match host_sudo(&[ + "python3", + "-c", + include_str!("../../../scripts/repair-app-gate-tls-permissions.py"), + ]) + .await + { + Ok(status) if status.success() => debug!("App gate TLS key permissions verified"), + Ok(status) => warn!("App gate TLS key permissions need attention: {status}"), + Err(error) => warn!("App gate TLS permission repair failed: {error}"), + } // A frontend payload can predate the binary during qualification or rollback. // Replace its doctor before starting reconciliation; direct writes cannot // escape the management service's ProtectSystem filesystem namespace. diff --git a/docs/https-app-gate-followup-20261006.md b/docs/https-app-gate-followup-20261006.md new file mode 100644 index 00000000..843df708 --- /dev/null +++ b/docs/https-app-gate-followup-20261006.md @@ -0,0 +1,58 @@ +# HTTPS app launches — 6 October 2026 + +Status: OPEN for the operator's exact iframe-only report. Node URL/app clarification +is pending. Separate confirmed defects below are repaired or under qualification. + +## Live Yaya TLS failure + +Read-only inspection found `archipelago.service` runs as `archipelago`, while +`/etc/archipelago/ssl/archipelago.key` was `0600 root:root`. The gate log explicitly +reported permission denied loading its TLS material. The dashboard's privileged +nginx could still use the same leaf. HTTPS to File Browser's gated port reset; +HTTP remained reachable. This does not establish that every reported gate page +has this cause. + +Changed only the existing key group/mode to `0640 root:archipelago`, retaining the +certificate and key. No app/nginx restart or identity rotation. The management +service account can read the key. A browser context with the existing authenticated +session loaded File Browser over HTTPS in an iframe on both dev and Yaya, HTTP200 +and no gate page (`/tmp/archy-https-frame-probe-after-permissions.log`). Certificate +errors were ignored only in the isolated context; normal certificate trust, the +operator's hostname, companion and expired-session behaviour are still unverified. +Metadata rollback, if necessary, is root:root0600; it would reintroduce the fault. + +Initial browser probes used an intercepted parent and Chromium blocked the private +network request. These were test-harness failures, corrected by navigating to the +real parent before injecting the test iframe. Logs remain `/tmp/archy-https-frame- +probe-2.log` and `...-3.log`; they are not reported as passing acceptance. + +## Durable source changes under qualification + +- Startup runs an embedded, idempotent permission repair so existing installations + and binary-only updates converge without generating or exposing keys. +- Hostname certificate regeneration repairs the staged key before either live + file changes. Failure leaves current material intact. +- First-boot provisioning and operator-authorized host-key rotation preserve the + daemon's group-read permission rather than forcing the leaf back to root-only. +- The repair rejects symlinks, non-regular files and unexpected owners, does not + provision absent keys, grants no group write/execute or world access, and keeps + read-only owner mode where present. It uses the daemon account's primary group. + +Six isolated Python permission tests pass. The real extracted ISO first-boot +script harness passes9cases and rotation harness passes8cases, now asserting the +service group/mode. Full isolated Rust qualification passes (see `/tmp/archy-wallet-storage-tls-full-tests.log`); no updated +backend or ISO has been deployed or published. + +## Embedded runtime URL correction + +Commit `88d473f6` fixes exact loopback authority handling for localhost, 127.0.0.1 +and IPv6 loopback, without rewriting external hostname/path substrings. Two +regressions reproduced the old failures;22focused tests and production UI build +pass. Source is not yet deployed. This is not claimed as the operator's gate cause. + +## Remaining acceptance + +Reproduce the exact hostname/app in iframe and tab; qualify trusted TLS, HTTP LAN, +authenticated/expired/logged-out sessions, companion, service restart, hostname +regeneration and update persistence. Verify unauthenticated app access is still +challenged. Preserve the public-management source guard and Shorty containment. diff --git a/image-recipe/_archived/build-auto-installer-iso.sh b/image-recipe/_archived/build-auto-installer-iso.sh index 81dde3a6..53b2f6ad 100755 --- a/image-recipe/_archived/build-auto-installer-iso.sh +++ b/image-recipe/_archived/build-auto-installer-iso.sh @@ -2117,9 +2117,13 @@ gen_tls() { && [ -s "$SSL_DIR/archipelago.crt.new" ] \ && tls_pair_matches "$SSL_DIR/archipelago.key.new" "$SSL_DIR/archipelago.crt.new"; then chmod 600 "$SSL_DIR/archipelago.key.new" + if getent passwd archipelago >/dev/null 2>&1; then + chgrp "$(id -gn archipelago)" "$SSL_DIR/archipelago.key.new" || return 1 + chmod 640 "$SSL_DIR/archipelago.key.new" || return 1 + fi mv "$SSL_DIR/archipelago.key.new" "$SSL_DIR/archipelago.key" \ && mv "$SSL_DIR/archipelago.crt.new" "$SSL_DIR/archipelago.crt" || return 1 - chmod 600 "$SSL_DIR/archipelago.key" + # Keep staged permissions: nginx and the app gate both read this leaf. return 0 fi rm -f "$SSL_DIR/archipelago.key.new" "$SSL_DIR/archipelago.crt.new" diff --git a/scripts/repair-app-gate-tls-permissions.py b/scripts/repair-app-gate-tls-permissions.py new file mode 100644 index 00000000..cad558e9 --- /dev/null +++ b/scripts/repair-app-gate-tls-permissions.py @@ -0,0 +1,38 @@ +#!/usr/bin/env python3 +"""Keep the node TLS leaf private while allowing the management daemon to read it.""" +import argparse +import os +from pathlib import Path +import pwd +import stat + + +def repair(key: Path, service_uid: int, service_gid: int) -> bool: + try: + fd = os.open(key, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK) + except FileNotFoundError: + return False # A node without TLS is not provisioned by this repair. + try: + before = os.fstat(fd) + if not stat.S_ISREG(before.st_mode) or before.st_uid not in (0, service_uid): + raise RuntimeError('Unexpected node TLS key type or owner; left unchanged') + # The daemon's primary group is the sole additional reader. Never make + # the key world-readable or grant group write/execute permissions. + mode = 0o640 if before.st_mode & stat.S_IWUSR else 0o440 + if before.st_gid == service_gid and stat.S_IMODE(before.st_mode) == mode: + return False + os.fchown(fd, -1, service_gid) + os.fchmod(fd, mode) + os.fsync(fd) + return True + finally: + os.close(fd) + + +if __name__ == '__main__': + parser = argparse.ArgumentParser() + parser.add_argument('--key-name', choices=['archipelago.key', 'archipelago.key.new', 'archipelago.key.rotnew'], default='archipelago.key') + args = parser.parse_args() + account = pwd.getpwnam('archipelago') + changed = repair(Path('/etc/archipelago/ssl') / args.key_name, account.pw_uid, account.pw_gid) + print('Node app TLS permissions repaired' if changed else 'Node app TLS permissions unchanged') diff --git a/scripts/security/host-secrets-audit.sh b/scripts/security/host-secrets-audit.sh index f98be6e3..33488074 100755 --- a/scripts/security/host-secrets-audit.sh +++ b/scripts/security/host-secrets-audit.sh @@ -362,6 +362,10 @@ stage_tls() { [ -s "$TLS_STAGE_KEY" ] && [ -s "$TLS_STAGE_CRT" ] || return 1 tls_pair_matches "$TLS_STAGE_KEY" "$TLS_STAGE_CRT" || return 1 chmod 600 "$TLS_STAGE_KEY" + if getent passwd archipelago >/dev/null 2>&1; then + chgrp "$(id -gn archipelago)" "$TLS_STAGE_KEY" || return 1 + chmod 640 "$TLS_STAGE_KEY" || return 1 + fi return 0 } @@ -376,7 +380,7 @@ stage_ssh() { swap_tls() { mv -f "$TLS_STAGE_KEY" "$TLS_KEY" || return 1 mv -f "$TLS_STAGE_CRT" "$TLS_CRT" || return 1 - chmod 600 "$TLS_KEY" + # Preserve the staged daemon-readable mode through the atomic rename. return 0 } diff --git a/tests/app-gate-tls/test_permissions.py b/tests/app-gate-tls/test_permissions.py new file mode 100644 index 00000000..229ede9a --- /dev/null +++ b/tests/app-gate-tls/test_permissions.py @@ -0,0 +1,61 @@ +import importlib.util +import os +from pathlib import Path +import stat +import tempfile +import unittest + +spec = importlib.util.spec_from_file_location('repair', Path(__file__).resolve().parents[2] / 'scripts/repair-app-gate-tls-permissions.py') +module = importlib.util.module_from_spec(spec) +spec.loader.exec_module(module) + +class Permissions(unittest.TestCase): + def test_existing_key_bytes_survive_and_repeat_is_noop(self): + with tempfile.TemporaryDirectory() as d: + key = Path(d) / 'key' + key.write_bytes(b'private fixture bytes') + key.chmod(0o600) + self.assertTrue(module.repair(key, os.getuid(), os.getgid())) + self.assertEqual(key.read_bytes(), b'private fixture bytes') + self.assertEqual(stat.S_IMODE(key.stat().st_mode), 0o640) + self.assertFalse(module.repair(key, os.getuid(), os.getgid())) + + def test_preserves_read_only_owner_and_removes_other_access(self): + with tempfile.TemporaryDirectory() as d: + key = Path(d) / 'key' + key.write_bytes(b'fixture') + key.chmod(0o444) + module.repair(key, os.getuid(), os.getgid()) + self.assertEqual(stat.S_IMODE(key.stat().st_mode), 0o440) + + def test_missing_key_does_not_generate_identity(self): + with tempfile.TemporaryDirectory() as d: + key = Path(d) / 'missing' + self.assertFalse(module.repair(key, os.getuid(), os.getgid())) + self.assertFalse(key.exists()) + + def test_symlink_target_is_never_modified(self): + with tempfile.TemporaryDirectory() as d: + target = Path(d) / 'target' + target.write_bytes(b'preserve') + target.chmod(0o600) + link = Path(d) / 'key' + link.symlink_to(target) + with self.assertRaises(OSError): module.repair(link, os.getuid(), os.getgid()) + self.assertEqual(stat.S_IMODE(target.stat().st_mode), 0o600) + self.assertEqual(target.read_bytes(), b'preserve') + + @unittest.skipIf(os.getuid() == 0, 'Root is an explicitly accepted owner') + def test_unexpected_owner_is_rejected_without_permission_change(self): + with tempfile.TemporaryDirectory() as d: + key = Path(d) / 'key' + key.write_bytes(b'fixture') + key.chmod(0o600) + with self.assertRaises(RuntimeError): module.repair(key, os.getuid() + 1, os.getgid()) + self.assertEqual(stat.S_IMODE(key.stat().st_mode), 0o600) + + def test_non_regular_file_is_rejected(self): + with tempfile.TemporaryDirectory() as d: + with self.assertRaises(RuntimeError): module.repair(Path(d), os.getuid(), os.getgid()) + +if __name__ == '__main__': unittest.main() diff --git a/tests/first-boot-secrets/rotation-tests.sh b/tests/first-boot-secrets/rotation-tests.sh index 31d2b971..47b1e363 100755 --- a/tests/first-boot-secrets/rotation-tests.sh +++ b/tests/first-boot-secrets/rotation-tests.sh @@ -414,6 +414,9 @@ grep -q 'reload nginx' "$WORK/rotate.systemctl" 2>/dev/null || c="$c nginx-not-r [ "$(sha256sum "$R/etc/archipelago/ssl/archipelago.key" | cut -d' ' -f1)" = "$OLD_TLS_SHA" ] && c="$c tls-key-not-replaced" ls "$R"/etc/ssh/ssh_host_*_key >/dev/null 2>&1 || c="$c NO-HOST-KEYS-LEFT" ls "$R"/etc/archipelago/ssl/*.rotnew >/dev/null 2>&1 && c="$c staging-leftover" +if getent passwd archipelago >/dev/null 2>&1; then + [ "$(stat -c '%a:%g' "$R/etc/archipelago/ssl/archipelago.key")" = "640:$(id -g archipelago)" ] || c="$c app-gate-cannot-read-rotated-key" +fi # The verdict file must reflect the post-rotation state, not the pre-rotation one. [ "$(verdict_of "$J" 2>/dev/null)" = "per-node" ] || c="$c post-rotation-verdict=$(verdict_of "$J" 2>/dev/null)" if [ -z "$c" ]; then diff --git a/tests/first-boot-secrets/run-tests.sh b/tests/first-boot-secrets/run-tests.sh index 7418bfb6..fdb0272e 100755 --- a/tests/first-boot-secrets/run-tests.sh +++ b/tests/first-boot-secrets/run-tests.sh @@ -306,6 +306,9 @@ grep -q BAKED-SHARED-TLS-KEY "$CASE_ROOT/etc/archipelago/ssl/archipelago.key" && [ -s "$CASE_ROOT/etc/ssh/ssh_host_ed25519_key" ] || c1="$c1 ssh-host-key-missing" grep -q BAKED-SHARED-HOST-KEY "$CASE_ROOT/etc/ssh/ssh_host_rsa_key" && c1="$c1 ssh-key-not-replaced" ls "$CASE_ROOT"/etc/archipelago/ssl/*.new >/dev/null 2>&1 && c1="$c1 dotnew-leftover" +if getent passwd archipelago >/dev/null 2>&1; then + [ "$(stat -c '%a:%g' "$CASE_ROOT/etc/archipelago/ssl/archipelago.key")" = "640:$(id -g archipelago)" ] || c1="$c1 app-gate-cannot-read-private-key" +fi if [ -z "$c1" ]; then ok "both generators succeed -> exit 0, marker set, keys swapped in" else