From 446fa7b7fd82f4390494de386cf90de93a9d0fe0 Mon Sep 17 00:00:00 2001 From: archipelago Date: Mon, 5 Oct 2026 15:08:42 -0400 Subject: [PATCH] fix: retain management guard through legacy runtime install and rollback --- core/archipelago/src/bootstrap.rs | 10 +-- core/archipelago/src/update.rs | 19 +++++ docs/npm-certificate-handoff-20261001.md | 46 +++++++++++ docs/release-1.9.0-acceptance.md | 64 +++++++++++++++ scripts/dashboard-public-guard.py | 37 +++++++-- scripts/tests/test_dashboard_public_guard.py | 78 +++++++++++++++++++ .../dashboard-public-guard-network.py | 43 +++++++++- 7 files changed, 283 insertions(+), 14 deletions(-) diff --git a/core/archipelago/src/bootstrap.rs b/core/archipelago/src/bootstrap.rs index b706c3c1..9f9f9c27 100644 --- a/core/archipelago/src/bootstrap.rs +++ b/core/archipelago/src/bootstrap.rs @@ -496,14 +496,14 @@ async fn run_runtime_assets() -> Result { if nginx_src.exists() { let src_s = nginx_src.to_string_lossy().to_string(); let status = host_sudo(&[ - "install", - "-m", - "644", + "python3", + "-c", + include_str!("../../../scripts/dashboard-public-guard.py"), + "--install", &src_s, - "/etc/nginx/sites-available/archipelago", ]) .await - .context("install nginx-archipelago.conf")?; + .context("install guarded nginx-archipelago.conf")?; if !status.success() { anyhow::bail!("install nginx-archipelago.conf exited with {}", status); } diff --git a/core/archipelago/src/update.rs b/core/archipelago/src/update.rs index a7f9937f..06bf14f4 100644 --- a/core/archipelago/src/update.rs +++ b/core/archipelago/src/update.rs @@ -2059,6 +2059,25 @@ pub async fn rollback_update(data_dir: &Path) -> Result<()> { let backup_binary = backup_dir.join("archipelago"); if backup_binary.exists() { + // The restored frontend can contain a pre-guard runtime template. An + // older binary copies that template verbatim on startup, undoing live + // containment. Protect it before permitting the binary downgrade. + let template = "/opt/archipelago/web-ui/archipelago-runtime/image-recipe/configs/nginx-archipelago.conf"; + if Path::new(template).exists() { + let protected = host_sudo(&[ + "python3", + "-c", + include_str!("../../../scripts/dashboard-public-guard.py"), + "--protect-template", + template, + ]) + .await + .context("protect nginx runtime template before rollback")?; + anyhow::ensure!( + protected.success(), + "unsafe nginx rollback template; previous binary not restored" + ); + } // Same two namespace gotchas as apply_update()'s binary swap: // `cp` straight onto the running binary is O_TRUNC and fails // ETXTBSY (exit 1 — exactly what broke the .116 rollback), and diff --git a/docs/npm-certificate-handoff-20261001.md b/docs/npm-certificate-handoff-20261001.md index 6d809134..461694d1 100644 --- a/docs/npm-certificate-handoff-20261001.md +++ b/docs/npm-certificate-handoff-20261001.md @@ -210,3 +210,49 @@ required. Also observed own `/api/v1/query/Angor/projects?limit=10` returns404; reference MempoolIndexerAngorApi.GetProjectsAsync uses this older specialized route, whereas current deployment docs recommend stock Mempool. Verify actual client version/discovery path rather than claiming fees/health prove compatibility. + +## Shorty live qualification: cached-runtime guard regression — 2026-10-05 + +The operator signed the final NPM candidate. Release-root verification and exact +reviewed payload comparison pass; signed SHA256 +`479f6193835a16dd4ab167e5c22306a878ac39b77c2e2793971e807874fbc0cb`. +This signature authorizes private qualification; it is not release publication. + +Shorty baseline public shop/www/indexer/relay trusted HTTPS passes. Its prior +NPM image bytes match the pinned2.14.0 image. Consistent stopped-NPM state, +backend, unit, nginx, helpers and app metadata were backed up under +`/var/lib/archipelago/support/190-npm-20261005`. Migration reached the new private +network/listeners but failed the guard acceptance check and was rolled back. +The test initially expected the emergency guard's legacy variable; further +inspection found a real source defect, not merely that assertion mismatch. + +Confirmed cause: `ensure_runtime_assets_ready` applies the management guard, +then `run_runtime_assets` installs the cached OTA's nginx template verbatim. +Shorty's cached template predates the guard. It overwrote protection before a +subsequent nginx reload; restoring the older backend repeated that path. A live +public IPv4 root probe returned200. Immediate containment applied the tested +source guard; HTTP/HTTPS root and HTTP RPC again return404. The cached legacy +runtime template is now also guarded, with its original saved privately, so +that old startup installer cannot remove protection on restart. Both emergency +and current guards are present in the active configuration. Do not claim this +attempt passed or that the broader migration is complete. + +Source fix: runtime installation now renders/validates the guarded candidate +before atomic replacement under the nginx transaction lock; syntax/reload +failure restores the previous protected bytes. Rollback protects the restored +runtime template before permitting an older binary to start.11 focused tests +pass; real isolated nginx verifies the actual legacy install, old-binary copy, +invalid-template rollback, public IPv4/IPv6 denial, ACME/private access and the +existing120-case Host/SNI/forwarded-header/UI/assets/RPC/WS matrix. +The first backend suite passed1,681/0failed/4ignored before the final rollback +addition; final rerun and optimized build are required. Logs: +`/tmp/archy-190-guard-runtime-final-unit.log`, +`/tmp/archy-190-guard-runtime-final-network.log`, +`/tmp/archy-190-shorty-activation.log` (failed attempt), +`/tmp/archy-190-shorty-prepare.log`. + +Only NPM's container restarted; Bitcoin, LND, ElectrumX, Angor indexer and relay +IDs/start times are unchanged. Restored shop/www/indexer/relay HTTPS returns200. +Shorty's old backend remains active under containment. Rebuild and requalify the +migration, external security and restart persistence before closing this gate. +The signed catalog contents are unchanged and need no further operator signature. diff --git a/docs/release-1.9.0-acceptance.md b/docs/release-1.9.0-acceptance.md index 9084c4ad..385cb3b2 100644 --- a/docs/release-1.9.0-acceptance.md +++ b/docs/release-1.9.0-acceptance.md @@ -596,3 +596,67 @@ and start times are unchanged; the qualified catalog and AIUI are preserved. Private rollback backup: `/var/lib/archipelago/support/190-mobile-20261005`. Evidence: `/tmp/archy-190-yaya-mobile-deploy.log`. Phone acceptance was on the dev APK; this byte-identity deployment check is not another physical-phone test. + +Source review proposal: [ngit5957be8c](https://gitworkshop.dev/nevent1qqs9j4a73jyu6xfrpzrqcx2tqkldnuc8wzfas2zdkq6s2qlvftdaakqpz3mhxue69uhhyetvv9ujumn8d96zuer9wcq8s3xt), +covering daac47ca,5aa74d05,b8266c28,ba8b1f29. Proposal publication succeeded; +remote main refs and release tags have not been advanced. Do not call it merged +or the mirrors synchronized from proposal upload alone. + +Private final NPM catalog candidate is ready for the operator's signature. +Compared with the previously signed candidate, only NPM's variant version2.14.0, +immutable image digest and the catalog timestamp changed.64 apps/63 manifests, +zero drift, registry trust and the pinned-image integration pass. This signature +is for migration qualification, not full-release acceptance or publication. +The operator was separately asked to resolve Angor's outstanding discovery scope. + +Yaya post-deployment browser checks pass at390/1440px for grouping, icons, hard +refresh and BTCPay Commerce-only placement. The first harness session had an +expired login cookie and used catalog fallback; after normal login, the signed +catalog endpoint returns200/64apps and the complete rerun passes without401. +Evidence: `/tmp/archy-190-yaya-final-ui-smoke-authenticated.log`. + +## Shorty live qualification: cached-runtime guard regression — 2026-10-05 + +The operator signed the final NPM candidate. Release-root verification and exact +reviewed payload comparison pass; signed SHA256 +`479f6193835a16dd4ab167e5c22306a878ac39b77c2e2793971e807874fbc0cb`. +This signature authorizes private qualification; it is not release publication. + +Shorty baseline public shop/www/indexer/relay trusted HTTPS passes. Its prior +NPM image bytes match the pinned2.14.0 image. Consistent stopped-NPM state, +backend, unit, nginx, helpers and app metadata were backed up under +`/var/lib/archipelago/support/190-npm-20261005`. Migration reached the new private +network/listeners but failed the guard acceptance check and was rolled back. +The test initially expected the emergency guard's legacy variable; further +inspection found a real source defect, not merely that assertion mismatch. + +Confirmed cause: `ensure_runtime_assets_ready` applies the management guard, +then `run_runtime_assets` installs the cached OTA's nginx template verbatim. +Shorty's cached template predates the guard. It overwrote protection before a +subsequent nginx reload; restoring the older backend repeated that path. A live +public IPv4 root probe returned200. Immediate containment applied the tested +source guard; HTTP/HTTPS root and HTTP RPC again return404. The cached legacy +runtime template is now also guarded, with its original saved privately, so +that old startup installer cannot remove protection on restart. Both emergency +and current guards are present in the active configuration. Do not claim this +attempt passed or that the broader migration is complete. + +Source fix: runtime installation now renders/validates the guarded candidate +before atomic replacement under the nginx transaction lock; syntax/reload +failure restores the previous protected bytes. Rollback protects the restored +runtime template before permitting an older binary to start.11 focused tests +pass; real isolated nginx verifies the actual legacy install, old-binary copy, +invalid-template rollback, public IPv4/IPv6 denial, ACME/private access and the +existing120-case Host/SNI/forwarded-header/UI/assets/RPC/WS matrix. +The first backend suite passed1,681/0failed/4ignored before the final rollback +addition; final rerun and optimized build are required. Logs: +`/tmp/archy-190-guard-runtime-final-unit.log`, +`/tmp/archy-190-guard-runtime-final-network.log`, +`/tmp/archy-190-shorty-activation.log` (failed attempt), +`/tmp/archy-190-shorty-prepare.log`. + +Only NPM's container restarted; Bitcoin, LND, ElectrumX, Angor indexer and relay +IDs/start times are unchanged. Restored shop/www/indexer/relay HTTPS returns200. +Shorty's old backend remains active under containment. Rebuild and requalify the +migration, external security and restart persistence before closing this gate. +The signed catalog contents are unchanged and need no further operator signature. diff --git a/scripts/dashboard-public-guard.py b/scripts/dashboard-public-guard.py index e5165911..80fd631d 100644 --- a/scripts/dashboard-public-guard.py +++ b/scripts/dashboard-public-guard.py @@ -168,16 +168,20 @@ def active_dashboard(nginx_root=Path('/etc/nginx')): return selected.resolve(strict=True) -def apply(path, command=subprocess.run, lock_path=Path('/run/lock/archy-nginx-config.lock')): +def apply(path, command=subprocess.run, lock_path=Path('/run/lock/archy-nginx-config.lock'), source=None): # The NPM bridge uses this same lock for nginx configuration transactions. with lock_path.open('a+b') as lock: fcntl.flock(lock, fcntl.LOCK_EX) - return apply_locked(path.resolve(strict=True), command) + return apply_locked(path.resolve(strict=True), command, source) -def apply_locked(path, command): +def apply_locked(path, command, source=None): old = path.read_bytes() - new = guarded(old.decode()).encode() + # A cached legacy OTA can predate the guard. Never install its unguarded + # bytes and repair them afterwards: another startup task can reload nginx + # in that gap. Validate/render before the atomic replacement, under the + # same lock as the public-host bridge. + new = guarded(source.read_text() if source is not None else old.decode()).encode() if new == old: return False backup_dir = (Path('/var/lib/archipelago/nginx-management-guard') @@ -209,16 +213,39 @@ def apply_locked(path, command): return True +def protect_template(path): + """Keep rollback to an older binary from reinstalling an unguarded template. + + This updates an inactive runtime payload, without reloading nginx. The old + binary will copy these already-guarded bytes using its legacy installer. + """ + path = path.resolve(strict=True) + old = path.read_bytes() + new = guarded(old.decode()).encode() + if new == old: + return False + atomic(path, new, path.stat().st_mode & 0o777) + return True + + def main(): parser = argparse.ArgumentParser() parser.add_argument('--render', action='store_true') + parser.add_argument('--install', type=Path, metavar='SOURCE') + parser.add_argument('--protect-template', type=Path, metavar='PATH') parser.add_argument('path', nargs='?') args = parser.parse_args() + if sum(bool(value) for value in [args.render, args.install, args.protect_template]) > 1: + parser.error('--render, --install and --protect-template cannot be combined') + if args.protect_template: + protect_template(args.protect_template) + print('Rollback runtime template protected') + return path = Path(args.path) if args.path else active_dashboard() if args.render: print(guarded(path.read_text()), end='') else: - print('Dashboard public source guard installed' if apply(path) else 'Dashboard source guard unchanged') + print('Dashboard public source guard installed' if apply(path, source=args.install) else 'Dashboard source guard unchanged') if __name__ == '__main__': diff --git a/scripts/tests/test_dashboard_public_guard.py b/scripts/tests/test_dashboard_public_guard.py index d7a39c16..95a986e7 100644 --- a/scripts/tests/test_dashboard_public_guard.py +++ b/scripts/tests/test_dashboard_public_guard.py @@ -91,6 +91,84 @@ class GuardTests(unittest.TestCase): self.assertFalse(guard.apply(path, command, Path(tmp) / 'nginx.lock')) self.assertEqual(len(calls), 2) + def test_legacy_runtime_install_is_guarded_before_any_validation_or_reload(self): + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / 'active' + source = Path(tmp) / 'legacy-runtime' + path.write_text(guard.guarded(SOURCE)) + source.write_text(SOURCE + '# legacy runtime revision\n') + calls = [] + def command(args, **kwargs): + # Even the first nginx -t must see a complete guarded candidate. + current = path.read_text() + self.assertEqual(current.count(guard.CHECK), 2) + self.assertEqual(guard.guarded(current), current) + self.assertIn('# legacy runtime revision', current) + calls.append(args) + return subprocess.CompletedProcess(args, 0) + self.assertTrue(guard.apply(path, command, Path(tmp) / 'lock', source)) + self.assertFalse(guard.apply(path, command, Path(tmp) / 'lock', source)) + self.assertEqual(len(calls), 2) + self.assertEqual(source.read_text(), SOURCE + '# legacy runtime revision\n') + + def test_invalid_runtime_never_replaces_protected_site(self): + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / 'active' + source = Path(tmp) / 'legacy-runtime' + previous = guard.guarded(SOURCE) + path.write_text(previous) + source.write_text('server { listen 80 default_server; server_name _; }') + def command(*args, **kwargs): + self.fail('invalid candidate must be rejected before any command') + with self.assertRaises(ValueError): + guard.apply(path, command, Path(tmp) / 'lock', source) + self.assertEqual(path.read_text(), previous) + + def test_runtime_validation_or_reload_failure_preserves_previous_guard(self): + for failure in ['nginx', 'systemctl']: + with self.subTest(failure=failure), tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / 'active' + source = Path(tmp) / 'legacy-runtime' + previous = guard.guarded(SOURCE) + path.write_text(previous) + source.write_text(SOURCE + '# incoming revision\n') + calls = [] + def command(args, **kwargs): + self.assertEqual(path.read_text().count(guard.CHECK), 2) + calls.append(args) + fail = args[0] == failure and sum(x[0] == failure for x in calls) == 1 + return subprocess.CompletedProcess(args, int(fail)) + with self.assertRaises(RuntimeError): + guard.apply(path, command, Path(tmp) / 'lock', source) + self.assertEqual(path.read_text(), previous) + + def test_runtime_bootstrap_uses_guarded_installer_instead_of_raw_copy(self): + # Wiring matters: a safe helper does not help if bootstrap bypasses it. + source = (Path(__file__).parents[2] / 'core/archipelago/src/bootstrap.rs').read_text() + block = source.split('let nginx_src = configs.join("nginx-archipelago.conf");', 1)[1].split('// archipelago-host-secrets-audit', 1)[0] + self.assertIn('scripts/dashboard-public-guard.py', block) + self.assertIn('"--install"', block) + self.assertNotIn('"install",', block) + + def test_rollback_payload_is_protected_idempotently_before_old_binary_can_copy_it(self): + with tempfile.TemporaryDirectory() as tmp: + template = Path(tmp) / 'legacy.conf' + template.write_text(SOURCE) + template.chmod(0o640) + self.assertTrue(guard.protect_template(template)) + self.assertEqual(template.read_text(), guard.guarded(SOURCE)) + self.assertEqual(template.stat().st_mode & 0o777, 0o640) + self.assertFalse(guard.protect_template(template)) + invalid = 'server { listen 80 default_server; }' + template.write_text(invalid) + with self.assertRaises(ValueError): + guard.protect_template(template) + self.assertEqual(template.read_text(), invalid) + source = (Path(__file__).parents[2] / 'core/archipelago/src/update.rs').read_text() + rollback = source.split('pub async fn rollback_update', 1)[1] + self.assertLess(rollback.index('"--protect-template"'), rollback.index('host_sudo(&["cp"')) + self.assertIn('protected.success()', rollback) + if __name__ == '__main__': unittest.main() diff --git a/tests/regression/dashboard-public-guard-network.py b/tests/regression/dashboard-public-guard-network.py index d5c4e8c4..f8b2da1c 100644 --- a/tests/regression/dashboard-public-guard-network.py +++ b/tests/regression/dashboard-public-guard-network.py @@ -60,12 +60,15 @@ http {{ }} }} ''' - # The reusable guard is an http-context include; apply to the inner block. + # Use the same http-context site include as a real appliance, so the + # guarded runtime installer is exercised against actual nginx reloads. start = source.index('http {') + len('http {') - source = source[:start] + '\n' + guard.guarded(source[start:]) - source = bridge.dashboard_acme_root(source, tmp) + legacy = tmp / 'legacy-runtime.conf' + legacy.write_text(bridge.dashboard_acme_root(source[start:source.rfind('}')], tmp)) + site = tmp / 'site.conf' + site.write_text(guard.guarded(legacy.read_text())) config = tmp / 'nginx.conf' - config.write_text(source) + config.write_text(source[:start] + f'\ninclude {site};\n}}\n') command = ['nginx', '-p', str(tmp), '-c', str(config)] subprocess.run(command + ['-t'], check=True, capture_output=True) subprocess.run(command, check=True, capture_output=True) @@ -125,6 +128,38 @@ http {{ subprocess.run(command + ['-s', 'reload'], check=True, capture_output=True) assert request('198.18.0.2')[0] == 404 assert request('fd00:1::2', tls=True)[0] == 200 + def fixture_command(args, **kwargs): + assert site.read_text().count(guard.CHECK) == 2 + actual = command + (['-t'] if args[0] == 'nginx' else ['-s', 'reload']) + return subprocess.run(actual, **kwargs) + assert guard.apply(site, fixture_command, tmp / 'lock', legacy) is False + legacy.write_text(legacy.read_text() + '# old OTA payload with no guard\n') + assert guard.apply(site, fixture_command, tmp / 'lock', legacy) + for src in ['198.18.0.2', '2001:db8:1::2']: + for tls in [False, True]: + for endpoint in ['/', '/assets/main.js', '/rpc/v1', '/ws']: + assert request(src, endpoint, tls, extra='X-Forwarded-For: 127.0.0.1\r\nX-Real-IP: 192.168.1.2\r\n')[0] == 404 + assert request(src, '/.well-known/acme-challenge/test-token', tls)[0] == 200 + assert request('fd00:1::2', tls=True)[0] == 200 + # Emulate the actual legacy rollback: its old binary copies the runtime + # template verbatim. Protect the payload before that old code can run. + assert guard.protect_template(legacy) + site.write_bytes(legacy.read_bytes()) + subprocess.run(command + ['-t'], check=True, capture_output=True) + subprocess.run(command + ['-s', 'reload'], check=True, capture_output=True) + assert request('198.18.0.2', '/rpc/v1')[0] == 404 + assert request('2001:db8:1::2', '/rpc/v1', tls=True)[0] == 404 + previous = site.read_bytes() + legacy.write_text(legacy.read_text() + 'invalid_nginx_directive;\n') + try: + guard.apply(site, fixture_command, tmp / 'lock', legacy) + raise AssertionError('Invalid runtime configuration was accepted') + except RuntimeError: + pass + assert site.read_bytes() == previous + assert request('198.18.0.2')[0] == 404 + assert request('fd00:1::2', tls=True)[0] == 200 + print('PASS real legacy runtime installation and invalid-template rollback: guarded before reload, public IPv4/IPv6 blocked, ACME/private access preserved') print(f'PASS {count} public HTTP/TLS negative cases: IPv4/IPv6, unknown/raw/forged Host/SNI, forwarded headers, UI/assets/RPC/WS; ACME/private access and reload') finally: subprocess.run(command + ['-s', 'quit'], check=True, capture_output=True)