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
+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)