fix: harden node upgrades and prepare 1.9.0-alpha
This commit is contained in:
@@ -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 });
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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))
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user