Files
archy/scripts/filebrowser-credentials.py
T

269 lines
12 KiB
Python
Raw Normal View History

#!/usr/bin/env python3
"""Provision File Browser's private Cloud account before the server starts.
Runs only with File Browser stopped. Uses its pinned image/CLI, backs up its
actual database, verifies login in a network-isolated container, then atomically
publishes the credential record. Never prints credentials or raw CLI output.
"""
import argparse
import fcntl
import json
import os
from pathlib import Path
import re
import secrets
import subprocess
import sys
import tempfile
class ProvisionError(Exception):
pass
def credentials(value):
if not isinstance(value, dict) or value.get('schema') != 1:
raise ProvisionError('Unsupported Cloud credential record')
if not re.fullmatch(r'archy-[0-9a-f]{32}', value.get('username', '')):
raise ProvisionError('Invalid managed Cloud username')
if not re.fullmatch(r'[0-9a-f]{64}', value.get('password', '')):
raise ProvisionError('Invalid managed Cloud password')
legacy = value.get('legacy_admin_password')
if legacy is not None and not re.fullmatch(r'[0-9a-f]{64}', legacy):
raise ProvisionError('Invalid legacy recovery password')
return value
def atomic_text(path, text):
if path.is_symlink():
raise ProvisionError('Refusing symlink credential file')
fd, tmp = tempfile.mkstemp(prefix='.credential-', dir=path.parent)
try:
os.fchmod(fd, 0o600)
with os.fdopen(fd, 'w') as stream:
stream.write(text)
stream.flush()
os.fsync(stream.fileno())
os.replace(tmp, path)
directory = os.open(path.parent, os.O_DIRECTORY)
try:
os.fsync(directory)
finally:
os.close(directory)
finally:
if os.path.exists(tmp):
os.unlink(tmp)
def atomic_json(path, value):
atomic_text(path, json.dumps(value))
def database_path(data):
config = data / '.filebrowser.json'
if config.is_symlink():
raise ProvisionError('Refusing symlink File Browser config')
if config.exists():
database = json.loads(config.read_text()).get('database')
if not isinstance(database, str) or not re.fullmatch(r'/data/[A-Za-z0-9_.-]+\.db', database):
raise ProvisionError('Unsupported File Browser database path; preserve it for manual review')
else:
existing = [name for name in ['filebrowser.db', 'database.db'] if (data / name).exists()]
if len(existing) > 1:
raise ProvisionError('Multiple File Browser databases without a selected config')
database = '/data/' + (existing[0] if existing else 'filebrowser.db')
if (data / database.removeprefix('/data/')).is_symlink():
raise ProvisionError('Refusing symlink File Browser database')
return database
# All variable input is passed in argv/environment, never interpolated into shell
# code. CLI output may contain sensitive state, so it stays inside the helper.
BOOTSTRAP = r'''
set -eu
umask 077
db="$1"
backup="/data/.archy-auth-backup-$2"
server_pid=""
had_db=0
had_config=0
success=0
changed=0
stop_server() {
if [ -n "$server_pid" ]; then
kill "$server_pid" 2>/dev/null || true
wait "$server_pid" 2>/dev/null || true
server_pid=""
fi
}
finish() {
stop_server
if [ "$success" != 1 ]; then
if [ "$had_db" = 1 ]; then cp -p "$backup/database" "$db"; else rm -f "$db"; fi
if [ "$had_config" = 1 ]; then cp -p "$backup/config" /data/.filebrowser.json; else rm -f /data/.filebrowser.json; fi
elif [ "$changed" = 0 ]; then
rm -f "$backup/database" "$backup/config"
rmdir "$backup"
fi
}
mkdir -m 700 "$backup"
if [ -f "$db" ]; then cp -p "$db" "$backup/database"; had_db=1; fi
if [ -f /data/.filebrowser.json ]; then cp -p /data/.filebrowser.json "$backup/config"; had_config=1; fi
trap finish EXIT
trap 'exit 1' INT TERM
if [ ! -f /data/.filebrowser.json ]; then
printf '{"port":80,"baseURL":"","address":"0.0.0.0","database":"%s","root":"/srv","log":"stdout"}\n' "$db" > /data/.filebrowser.json
chmod 644 /data/.filebrowser.json
fi
cli() { filebrowser "$@" --database "$db" >/tmp/setup.out 2>&1; }
if [ "$had_db" = 0 ]; then
changed=1
mkdir -p /srv/Documents /srv/Photos /srv/Music /srv/Downloads /srv/Builds
cli config init --root /srv --auth.method=json
cli users add "$FB_CLOUD_USERNAME" "$FB_CLOUD_PASSWORD" --perm.admin --perm.execute=false --scope . --lockPassword
fi
cli config export /tmp/settings.json
if grep -Eq '"authMethod"[[:space:]]*:[[:space:]]*"noauth"' /tmp/settings.json; then
changed=1
cli config set --auth.method=json
elif ! grep -Eq '"authMethod"[[:space:]]*:[[:space:]]*"json"' /tmp/settings.json; then
echo 'Unsupported custom File Browser authentication method' >&2
exit 1
fi
printf '{"username":"%s","password":"%s"}' "$FB_CLOUD_USERNAME" "$FB_CLOUD_PASSWORD" >/tmp/cloud-login.json
printf '{"username":"admin","password":"admin"}' >/tmp/default-login.json
printf '{"username":"admin","password":"%s"}' "$FB_LEGACY_PASSWORD" >/tmp/recovery-login.json
start_server() {
filebrowser --database "$db" --root /srv --address 127.0.0.1 --port 18080 >/tmp/server.out 2>&1 &
server_pid=$!
attempts=0
until wget -q -O /dev/null http://127.0.0.1:18080/health; do
attempts=$((attempts + 1))
[ "$attempts" -lt 30 ] && kill -0 "$server_pid" || return 1
sleep 0.2
done
}
login() { wget -q -O /tmp/login-token --header='Content-Type: application/json' --post-file="$1" http://127.0.0.1:18080/api/login 2>/dev/null && [ -s /tmp/login-token ]; }
cloud_root() {
token=$(tr -d '\"' </tmp/login-token)
wget -q -O /tmp/cloud-root --header="X-Auth: $token" http://127.0.0.1:18080/api/resources/ 2>/dev/null
}
start_server
if ! { login /tmp/cloud-login.json && cloud_root; }; then
changed=1
stop_server
if cli users find "$FB_CLOUD_USERNAME"; then
cli users update "$FB_CLOUD_USERNAME" --password "$FB_CLOUD_PASSWORD" --scope . --perm.admin --perm.execute=false --lockPassword
else
cli users add "$FB_CLOUD_USERNAME" "$FB_CLOUD_PASSWORD" --perm.admin --perm.execute=false --scope . --lockPassword
fi
start_server
login /tmp/cloud-login.json
fi
# Only rotate a proven default login. Preserve custom administrator credentials,
# all user IDs, scopes, permissions and shared links.
if login /tmp/default-login.json; then
changed=1
stop_server
cli users update admin --password "$FB_LEGACY_PASSWORD"
start_server
login /tmp/cloud-login.json
if login /tmp/default-login.json; then exit 1; fi
fi
# noauth must not masquerade as a successful private Cloud login.
if wget -q -O /dev/null http://127.0.0.1:18080/api/resources/ 2>/dev/null; then exit 1; fi
if login /tmp/recovery-login.json; then echo DEFAULT_ADMIN_ROTATED; fi
login /tmp/cloud-login.json
cloud_root
stop_server
success=1
'''
def prepare(args):
data = Path(args.data_dir).absolute()
secret_dir = Path(args.secrets_dir).absolute()
if data.is_symlink() or secret_dir.is_symlink():
raise ProvisionError('Refusing symlink provisioning directories')
if not data.is_dir():
raise ProvisionError('Create File Browser storage with the runtime UID before provisioning')
secret_dir.mkdir(parents=True, exist_ok=True, mode=0o700)
record = secret_dir / 'credentials.json'
pending = secret_dir / 'credentials.pending.json'
with (secret_dir / '.provision.lock').open('a') as lock:
os.chmod(lock.name, 0o600)
fcntl.flock(lock, fcntl.LOCK_EX)
result = subprocess.run([args.runtime, 'inspect', args.container], capture_output=True, text=True)
if result.returncode == 0 and json.loads(result.stdout)[0]['State']['Running']:
raise ProvisionError('File Browser must be stopped through its managed service before provisioning')
database = database_path(data)
if record.exists() or pending.exists():
path = record if record.exists() else pending
if path.is_symlink():
raise ProvisionError('Refusing symlink credential file')
value = credentials(json.loads(path.read_text()))
else:
value = {'schema': 1, 'username': 'archy-' + secrets.token_hex(16), 'password': secrets.token_hex(32)}
atomic_json(pending, value)
# Keep the rotated default admin password private for recovery. The Cloud
# account is separate; administrator identity and existing shares survive.
value.setdefault('legacy_admin_password', secrets.token_hex(32))
atomic_json(pending, value)
nonce = secrets.token_hex(8)
env = {**os.environ, 'FB_CLOUD_USERNAME': value['username'], 'FB_CLOUD_PASSWORD': value['password'], 'FB_LEGACY_PASSWORD': value['legacy_admin_password']}
command = [args.runtime, 'run', '--rm', '--network', 'none', '--pull', 'never',
'--name', 'credential_' + ''.join(secrets.choice('abcdefghijklmnopqrstuvwxyz') for _ in range(20)),
'--security-opt', 'no-new-privileges:true', '--cap-drop', 'ALL',
# Match the managed server's storage access. Legacy manifests
# use host UID100000, which need not map to the image's UID.
# Preserve ownership instead of recursively chowning user files.
'--cap-add', 'DAC_OVERRIDE',
'--tmpfs', '/tmp:rw,noexec,nosuid,size=32m',
'-v', str(data) + ':/data:rw', '-v', str(Path(args.srv_root).absolute()) + ':/srv:rw',
'--env', 'FB_CLOUD_USERNAME', '--env', 'FB_CLOUD_PASSWORD', '--env', 'FB_LEGACY_PASSWORD',
'--entrypoint', '/bin/sh', args.image, '-ec', BOOTSTRAP, 'setup', database, nonce]
try:
result = subprocess.run(command, env=env, capture_output=True, timeout=90)
except subprocess.TimeoutExpired:
# Do not print subprocess arguments/environment: they may contain
# private data. SIGTERM lets the helper restore the DB before exit.
subprocess.run([args.runtime, 'stop', '--time', '15', command[command.index('--name') + 1]], capture_output=True)
raise ProvisionError('File Browser credential setup timed out; inspect private backup before retrying') from None
if result.returncode:
raise ProvisionError('File Browser credential setup failed; prior DB/config restored when possible, backup retained')
if b'DEFAULT_ADMIN_ROTATED' in result.stdout:
value['legacy_admin_rotated'] = True
atomic_json(pending, value)
legacy = secret_dir / 'password'
previous = secret_dir / 'password.before-secure-cloud'
if legacy.is_symlink() or previous.is_symlink():
raise ProvisionError('Refusing symlink legacy credential')
if legacy.exists() and not previous.exists():
atomic_text(previous, legacy.read_text())
# Preserve old-backend Cloud access if an OTA is rolled back.
atomic_text(legacy, value['legacy_admin_password'])
atomic_json(record, value)
pending.unlink(missing_ok=True)
print('File Browser Cloud credentials verified; existing files and users preserved.')
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--image', required=True)
parser.add_argument('--runtime', choices=['podman', 'docker'], default='podman')
parser.add_argument('--container', default='filebrowser')
parser.add_argument('--data-dir', default='/var/lib/archipelago/filebrowser-data')
parser.add_argument('--srv-root', default='/var/lib/archipelago/filebrowser')
parser.add_argument('--secrets-dir', default='/var/lib/archipelago/secrets/filebrowser')
args = parser.parse_args()
try:
prepare(args)
except (ProvisionError, OSError, ValueError) as error:
print('File Browser credential setup: ' + str(error), file=sys.stderr)
return 1
return 0
if __name__ == '__main__':
sys.exit(main())