fix: retain management guard through legacy runtime install and rollback

This commit is contained in:
archipelago
2026-10-05 15:08:42 -04:00
parent ba8b1f29b2
commit 446fa7b7fd
7 changed files with 283 additions and 14 deletions
+5 -5
View File
@@ -496,14 +496,14 @@ async fn run_runtime_assets() -> Result<bool> {
if nginx_src.exists() { if nginx_src.exists() {
let src_s = nginx_src.to_string_lossy().to_string(); let src_s = nginx_src.to_string_lossy().to_string();
let status = host_sudo(&[ let status = host_sudo(&[
"install", "python3",
"-m", "-c",
"644", include_str!("../../../scripts/dashboard-public-guard.py"),
"--install",
&src_s, &src_s,
"/etc/nginx/sites-available/archipelago",
]) ])
.await .await
.context("install nginx-archipelago.conf")?; .context("install guarded nginx-archipelago.conf")?;
if !status.success() { if !status.success() {
anyhow::bail!("install nginx-archipelago.conf exited with {}", status); anyhow::bail!("install nginx-archipelago.conf exited with {}", status);
} }
+19
View File
@@ -2059,6 +2059,25 @@ pub async fn rollback_update(data_dir: &Path) -> Result<()> {
let backup_binary = backup_dir.join("archipelago"); let backup_binary = backup_dir.join("archipelago");
if backup_binary.exists() { 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: // Same two namespace gotchas as apply_update()'s binary swap:
// `cp` straight onto the running binary is O_TRUNC and fails // `cp` straight onto the running binary is O_TRUNC and fails
// ETXTBSY (exit 1 — exactly what broke the .116 rollback), and // ETXTBSY (exit 1 — exactly what broke the .116 rollback), and
+46
View File
@@ -210,3 +210,49 @@ required. Also observed own `/api/v1/query/Angor/projects?limit=10` returns404;
reference MempoolIndexerAngorApi.GetProjectsAsync uses this older specialized reference MempoolIndexerAngorApi.GetProjectsAsync uses this older specialized
route, whereas current deployment docs recommend stock Mempool. Verify actual route, whereas current deployment docs recommend stock Mempool. Verify actual
client version/discovery path rather than claiming fees/health prove compatibility. 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.
+64
View File
@@ -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`. Private rollback backup: `/var/lib/archipelago/support/190-mobile-20261005`.
Evidence: `/tmp/archy-190-yaya-mobile-deploy.log`. Phone acceptance was on the dev 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. 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.
+32 -5
View File
@@ -168,16 +168,20 @@ def active_dashboard(nginx_root=Path('/etc/nginx')):
return selected.resolve(strict=True) 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. # The NPM bridge uses this same lock for nginx configuration transactions.
with lock_path.open('a+b') as lock: with lock_path.open('a+b') as lock:
fcntl.flock(lock, fcntl.LOCK_EX) 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() 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: if new == old:
return False return False
backup_dir = (Path('/var/lib/archipelago/nginx-management-guard') backup_dir = (Path('/var/lib/archipelago/nginx-management-guard')
@@ -209,16 +213,39 @@ def apply_locked(path, command):
return True 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(): def main():
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser()
parser.add_argument('--render', action='store_true') 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='?') parser.add_argument('path', nargs='?')
args = parser.parse_args() 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() path = Path(args.path) if args.path else active_dashboard()
if args.render: if args.render:
print(guarded(path.read_text()), end='') print(guarded(path.read_text()), end='')
else: 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__': if __name__ == '__main__':
@@ -91,6 +91,84 @@ class GuardTests(unittest.TestCase):
self.assertFalse(guard.apply(path, command, Path(tmp) / 'nginx.lock')) self.assertFalse(guard.apply(path, command, Path(tmp) / 'nginx.lock'))
self.assertEqual(len(calls), 2) 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__': if __name__ == '__main__':
unittest.main() unittest.main()
@@ -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 {') start = source.index('http {') + len('http {')
source = source[:start] + '\n' + guard.guarded(source[start:]) legacy = tmp / 'legacy-runtime.conf'
source = bridge.dashboard_acme_root(source, tmp) 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 = 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)] command = ['nginx', '-p', str(tmp), '-c', str(config)]
subprocess.run(command + ['-t'], check=True, capture_output=True) subprocess.run(command + ['-t'], check=True, capture_output=True)
subprocess.run(command, 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) subprocess.run(command + ['-s', 'reload'], check=True, capture_output=True)
assert request('198.18.0.2')[0] == 404 assert request('198.18.0.2')[0] == 404
assert request('fd00:1::2', tls=True)[0] == 200 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') 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: finally:
subprocess.run(command + ['-s', 'quit'], check=True, capture_output=True) subprocess.run(command + ['-s', 'quit'], check=True, capture_output=True)