fix: retain management guard through legacy runtime install and rollback
This commit is contained in:
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user