Preserve app gate TLS access through provisioning and certificate rotation
This commit is contained in:
@@ -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
|
// 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
|
// 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
|
// be swapped in a single atomic step, so there is a sub-millisecond
|
||||||
|
|||||||
@@ -162,6 +162,20 @@ pub async fn ensure_runtime_assets_ready() {
|
|||||||
Ok(_) => debug!("No OTA runtime payload to synchronize"),
|
Ok(_) => debug!("No OTA runtime payload to synchronize"),
|
||||||
Err(e) => warn!("Runtime asset bootstrap failed (non-fatal): {:#}", e),
|
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.
|
// A frontend payload can predate the binary during qualification or rollback.
|
||||||
// Replace its doctor before starting reconciliation; direct writes cannot
|
// Replace its doctor before starting reconciliation; direct writes cannot
|
||||||
// escape the management service's ProtectSystem filesystem namespace.
|
// escape the management service's ProtectSystem filesystem namespace.
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -2117,9 +2117,13 @@ gen_tls() {
|
|||||||
&& [ -s "$SSL_DIR/archipelago.crt.new" ] \
|
&& [ -s "$SSL_DIR/archipelago.crt.new" ] \
|
||||||
&& tls_pair_matches "$SSL_DIR/archipelago.key.new" "$SSL_DIR/archipelago.crt.new"; then
|
&& tls_pair_matches "$SSL_DIR/archipelago.key.new" "$SSL_DIR/archipelago.crt.new"; then
|
||||||
chmod 600 "$SSL_DIR/archipelago.key.new"
|
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.key.new" "$SSL_DIR/archipelago.key" \
|
||||||
&& mv "$SSL_DIR/archipelago.crt.new" "$SSL_DIR/archipelago.crt" || return 1
|
&& 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
|
return 0
|
||||||
fi
|
fi
|
||||||
rm -f "$SSL_DIR/archipelago.key.new" "$SSL_DIR/archipelago.crt.new"
|
rm -f "$SSL_DIR/archipelago.key.new" "$SSL_DIR/archipelago.crt.new"
|
||||||
|
|||||||
@@ -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')
|
||||||
@@ -362,6 +362,10 @@ stage_tls() {
|
|||||||
[ -s "$TLS_STAGE_KEY" ] && [ -s "$TLS_STAGE_CRT" ] || return 1
|
[ -s "$TLS_STAGE_KEY" ] && [ -s "$TLS_STAGE_CRT" ] || return 1
|
||||||
tls_pair_matches "$TLS_STAGE_KEY" "$TLS_STAGE_CRT" || return 1
|
tls_pair_matches "$TLS_STAGE_KEY" "$TLS_STAGE_CRT" || return 1
|
||||||
chmod 600 "$TLS_STAGE_KEY"
|
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
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -376,7 +380,7 @@ stage_ssh() {
|
|||||||
swap_tls() {
|
swap_tls() {
|
||||||
mv -f "$TLS_STAGE_KEY" "$TLS_KEY" || return 1
|
mv -f "$TLS_STAGE_KEY" "$TLS_KEY" || return 1
|
||||||
mv -f "$TLS_STAGE_CRT" "$TLS_CRT" || 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
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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"
|
[ "$(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/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"
|
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.
|
# 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)"
|
[ "$(verdict_of "$J" 2>/dev/null)" = "per-node" ] || c="$c post-rotation-verdict=$(verdict_of "$J" 2>/dev/null)"
|
||||||
if [ -z "$c" ]; then
|
if [ -z "$c" ]; then
|
||||||
|
|||||||
@@ -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"
|
[ -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"
|
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"
|
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
|
if [ -z "$c1" ]; then
|
||||||
ok "both generators succeed -> exit 0, marker set, keys swapped in"
|
ok "both generators succeed -> exit 0, marker set, keys swapped in"
|
||||||
else
|
else
|
||||||
|
|||||||
Reference in New Issue
Block a user