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