fix: harden node upgrades and prepare 1.9.0-alpha

This commit is contained in:
archipelago
2026-10-05 12:43:49 -04:00
parent 138a541d01
commit daac47cac4
129 changed files with 9910 additions and 794 deletions
+138
View File
@@ -0,0 +1,138 @@
// Real File Browser acceptance: only unique test files are created/deleted.
// CLOUD_URL=http://node CLOUD_COOKIE_FILE=/private/session.json node this-file
const fs = require('node:fs');
const crypto = require('node:crypto');
const { chromium, expect } = require('../../neode-ui/node_modules/@playwright/test');
const base = process.env.CLOUD_URL;
if (!base || !process.env.CLOUD_COOKIE_FILE) throw Error('Set CLOUD_URL and CLOUD_COOKIE_FILE');
(async () => {
const browser = await chromium.launch({ headless: true });
try {
for (const width of [1440, 390]) {
const ctx = await browser.newContext({ viewport: { width, height: 950 }, serviceWorkers: 'block' });
await ctx.addCookies(Object.entries(JSON.parse(fs.readFileSync(process.env.CLOUD_COOKIE_FILE))).map(([name, value]) => ({ name, value, domain: new URL(base).hostname, path: '/' })));
const meta = await ctx.request.get(`${base}/packages/archipelago-companion.json`).then(r => r.json());
await ctx.addInitScript(marker => { localStorage.setItem('neode-auth', 'true'); localStorage.setItem('neode_companion_intro_seen', marker) }, meta.versionCode ? `build:${meta.versionCode}` : `version:${meta.versionName}`);
const page = await ctx.newPage();
const lifecycle = await ctx.newCDPSession(page);
await page.goto(`${base}/dashboard/cloud/files`, { waitUntil: 'domcontentloaded', timeout: 60000 });
await page.locator('button[title="Upload file"]').waitFor({ timeout: 60000 });
await page.evaluate(() => {
const app = document.querySelector('#app').__vue_app__;
window.testRouter = app.config.globalProperties.$router;
window.testCloud = Reflect.ownKeys(app._context.provides).map(k => app._context.provides[k]).find(v => v?._s?.has('cloud'))._s.get('cloud');
});
const dest = await page.evaluate(() => window.testCloud.currentPath);
const tag = `archy-resume-${width}-${Date.now()}`;
const names = [`${tag} + 100% ü.txt`, `${tag}-cancel.txt`, `${tag}-never.txt`, `${tag}-empty.txt`];
const bytes = crypto.randomBytes(5 * 1024 * 1024 + 123);
const expected = crypto.createHash('sha256').update(bytes).digest('hex');
const offsets = [];
let partial = false, final = false, renamed = false, posts = 0;
let refreshArmed = false, refreshAttempts = 0;
const authResponses = [];
page.on('response', async response => {
if (!response.url().endsWith('/rpc/v1') || response.request().postDataJSON()?.method !== 'app.filebrowser-token') return;
const body = await response.json().catch(() => ({}));
authResponses.push({ status: response.status(), tokenPresent: !!body.result?.token,
error: body.error ? String(body.error.message || body.error).slice(0, 180) : null });
});
await page.route('**/rpc/v1', async route => {
const body = route.request().postDataJSON();
if (refreshArmed && body?.method === 'app.filebrowser-token') {
refreshAttempts++;
if (refreshAttempts === 1) return route.abort('connectionreset');
}
return route.continue();
});
await page.route('**/api/tus/**', async route => {
const req = route.request();
if (req.method() === 'POST') posts++;
if (req.method() !== 'PATCH') return route.continue();
offsets.push(Number(req.headers()['upload-offset']));
if (!partial) {
partial = true;
const response = await route.fetch({ postData: req.postDataBuffer().subarray(0, 123456) });
if (response.status() !== 204) throw Error('Partial chunk rejected: ' + response.status());
return route.abort('connectionreset');
}
if (!final && offsets[offsets.length - 1] + req.postDataBuffer().length === bytes.length) {
final = true;
const response = await route.fetch();
if (response.status() !== 204) throw Error('Final chunk rejected: ' + response.status());
return route.abort('connectionreset');
}
return route.continue();
});
await page.route('**/api/resources/**', async route => {
if (route.request().method() === 'PATCH' && !renamed) {
renamed = true;
const response = await route.fetch();
if (!response.ok()) throw Error('Rename rejected: ' + response.status());
return route.abort('connectionreset');
}
return route.continue();
});
try {
await page.locator('input[type=file]').setInputFiles({ name: names[0], mimeType: 'application/octet-stream', buffer: bytes });
await page.getByText(`Connection interrupted · resuming ${names[0]}`, { exact: true }).waitFor({ timeout: 30000 });
await ctx.setOffline(true);
refreshArmed = true;
await page.evaluate(() => { document.cookie = 'auth=; path=/; Max-Age=0' });
await lifecycle.send('Page.setWebLifecycleState', { state: 'frozen' });
await new Promise(resolve => setTimeout(resolve, 1200));
await ctx.setOffline(false);
await lifecycle.send('Page.setWebLifecycleState', { state: 'active' });
await page.evaluate(async () => {
const failure = await window.testRouter.push('/dashboard/apps');
if (failure) throw Error('Apps navigation failed: ' + failure.message);
});
await expect(page).toHaveURL(/\/dashboard\/apps$/, { timeout: 15000 });
await expect(page.getByRole('button', { name: 'Cancel upload', exact: true })).toHaveCount(0);
await page.getByText(`Upload complete · ${names[0]}`, { exact: true }).waitFor({ timeout: 90000 });
await page.getByRole('button', { name: 'View upload', exact: false }).click();
await page.getByText(`Complete · ${names[0]}`, { exact: true }).waitFor();
const height = await page.getByRole('button', { name: 'Dismiss upload', exact: true }).locator('..').evaluate(el => el.getBoundingClientRect().height);
if (height !== 44 || posts !== 1 || offsets[1] !== 123456 || !final || !renamed || refreshAttempts < 2) throw Error('Resume/commit/auth-refresh/bar invariant failed');
const actual = await page.evaluate(async ({ dest, name }) => {
const url = await window.testCloud.fetchBlobUrl(dest.replace(/\/$/, '') + '/' + name);
const data = await (await fetch(url)).arrayBuffer(); URL.revokeObjectURL(url);
// crypto.subtle is unavailable on plain LAN HTTP; return base64 to hash in harness.
let text = ''; for (const n of new Uint8Array(data)) text += String.fromCharCode(n);
return btoa(text);
}, { dest, name: names[0] });
if (crypto.createHash('sha256').update(Buffer.from(actual, 'base64')).digest('hex') !== expected) throw Error('Saved bytes mismatch');
await page.getByRole('button', { name: 'Dismiss upload', exact: true }).click();
await page.unroute('**/api/tus/**'); await page.unroute('**/api/resources/**');
let cancelPatch;
const seen = new Promise(resolve => cancelPatch = resolve);
await page.route('**/api/tus/**', async route => {
if (route.request().method() === 'PATCH') { cancelPatch(); return route.abort('connectionreset') }
return route.continue();
});
await page.locator('input[type=file]').setInputFiles(names.slice(1, 3).map(name => ({ name, mimeType: 'text/plain', buffer: Buffer.from('cancel fixture') })));
await seen;
await page.getByRole('button', { name: 'Cancel upload', exact: true }).click();
await page.getByText('Upload stopped · 0/2 saved', { exact: true }).waitFor({ timeout: 15000 });
await page.unroute('**/api/tus/**');
await page.getByRole('button', { name: 'Dismiss upload', exact: true }).click();
await page.locator('input[type=file]').setInputFiles({ name: names[3], mimeType: 'text/plain', buffer: Buffer.alloc(0) });
await page.getByText(`Complete · ${names[3]}`, { exact: true }).waitFor({ timeout: 30000 });
console.log('PASS real upload', width, 'partial-write resume at123456, offline frozen-page return, interrupted JWT refresh, final ACK loss, rename ACK loss, exact SHA256, encoded name, origin-only44px, notification, cancel queue, empty file');
} catch (error) {
console.error('Token refresh diagnostics:', { refreshAttempts, authResponses });
console.error('Upload state at failure:', await page.evaluate(() => ({ route: window.testRouter.currentRoute.value.fullPath, origin: window.testCloud.upload?.originRoute, active: window.testCloud.upload?.active, error: window.testCloud.upload?.error, sent: window.testCloud.upload?.sent, paused: window.testCloud.upload?.paused })));
throw error;
} finally {
await lifecycle.send('Page.setWebLifecycleState', { state: 'active' });
await ctx.setOffline(false);
await page.unrouteAll({ behavior: 'ignoreErrors' });
await page.evaluate(async ({ dest, names }) => {
window.testCloud.cancelUpload();
for (const name of names) { try { await window.testCloud.deleteItem(dest.replace(/\/$/, '') + '/' + name) } catch {} }
}, { dest, names });
await ctx.close();
}
}
} finally { await browser.close() }
})().catch(e => { console.error(e.message); process.exitCode = 1 });
+186
View File
@@ -0,0 +1,186 @@
#!/usr/bin/env python3
"""Exercise the production Bump RPC against disposable Bitcoin/LND regtest wallets."""
import http.cookiejar
import json
import os
from pathlib import Path
import secrets
import subprocess
import time
import urllib.error
import urllib.request
assert os.environ.get('ARCHY_FEE_REGTEST_ISOLATED') == '1'
assert os.getpid() == 1, 'A private PID namespace is required'
assert os.readlink('/proc/self/ns/net') != os.environ['ARCHY_HOST_NET_NS'], 'Host network refused'
BIN = Path('/opt/archy-regtest-bin')
ROOT = Path('/var/lib/archipelago')
RESULTS = Path('/opt/archy-regtest-results')
ROOT.mkdir(parents=True, exist_ok=True)
RESULTS.mkdir(parents=True, exist_ok=True)
procs = []
logs = []
def launch(name, args, env=None):
log = (RESULTS / (name + '.log')).open('ab'); logs.append(log)
proc = subprocess.Popen(args, stdout=log, stderr=subprocess.STDOUT, env=env)
procs.append(proc)
return proc
def wait(check, seconds=90):
until = time.monotonic() + seconds
last = None
while time.monotonic() < until:
try:
result = check()
if result: return result
except Exception as error: last = error
time.sleep(.5)
raise RuntimeError('Regtest readiness timeout: ' + str(last))
def cli(args):
proc = subprocess.run(args, capture_output=True, text=True, timeout=20)
if proc.returncode: raise RuntimeError(proc.stderr.strip()[:300])
try: return json.loads(proc.stdout)
except json.JSONDecodeError: return proc.stdout.strip()
bitcoin_dir = ROOT / 'regtest-bitcoin'
bitcoin_dir.mkdir()
password = secrets.token_hex(24)
conf = bitcoin_dir / 'bitcoin.conf'
conf.write_text('regtest=1\nserver=1\ndbcache=64\nlisten=0\ndiscover=0\ndnsseed=0\nfallbackfee=0.00002\n'
'[regtest]\nrpcbind=127.0.0.1\nrpcallowip=127.0.0.1\nrpcport=8332\n'
'rpcuser=archipelago\nrpcpassword=' + password + '\n'
'zmqpubrawblock=tcp://127.0.0.1:28332\nzmqpubrawtx=tcp://127.0.0.1:28333\n')
conf.chmod(0o600)
secrets_dir = ROOT / 'secrets'; secrets_dir.mkdir()
(secrets_dir / 'bitcoin-rpc-password').write_text(password)
(secrets_dir / 'bitcoin-rpc-password').chmod(0o600)
def btc(method, *args):
return cli([str(BIN/'bitcoin-cli'), '-datadir='+str(bitcoin_dir), method, *[str(a) for a in args]])
def ln(method, *args):
return cli([str(BIN/'lncli'), '--lnddir='+str(ROOT/'lnd'), '--network=regtest', method, *[str(a) for a in args]])
jar = http.cookiejar.CookieJar()
opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar))
def rpc(method, params=None):
headers = {'Content-Type':'application/json'}
csrf = next((c.value for c in jar if c.name == 'csrf_token'), None)
if csrf: headers['X-CSRF-Token'] = csrf
req = urllib.request.Request('http://127.0.0.1:5678/rpc/v1',
data=json.dumps({'jsonrpc':'2.0','id':1,'method':method,'params':params or {}}).encode(), headers=headers)
with opener.open(req, timeout=60) as response: data = json.load(response)
if data.get('error'): raise RuntimeError(data['error'].get('message', 'RPC error'))
return data['result']
try:
launch('bitcoin', [str(BIN/'bitcoind'), '-datadir='+str(bitcoin_dir)])
wait(lambda: btc('getblockchaininfo')['chain'] == 'regtest')
btc('createwallet', 'miner')
miner = btc('getnewaddress')
btc('generatetoaddress', 101, miner)
lnd_dir = ROOT/'lnd'; lnd_dir.mkdir()
lnd_conf = lnd_dir/'lnd.conf'
lnd_conf.write_text('[Application Options]\nnoseedbackup=1\nrestlisten=127.0.0.1:18080\nrpclisten=127.0.0.1:10009\nlisten=127.0.0.1:9735\n'
'[Bitcoin]\nbitcoin.active=1\nbitcoin.regtest=1\nbitcoin.node=bitcoind\n'
'[Bitcoind]\nbitcoind.rpchost=127.0.0.1:8332\nbitcoind.rpcuser=archipelago\nbitcoind.rpcpass='+password+'\n'
'bitcoind.zmqpubrawblock=tcp://127.0.0.1:28332\nbitcoind.zmqpubrawtx=tcp://127.0.0.1:28333\n')
lnd_conf.chmod(0o600)
launch('lnd', [str(BIN/'lnd'), '--lnddir='+str(lnd_dir)])
wait(lambda: ln('getinfo')['synced_to_chain'], 120)
# The production backend uses this canonical pathname. Only the namespace's
# disposable filesystem is changed; the host's real wallet is inaccessible.
(lnd_dir/'data/chain/bitcoin/mainnet').symlink_to('regtest')
address = ln('newaddress','p2wkh')['address']
btc('sendtoaddress', address, '0.01'); btc('generatetoaddress', 6, miner)
wait(lambda: int(ln('walletbalance')['confirmed_balance']) >= 1_000_000)
env = dict(os.environ, ARCHIPELAGO_DATA_DIR=str(ROOT), ARCHIPELAGO_BIND='127.0.0.1:5678',
ARCHIPELAGO_APPS_DIR=str(ROOT/'empty-apps'), ARCHIPELAGO_LOG_LEVEL='warn')
(ROOT/'empty-apps').mkdir()
backend = launch('backend', [str(BIN/'archipelago')], env)
wait(lambda: urllib.request.urlopen('http://127.0.0.1:5678/health',timeout=3).status == 200, 180)
account_password = secrets.token_urlsafe(24)
rpc('auth.setup', {'password':account_password})
rpc('auth.login', {'password':account_password})
assert rpc('system.get-hostname')
recipient = btc('getnewaddress')
sent = ln('sendcoins', '--addr='+recipient, '--amt=100000', '--sat_per_vbyte=1')
txid = sent['txid']
wait(lambda: btc('getmempoolentry', txid))
quote = rpc('lnd.bump-quote', {'txid':txid, 'sat_per_vbyte':5})
assert quote['method'] == 'cpfp' and quote['recipient_sats'] == 100000
assert 0 < quote['budget_sats'] < quote['input_sats']
result = rpc('lnd.bump-submit', {'txid':txid, 'quote_id':quote['quote_id']})
assert result['status'] in ['registered','mempool']
status = wait(lambda: (s if (s:=rpc('lnd.bump-status',{'txid':txid}))['status']=='mempool' else None),90)
child = status['bump_txid']
raw_parent=btc('getrawtransaction',txid,'true')
assert any(o['scriptPubKey'].get('address')==recipient and round(o['value']*100_000_000)==100000 for o in raw_parent['vout'])
fee = int(status['actual_sweep_fee_sats'])
assert 0 < fee <= quote['budget_sats']
duplicate=rpc('lnd.bump-submit',{'txid':txid,'quote_id':quote['quote_id']})
assert duplicate['bump_txid']==child
print('PASS real regtest CPFP: quote, explicit budget, broadcast, mempool, recipient preserved, duplicate submission', flush=True)
replacement_quote = wait(lambda: rpc('lnd.bump-quote', {'txid':child, 'sat_per_vbyte':10}), 30)
assert replacement_quote['method'] == 'rbf'
assert replacement_quote['recipient_sats'] == 100000
old_child = child
rpc('lnd.bump-submit', {'txid':old_child, 'quote_id':replacement_quote['quote_id']})
replaced = wait(lambda: (s if (s:=rpc('lnd.bump-status', {'txid':old_child}))['status']=='mempool' else None), 90)
child = replaced['bump_txid']
assert child != old_child and int(replaced['actual_sweep_fee_sats']) <= replacement_quote['budget_sats']
assert old_child not in btc('getrawmempool')
assert rpc('lnd.bump-status', {'txid':txid})['bump_txid'] == child
print('PASS real regtest RBF of CPFP child and original operation tracks replacement', flush=True)
def verify_history():
rows = rpc('lnd.gettransactions')['transactions']
parent = next(t for t in rows if t['tx_hash'] == txid)
assert parent['amount_sats'] - parent['total_fees'] == 100000
assert parent['fee_bump_txid'] == child
assert parent['bump_fee_sats'] == int(replaced['actual_sweep_fee_sats'])
assert not any(t['tx_hash'] in [child, old_child] for t in rows)
assert sum(h['status'] != 'replaced' for h in parent['fee_bump_history']) == 1
return True
wait(verify_history)
print('PASS one payment with verified fee history; replacement fee not double-counted', flush=True)
backend.terminate(); backend.wait(timeout=25)
backend=launch('backend',[str(BIN/'archipelago')],env)
wait(lambda: urllib.request.urlopen('http://127.0.0.1:5678/health',timeout=3).status == 200,180)
rpc('auth.login',{'password':account_password})
restored=rpc('lnd.bump-status',{'txid':txid})
assert restored['bump_txid']==child and restored['status']=='mempool'
wait(verify_history)
print('PASS durable operation and grouped history after actual backend restart',flush=True)
block=btc('generatetoaddress',1,miner)[0]
wait(lambda: rpc('lnd.bump-status',{'txid':txid})['status']=='confirmed')
btc('invalidateblock',block)
# A competing empty block announces the alternative chain to LND's ZMQ
# backend while leaving the payment package unconfirmed in Bitcoin.
btc('generateblock', miner, '[]')
try:
wait(lambda: rpc('lnd.bump-status',{'txid':txid})['status']=='mempool')
except RuntimeError:
diagnostic = {'bump':rpc('lnd.bump-status',{'txid':txid}), 'mempool':btc('getrawmempool'),
'lnd_history':ln('listchaintxns'), 'lnd_info':ln('getinfo')}
(RESULTS/'reorg.json').write_text(json.dumps(diagnostic,indent=2))
print('Reorg diagnostic:', diagnostic['bump']['status'], 'mempool size', len(diagnostic['mempool']),flush=True)
raise
wait(verify_history)
print('PASS confirmation and reorg return to mempool with grouped history',flush=True)
(RESULTS/'result.json').write_text(json.dumps({'passed':True,'network':'regtest','real_funds_used':False}))
except Exception:
# Disposable regtest data only; retain enough evidence to diagnose a failed
# relationship/chain-state assertion without weakening the acceptance test.
diagnostic = {}
for name, call in [('wallet_history', lambda: ln('listchaintxns')),
('normalized_history', lambda: rpc('lnd.gettransactions'))]:
try: diagnostic[name] = call()
except Exception as error: diagnostic[name] = str(error)
(RESULTS/'failure-history.json').write_text(json.dumps(diagnostic, indent=2))
raise
finally:
for proc in reversed(procs):
if proc.poll() is None:
proc.terminate()
try: proc.wait(timeout=20)
except subprocess.TimeoutExpired: proc.kill(); proc.wait()
for log in logs: log.close()
+147
View File
@@ -0,0 +1,147 @@
#!/usr/bin/env python3
"""Disposable real-image credential migration acceptance. No live DB mounts."""
import hashlib
import json
import os
from pathlib import Path
import secrets
import subprocess
import tempfile
import time
import urllib.request
import urllib.error
REPO = Path(__file__).resolve().parents[2]
IMAGE = os.environ.get('FILEBROWSER_TEST_IMAGE', 'source.archipelago-foundation.org/lfg2025/filebrowser:v2.63.23')
def command(*args, **kwargs):
return subprocess.run(list(args), check=True, capture_output=True, **kwargs)
def request(port, path, method='GET', data=None, token=None):
headers = {'Content-Type': 'application/json'}
if token:
headers['X-Auth'] = token
req = urllib.request.Request(f'http://127.0.0.1:{port}{path}', data=json.dumps(data).encode() if data is not None else None, method=method, headers=headers)
try:
with urllib.request.urlopen(req, timeout=5) as response:
return response.status, response.read()
except urllib.error.HTTPError as error:
return error.code, b''
def test(case):
root = Path(tempfile.mkdtemp(prefix='archy-fb-acceptance-'))
name = 'credential_' + ''.join(secrets.choice('abcdefghijklmnopqrstuvwxyz') for _ in range(20))
for folder in ['data', 'srv', 'secrets']:
(root / folder).mkdir(mode=0o700 if folder == 'secrets' else 0o755)
command('podman', 'unshare', 'chown', '1000:1000', str(root/'data'), str(root/'srv'))
mount = ['-v', str(root/'data')+':/data', '-v', str(root/'srv')+':/srv']
def cli(*args):
return command('podman', 'run', '--rm', '--network', 'none', *mount, '--entrypoint', 'filebrowser', IMAGE, *args, '--database', '/data/database.db')
def start():
command('podman', 'run', '-d', '--name', name, '--cap-drop', 'ALL', '--cap-add', 'NET_BIND_SERVICE', '--cap-add', 'DAC_OVERRIDE', '-p', '127.0.0.1::80', *mount, IMAGE, '--config', '/data/.filebrowser.json', '--port', '80')
inspect = json.loads(command('podman', 'inspect', name).stdout)[0]
port = inspect['NetworkSettings']['Ports']['80/tcp'][0]['HostPort']
for _ in range(60):
try:
if request(port, '/health')[0] == 200:
return port
except OSError:
pass
time.sleep(.2)
info = json.loads(command('podman', 'inspect', name).stdout)[0]
print('Fixture state:', {k:info['State'].get(k) for k in ['Status', 'ExitCode', 'Error']})
logs = command('podman', 'logs', name)
print((logs.stdout + logs.stderr).decode()[-1400:])
raise AssertionError('Fixture server did not become ready')
def stop():
subprocess.run(['podman', 'rm', '-f', name], capture_output=True)
try:
prior_users = None
if case != 'fresh':
cli('config', 'init', '--root', '/srv', '--minimum-password-length', '3', '--auth.method', 'json')
admin_password = 'admin' if case in ['legacy-default', 'legacy-noauth'] else secrets.token_hex(16)
cli('users', 'add', 'admin', 'initial-fixture-password' if case in ['legacy-default', 'legacy-noauth'] else admin_password, '--perm.admin')
if case in ['legacy-default', 'legacy-noauth']:
# Current File Browser refuses creating weak passwords; import
# a real bcrypt hash to reproduce an older admin/admin DB.
legacy_hash = cli('hash', 'admin').stdout.decode().strip()
assert legacy_hash.startswith('$2')
cli('users', 'export', '/data/legacy-fixture.json')
command('podman', 'unshare', 'python3', '-c', 'import json,pathlib,sys;p=pathlib.Path(sys.argv[1]);u=json.loads(p.read_text());u[0]["password"]=sys.argv[2];p.write_text(json.dumps(u))', str(root/'data'/'legacy-fixture.json'), legacy_hash)
cli('users', 'import', '/data/legacy-fixture.json', '--overwrite')
cli('users', 'add', 'existing-owner', 'fixture-owner-password', '--perm.admin=false', '--perm.delete=false')
config = {'root':'/srv','database':'/data/database.db','address':'0.0.0.0','port':80}
command('podman', 'unshare', 'python3', '-c', 'import pathlib,sys;pathlib.Path(sys.argv[1]).write_text(sys.argv[2]);pathlib.Path(sys.argv[1]).chmod(0o644)', str(root/'data'/'.filebrowser.json'), json.dumps(config))
port = start()
code, token = request(port, '/api/login', 'POST', {'username':'admin','password':admin_password})
assert code == 200
token = token.decode().strip('"')
code, users = request(port, '/api/users', token=token)
assert code == 200
prior_users = json.loads(users)
(root/'secrets'/'password').write_text(admin_password)
stop()
command('podman', 'unshare', 'python3', '-c', 'import pathlib,sys;pathlib.Path(sys.argv[1]).write_bytes(b"preserve original file bytes")', str(root/'srv'/'preserve.txt'))
prepare = ['python3', str(REPO/'scripts/filebrowser-credentials.py'), '--image', IMAGE, '--container', name, '--data-dir', str(root/'data'), '--srv-root', str(root/'srv'), '--secrets-dir', str(root/'secrets')]
if case == 'legacy-noauth':
cli('config', 'set', '--auth.method=noauth')
if case == 'legacy-manifest-owner':
command('podman', 'unshare', 'chown', '-R', '1:1', str(root/'data'), str(root/'srv'))
owner_before = (root/'data').stat().st_uid
if case == 'rollback':
# Force failure AFTER noauth has been migrated to json: creating the
# managed account must respect a stricter operator password policy.
cli('config', 'set', '--minimum-password-length', '128', '--auth.method=noauth')
before = hashlib.sha256(command('podman', 'unshare', 'cat', str(root/'data'/'database.db')).stdout).digest()
result = subprocess.run(prepare, capture_output=True)
assert result.returncode != 0 and not (root/'secrets'/'credentials.json').exists()
after = hashlib.sha256(command('podman', 'unshare', 'cat', str(root/'data'/'database.db')).stdout).digest()
assert before == after, 'Failed migration did not restore original database'
print('PASS forced account-policy failure after auth migration restores exact DB and does not publish credentials')
return
command(*prepare)
if case == 'legacy-manifest-owner':
assert (root/'data').stat().st_uid == owner_before
assert (root/'srv'/'preserve.txt').stat().st_uid == owner_before
record = json.loads((root/'secrets'/'credentials.json').read_text())
assert record['username'] != 'admin' and len(record['password']) == 64
assert (root/'secrets'/'credentials.json').stat().st_mode & 0o777 == 0o600
if case in ['legacy-default', 'legacy-noauth']:
assert (root/'secrets'/'password').read_text() == record['legacy_admin_password']
assert (root/'secrets'/'password.before-secure-cloud').read_text() == 'admin'
for cycle in range(2):
port = start()
assert request(port, '/api/login', 'POST', {'username':'admin','password':'admin'})[0] == 403
assert request(port, '/api/resources/')[0] == 401
code, token = request(port, '/api/login', 'POST', {key:record[key] for key in ['username','password']})
assert code == 200
token = token.decode().strip('"')
assert request(port, '/api/raw/preserve.txt', token=token) == (200, b'preserve original file bytes')
code, users = request(port, '/api/users', token=token)
assert code == 200
users = json.loads(users)
assert len([u for u in users if u['username'] == record['username']]) == 1
if prior_users:
for prior in prior_users:
current = next(u for u in users if u['id'] == prior['id'])
assert current == prior, 'Existing user attributes changed'
assert request(port, '/api/login', 'POST', {'username':'existing-owner','password':'fixture-owner-password'})[0] == 200
if case == 'legacy-custom':
assert request(port, '/api/login', 'POST', {'username':'admin','password':admin_password})[0] == 200
stop()
if cycle == 0:
command(*prepare)
assert json.loads((root/'secrets'/'credentials.json').read_text()) == record
print('PASS', case, 'unique credentials, rejected admin/admin, private access, exact files, accounts/permissions preserved, repeat/restart stable')
finally:
stop()
assert root.name.startswith('archy-fb-acceptance-') and root.parent == Path('/tmp')
command('podman', 'unshare', 'rm', '-rf', str(root))
if __name__ == '__main__':
for case in ['fresh', 'legacy-default', 'legacy-custom', 'legacy-noauth', 'legacy-manifest-owner', 'rollback']:
test(case)
+78
View File
@@ -0,0 +1,78 @@
#!/usr/bin/env python3
"""Exercise the credential pre-start hook through a disposable real Quadlet."""
import importlib.util
import json
from pathlib import Path
import secrets
import socket
import subprocess
import tempfile
import time
spec = importlib.util.spec_from_file_location('fixture', Path(__file__).with_name('filebrowser-credentials.py'))
fixture = importlib.util.module_from_spec(spec)
spec.loader.exec_module(fixture)
run = fixture.command
name = 'credential_' + ''.join(secrets.choice('abcdefghijklmnopqrstuvwxyz') for _ in range(20))
root = Path(tempfile.mkdtemp(prefix='archy-fb-quadlet-'))
units = Path.home() / '.config/containers/systemd'
units.mkdir(parents=True, exist_ok=True)
unit = units / (name + '.container')
assert not unit.exists()
with socket.socket() as probe:
probe.bind(('127.0.0.1', 0))
port = probe.getsockname()[1]
try:
for folder in ['data', 'srv', 'secrets']:
(root / folder).mkdir(mode=0o700 if folder == 'secrets' else 0o755)
# Reproduce the shipped manifest's legacy storage owner, rather than
# pre-aligning fixture ownership to the image user and hiding migration bugs.
run('podman', 'unshare', 'chown', '1:1', str(root/'data'), str(root/'srv'))
helper = fixture.REPO / 'scripts/filebrowser-credentials.py'
unit.write_text(f'''[Container]
Image={fixture.IMAGE}
ContainerName={name}
PublishPort=127.0.0.1:{port}:80
Volume={root}/data:/data
Volume={root}/srv:/srv
DropCapability=all
AddCapability=NET_BIND_SERVICE
AddCapability=DAC_OVERRIDE
NoNewPrivileges=true
Exec=--config /data/.filebrowser.json
[Service]
ExecStartPre=/usr/bin/python3 {helper} --image {fixture.IMAGE} --container {name} --data-dir {root}/data --srv-root {root}/srv --secrets-dir {root}/secrets
TimeoutStartSec=150
Restart=no
''')
run('systemctl', '--user', 'daemon-reload')
previous = None
for cycle in range(2):
run('systemctl', '--user', 'start' if cycle == 0 else 'restart', name + '.service')
record = json.loads((root/'secrets/credentials.json').read_text())
if previous is not None:
assert record == previous, 'Service restart changed the managed account'
previous = record
deadline = time.monotonic() + 30
while True:
try:
code, token = fixture.request(port, '/api/login', 'POST', {key:record[key] for key in ['username', 'password']})
if code == 200:
break
except OSError:
pass
assert time.monotonic() < deadline, 'Service did not provide Cloud login'
time.sleep(.2)
assert fixture.request(port, '/api/resources/', token=token.decode().strip('"'))[0] == 200
assert fixture.request(port, '/api/resources/')[0] == 401
assert fixture.request(port, '/api/login', 'POST', {'username':'admin', 'password':'admin'})[0] == 403
print('PASS actual Quadlet pre-start, fresh secure login, managed restart, stable account, rejected anonymous/default access')
finally:
subprocess.run(['systemctl', '--user', 'stop', name + '.service'], capture_output=True)
unit.unlink(missing_ok=True)
subprocess.run(['systemctl', '--user', 'daemon-reload'], capture_output=True)
subprocess.run(['systemctl', '--user', 'reset-failed', name + '.service'], capture_output=True)
subprocess.run(['podman', 'rm', '-f', name], capture_output=True)
assert root.name.startswith('archy-fb-quadlet-') and root.parent == Path('/tmp')
run('podman', 'unshare', 'rm', '-rf', str(root))
+121
View File
@@ -0,0 +1,121 @@
"""Local Pebble ACME authority for real NPM issuance/renewal tests only.
No production CA requests, DNS changes, or system trust-store modifications.
See https://github.com/letsencrypt/pebble for the test server's protocol/limits.
"""
import hashlib
import http.client
import json
import socket
import ssl
import subprocess
import time
import urllib.request
import uuid
class AcmeFixture:
def __init__(self, root, http_port, run, port):
self.root = root / 'acme'
self.root.mkdir(mode=0o700)
self.http_port, self.run = http_port, run
self.api_port, self.management_port, self.dns_management = port(), port(), port()
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as probe:
probe.bind(('127.0.0.1', 0))
self.dns_port = probe.getsockname()[1]
suffix = ''.join(chr(ord('a') + int(char, 16)) for char in uuid.uuid4().hex)
self.ca_name, self.dns_name = 'credential_acme' + suffix, 'credential_dns' + suffix
self.root_pem = b''
def start(self):
self.run('openssl', 'req', '-x509', '-newkey', 'rsa:2048', '-nodes', '-days', '2',
'-subj', '/CN=acme-fixture.test',
'-addext', 'subjectAltName=DNS:acme-fixture.test,IP:127.0.0.1',
'-addext', 'basicConstraints=critical,CA:TRUE',
'-keyout', str(self.root/'api-key.pem'), '-out', str(self.root/'api-cert.pem'))
config = {'pebble': {
'listenAddress': f'127.0.0.1:{self.api_port}',
'managementListenAddress': f'127.0.0.1:{self.management_port}',
'certificate': '/fixture/api-cert.pem', 'privateKey': '/fixture/api-key.pem',
'httpPort': self.http_port, 'tlsPort': 5001,
'externalAccountBindingRequired': False,
'retryAfter': {'authz': 1, 'order': 1}}}
(self.root/'pebble.json').write_text(json.dumps(config))
self.run('podman', 'run', '-d', '--name', self.dns_name, '--network', 'host', '--memory', '128m',
'ghcr.io/letsencrypt/pebble-challtestsrv:latest',
'-dnsserver', f'127.0.0.1:{self.dns_port}', '-management', f'127.0.0.1:{self.dns_management}',
'-http01', '', '-https01', '', '-tlsalpn01', '', '-doh', '',
'-defaultIPv4', '127.0.0.1', '-defaultIPv6', '')
self.run('podman', 'run', '-d', '--name', self.ca_name, '--network', 'host', '--memory', '192m',
'-e', 'PEBBLE_VA_NOSLEEP=1', '-e', 'PEBBLE_AUTHZREUSE=0',
'-e', 'PEBBLE_WFE_NONCEREJECT=0',
'-v', str(self.root)+':/fixture:ro', 'ghcr.io/letsencrypt/pebble:latest',
'-config', '/fixture/pebble.json', '-dnsserver', f'127.0.0.1:{self.dns_port}', '-strict=false')
context = ssl.create_default_context(cafile=str(self.root/'api-cert.pem'))
deadline = time.monotonic() + 30
while True:
try:
with urllib.request.urlopen(f'https://127.0.0.1:{self.management_port}/roots/0',
context=context, timeout=5) as response:
self.root_pem = response.read()
assert b'BEGIN CERTIFICATE' in self.root_pem
(self.root/'root-ca.pem').write_bytes(self.root_pem)
break
except OSError:
if time.monotonic() >= deadline:
raise RuntimeError('Local ACME authority did not become ready') from None
time.sleep(.2)
def npm_args(self):
# Explicit slirp host-loopback gateway: Podman's automatic host alias
# can resolve to a LAN address where the loopback-only CA does not listen.
return ['--add-host', 'acme-fixture.test:169.254.1.2',
'-e', f'LE_SERVER=https://acme-fixture.test:{self.api_port}/dir',
'-e', 'REQUESTS_CA_BUNDLE=/acme-fixture/api-cert.pem',
'-v', str(self.root)+':/acme-fixture:ro']
def verify(self, api, sync, public, payload, tls_port, log):
# Issue before creating this NPM host: challenge must use the default
# ACME location, without making the management vhost publicly available.
domain = 'prehost.example'
certificate = api('/nginx/certificates', {
'provider': 'letsencrypt', 'domain_names': [domain],
'meta': {'dns_challenge': False}}, 'POST')
assert certificate['provider'] == 'letsencrypt'
host = api('/nginx/proxy-hosts', {
**payload, 'domain_names': [domain], 'certificate_id': certificate['id'], 'ssl_forced': True}, 'POST')
assert sync()
time.sleep(.4)
context = ssl.create_default_context(cafile=str(self.root/'root-ca.pem'))
def served():
stream = context.wrap_socket(socket.create_connection(('127.0.0.1', tls_port), timeout=15),
server_hostname=domain)
fingerprint = hashlib.sha256(stream.getpeercert(binary_form=True)).hexdigest()
connection = http.client.HTTPConnection(domain, tls_port, timeout=15)
connection.sock = stream
try:
connection.request('GET', '/', headers={'Host': domain})
response = connection.getresponse()
assert response.status == 200, 'Issued certificate route did not reach the app'
response.read()
return fingerprint
finally:
connection.close()
before = served()
assert public(host=domain)[0] == 301
initial_challenges = log.read_text().count('/.well-known/acme-challenge/')
assert initial_challenges > 0, 'No actual ACME HTTP validation reached the bridge'
api(f'/nginx/certificates/{certificate["id"]}/renew', {}, 'POST')
assert sync(), 'Renewed certificate did not trigger bridge reload'
time.sleep(.4)
assert served() != before, 'Public TLS still serves the pre-renewal certificate'
assert log.read_text().count('/.well-known/acme-challenge/') > initial_challenges
assert public('/rpc/v1', host='unknown.example')[0] == 404
print('PASS actual local Pebble ACME: pre-host issuance, HTTP validation, forced-HTTPS renewal, new served certificate, unknown management route404', flush=True)
api(f'/nginx/proxy-hosts/{host["id"]}', method='DELETE')
assert sync()
def close(self):
for name in [self.ca_name, self.dns_name]:
subprocess.run(['podman', 'rm', '-f', '--ignore', '--time', '3', name],
check=True, capture_output=True, timeout=90)
+374
View File
@@ -0,0 +1,374 @@
#!/usr/bin/env python3
"""Opt-in real NPM API/host-nginx integration with disposable state and listeners."""
import importlib.util
import base64
import http.client
import ipaddress
import json
import os
from pathlib import Path
import secrets
import socket
import ssl
import subprocess
import tempfile
import time
import urllib.error
import urllib.request
import uuid
import yaml
if os.environ.get('ARCHY_ALLOW_DISPOSABLE_CONTAINERS') != '1':
raise SystemExit('Set ARCHY_ALLOW_DISPOSABLE_CONTAINERS=1 for isolated NPM integration')
ROOT = Path(__file__).resolve().parents[2]
NPM_IMAGE = yaml.safe_load((ROOT / 'apps/nginx-proxy-manager/manifest.yml').read_text())['app']['container']['image']
spec = importlib.util.spec_from_file_location('bridge', ROOT / 'scripts/npm-public-bridge.py')
bridge = importlib.util.module_from_spec(spec)
spec.loader.exec_module(bridge)
def run(*args):
result = subprocess.run(args, capture_output=True, timeout=120)
if result.returncode:
# NPM logs/API setup may contain credentials. Never dump them on failure.
raise RuntimeError(f'{args[0]} fixture operation failed ({result.returncode})')
return result.stdout
def available_port():
with socket.socket() as probe:
probe.bind(('127.0.0.1', 0))
return probe.getsockname()[1]
class NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, *args, **kwargs):
return None
def request(url, data=None, method=None, headers=None, timeout=15):
req = urllib.request.Request(url, data=data, method=method, headers=headers or {})
try:
with urllib.request.build_opener(NoRedirect).open(req, timeout=timeout) as response:
return response.status, response.headers, response.read()
except urllib.error.HTTPError as error:
return error.code, error.headers, error.read()
name = 'archy-npm-test-' + uuid.uuid4().hex[:10]
backend = name + '-upstream'
network = name + '-net'
npm_network = os.environ.get('ARCHY_NPM_NETWORK', 'slirp4netns:allow_host_loopback=true,cidr=169.254.1.0/24')
upstream_port = available_port()
lan_address = json.loads(run('ip', '-j', 'route', 'get', '1.1.1.1'))[0]['prefsrc']
assert ipaddress.ip_address(lan_address).is_private, 'Fixture requires a private LAN address'
upstream_host = os.environ.get('ARCHY_NPM_UPSTREAM', lan_address)
nginx_started = False
network_created = False
acme = None
with tempfile.TemporaryDirectory(prefix='archy-npm-bridge-test-') as directory:
root = Path(directory)
layout = os.environ.get('ARCHY_NPM_LAYOUT', 'flat')
assert layout in ('flat', 'nested')
base = root / 'npm'
data = base if layout == 'flat' else base / 'data'
certs = base / 'letsencrypt'
data.mkdir(parents=True); certs.mkdir(parents=True)
bridge.prepare_realip({'data': str(data)})
nginx = ['/usr/sbin/nginx', '-p', str(root), '-c', str(root / 'nginx.conf')]
try:
http_port, tls_port = available_port(), available_port()
if os.environ.get('ARCHY_NPM_ACME') == '1':
acme_spec = importlib.util.spec_from_file_location('acme_fixture', Path(__file__).with_name('npm-acme-fixture.py'))
acme_module = importlib.util.module_from_spec(acme_spec)
acme_spec.loader.exec_module(acme_module)
acme = acme_module.AcmeFixture(root, http_port, run, available_port)
acme.start()
run('podman', 'network', 'create', network)
network_created = True
fixture = r"""const server=require('http').createServer((q,r)=>{r.setHeader('Content-Type','application/json');r.end(JSON.stringify({route:q.url,client:q.headers['x-real-ip'],xff:q.headers['x-forwarded-for'],publicIngress:q.headers['x-archipelago-public-ingress']}))});server.on('upgrade',(q,s)=>{const accept=require('crypto').createHash('sha1').update(q.headers['sec-websocket-key']+'258EAFA5-E914-47DA-95CA-C5AB0DC85B11').digest('base64');const frame='["EOSE","fixture"]';s.end('HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: '+accept+'\r\n\r\n'+String.fromCharCode(129,frame.length)+frame,'latin1')});server.listen(8080,'0.0.0.0')"""
run('podman', 'run', '-d', '--name', backend, '--network', network,
'--network-alias', 'fixture-upstream', '--memory', '128m',
'-p', f'127.0.0.1:{upstream_port}:8080',
'-p', f'{lan_address}:{upstream_port}:8080',
'docker.io/library/node:24-alpine', 'node', '-e', fixture)
run('podman', 'run', '-d', '--name', name, '--network', npm_network,
'--memory', '512m', '--pids-limit', '512',
'-p', '127.0.0.1::81', '-p', '127.0.0.1::80', '-p', '127.0.0.1::443',
'-v', f'{data}:/data', '-v', f'{certs}:/etc/letsencrypt',
*(acme.npm_args() if acme else []),
NPM_IMAGE)
runtime = json.loads(run('podman', 'inspect', name))[0]
admin = 'http://' + bridge.local_port(runtime, 81)
deadline = time.monotonic() + 150
while time.monotonic() < deadline:
try:
if request(admin + '/api/')[0] == 200:
break
except OSError:
pass
time.sleep(2)
else:
raise RuntimeError('Disposable NPM admin did not become ready')
token = None
def api(path, payload=None, method=None):
headers = {'Content-Type': 'application/json'}
if token:
headers['Authorization'] = 'Bearer ' + token
status, _, body = request(admin + '/api' + path,
None if payload is None else json.dumps(payload).encode(), method, headers,
timeout=180 if acme else 15)
if status not in (200, 201, 204):
# Only non-secret schema diagnostics, never raw request/response.
if acme and path.startswith('/nginx/certificates'):
fd, diagnostic = tempfile.mkstemp(prefix='archy-npm-acme-error-', suffix='.json')
with os.fdopen(fd, 'wb') as stream:
stream.write(body)
print('Private ACME failure diagnostic: ' + diagnostic, flush=True)
raise RuntimeError(f'NPM API {method or "GET"} {path} returned {status}')
return json.loads(body) if body else None
password = secrets.token_urlsafe(32)
api('/users', {'name': 'Disposable Fixture', 'nickname': 'Fixture', 'email': 'fixture@example.test',
'auth': {'type': 'password', 'secret': password}}, 'POST')
token = api('/tokens', {'identity': 'fixture@example.test', 'secret': password}, 'POST')['token']
deadline = time.monotonic() + 30
while True:
try:
assert request(f'http://127.0.0.1:{upstream_port}/fixture-ready')[0] == 200
probe = run('podman', 'exec', name, 'curl', '--silent', '--show-error',
'--max-time', '5', '--fail', f'http://{upstream_host}:{upstream_port}/fixture-ready')
assert json.loads(probe)['route'] == '/fixture-ready'
break
except (OSError, RuntimeError, AssertionError):
if time.monotonic() >= deadline:
raise RuntimeError('NPM same-node fixture upstream is not reachable') from None
time.sleep(1)
print('PASS NPM actual namespace reaches same-node upstream', flush=True)
if 'cidr=169.254.1.0/24' in npm_network:
for address in [lan_address, 'host.containers.internal', '169.254.1.2']:
probe = run('podman', 'exec', name, 'curl', '--silent', '--show-error',
'--max-time', '5', '--fail', f'http://{address}:{upstream_port}/fixture-ready')
assert json.loads(probe)['route'] == '/fixture-ready'
print('PASS LAN, stable host alias and legacy gateway from NPM namespace', flush=True)
payload = {'domain_names': ['fixture.example', 'second.example'], 'forward_scheme': 'http',
'forward_host': upstream_host, 'forward_port': upstream_port,
'allow_websocket_upgrade': True,
'advanced_config': 'location = /custom { return 200 "custom-route"; }'}
host = api('/nginx/proxy-hosts', payload, 'POST')
assert host['meta'].get('nginx_online', True), 'NPM rejected fixture config'
paths = bridge.resolve_paths(base, runtime)
assert paths == {'data': str(data), 'certificates': str(certs)}
output, trust_file = root / 'public.conf', root / 'trust.pem'
def sync():
config, trust, fingerprints = bridge.render(bridge.hosts(data), paths,
bridge.local_port(runtime, 80), bridge.local_port(runtime, 443),
data / 'letsencrypt-acme-challenge', trust_file)
if acme:
# Trust the local test CA only inside this disposable nginx.
trust += b'\n' + acme.root_pem
config = config.replace(b'listen 80;', f'listen 127.0.0.1:{http_port};'.encode())
config = config.replace(b'listen [::]:80;', b'')
config = config.replace(b'listen 443 ssl;', f'listen 127.0.0.1:{tls_port} ssl;'.encode())
config = config.replace(b'listen [::]:443 ssl;', b'')
def command(args, **kwargs):
translated = nginx + (['-t'] if args[0] == 'nginx' else ['-s', 'reload'])
return subprocess.run(translated, **kwargs)
def reload_certificate():
run('podman', 'exec', name, 'nginx', '-t')
run('podman', 'exec', name, 'nginx', '-s', 'reload')
return bridge.apply_files([(output, config, 0o644), (trust_file, trust, 0o600)],
root / 'state', command, root / 'lock', json.dumps(fingerprints),
certificate_reload=reload_certificate)
output.write_text('# initial\n')
(root / 'nginx.conf').write_text(f'''pid {root}/nginx.pid;
error_log {root}/nginx-error.log;
events {{ worker_connections 128; }}
http {{ access_log {root}/access.log;
server {{ listen 127.0.0.1:{http_port} default_server;
location ^~ /.well-known/acme-challenge/ {{ root {data}/letsencrypt-acme-challenge; try_files $uri =404; }}
location / {{ return 404; }}
}} include {output}; }}
''')
run(*nginx, '-t'); run(*nginx); nginx_started = True
assert sync()
time.sleep(.3)
def public(path='/', host='fixture.example'):
return request(f'http://127.0.0.1:{http_port}' + path,
headers={'Host': host, 'X-Real-IP': '192.168.99.99', 'X-Forwarded-For': '192.168.99.99'})
status, _, body = public()
assert status == 200, f'Public bridge status {status}'
observed = json.loads(body)
assert observed['client'] == '127.0.0.1', 'NPM did not preserve actual bridge client IP: ' + str(observed['client'])
assert '192.168.99.99' not in observed['xff'], 'Forged forwarding header survived bridge'
assert observed['publicIngress'] == '1', 'Public ingress marker missing at upstream'
assert public('/custom')[2] == b'custom-route'
assert public(host='second.example')[0] == 200
assert public(host='unknown.example')[0] == 404
assert not sync(), 'Unchanged configuration reloaded nginx'
print('PASS real NPM fresh setup/API host creation, shared domains, custom route, client IP and spoof rejection', flush=True)
if acme:
acme.verify(api, sync, public, payload, tls_port, root/'access.log')
certificate = api('/nginx/certificates', {'provider': 'other', 'nice_name': 'Disposable TLS fixture'}, 'POST')
cert_path, key_path = root / 'leaf.pem', root / 'key.pem'
def upload_certificate():
run('openssl', 'req', '-x509', '-newkey', 'rsa:2048', '-nodes', '-days', '2',
'-subj', '/CN=fixture.example', '-addext', 'subjectAltName=DNS:fixture.example,DNS:second.example',
'-keyout', str(key_path), '-out', str(cert_path))
boundary = 'archy-' + uuid.uuid4().hex
parts = []
for field, path in [('certificate', cert_path), ('certificate_key', key_path)]:
parts.append((f'--{boundary}\r\nContent-Disposition: form-data; name="{field}"; filename="{path.name}"\r\n'
'Content-Type: application/octet-stream\r\n\r\n').encode() + path.read_bytes() + b'\r\n')
body = b''.join(parts) + f'--{boundary}--\r\n'.encode()
status, _, _ = request(admin + '/api/nginx/certificates/' + str(certificate['id']) + '/upload',
body, 'POST', {'Authorization': 'Bearer ' + token,
'Content-Type': 'multipart/form-data; boundary=' + boundary})
assert status == 200, f'Fixture certificate upload failed ({status})'
upload_certificate()
secure_payload = {**payload, 'certificate_id': certificate['id'], 'ssl_forced': True}
api('/nginx/proxy-hosts/' + str(host['id']), secure_payload, 'PUT')
assert sync(); time.sleep(.3)
def secure(headers=None):
context = ssl.create_default_context(cafile=str(cert_path))
stream = context.wrap_socket(socket.create_connection(('127.0.0.1', tls_port), timeout=15),
server_hostname='fixture.example')
connection = http.client.HTTPConnection('fixture.example', tls_port, timeout=15)
connection.sock = stream
try:
connection.request('GET', '/', headers={'Host': 'fixture.example', **(headers or {})})
response = connection.getresponse()
return response.status, response.read()
finally:
connection.close()
assert public()[0] == 301, 'NPM forced HTTPS was not preserved'
assert secure()[0] == 200, 'Verified TLS bridge failed or redirected in a loop'
run('podman', 'exec', name, 'sh', '-c',
'mkdir -p /data/letsencrypt-acme-challenge/.well-known/acme-challenge && '
'printf exact-challenge > /data/letsencrypt-acme-challenge/.well-known/acme-challenge/fixture-token')
challenge = public('/.well-known/acme-challenge/fixture-token')
assert challenge[0] == 200 and challenge[2] == b'exact-challenge', 'Forced HTTPS intercepted NPM challenge file'
assert public('/.well-known/acme-challenge/missing-token')[0] == 404
context = ssl.create_default_context(cafile=str(cert_path))
with context.wrap_socket(socket.create_connection(('127.0.0.1', tls_port), timeout=15),
server_hostname='fixture.example') as stream:
stream.sendall(b'GET /relay HTTP/1.1\r\nHost: fixture.example\r\nConnection: Upgrade\r\n'
b'Upgrade: websocket\r\nSec-WebSocket-Version: 13\r\n'
b'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\r\n')
response = b''
while b'EOSE' not in response:
chunk = stream.recv(4096)
if not chunk:
break
response += chunk
assert b'101 Switching Protocols' in response and b'EOSE' in response, 'WSS upgrade/frame failed through NPM'
print(f'PASS {layout} active-mount ACME file under forced HTTPS and trusted WSS upgrade/frame through NPM', flush=True)
upload_certificate()
assert sync(), 'Certificate replacement did not trigger a host nginx reload'
time.sleep(.3)
renewed_status = secure()[0]
assert renewed_status == 200, f'Certificate replacement TLS returned {renewed_status}'
print('PASS trusted TLS to/from NPM, forced HTTPS without redirect loop, certificate replacement/reload', flush=True)
acl_secret = secrets.token_urlsafe(24)
acl = api('/nginx/access-lists', {'name': 'Fixture ACL', 'satisfy_any': False, 'pass_auth': False,
'items': [{'username': 'fixture', 'password': acl_secret}],
'clients': [{'directive': 'allow', 'address': '127.0.0.1'},
{'directive': 'deny', 'address': 'all'}]}, 'POST')
api('/nginx/proxy-hosts/' + str(host['id']), {**secure_payload, 'access_list_id': acl['id']}, 'PUT')
authorization = 'Basic ' + base64.b64encode(('fixture:' + acl_secret).encode()).decode()
time.sleep(.3)
assert secure()[0] == 401, 'NPM access-list authentication was bypassed'
assert secure({'Authorization': authorization})[0] == 200
api('/nginx/access-lists/' + str(acl['id']), {'name': 'Fixture ACL', 'satisfy_any': False, 'pass_auth': False,
'items': [{'username': 'fixture', 'password': acl_secret}],
'clients': [{'directive': 'allow', 'address': '192.168.0.0/16'},
{'directive': 'deny', 'address': 'all'}]}, 'PUT')
time.sleep(.3)
assert secure({'Authorization': authorization, 'X-Real-IP': '192.168.99.99',
'X-Forwarded-For': '192.168.99.99'})[0] == 403, 'Forged source bypassed NPM network ACL'
print('PASS NPM password and network ACL enforcement through bridge; forged client address rejected', flush=True)
api('/nginx/proxy-hosts/' + str(host['id']), {**payload, 'certificate_id': 0, 'ssl_forced': False, 'access_list_id': 0}, 'PUT')
assert sync(); time.sleep(.3)
api('/nginx/proxy-hosts/' + str(host['id']), {**payload, 'enabled': False}, 'PUT')
assert sync(); time.sleep(.3)
assert public()[0] == 404, 'Disabled NPM host remains routed'
api('/nginx/proxy-hosts/' + str(host['id']), {**payload, 'enabled': True}, 'PUT')
assert sync(); time.sleep(.3)
assert public()[0] == 200
run('podman', 'restart', name)
restarted_at = time.monotonic()
# Match the app's declared first-start/restart readiness budget.
deadline = restarted_at + 180
observed = {}
while time.monotonic() < deadline:
try:
observed['public_http'] = public()[0]
observed['admin_http'] = request(admin + '/api/users/me',
headers={'Authorization': 'Bearer ' + token})[0]
if observed['public_http'] == 200 and observed['admin_http'] == 200:
break
except OSError as error:
observed['transport_error'] = type(error).__name__
time.sleep(2)
else:
state = json.loads(run('podman', 'inspect', name))[0]['State']
observed.update({key: state.get(key) for key in ['Status', 'ExitCode', 'OOMKilled']})
raise RuntimeError('NPM restart exceeded the 180-second app budget: ' + json.dumps(observed))
print(f'PASS NPM restart became ready in {time.monotonic() - restarted_at:.1f}s', flush=True)
# Exercise the actual Podman failure/rollback primitive with retained
# NPM state. The fixture upstream owns this port, forcing replacement
# startup to fail before the old definition may safely be discarded.
original_id = json.loads(run('podman', 'inspect', name))[0]['Id']
previous = name + '-previous'
run('podman', 'stop', '--time', '10', name)
run('podman', 'rename', name, previous)
failed = subprocess.run([
'podman', 'run', '-d', '--name', name, '--pull', 'never',
'--network', npm_network, '-p', f'127.0.0.1:{upstream_port}:81',
'--memory', '512m', '-v', f'{data}:/data', '-v', f'{certs}:/etc/letsencrypt',
NPM_IMAGE,
], capture_output=True, timeout=120)
assert failed.returncode != 0, 'Occupied fixture port did not reject replacement'
run('podman', 'rm', '-f', '--ignore', name)
run('podman', 'rename', previous, name)
run('podman', 'start', name)
assert json.loads(run('podman', 'inspect', name))[0]['Id'] == original_id
deadline = time.monotonic() + 180
while time.monotonic() < deadline:
try:
if public()[0] == 200 and request(admin + '/api/users/me',
headers={'Authorization': 'Bearer ' + token})[0] == 200:
break
except OSError:
pass
time.sleep(2)
else:
raise RuntimeError('Original NPM did not recover after rejected replacement')
print('PASS forced replacement bind failure: original container ID, hosts, DB and authenticated API restored', flush=True)
api('/nginx/proxy-hosts/' + str(host['id']), method='DELETE')
assert sync(); time.sleep(.3)
assert public()[0] == 404, 'Deleted NPM host remains routed'
print('PASS NPM disable/enable/delete propagation and restart with preserved DB', flush=True)
finally:
# An overloaded node can time out removing one fixture. Always attempt
# the others, then retry failed cleanup instead of leaving them running.
cleanup = []
if nginx_started:
cleanup.append((nginx + ['-s', 'quit'], 15))
cleanup.extend((['podman', 'rm', '-f', '--ignore', '--time', '3', container], 90)
for container in [name, name + '-previous', backend])
if acme:
cleanup.extend((['podman', 'rm', '-f', '--ignore', '--time', '3', container], 90)
for container in [acme.ca_name, acme.dns_name])
if network_created:
cleanup.append((['podman', 'network', 'rm', network], 30))
# The fixture may contain rootless-mapped nginx ownership.
cleanup.append((['podman', 'unshare', 'rm', '-rf', str(data), str(certs)], 30))
failed = []
for args, limit in cleanup:
try:
if subprocess.run(args, capture_output=True, timeout=limit).returncode:
failed.append((args, limit))
except subprocess.TimeoutExpired:
failed.append((args, limit))
for args, limit in failed:
subprocess.run(args, capture_output=True, timeout=limit, check=True)
@@ -0,0 +1,134 @@
#!/usr/bin/env python3
"""Actual nginx HTTP/TLS source-boundary tests in a disposable network namespace."""
import importlib.util
import os
from pathlib import Path
import socket
import ssl
import subprocess
import tempfile
import time
assert os.geteuid() == 0, 'Run through the isolated systemd test unit'
assert os.readlink('/proc/self/ns/net') != os.readlink('/proc/1/ns/net'), 'Refusing host network namespace'
root = Path(__file__).resolve().parents[2]
spec = importlib.util.spec_from_file_location('guard', root / 'scripts/dashboard-public-guard.py')
guard = importlib.util.module_from_spec(spec)
spec.loader.exec_module(guard)
bridge_spec = importlib.util.spec_from_file_location('bridge', root / 'scripts/npm-public-bridge.py')
bridge = importlib.util.module_from_spec(bridge_spec)
bridge_spec.loader.exec_module(bridge)
subprocess.run(['ip', 'link', 'set', 'lo', 'up'], check=True)
for address in ['198.18.0.1/32', '198.18.0.2/32', '192.168.10.2/32', '100.64.123.2/32',
'2001:db8:1::1/128', '2001:db8:1::2/128', 'fd00:1::2/128']:
subprocess.run(['ip', 'addr', 'add', address, 'dev', 'lo'], check=True)
with tempfile.TemporaryDirectory(prefix='archy-guard-network-') as tmp:
tmp = Path(tmp)
tmp.chmod(0o755)
challenge = tmp / 'letsencrypt-acme-challenge/.well-known/acme-challenge'
challenge.mkdir(parents=True)
(challenge / 'test-token').write_text('exact-acme-token')
cert, key = tmp / 'cert.pem', tmp / 'key.pem'
subprocess.run(['openssl', 'req', '-x509', '-newkey', 'rsa:2048', '-nodes', '-days', '1',
'-subj', '/CN=fixture.example', '-keyout', str(key), '-out', str(cert)],
check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
source = f'''pid {tmp}/nginx.pid;
error_log {tmp}/error.log;
events {{ worker_connections 128; }}
http {{
access_log off;
set_real_ip_from 127.0.0.1;
set_real_ip_from ::1;
real_ip_header X-Real-IP;
server {{
listen 80 default_server;
listen [::]:80 default_server;
server_name _;
location ^~ /.well-known/acme-challenge/ {{ root {bridge.BASE}/data/letsencrypt-acme-challenge; try_files $uri =404; }}
location / {{ return 200 "dashboard-or-rpc"; }}
}}
server {{
listen 443 ssl default_server;
listen [::]:443 ssl default_server;
ssl_certificate {cert}; ssl_certificate_key {key};
server_name _;
# Legacy shipped HTTPS template omitted the ACME location.
location / {{ return 200 "dashboard-or-rpc"; }}
}}
server {{ listen 80; listen [::]:80; server_name public.example;
location / {{ return 200 "public-app"; }}
}}
}}
'''
# The reusable guard is an http-context include; apply to the inner block.
start = source.index('http {') + len('http {')
source = source[:start] + '\n' + guard.guarded(source[start:])
source = bridge.dashboard_acme_root(source, tmp)
config = tmp / 'nginx.conf'
config.write_text(source)
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)
context = ssl.create_default_context(cafile=str(cert))
context.check_hostname = False # Unknown-SNI routing probe; certificate chain still verified.
def request(src, path='/', tls=False, host='unknown.example', sni='unknown.example', extra='', method='GET', websocket=False):
family = socket.AF_INET6 if ':' in src else socket.AF_INET
target = '2001:db8:1::1' if family == socket.AF_INET6 else '198.18.0.1'
stream = socket.socket(family)
stream.settimeout(4)
stream.bind((src, 0))
stream.connect((target, 443 if tls else 80))
if tls:
stream = context.wrap_socket(stream, server_hostname=sni)
with stream:
connection = 'Upgrade' if websocket else 'close'
if websocket:
extra += 'Upgrade: websocket\r\nSec-WebSocket-Version: 13\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n'
stream.sendall(f'{method} {path} HTTP/1.1\r\nHost: {host}\r\nConnection: {connection}\r\nContent-Length: 0\r\n{extra}\r\n'.encode())
body = b''
while data := stream.recv(65536):
body += data
# Upgrade requests can leave a rejected connection persistent.
if websocket and b'\r\n\r\n' in body:
headers, payload = body.split(b'\r\n\r\n', 1)
lengths = [int(line.split(b':', 1)[1]) for line in headers.split(b'\r\n')
if line.lower().startswith(b'content-length:')]
if lengths and len(payload) >= lengths[0]:
break
return int(body.split(b' ', 2)[1]), body
try:
count = 0
for src in ['198.18.0.2', '2001:db8:1::2']:
for tls in [False, True]:
for host in ['unknown.example', '127.0.0.1', 'archipelago.local', '198.18.0.1', '[2001:db8:1::1]']:
for path in ['/', '/login', '/assets/dashboard.js', '/rpc/v1', '/ws', '/.well-known/acme-challenge/../rpc/v1']:
status, body = request(src, path, tls, host, None if host in ['198.18.0.1', '[2001:db8:1::1]'] else host,
'X-Forwarded-For: 127.0.0.1\r\nX-Real-IP: 192.168.1.2\r\n',
method='POST' if path == '/rpc/v1' else 'GET', websocket=path == '/ws')
assert status == 404 and b'dashboard-or-rpc' not in body, (src, tls, host, path, status)
count += 1
status, body = request(src, '/.well-known/acme-challenge/test-token', tls)
assert status == 200 and body.endswith(b'exact-acme-token'), (status, body)
assert request(src, host='public.example')[1].endswith(b'public-app')
for src in ['127.0.0.1', '192.168.10.2', '100.64.123.2', '::1', 'fd00:1::2']:
for tls in [False, True]:
assert request(src, '/rpc/v1', tls)[0] == 200
for src in ['127.0.0.1', '::1']:
for tls in [False, True]:
for client in ['198.18.0.2', '2001:db8:1::2']:
assert request(src, '/rpc/v1', tls, extra=f'X-Real-IP: {client}\r\n', method='POST')[0] == 404
assert request(src, '/rpc/v1', tls, extra='X-Real-IP: 192.168.10.2\r\n')[0] == 200
assert request(src, '/rpc/v1', tls, extra='X-Archipelago-Public-Ingress: 1\r\n', method='POST')[0] == 404
status, body = request(src, '/.well-known/acme-challenge/test-token', tls,
extra='X-Real-IP: 198.18.0.2\r\nX-Archipelago-Public-Ingress: 1\r\n')
assert status == 200 and body.endswith(b'exact-acme-token')
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
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)
for _ in range(40):
if not (tmp / 'nginx.pid').exists():
break
time.sleep(.05)
+16 -1
View File
@@ -20,7 +20,13 @@ class DoctorOverlayTests(unittest.TestCase):
(dest / 'container-doctor.sh').write_text('UNSAFE OLD SCRIPT')
files = [ROOT / 'scripts/container-doctor.sh',
ROOT / 'image-recipe/configs/archipelago-doctor.service',
ROOT / 'image-recipe/configs/archipelago-doctor.timer']
ROOT / 'image-recipe/configs/archipelago-doctor.timer',
ROOT / 'image-recipe/configs/archipelago-npm-bridge.service',
ROOT / 'image-recipe/configs/archipelago-npm-bridge.timer']
helpers = [ROOT / 'scripts' / name for name in
['dashboard-public-guard.py', 'npm-public-bridge.py', 'sync-npm-public-hosts.sh', 'filebrowser-credentials.py']]
for path in helpers:
shutil.copyfile(path, payload / path.name)
for path in files:
shutil.copyfile(path, payload / path.name)
script = block.replace('/mnt/target', str(target))
@@ -32,6 +38,15 @@ class DoctorOverlayTests(unittest.TestCase):
self.assertEqual(dest.stat().st_mode & 0o777, 0o755)
for path in files[1:]:
self.assertEqual((units / path.name).read_bytes(), path.read_bytes())
for path in helpers:
installed = target / 'opt/archipelago/scripts' / path.name
self.assertEqual(installed.read_bytes(), path.read_bytes())
self.assertEqual(installed.stat().st_mode & 0o777, 0o755)
self.assertTrue((units / 'timers.target.wants/archipelago-npm-bridge.timer').is_symlink())
(payload / 'filebrowser-credentials.py').unlink()
failed = subprocess.run(['bash', '-c', script], env={'BOOT_MEDIA': str(media), 'PATH': '/usr/bin:/bin'}, capture_output=True)
self.assertNotEqual(failed.returncode, 0, 'Missing credential provisioner must fail installation')
shutil.copyfile(ROOT / 'scripts/filebrowser-credentials.py', payload / 'filebrowser-credentials.py')
(payload / 'container-doctor.sh').unlink()
failed = subprocess.run(['bash', '-c', script], env={'BOOT_MEDIA': str(media), 'PATH': '/usr/bin:/bin'}, capture_output=True)
self.assertNotEqual(failed.returncode, 0, 'Missing safety overlay must fail installation')
+7
View File
@@ -70,10 +70,17 @@ summary() {
# ── Stage 1: static ──────────────────────────────────────────────────
stage "git-diff-check" git diff --check
stage "mirror-gate-regression" python3 scripts/tests/test_git_mirrors.py
stage "iso-boot-runner-regression" python3 scripts/tests/test_iso_qemu_runner.py
stage "cargo-fmt" timeout 240 cargo fmt --manifest-path core/Cargo.toml --all --check
stage "app-build-contexts" python3 tests/regression/app-build-contexts.py
stage "manifest-shell" python3 scripts/check-manifest-shell.py
stage "npm-tunnel-migration" python3 -m unittest discover -s scripts/tests -p test_repair_npm_tunnel.py
stage "npm-public-bridge" python3 -m unittest discover -s scripts/tests -p test_npm_public_bridge.py
stage "filebrowser-credentials" python3 -m unittest discover -s scripts/tests -p test_filebrowser_credentials.py
stage "dashboard-source-guard" python3 -m unittest discover -s scripts/tests -p test_dashboard_public_guard.py
stage "catalog-capabilities" python3 -m unittest discover -s scripts/tests -p test_catalog_capabilities.py
stage "dashboard-network-isolated" bash scripts/test-dashboard-guard-isolated.sh
stage "iso-doctor-overlay" python3 tests/regression/iso-doctor-overlay.py
stage "doctor-egress" bash tests/regression/container-doctor-egress.sh
stage "doctor-ports" bash tests/regression/container-doctor-ports.sh