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
+22 -8
View File
@@ -162,22 +162,36 @@ ISO="$(find_iso)"
# ── Stage 4: mount-level smoke test ──────────────────────────────────
stage "iso-smoke" bash scripts/iso-smoke-test.sh "$ISO" "$VERSION"
# ── Stage 5: QEMU boot test (best-effort) ────────────────────────────
# ── Stage 5: QEMU boot test ─────────────────────────────────────────
# The ISO's kernel cmdline has no serial console, so the serial-log
# sanity grep can miss a perfectly healthy boot. Run it, report it,
# but don't fail an otherwise-green build on it.
# sanity grep can miss a healthy boot. That requires separate evidence;
# inconclusive results must never be counted as passing release gates.
if [ "$NO_QEMU" = "0" ] && command -v qemu-system-x86_64 >/dev/null 2>&1; then
echo
echo "═══ [qemu-boot] (best-effort) test-iso-qemu.sh $ISO 180"
if bash image-recipe/_archived/test-iso-qemu.sh "$ISO" 180; then
echo "═══ [qemu-boot] test-iso-qemu.sh $ISO 180"
qemu_work=$(mktemp -d -t archipelago-iso-boot.XXXXXX)
echo " Disposable boot disk/logs: $qemu_work"
read -r qemu_ssh qemu_http < <(python3 - <<'PY'
import socket
with socket.socket() as ssh, socket.socket() as http:
ssh.bind(('127.0.0.1', 0)); http.bind(('127.0.0.1', 0))
print(ssh.getsockname()[1], http.getsockname()[1])
PY
)
if TMPDIR="$qemu_work" QEMU_SSH_PORT="$qemu_ssh" QEMU_HTTP_PORT="$qemu_http" bash image-recipe/_archived/test-iso-qemu.sh "$ISO" 180; then
echo "═══ [qemu-boot] PASS"
PASS+=("qemu-boot")
else
echo "═══ [qemu-boot] INCONCLUSIVE (not gating — verify on real hardware)"
PASS+=("qemu-boot(inconclusive)")
echo "═══ [qemu-boot] NOT VERIFIED — inspect boot evidence before publication"
FAIL+=("qemu-boot")
summary 1
fi
else
elif [ "$NO_QEMU" = "1" ]; then
echo; echo "═══ [qemu-boot] SKIPPED"
else
echo "═══ [qemu-boot] NOT VERIFIED — qemu-system-x86_64 is missing"
FAIL+=("qemu-boot")
summary 1
fi
# ── Done ─────────────────────────────────────────────────────────────
+1 -1
View File
@@ -83,7 +83,7 @@ def load_catalog(path: Path) -> dict[str, dict[str, Any]]:
manifest = entry.get("manifest")
for variant in reversed(entry.get("manifest_variants", [])):
requires = variant.get("requires", [])
if requires and all(cap == "runtime-migration-backup-v1" for cap in requires):
if requires and all(cap in {"runtime-migration-backup-v1", "npm-legacy-host-gateway-v1"} for cap in requires):
manifest = variant.get("manifest")
break
if isinstance(manifest, dict) and isinstance(manifest.get("app"), dict):
-1
View File
@@ -558,7 +558,6 @@ fix_npm_public_hosts() {
local script="/opt/archipelago/scripts/sync-npm-public-hosts.sh"
[ -x "$script" ] || script="$SCRIPT_DIR/sync-npm-public-hosts.sh"
[ -x "$script" ] || return 1
[ -f /var/lib/archipelago/nginx-proxy-manager/data/database.sqlite ] || return 1
if "$script" >/dev/null 2>&1; then
log "Synced Nginx Proxy Manager public hosts into host nginx"
+3 -2
View File
@@ -109,7 +109,8 @@ if [ -z "$FRONTEND_ARCHIVE" ]; then
# it, and silently installs nothing. There is no error to notice.
mkdir -p "$RUNTIME_DIR/image-recipe/configs"
for unit in archipelago-doctor.service archipelago-doctor.timer \
archipelago-host-secrets-audit.service; do
archipelago-host-secrets-audit.service \
archipelago-npm-bridge.service archipelago-npm-bridge.timer; do
if [ -f "$PROJECT_ROOT/image-recipe/configs/$unit" ]; then
echo " Including runtime unit $unit"
cp "$PROJECT_ROOT/image-recipe/configs/$unit" "$RUNTIME_DIR/image-recipe/configs/$unit"
@@ -127,7 +128,7 @@ if [ -z "$FRONTEND_ARCHIVE" ]; then
# flag → mesh dead) and no archy-rnodeconf at all (Flash LoRa fails
# with "No such file or directory"). bootstrap.rs promotes these to
# /usr/local/bin on first startup after the update.
for tool in archy-reticulum-daemon archy-rnodeconf; do
for tool in archy-reticulum-daemon archy-rnodeconf archy-esptool; do
if [ -f "$PROJECT_ROOT/reticulum-daemon/dist/$tool" ]; then
mkdir -p "$RUNTIME_DIR/radio-tools"
echo " Including radio tool $tool"
+225
View File
@@ -0,0 +1,225 @@
#!/usr/bin/env python3
"""Keep default dashboard vhosts private while allowing HTTP-01 challenges.
Apply only to Archipelago's default HTTP/HTTPS servers. Named NPM public
services remain separate. Never trust Host, XFF or rewritten client addresses
as evidence that a request came from a private network.
"""
from pathlib import Path
import argparse
import fcntl
import os
import re
import subprocess
import tempfile
import time
BEGIN = '# BEGIN ARCHIPELAGO MANAGEMENT SOURCE GUARD'
END = '# END ARCHIPELAGO MANAGEMENT SOURCE GUARD'
GUARD = '''# BEGIN ARCHIPELAGO MANAGEMENT SOURCE GUARD
# Use the original socket peer, before any real_ip / forwarded-header rewrite.
geo $realip_remote_addr $archy_management_private_source {
default 0;
127.0.0.0/8 1;
169.254.0.0/16 1;
10.0.0.0/8 1;
172.16.0.0/12 1;
192.168.0.0/16 1;
100.64.0.0/10 1;
::1/128 1;
fc00::/7 1;
fe80::/10 1;
}
# A configured trusted proxy may have rewritten remote_addr. Require both
# the original peer and the validated effective client to be private.
geo $remote_addr $archy_management_private_client {
default 0;
127.0.0.0/8 1;
169.254.0.0/16 1;
10.0.0.0/8 1;
172.16.0.0/12 1;
192.168.0.0/16 1;
100.64.0.0/10 1;
::1/128 1;
fc00::/7 1;
fe80::/10 1;
}
map $http_x_archipelago_public_ingress $archy_management_public_ingress {
default 1;
'' 0;
}
map "$archy_management_private_source:$archy_management_private_client:$archy_management_public_ingress:$uri" $archy_management_denied {
default 1;
~^1:1:0: 0;
"~^[01]:[01]:[01]:/\\.well-known/acme-challenge/[A-Za-z0-9_-]+$" 0;
}
# END ARCHIPELAGO MANAGEMENT SOURCE GUARD
'''
CHECK = ' if ($archy_management_denied) { return 404; }\n'
def server_blocks(text):
"""Find server blocks without interpreting braces in comments or strings."""
masked = list(text)
quote = None
escaped = False
comment = False
for i, char in enumerate(text):
if comment:
if char == '\n':
comment = False
else:
masked[i] = ' '
elif escaped:
masked[i] = ' '
escaped = False
elif quote:
masked[i] = ' '
if char == '\\':
escaped = True
elif char == quote:
quote = None
elif char == '#':
comment = True
masked[i] = ' '
elif char in ('"', "'"):
quote = char
masked[i] = ' '
plain = ''.join(masked)
for found in re.finditer(r'\bserver\s*\{', plain):
opening = found.end() - 1
depth = 1
end = opening + 1
while end < len(plain) and depth:
if plain[end] == '{':
depth += 1
elif plain[end] == '}':
depth -= 1
end += 1
if depth:
raise ValueError('Unbalanced nginx server block; configuration left unchanged')
yield opening, end, plain[opening + 1:end - 1]
def guarded(text):
original = text
if text.count(BEGIN) != text.count(END) or text.count(BEGIN) > 1:
raise ValueError('Ambiguous managed source guard; configuration left unchanged')
if BEGIN in text and text.index(BEGIN) > text.index(END):
raise ValueError('Reversed managed source guard markers; configuration left unchanged')
text = re.sub(re.escape(BEGIN) + r'.*?' + re.escape(END) + r'\n?', '', text, flags=re.S)
edits = []
protected = set()
for opening, end, body in server_blocks(text):
ports = set()
# Older node-CA setup used address-specific HTTPS listeners without
# default_server. The dashboard's catch-all name still identifies it.
management = any('_' in names.split() for names in
re.findall(r'\bserver_name\s+([^;]+);', body))
for listen in re.findall(r'\blisten\s+([^;]+);', body):
tokens = listen.split()
if 'default_server' not in tokens and not management:
continue
match = re.search(r'(?:^|:)(80|443)$', tokens[0])
if match:
ports.add(int(match[1]))
if not ports:
continue
protected.update(ports)
actual = text[opening + 1:end - 1]
if CHECK.strip() not in actual:
edits.append(opening + 1)
if protected != {80, 443}:
raise ValueError('Expected both default HTTP and HTTPS dashboard servers; no partial guard installed')
for at in reversed(edits):
text = text[:at] + '\n' + CHECK + text[at:]
text = GUARD + '\n' + text.lstrip('\n')
return text if text != original else original
def atomic(path, data, mode):
with tempfile.NamedTemporaryFile(dir=path.parent, delete=False) as stream:
temporary = Path(stream.name)
os.fchmod(stream.fileno(), mode)
stream.write(data)
stream.flush()
os.fsync(stream.fileno())
try:
os.replace(temporary, path)
sync_directory(path.parent)
finally:
temporary.unlink(missing_ok=True)
def sync_directory(path):
descriptor = os.open(path, os.O_RDONLY | os.O_DIRECTORY)
try:
os.fsync(descriptor)
finally:
os.close(descriptor)
def active_dashboard(nginx_root=Path('/etc/nginx')):
enabled = nginx_root / 'sites-enabled/archipelago'
available = nginx_root / 'sites-available/archipelago'
# Installed systems have both symlinks and standalone enabled copies.
# Follow a symlink without replacing it; patch the copy when it is active.
selected = enabled if enabled.exists() or enabled.is_symlink() else available
return selected.resolve(strict=True)
def apply(path, command=subprocess.run, lock_path=Path('/run/lock/archy-nginx-config.lock')):
# The NPM bridge uses this same lock for nginx configuration transactions.
with lock_path.open('a+b') as lock:
fcntl.flock(lock, fcntl.LOCK_EX)
return apply_locked(path.resolve(strict=True), command)
def apply_locked(path, command):
old = path.read_bytes()
new = guarded(old.decode()).encode()
if new == old:
return False
backup_dir = (Path('/var/lib/archipelago/nginx-management-guard')
if path.is_relative_to('/etc/nginx') else path.parent)
backup_dir.mkdir(parents=True, exist_ok=True, mode=0o700)
backup = backup_dir / (path.name + '.before-management-guard-' + str(time.time_ns()))
# Exclusive, synced backup is a prerequisite to modifying the live config.
with backup.open('xb') as stream:
os.fchmod(stream.fileno(), 0o600)
stream.write(old)
stream.flush()
os.fsync(stream.fileno())
sync_directory(backup.parent)
mode = path.stat().st_mode & 0o777
atomic(path, new, mode)
try:
for args in (['nginx', '-t'], ['systemctl', 'reload', 'nginx']):
result = command(args, capture_output=True, timeout=30)
if result.returncode:
raise RuntimeError('Dashboard source guard validation/reload failed')
except Exception as failure:
atomic(path, old, mode)
# Reload the known previous configuration if a failed reload changed state.
for args in (['nginx', '-t'], ['systemctl', 'reload', 'nginx']):
result = command(args, capture_output=True, timeout=30)
if result.returncode:
raise RuntimeError('Previous configuration restored on disk, but rollback validation/reload failed') from failure
raise
return True
def main():
parser = argparse.ArgumentParser()
parser.add_argument('--render', action='store_true')
parser.add_argument('path', nargs='?')
args = parser.parse_args()
path = Path(args.path) if args.path else active_dashboard()
if args.render:
print(guarded(path.read_text()), end='')
else:
print('Dashboard public source guard installed' if apply(path) else 'Dashboard source guard unchanged')
if __name__ == '__main__':
main()
+268
View File
@@ -0,0 +1,268 @@
#!/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())
+55 -54
View File
@@ -48,6 +48,13 @@ SCRIPT_DIR_FBC="$(cd "$(dirname "$0")" && pwd)"
# as root (rootful podman), the backend can't see them at all.
DOCKER="runuser -u archipelago -- env XDG_RUNTIME_DIR=/run/user/$(id -u archipelago) podman"
prepare_filebrowser_credentials() {
install -d -o archipelago -g archipelago -m 700 /var/lib/archipelago/secrets/filebrowser
chown 100000:100000 /var/lib/archipelago/filebrowser /var/lib/archipelago/filebrowser-data
runuser -u archipelago -- env XDG_RUNTIME_DIR="/run/user/$(id -u archipelago)" \
python3 "$SCRIPT_DIR_FBC/filebrowser-credentials.py" --image "$FILEBROWSER_IMAGE"
}
PORT_ALLOC_FILE="/var/lib/archipelago/port-allocations.env"
mkdir -p /var/lib/archipelago 2>/dev/null || true
[ -f "$PORT_ALLOC_FILE" ] && . "$PORT_ALLOC_FILE"
@@ -142,19 +149,14 @@ if [ -f "$UNBUNDLED_MARKER" ]; then
return 1
}
# Create FileBrowser (noauth — behind Archipelago login)
if ! $DOCKER ps -a --format '{{.Names}}' 2>/dev/null | grep -q filebrowser; then
log "Creating FileBrowser (noauth)..."
# Create FileBrowser (unique private Cloud credentials)
if ! $DOCKER container exists filebrowser; then
log "Creating FileBrowser (secure Cloud login)..."
mkdir -p /var/lib/archipelago/filebrowser /var/lib/archipelago/filebrowser-data
mkdir -p /var/lib/archipelago/filebrowser/{Documents,Photos,Music,Videos,Downloads}
chown -R 100000:100000 /var/lib/archipelago/filebrowser
chown -R 100000:100000 /var/lib/archipelago/filebrowser-data
# Write config with database on persistent volume
cat > /var/lib/archipelago/filebrowser-data/.filebrowser.json <<'FBEOF'
{"port":80,"baseURL":"","address":"0.0.0.0","database":"/data/filebrowser.db","root":"/srv","log":"stdout"}
FBEOF
chown 100000:100000 /var/lib/archipelago/filebrowser-data/.filebrowser.json
pull_with_fallback "${FILEBROWSER_IMAGE}"
chown 100000:100000 /var/lib/archipelago/filebrowser
chown 100000:100000 /var/lib/archipelago/filebrowser-data
pull_with_fallback "${FILEBROWSER_IMAGE}" || exit 1
prepare_filebrowser_credentials || { log "ERROR: secure File Browser setup failed"; exit 1; }
$DOCKER run -d --name filebrowser --restart unless-stopped \
--network archy-net \
--cap-drop=ALL --cap-add=DAC_OVERRIDE --cap-add=NET_BIND_SERVICE \
@@ -162,21 +164,13 @@ FBEOF
--health-cmd='wget -q --spider http://localhost:80/health || exit 1' \
--health-interval=30s --health-timeout=5s --health-retries=3 \
--memory=256m \
-p 8083:80 \
-p 127.0.0.1:8083:80 \
-v /var/lib/archipelago/filebrowser:/srv \
-v /var/lib/archipelago/filebrowser-data:/data \
${FILEBROWSER_IMAGE} \
--config /data/.filebrowser.json 2>>"$LOG" && \
log " FileBrowser created" || log " WARNING: FileBrowser creation failed"
# Set noauth after first start
sleep 3
$DOCKER exec filebrowser /filebrowser config set --auth.method=noauth --database /data/filebrowser.db 2>>"$LOG" || true
$DOCKER exec filebrowser /filebrowser users add admin admin --perm.admin --database /data/filebrowser.db 2>>"$LOG" || true
$DOCKER restart filebrowser 2>>"$LOG" || true
# Create filebrowser password for backend token flow
mkdir -p /var/lib/archipelago/secrets/filebrowser
echo -n "admin" > /var/lib/archipelago/secrets/filebrowser/password
chown -R 1000:1000 /var/lib/archipelago/secrets
fi
# Create Fedimint Client (fmcd) alongside FileBrowser so ecash / networking
@@ -537,7 +531,7 @@ for dir in lnd electrumx btcpay nbxplorer jellyfin vaultwarden \
done
# Nginx Proxy Manager runs as root in the rootless user namespace, which maps to
# the archipelago user on host bind mounts. Keep certbot's webroot writable.
[ -d /var/lib/archipelago/nginx-proxy-manager ] && chown -R 1000:1000 /var/lib/archipelago/nginx-proxy-manager 2>/dev/null
# Existing NPM ownership is preserved; new storage is initialized below.
# Bitcoin Knots: container UID 101 → host UID 100101
[ -d /var/lib/archipelago/bitcoin ] && chown -R 100101:100101 /var/lib/archipelago/bitcoin 2>/dev/null
# Postgres: container UID 70 → host UID 100070
@@ -1249,48 +1243,55 @@ fi
track_container "searxng"
# OnlyOffice removed — incompatible with rootless Podman (internal postgres/rabbitmq)
# CryptPad is the replacement (single Node.js process, e2e encrypted)
if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q filebrowser; then
log "Creating File Browser (noauth — behind Archipelago login)..."
if ! $DOCKER container exists filebrowser; then
log "Creating File Browser (secure Cloud login)..."
mkdir -p /var/lib/archipelago/filebrowser /var/lib/archipelago/filebrowser-data
mkdir -p /var/lib/archipelago/filebrowser/{Documents,Photos,Music,Downloads,Builds}
# Config with noauth + database on persistent volume (survives container recreation)
cat > /var/lib/archipelago/filebrowser-data/.filebrowser.json << 'FBEOF'
{"port":80,"baseURL":"","address":"0.0.0.0","database":"/data/filebrowser.db","root":"/srv","log":"stdout"}
FBEOF
$DOCKER image exists "$FILEBROWSER_IMAGE" || $DOCKER pull "$FILEBROWSER_IMAGE" || exit 1
prepare_filebrowser_credentials || { log "ERROR: secure File Browser setup failed"; exit 1; }
$DOCKER run -d --name filebrowser --restart unless-stopped \
--health-cmd="wget -q --spider http://localhost:80/health || exit 1" --health-interval=120s --health-timeout=5s --health-retries=3 \
--memory=$(mem_limit filebrowser) \
--cap-drop ALL --security-opt no-new-privileges:true \
--cap-drop ALL --cap-add=DAC_OVERRIDE --cap-add=NET_BIND_SERVICE --security-opt no-new-privileges:true \
--tmpfs=/tmp:rw,noexec,nosuid,size=256m --tmpfs=/run:rw,noexec,nosuid,size=64m \
-p 8083:80 \
-p 127.0.0.1:8083:80 \
-v /var/lib/archipelago/filebrowser:/srv \
-v /var/lib/archipelago/filebrowser-data:/data \
"$FILEBROWSER_IMAGE" \
--config /data/.filebrowser.json 2>>"$LOG" || true
# Set noauth after first start (initializes database on volume)
sleep 3
$DOCKER exec filebrowser /filebrowser config set --auth.method=noauth --database /data/filebrowser.db 2>>"$LOG" || true
$DOCKER exec filebrowser /filebrowser users add admin admin --perm.admin --database /data/filebrowser.db 2>>"$LOG" || true
$DOCKER restart filebrowser 2>>"$LOG" || true
fi
track_container "filebrowser"
if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q nginx-proxy-manager; then
log "Creating Nginx Proxy Manager..."
mkdir -p /var/lib/archipelago/nginx-proxy-manager/data /var/lib/archipelago/nginx-proxy-manager/letsencrypt
mkdir -p /var/lib/archipelago/nginx-proxy-manager/data/letsencrypt-acme-challenge/.well-known/acme-challenge
chown -R 1000:1000 /var/lib/archipelago/nginx-proxy-manager 2>/dev/null || true
NPM_ADMIN_PORT=$(alloc_port nginx-proxy-manager 8081)
NPM_HTTP_PORT=$(alloc_port nginx-proxy-manager-http 8084)
NPM_HTTPS_PORT=$(alloc_port nginx-proxy-manager-https 8444)
$DOCKER run -d --name nginx-proxy-manager --restart unless-stopped \
--health-cmd="curl -sf http://localhost:81/ || exit 1" --health-interval=120s --health-timeout=5s --health-retries=3 \
--memory=$(mem_limit nginx-proxy-manager) \
--cap-drop ALL --cap-add CHOWN --cap-add FOWNER --cap-add SETUID --cap-add SETGID --cap-add DAC_OVERRIDE --cap-add NET_BIND_SERVICE \
--security-opt no-new-privileges:true \
-p ${NPM_ADMIN_PORT}:81 -p ${NPM_HTTP_PORT}:80 -p ${NPM_HTTPS_PORT}:443 \
-v /var/lib/archipelago/nginx-proxy-manager/data:/data \
-v /var/lib/archipelago/nginx-proxy-manager/letsencrypt:/etc/letsencrypt \
"${NPM_IMAGE}" 2>>"$LOG" || true
# Resolve existing storage before creating anything; never revive a stopped
# container through a duplicate run or recursively rewrite its ownership.
if ! $DOCKER container exists nginx-proxy-manager; then
if NPM_LAYOUT=$(python3 "$SCRIPT_DIR_FBC/npm-public-bridge.py" --resolve); then
NPM_DATA_DIR=$(python3 -c 'import json,sys; print(json.load(sys.stdin)["data"])' <<<"$NPM_LAYOUT")
NPM_CERT_DIR=$(python3 -c 'import json,sys; print(json.load(sys.stdin)["certificates"])' <<<"$NPM_LAYOUT")
if [ -n "$NPM_DATA_DIR" ] && [ -n "$NPM_CERT_DIR" ]; then
for npm_dir in "$NPM_DATA_DIR" "$NPM_CERT_DIR" "$NPM_DATA_DIR/letsencrypt-acme-challenge/.well-known/acme-challenge"; do
if [ ! -d "$npm_dir" ]; then
install -d -o archipelago -g archipelago "$npm_dir" || exit 1
fi
done
NPM_ADMIN_PORT=$(alloc_port nginx-proxy-manager 8081)
python3 "$SCRIPT_DIR_FBC/npm-public-bridge.py" --resolve --prepare-realip >/dev/null || exit 1
NPM_HTTP_PORT=$(alloc_port nginx-proxy-manager-http 8088)
NPM_HTTPS_PORT=$(alloc_port nginx-proxy-manager-https 8444)
if ! $DOCKER run -d --name nginx-proxy-manager --restart unless-stopped \
--network slirp4netns:allow_host_loopback=true,cidr=169.254.1.0/24 \
--health-cmd="curl -sf http://127.0.0.1:81/api/ || exit 1" --health-interval=120s --health-timeout=5s --health-retries=3 \
--memory=$(mem_limit nginx-proxy-manager) \
--cap-drop ALL --cap-add CHOWN --cap-add FOWNER --cap-add SETUID --cap-add SETGID --cap-add DAC_OVERRIDE --cap-add NET_BIND_SERVICE \
--security-opt no-new-privileges:true \
-p "127.0.0.1:${NPM_ADMIN_PORT}:81" -p "127.0.0.1:${NPM_HTTP_PORT}:80" -p "127.0.0.1:${NPM_HTTPS_PORT}:443" \
-v "$NPM_DATA_DIR:/data" -v "$NPM_CERT_DIR:/etc/letsencrypt" \
"${NPM_IMAGE}" 2>>"$LOG"; then
log "ERROR: NPM creation failed; existing persistent state preserved"
fi
fi
else
log "ERROR: NPM storage resolution failed; no directories or containers changed"
fi
fi
track_container "nginx-proxy-manager"
if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q portainer; then
+4 -1
View File
@@ -192,8 +192,11 @@ if os.environ.get("EMBED_MANIFESTS") and apps_dir:
if not baseline or baseline.get("app", {}).get("backup_before_runtime_change"):
raise SystemExit(f"{app_id}: a pre-migration BASE_CATALOG manifest is required for old-node compatibility")
entry["manifest"] = baseline
requires = ["runtime-migration-backup-v1"]
if app_id == "nginx-proxy-manager":
requires.append("npm-legacy-host-gateway-v1")
entry["manifest_variants"] = [{
"requires": ["runtime-migration-backup-v1"], "manifest": rendered,
"requires": requires, "manifest": rendered,
}]
else:
entry["manifest"] = rendered
+699
View File
@@ -0,0 +1,699 @@
#!/usr/bin/env python3
"""Resolve NPM's existing storage and bridge public requests through NPM itself.
Never move a database or reimplement NPM access lists/custom locations. The
host terminates public TLS, then forwards to NPM's loopback-only listeners.
"""
import argparse
import contextlib
import fcntl
import hashlib
import ipaddress
import json
import os
from pathlib import Path
import pwd
import re
import shutil
import sqlite3
import subprocess
import tempfile
import time
BASE = Path('/var/lib/archipelago/nginx-proxy-manager')
STATE = Path('/var/lib/archipelago/npm-public-bridge')
OUTPUT = Path('/etc/nginx/conf.d/public-npm-proxy-hosts.conf')
def active_dashboard(nginx_root=Path('/etc/nginx')):
enabled = nginx_root / 'sites-enabled/archipelago'
selected = enabled if enabled.exists() or enabled.is_symlink() else nginx_root / 'sites-available/archipelago'
return selected.resolve()
DASHBOARD = active_dashboard()
def run(args, **kwargs):
result = subprocess.run(args, capture_output=True, timeout=45, **kwargs)
if result.returncode:
raise RuntimeError(f'{args[0]} failed (exit {result.returncode}); previous configuration retained')
return result.stdout
def podman_args():
if os.geteuid() == 0:
account = pwd.getpwnam('archipelago')
return ['sudo', '-n', '-u', account.pw_name, 'env',
f'XDG_RUNTIME_DIR=/run/user/{account.pw_uid}', 'podman']
return ['podman']
def inspect_runtime():
command = podman_args()
exists = subprocess.run(command + ['container', 'exists', 'nginx-proxy-manager'],
capture_output=True, timeout=20)
if exists.returncode == 1:
return None
if exists.returncode:
raise RuntimeError('Cannot inspect NPM runtime; refusing to guess its data directory')
info = json.loads(run(command + ['inspect', 'nginx-proxy-manager']))
if len(info) != 1:
raise RuntimeError('Ambiguous NPM runtime')
return info[0]
def checked_path(value):
path = Path(value)
if not path.is_absolute() or any(c in str(path) for c in '\r\n\x00'):
raise ValueError('NPM mount must be an absolute path without control characters')
return path
def resolve_paths(base=BASE, runtime=None):
"""Keep the active mount; without one, reuse the single existing database."""
base = checked_path(base)
remembered = {}
layout_file = base / '.archy-storage.json'
if layout_file.exists():
saved = json.loads(layout_file.read_text())
remembered = {name: checked_path(saved[name]) for name in ('data', 'certificates')}
mounts = {}
for mount in [] if runtime is None else runtime.get('Mounts', []):
destination = mount.get('Destination')
if destination not in ('/data', '/etc/letsencrypt'):
continue
if destination in mounts:
raise ValueError('Duplicate NPM persistent mount; refusing ambiguous runtime configuration')
mounts[destination] = checked_path(mount['Source'])
if runtime is not None and set(mounts) != {'/data', '/etc/letsencrypt'}:
raise ValueError('NPM must have explicit /data and /etc/letsencrypt mounts; review before recreation')
candidates = {base, base / 'data'}
if remembered:
candidates.add(remembered['data'])
if '/data' in mounts:
candidates.add(mounts['/data'])
databases = {p.resolve() for p in candidates if (p / 'database.sqlite').exists()}
# A live, explicit mount (or our previously validated receipt after a
# managed stop) identifies the database without guessing. Older installers
# can leave an unused second database behind; preserve it, never merge it
# or let its mere existence displace the database NPM actually uses.
if len(databases) > 1 and '/data' not in mounts and not remembered:
raise ValueError('Multiple NPM databases found; choose the active layout explicitly before upgrading')
data = mounts.get('/data', remembered.get('data', next(iter(databases), base)))
if databases and data.resolve() not in databases:
raise ValueError('NPM active mount differs from the saved database; refusing an empty replacement')
db = data / 'database.sqlite'
if remembered and not db.exists():
raise ValueError('Previously initialized NPM database is missing; refusing an empty replacement')
if db.exists():
# Read-only opening never creates an empty replacement database.
con = sqlite3.connect(db.resolve().as_uri() + '?mode=ro', uri=True, timeout=5)
try:
if con.execute('PRAGMA quick_check').fetchone()[0] != 'ok':
raise ValueError('NPM database integrity check failed')
tables = {r[0] for r in con.execute("SELECT name FROM sqlite_master WHERE type='table'")}
if not {'proxy_host', 'certificate'} <= tables:
raise ValueError('NPM database schema is not initialized; retry after NPM startup')
finally:
con.close()
certs = mounts.get('/etc/letsencrypt', remembered.get('certificates', base / 'letsencrypt'))
return {'data': str(data), 'certificates': str(certs)}
def quote(value):
value = str(value)
if any(ord(c) < 32 for c in value):
raise ValueError('Control character in nginx configuration value')
return '"' + value.replace('\\', '\\\\').replace('"', '\\"').replace('$', '\\$') + '"'
def prepare_realip(paths):
"""Append one managed block through NPM's supported custom HTTP include.
A read-only mount in /etc/nginx/conf.d breaks NPM's startup ownership pass.
Preserve existing custom directives and a private copy before adding trust
for rootlessport's single forwarding source on our legacy subnet.
"""
data = Path(paths['data'])
path = data / 'nginx/custom/http_top.conf'
for candidate in [path, path.parent, path.parent.parent]:
if candidate.is_symlink():
raise ValueError('NPM custom configuration is a symlink; preserved for review')
source = path.read_bytes() if path.exists() else b''
begin = b'# BEGIN ARCHY HOST BRIDGE\n'
end = b'# END ARCHY HOST BRIDGE\n'
block = begin + b'set_real_ip_from 169.254.1.100;\n' + end
if begin.strip() in source or end.strip() in source:
if source.count(begin) != 1 or source.count(end) != 1 or block not in source:
raise ValueError('NPM managed trust block has an operator override; preserved for review')
return path
if source:
backups = data / '.archy-http-top-backups'
backups.mkdir(exist_ok=True, mode=0o700)
backup = backups / hashlib.sha256(source).hexdigest()
if not backup.exists():
atomic(backup, source, 0o600)
content = source + (b'\n' if source and not source.endswith(b'\n') else b'') + block
mode = path.stat().st_mode & 0o777 if path.exists() else 0o644
atomic(path, content, mode)
return path
def domains(value):
names = json.loads(value)
if not isinstance(names, list) or not names:
raise ValueError('NPM host has no valid domain names')
result = []
for name in names:
if not isinstance(name, str):
raise ValueError('Invalid NPM domain name')
name = name.lower().rstrip('.')
plain = name[2:] if name.startswith('*.') else name
if len(name) > 253 or not plain or any(
not re.fullmatch(r'[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?', label)
for label in plain.split('.')
):
raise ValueError('Invalid NPM domain name; no nginx configuration was generated')
result.append(name)
return sorted(set(result))
def hosts(data):
db = Path(data) / 'database.sqlite'
con = sqlite3.connect(db.resolve().as_uri() + '?mode=ro', uri=True, timeout=5)
con.row_factory = sqlite3.Row
try:
# Avoid reading credentials, access-list passwords or advanced snippets.
tables = {r[0] for r in con.execute("SELECT name FROM sqlite_master WHERE type='table'")}
rows = []
for table in ('proxy_host', 'redirection_host', 'dead_host'):
if table not in tables:
continue
# Table names come solely from this fixed allowlist, never DB data.
rows.extend(dict(r) for r in con.execute(f'''
SELECT p.id, p.domain_names, p.certificate_id, c.provider,
COALESCE(c.is_deleted, 0) AS certificate_deleted
FROM {table} p LEFT JOIN certificate c ON c.id = p.certificate_id
WHERE p.enabled = 1 AND p.is_deleted = 0 ORDER BY p.id
'''))
return rows
finally:
con.close()
def managed_tunnel_listener(binding, container_port):
"""Recognize the existing private WireGuard web ingress, never a LAN bind.
This does not select the tunnel as an upstream: the host bridge still
requires a separate loopback listener. NPM's admin port has no exception.
"""
expected_port = {80: '18081', 443: '18443'}.get(container_port)
if expected_port is None or str(binding.get('HostPort')) != expected_port:
return False
try:
address = ipaddress.IPv4Address(binding.get('HostIp', ''))
if not any(address in ipaddress.IPv4Network(network)
for network in ('10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16')):
return False
interfaces = json.loads(run(['ip', '-d', '-j', 'address', 'show', 'dev', 'wg-web']))
return any(item.get('ifname') == 'wg-web' and
item.get('linkinfo', {}).get('info_kind') == 'wireguard' and
any(entry.get('family') == 'inet' and entry.get('local') == str(address)
for entry in item.get('addr_info', [])) for item in interfaces)
except (ValueError, RuntimeError, OSError):
return False
def local_port(runtime, container_port):
bindings = runtime.get('NetworkSettings', {}).get('Ports', {}).get(f'{container_port}/tcp') or []
# A loopback mapping alongside a wildcard mapping is still public exposure.
if any(binding.get('HostIp') not in ('127.0.0.1', '::1') and
not managed_tunnel_listener(binding, container_port) for binding in bindings):
raise ValueError(f'NPM container port {container_port} has a non-loopback published listener')
for binding in bindings:
if binding.get('HostIp') in ('127.0.0.1', '::1'):
port = int(binding['HostPort'])
if 1 <= port <= 65535:
host = '[::1]' if binding['HostIp'] == '::1' else '127.0.0.1'
return f'{host}:{port}'
raise ValueError(f'NPM container port {container_port} needs a loopback-only published listener')
def certificate_paths(paths, row):
number = row['certificate_id']
if not isinstance(number, int) or number < 0:
raise ValueError('Invalid NPM certificate ID')
if number == 0 or row['certificate_deleted']:
return None
parent = (Path(paths['certificates']) / 'live' if row['provider'] == 'letsencrypt'
else Path(paths['data']) / 'custom_ssl') / f'npm-{number}'
cert, key = parent / 'fullchain.pem', parent / 'privkey.pem'
if not cert.is_file() or not key.is_file():
raise ValueError(f'NPM certificate {number} files are missing; inspect certificate issuance')
return cert, key
def render(rows, paths, http_address, https_address, acme_root, trust_file):
"""Only route through NPM; never bypass its authentication or custom routes."""
for address in (http_address, https_address):
host, port = address.rsplit(':', 1)
if not ipaddress.ip_address(host.strip('[]')).is_loopback or not 1 <= int(port) <= 65535:
raise ValueError('NPM upstream must be loopback')
chunks = ['# Generated by npm-public-bridge.py; routes and access control remain owned by NPM.\n']
fingerprints = []
trust = Path('/etc/ssl/certs/ca-certificates.crt').read_bytes()
seen = set()
for row in rows:
names = domains(row['domain_names'])
if seen.intersection(names):
raise ValueError('Duplicate public NPM domain; resolve conflicting hosts first')
seen.update(names)
cert = certificate_paths(paths, row)
common = f'''
location ^~ /.well-known/acme-challenge/ {{
default_type text/plain;
root {quote(acme_root)};
try_files $uri =404;
}}
'''
def proxy(address, scheme):
tls = '' if scheme == 'http' else f'''
proxy_ssl_server_name on;
proxy_ssl_name $host;
proxy_ssl_verify on;
proxy_ssl_verify_depth 5;
proxy_ssl_trusted_certificate {quote(trust_file)};'''
return f'''
location / {{
proxy_pass {scheme}://{address};
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Scheme $scheme;
proxy_set_header X-Archipelago-Public-Ingress 1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $http_connection;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_buffering off;
proxy_request_buffering off;
# NPM/app configuration owns upload limits; do not impose a second cap.
client_max_body_size 0;{tls}
}}
'''
chunks.append(f'''server {{
listen 80;
listen [::]:80;
server_name {' '.join(names)};
{common}{proxy(http_address, 'http')}
}}
''')
if cert:
chain, key = cert
cert_bytes = chain.read_bytes()
trust += b'\n' + cert_bytes
fingerprints.append(hashlib.sha256(cert_bytes).hexdigest())
# Including the key fingerprint detects replacement without logging keys.
fingerprints.append(hashlib.sha256(key.read_bytes()).hexdigest())
chunks.append(f'''server {{
listen 443 ssl;
listen [::]:443 ssl;
server_name {' '.join(names)};
ssl_certificate {quote(chain)};
ssl_certificate_key {quote(key)};
{common}{proxy(https_address, 'https')}
}}
''')
return ''.join(chunks).encode(), trust, fingerprints
def atomic(path, content, mode=0o600):
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
with tempfile.NamedTemporaryFile(dir=path.parent, delete=False) as stream:
temporary = Path(stream.name)
os.fchmod(stream.fileno(), mode)
stream.write(content)
stream.flush()
os.fsync(stream.fileno())
try:
os.replace(temporary, path)
fd = os.open(path.parent, os.O_DIRECTORY)
try:
os.fsync(fd)
finally:
os.close(fd)
finally:
temporary.unlink(missing_ok=True)
def recover_pending(state=STATE, command=subprocess.run):
"""Caller holds the nginx lock; recover BEFORE reading candidate input files."""
journal = Path(state) / 'pending.json'
if not journal.exists():
return False
saved = json.loads(journal.read_text())
for entry in saved['files']:
target = Path(entry['path'])
if entry['backup'] is None:
target.unlink(missing_ok=True)
else:
atomic(target, Path(entry['backup']).read_bytes(), entry['mode'])
for args in (['nginx', '-t'], ['systemctl', 'reload', 'nginx']):
result = command(args, capture_output=True, timeout=30)
if result.returncode:
raise RuntimeError('NPM bridge pending transaction recovery failed; journal retained')
journal.unlink()
return True
def apply_files(files, state=STATE, command=subprocess.run,
lock_path=Path('/run/lock/archy-nginx-config.lock'), renewal_fingerprint='', locked=False,
certificate_reload=None):
"""Commit managed files together, with durable rollback before nginx reload.
The journal contains file paths and backups, never private key contents.
A interrupted transaction is rolled back before attempting the next one.
"""
state = Path(state)
state.mkdir(parents=True, exist_ok=True, mode=0o700)
os.chmod(state, 0o700)
journal = state / 'pending.json'
receipt = state / 'applied.json'
def reload_nginx():
for args in (['nginx', '-t'], ['systemctl', 'reload', 'nginx']):
result = command(args, capture_output=True, timeout=30)
if result.returncode:
raise RuntimeError('NPM bridge nginx validation/reload failed')
def restore(saved):
for entry in saved['files']:
target = Path(entry['path'])
if entry['backup'] is None:
target.unlink(missing_ok=True)
else:
atomic(target, Path(entry['backup']).read_bytes(), entry['mode'])
reload_nginx()
journal.unlink()
with contextlib.nullcontext() if locked else Path(lock_path).open('a+b') as lock:
if not locked:
fcntl.flock(lock, fcntl.LOCK_EX)
if journal.exists():
restore(json.loads(journal.read_text()))
current = {}
if receipt.exists():
current = json.loads(receipt.read_text())
changed = [(Path(path), content, mode) for path, content, mode in files
if not Path(path).is_file() or Path(path).read_bytes() != content
or Path(path).stat().st_mode & 0o777 != mode]
if not changed and current.get('renewal_fingerprint') == renewal_fingerprint:
return False
backup_dir = state / ('backup-' + str(time.time_ns()))
backup_dir.mkdir(mode=0o700)
entries = []
for index, (target, _, _) in enumerate(changed):
if target.is_symlink():
raise ValueError('Managed nginx output is a symlink; review operator override before updating')
backup = None
mode = 0o600
if target.exists():
backup = backup_dir / str(index)
mode = target.stat().st_mode & 0o777
atomic(backup, target.read_bytes())
entries.append({'path': str(target), 'backup': None if backup is None else str(backup), 'mode': mode})
saved = {'files': entries}
atomic(journal, json.dumps(saved).encode())
try:
for target, content, mode in changed:
atomic(target, content, mode)
if certificate_reload and current.get('renewal_fingerprint') != renewal_fingerprint:
# Replacing a custom certificate through NPM's API can update
# files without reloading its running TLS listener. Ensure both
# TLS endpoints pick up the replacement, not just host nginx.
certificate_reload()
reload_nginx()
atomic(receipt, json.dumps({'renewal_fingerprint': renewal_fingerprint}).encode())
journal.unlink()
except Exception as failure:
try:
restore(saved)
except Exception as rollback:
raise RuntimeError('NPM bridge failed; rollback incomplete, durable recovery journal retained') from rollback
raise failure
return True
def dashboard_acme_root(source, data):
"""Update only known managed ACME roots, without changing custom locations."""
root = Path(data) / 'letsencrypt-acme-challenge'
pattern = re.compile(r'(location\s+\^~\s+/\.well-known/acme-challenge/\s*\{)([^{}]*)(\})', re.S)
count = 0
def replace(found):
nonlocal count
body = found[2]
existing = re.findall(r'\broot\s+([^;]+);', body)
allowed = {str(BASE / 'letsencrypt-acme-challenge'),
str(BASE / 'data/letsencrypt-acme-challenge'), str(root)}
if len(existing) != 1 or existing[0].strip().strip('"') not in allowed:
raise ValueError('Custom dashboard ACME location requires review; configuration preserved')
count += 1
body = re.sub(r'\broot\s+[^;]+;', lambda _: 'root ' + quote(root) + ';', body)
return found[1] + body + found[3]
result = pattern.sub(replace, source)
if count == 1:
# Older shipped dashboard templates omitted HTTPS ACME entirely. A
# public challenge exception must never fall through to the SPA there.
# Recognize only our canonical default servers; preserve custom layouts.
prefix = r'server\s*\{\s*(?:if\s*\(\$archy_management_denied\)\s*\{\s*return 404;\s*\}\s*)?'
http = list(re.finditer(prefix + r'listen 80 default_server;', result))
https = list(re.finditer(prefix + r'listen 443 ssl default_server;', result))
challenge = list(pattern.finditer(result))
if (len(http) == len(https) == 1
and http[0].end() < challenge[0].start() < https[0].start()
and result.count('/.well-known/acme-challenge/') == 1):
at = https[0].end()
location = ('\n\n location ^~ /.well-known/acme-challenge/ {\n'
' default_type text/plain;\n'
' root ' + quote(root) + ';\n'
' try_files $uri =404;\n }')
result = result[:at] + location + result[at:]
count += 1
if count != 2:
raise ValueError('Expected HTTP and HTTPS dashboard ACME locations; refusing partial migration')
return result
def legacy_angor_route(source, paths, relay=False):
"""Recognize only the exact temporary routes recorded in the live handoff.
Operator edits must fail recognition, not be overwritten by migration.
Domain and certificate IDs are extracted, then the entire configuration is
compared to the known template; no deployment hostname is hardcoded.
"""
marker = ('# Live repair: NPM relay host, certificate11. Retire through release migration.'
if relay else '# Shorty repair 2026-10-01: NPM host 2, certificate 8.')
if not source.startswith(marker + '\n'):
return False
names = re.findall(r'\bserver_name\s+([^;]+);', source)
if len(names) != 2 or names[0] != names[1]:
return False
try:
name = domains(json.dumps([names[0].strip()]))[0]
except ValueError:
return False
certificate_id = 11 if relay else 8
parent = Path(paths['certificates']) / 'live' / f'npm-{certificate_id}'
extra = '''proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;''' if relay else ''
limit = '' if relay else 'client_max_body_size 4m;'
expected = f'''
server {{
listen 80; listen [::]:80; server_name {name};
location ^~ /.well-known/acme-challenge/ {{
default_type text/plain; root {Path(paths['data']) / 'letsencrypt-acme-challenge'}; try_files $uri =404;
}}
location / {{ return 301 https://$host$request_uri; }}
}}
server {{
listen 443 ssl; listen [::]:443 ssl; server_name {name};
ssl_certificate {parent / 'fullchain.pem'};
ssl_certificate_key {parent / 'privkey.pem'};
ssl_protocols TLSv1.2 TLSv1.3; {limit}
location / {{
proxy_pass http://127.0.0.1:{8091 if relay else 8998};
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
{extra}
}}
}}
'''
def normalized(text):
return ''.join(re.sub(r'#[^\n]*', '', text).split())
return normalized(source) == normalized(expected)
def legacy_shop_route(source, paths):
"""Recognize the exact BTCPay emergency route; preserve any operator edits."""
marker = re.match(r'# ([a-z0-9.-]+) — BTCPay Server\. LetsEncrypt cert \(npm-([0-9]+)\) obtained via NPM webroot\.\n', source)
if not marker:
return False
try:
name = domains(json.dumps([marker[1]]))[0]
except ValueError:
return False
parent = Path(paths['certificates']) / 'live' / f'npm-{marker[2]}'
expected = f'''
server {{
listen 80; listen [::]:80;
server_name {name} www.{name};
location ^~ /.well-known/acme-challenge/ {{
default_type text/plain; root {Path(paths['data']) / 'letsencrypt-acme-challenge'}; try_files $uri =404;
}}
location / {{ return 301 https://$host$request_uri; }}
}}
server {{
listen 443 ssl; listen [::]:443 ssl;
server_name {name} www.{name};
ssl_certificate {parent / 'fullchain.pem'};
ssl_certificate_key {parent / 'privkey.pem'};
ssl_protocols TLSv1.2 TLSv1.3;
location / {{
proxy_pass http://127.0.0.1:23000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header X-Forwarded-Scheme https;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 300s;
}}
}}
'''
def normalized(text):
return ''.join(re.sub(r'#[^\n]*', '', text).split())
return normalized(source) == normalized(expected)
def existing_route_changes(rows, paths, output=OUTPUT, directories=None):
"""Retire exact managed emergency routes and refuse other duplicate hosts."""
if directories is None:
directories = [Path('/etc/nginx/conf.d'), Path('/etc/nginx/sites-enabled')]
claimed = {name for row in rows for name in domains(row['domain_names'])}
tls_claimed = {name for row in rows if row.get('certificate_id') and not row.get('certificate_deleted')
for name in domains(row['domain_names'])}
changes = []
for directory in directories:
if not directory.exists():
continue
for path in directory.iterdir():
if not path.is_file() or path.resolve() == Path(output).resolve():
continue
if directory.name == 'conf.d' and path.suffix != '.conf':
continue
source = path.read_text()
uncommented = re.sub(r'#[^\n]*', '', source)
existing = {name for group in re.findall(r'\bserver_name\s+([^;]+);', uncommented)
for name in group.split()}
recognized = (path.name in ('angor-indexer-npm.conf', 'angor-relay-npm.conf') and legacy_angor_route(
source, paths, relay=path.name == 'angor-relay-npm.conf'
)) or (path.name == 'shop-btcpay.conf' and legacy_shop_route(source, paths))
# Never retire a working emergency endpoint without a complete
# replacement, including every alias and its HTTPS listener.
if recognized and existing and existing.issubset(tls_claimed):
changes.append((path, b'# Temporary managed route retired; NPM owns routing through public-npm-proxy-hosts.conf.\n', 0o644))
continue
if claimed.intersection(existing):
raise ValueError(f'Existing public nginx route conflicts with NPM in {path.name}; custom configuration preserved for review')
return changes
def build_files(runtime, paths, dashboard=DASHBOARD, output=OUTPUT, state=STATE):
"""Create a reviewable candidate without altering database, keys or services."""
trust_file = Path(state) / 'upstream-trust.pem'
rows = hosts(paths['data'])
configuration, trust, fingerprints = render(
rows, paths, local_port(runtime, 80), local_port(runtime, 443),
Path(paths['data']) / 'letsencrypt-acme-challenge', trust_file)
dashboard = Path(dashboard)
default_site = dashboard_acme_root(dashboard.read_text(), paths['data'])
files = [(Path(output), configuration, 0o644), (trust_file, trust, 0o644),
(dashboard, default_site.encode(), dashboard.stat().st_mode & 0o777)]
files.extend(existing_route_changes(rows, paths, output))
digest = hashlib.sha256(json.dumps(fingerprints).encode()).hexdigest()
return files, digest
def main():
parser = argparse.ArgumentParser()
parser.add_argument('--resolve', action='store_true')
parser.add_argument('--remember', action='store_true')
parser.add_argument('--prepare-realip', action='store_true')
parser.add_argument('--acme-only', action='store_true')
args = parser.parse_args()
runtime = inspect_runtime()
if args.resolve:
paths = resolve_paths(runtime=runtime)
if args.prepare_realip:
prepare_realip(paths)
if args.remember and runtime is not None and (Path(paths['data']) / 'database.sqlite').is_file():
# Capture authoritative mounts BEFORE lifecycle code removes the
# inspect record. Subsequent recreation must reuse custom storage.
atomic(BASE / '.archy-storage.json', json.dumps(paths).encode())
print(json.dumps(paths))
return
if args.acme_only:
with Path('/run/lock/archy-nginx-config.lock').open('a+b') as lock:
fcntl.flock(lock, fcntl.LOCK_EX)
recover_pending(STATE / 'acme')
recover_pending(STATE)
paths = resolve_paths(runtime=runtime)
candidate = dashboard_acme_root(DASHBOARD.read_text(), paths['data']).encode()
apply_files([(DASHBOARD, candidate, DASHBOARD.stat().st_mode & 0o777)],
state=STATE / 'acme', locked=True)
print('NPM default ACME root verified')
return
with Path('/run/lock/archy-nginx-config.lock').open('a+b') as lock:
fcntl.flock(lock, fcntl.LOCK_EX)
recover_pending(STATE / 'acme')
recover_pending(STATE)
if runtime is None:
# A saved DB does not authorize resurrecting an uninstalled app's routes.
files = existing_route_changes([], resolve_paths(runtime=None))
if OUTPUT.exists():
files.append((OUTPUT, b'# NPM is not installed; public bridge disabled.\n', 0o644))
if files:
apply_files(files, locked=True)
print('NPM absent; no public routes installed')
return
paths = resolve_paths(runtime=runtime)
# Certificate issuance must not wait for the optional HTTP/TLS bridge
# listeners to be migrated or become ready.
acme_candidate = dashboard_acme_root(DASHBOARD.read_text(), paths['data']).encode()
apply_files([(DASHBOARD, acme_candidate, DASHBOARD.stat().st_mode & 0o777)],
state=STATE / 'acme', locked=True)
files, fingerprint = build_files(runtime, paths)
def reload_certificate():
for args in (['nginx', '-t'], ['nginx', '-s', 'reload']):
run(podman_args() + ['exec', 'nginx-proxy-manager'] + args)
changed = apply_files(files, renewal_fingerprint=fingerprint, locked=True,
certificate_reload=reload_certificate)
print('NPM public bridge updated' if changed else 'NPM public bridge unchanged')
if __name__ == '__main__':
try:
main()
except Exception as error:
# Do not expose DB values, private keys, command output or account data.
print(f'NPM public bridge: {type(error).__name__}: {error}', file=__import__('sys').stderr)
raise SystemExit(1)
+4 -38
View File
@@ -107,42 +107,8 @@ if ! command -v ping >/dev/null 2>&1; then
fi
fi
if ! command -v esptool >/dev/null 2>&1; then
log "Installing esptool for LoRa radio firmware flashing..."
if sudo apt-get update -qq 2>>"$LOG_FILE" && sudo apt-get install -y -qq esptool 2>>"$LOG_FILE"; then
ok "esptool installed"
else
warn "Unable to install esptool automatically; radio firmware flashing will be unavailable"
fi
fi
# Debian's esptool package (4.7.0+dfsg-0.1) ships without the precompiled
# esp32s3 "stub flasher" blob (stripped for DFSG compliance — no
# buildable-from-source path Debian could verify). Without it, esptool's
# normal stub-loader mode fails outright (FileNotFoundError), and the ROM
# bootloader fallback (--no-stub) doesn't implement a full-chip erase at
# all — confirmed live 2026-07-23 flashing a real Heltec V4, both ways.
# Fetching the exact same file from the matching upstream esptool release
# tag restores full (and correct) flashing behavior — it's the same
# open-source codebase, just the one blob Debian's packaging couldn't
# include.
if command -v esptool >/dev/null 2>&1; then
STUB_DIR="/usr/lib/python3/dist-packages/esptool/targets/stub_flasher"
STUB_FILE="$STUB_DIR/stub_flasher_32s3.json"
if [ ! -f "$STUB_FILE" ]; then
log "Fetching esptool's esp32s3 stub flasher (missing from the Debian package)..."
ESPTOOL_VERSION=$(esptool version 2>/dev/null | tail -1 | tr -d ' \t')
if [ -n "$ESPTOOL_VERSION" ] && sudo curl -fsSL -o "$STUB_FILE" \
"https://raw.githubusercontent.com/espressif/esptool/v${ESPTOOL_VERSION}/esptool/targets/stub_flasher/stub_flasher_32s3.json" \
2>>"$LOG_FILE"; then
sudo chmod 644 "$STUB_FILE"
ok "esp32s3 stub flasher installed"
else
sudo rm -f "$STUB_FILE" 2>/dev/null
warn "Unable to fetch esp32s3 stub flasher; LoRa firmware flashing will be unavailable"
fi
fi
fi
# ESP32 flashing is provided by the self-contained archy-esptool built below.
# Do not depend on a first-use apt install or a separately fetched stub blob.
# Build-time prerequisites for reticulum-daemon/build.sh's PyInstaller step
# below (discovered the hard way: ensurepip needs python3-venv, and
@@ -246,7 +212,7 @@ ok "Backend installed"
if [ -f "$REPO_DIR/reticulum-daemon/build.sh" ]; then
log "Building reticulum-daemon tools (archy-reticulum-daemon, archy-rnodeconf)..."
if (cd "$REPO_DIR/reticulum-daemon" && ./build.sh) 2>>"$LOG_FILE"; then
for tool in archy-reticulum-daemon archy-rnodeconf; do
for tool in archy-reticulum-daemon archy-rnodeconf archy-esptool; do
if [ -f "$REPO_DIR/reticulum-daemon/dist/$tool" ]; then
sudo cp "$REPO_DIR/reticulum-daemon/dist/$tool" /usr/local/bin/
sudo chmod +x "/usr/local/bin/$tool"
@@ -300,7 +266,7 @@ fi
# for backward compatibility with older binaries that still look there.
SCRIPTS_DEST="/opt/archipelago/scripts"
sudo mkdir -p "$SCRIPTS_DEST"
for script in image-versions.sh reconcile-containers.sh container-specs.sh container-doctor.sh sync-npm-public-hosts.sh app-surface-smoke-test.sh bitcoin-stack-lifecycle-test.sh; do
for script in image-versions.sh reconcile-containers.sh container-specs.sh container-doctor.sh dashboard-public-guard.py npm-public-bridge.py sync-npm-public-hosts.sh app-surface-smoke-test.sh bitcoin-stack-lifecycle-test.sh; do
src="$REPO_DIR/scripts/$script"
if [ -f "$src" ]; then
sudo install -m 755 "$src" "$SCRIPTS_DEST/$script"
+2 -126
View File
@@ -1,128 +1,4 @@
#!/bin/bash
set -euo pipefail
DB="/var/lib/archipelago/nginx-proxy-manager/data/database.sqlite"
OUT="/etc/nginx/conf.d/public-npm-proxy-hosts.conf"
ACME_ROOT="/var/lib/archipelago/nginx-proxy-manager/data/letsencrypt-acme-challenge"
LE_ROOT="/var/lib/archipelago/nginx-proxy-manager/letsencrypt/live"
[ -f "$DB" ] || exit 0
mkdir -p "$ACME_ROOT/.well-known/acme-challenge"
chown -R 1000:1000 /var/lib/archipelago/nginx-proxy-manager 2>/dev/null || true
tmp=$(mktemp)
trap 'rm -f "$tmp"' EXIT
python3 - "$DB" "$ACME_ROOT" "$LE_ROOT" >"$tmp" <<'PY'
import json
import os
import sqlite3
import sys
db, acme_root, le_root = sys.argv[1:]
con = sqlite3.connect(db)
con.row_factory = sqlite3.Row
rows = con.execute(
"""
select p.id, p.domain_names, p.forward_scheme, p.forward_host, p.forward_port,
p.certificate_id, p.ssl_forced, c.provider
from proxy_host p
left join certificate c on c.id = p.certificate_id
where p.enabled = 1 and p.certificate_id > 0
order by p.id
"""
).fetchall()
print("# Generated by sync-npm-public-hosts.sh; do not edit by hand.")
for row in rows:
try:
domains = [d for d in json.loads(row["domain_names"] or "[]") if d]
except Exception:
domains = []
if not domains:
continue
cert_id = row["certificate_id"]
cert = f"{le_root}/npm-{cert_id}/fullchain.pem"
key = f"{le_root}/npm-{cert_id}/privkey.pem"
if row["provider"] != "letsencrypt":
continue
if not os.path.isfile(cert) or not os.path.isfile(key):
continue
names = " ".join(domains)
scheme = row["forward_scheme"] or "http"
host = row["forward_host"]
port = row["forward_port"]
if not host or not port:
continue
# NPM containers use this name to reach host-published services; host nginx
# itself should use loopback for the same services.
nginx_host = "127.0.0.1" if host == "host.containers.internal" else host
try:
forward_port = int(port)
except (TypeError, ValueError):
forward_port = None
graphql_location = ""
extra_proxy_headers = ""
print(f"""
server {{
listen 80;
server_name {names};
location ^~ /.well-known/acme-challenge/ {{
default_type text/plain;
root {acme_root};
try_files $uri =404;
}}
location / {{
return 301 https://$host$request_uri;
}}
}}
server {{
listen 443 ssl;
server_name {names};
ssl_certificate {cert};
ssl_certificate_key {key};
{graphql_location}
location / {{
proxy_pass {scheme}://{nginx_host}:{port};
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header X-Forwarded-Scheme https;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
{extra_proxy_headers}
}}
}}
""")
PY
backup=""
if [ -f "$OUT" ]; then
backup=$(mktemp)
cp "$OUT" "$backup"
fi
restore_previous() {
if [ -n "$backup" ] && [ -f "$backup" ]; then
install -m 0644 "$backup" "$OUT"
else
rm -f "$OUT"
fi
}
if ! install -m 0644 "$tmp" "$OUT" || ! nginx -t >/dev/null; then
restore_previous
nginx -t >/dev/null 2>&1 || true
exit 1
fi
systemctl reload nginx
SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
exec python3 "$SCRIPT_DIR/npm-public-bridge.py"
+7 -3
View File
@@ -87,7 +87,7 @@ def version_key(version):
return tuple(map(int, match.groups()))
def sort_modal_blocks(entries):
def sort_modal_blocks(entries, *, check=False):
"""Re-render current release-note blocks newest-first and remove old history."""
lines = MODAL.read_text().splitlines(keepends=True)
# Include the old hand-written `alpha.*` blocks in the replace range so
@@ -137,7 +137,7 @@ def sort_modal_blocks(entries):
output.extend(segment)
output.extend(lines[blocks[-1][1]:])
changed = output != lines
if changed:
if changed and not check:
MODAL.write_text("".join(output))
return changed
@@ -195,7 +195,11 @@ def main():
too_old.extend(legacy_blocks())
if not missing and not out_of_order and not too_old:
changed = False if check else sort_modal_blocks(entries)
changed = sort_modal_blocks(entries, check=check)
if changed and check:
print("FAIL: What's New dates or descriptions differ from CHANGELOG.md. "
"Run: python3 scripts/sync-whats-new.py", file=sys.stderr)
return 1
if changed:
print("Re-rendered What's New blocks from the curated changelog.")
else:
+12
View File
@@ -0,0 +1,12 @@
#!/usr/bin/env bash
# Exercise actual nginx without using the node's network or writable configuration.
set -euo pipefail
ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
sudo -n true
sudo -n systemd-run --unit="archy-guard-test-$(date +%s)-$$" --wait --pipe --collect \
--property="WorkingDirectory=$ROOT" \
--property=PrivateNetwork=yes --property=PrivateTmp=yes \
--property=ProtectSystem=strict --property=ProtectHome=read-only \
--property=NoNewPrivileges=yes \
--property='TemporaryFileSystem=/var/log/nginx:rw /var/lib/nginx:rw' \
python3 "$ROOT/tests/regression/dashboard-public-guard-network.py"
+17
View File
@@ -0,0 +1,17 @@
#!/usr/bin/env bash
# Requires separately staged binaries; never runs against real node wallets.
set -euo pipefail
ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
BIN_DIR=${ARCHY_REGTEST_BIN_DIR:?Set the path to the disposable fixture binaries}
RESULT_DIR=${ARCHY_REGTEST_RESULT_DIR:?Set a fresh private result directory}
for binary in bitcoind bitcoin-cli lnd lncli archipelago; do test -x "$BIN_DIR/$binary"; done
install -d -m 700 "$RESULT_DIR"
test ! -e "$RESULT_DIR/result.json"
sudo -n systemd-run --unit="archy-fee-regtest-$(date +%s)-$$" --wait --pipe --collect \
--property=PrivateNetwork=yes --property=PrivateTmp=yes --property=PrivateDevices=yes \
--property=ProtectSystem=strict --property=ProtectHome=yes --property=NoNewPrivileges=yes \
--property='TemporaryFileSystem=/run:rw /var/lib/archipelago:rw /var/lib/containers:rw /root:rw /etc/archipelago:rw /etc/nginx:rw /etc/systemd/system:rw /opt/archipelago:rw' \
--property="BindReadOnlyPaths=$BIN_DIR:/opt/archy-regtest-bin $ROOT/tests/lifecycle/fee-bump-regtest.py:/opt/archy-fee-regtest.py" \
--property="BindPaths=$RESULT_DIR:/opt/archy-regtest-results" \
--setenv=ARCHY_FEE_REGTEST_ISOLATED=1 --setenv="ARCHY_HOST_NET_NS=$(readlink /proc/self/ns/net)" \
/usr/bin/unshare --pid --fork --mount-proc --kill-child python3 -u /opt/archy-fee-regtest.py
+30
View File
@@ -0,0 +1,30 @@
# Shorty repair 2026-10-01: NPM host 2, certificate 8.
# Replace through durable NPM integration migration once released.
server {
listen 80;
listen [::]:80;
server_name fixture.example;
location ^~ /.well-known/acme-challenge/ {
default_type text/plain;
root /var/lib/archipelago/nginx-proxy-manager/letsencrypt-acme-challenge;
try_files $uri =404;
}
location / { return 301 https://$host$request_uri; }
}
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name fixture.example;
ssl_certificate /var/lib/archipelago/nginx-proxy-manager/letsencrypt/live/npm-8/fullchain.pem;
ssl_certificate_key /var/lib/archipelago/nginx-proxy-manager/letsencrypt/live/npm-8/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
client_max_body_size 4m;
location / {
proxy_pass http://127.0.0.1:8998;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
+32
View File
@@ -0,0 +1,32 @@
# Live repair: NPM relay host, certificate11. Retire through release migration.
server {
listen 80;
listen [::]:80;
server_name fixture.example;
location ^~ /.well-known/acme-challenge/ {
default_type text/plain;
root /var/lib/archipelago/nginx-proxy-manager/letsencrypt-acme-challenge;
try_files $uri =404;
}
location / { return 301 https://$host$request_uri; }
}
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name fixture.example;
ssl_certificate /var/lib/archipelago/nginx-proxy-manager/letsencrypt/live/npm-11/fullchain.pem;
ssl_certificate_key /var/lib/archipelago/nginx-proxy-manager/letsencrypt/live/npm-11/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
location / {
proxy_pass http://127.0.0.1:8091;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
}
+32
View File
@@ -0,0 +1,32 @@
# fixture.example — BTCPay Server. LetsEncrypt cert (npm-10) obtained via NPM webroot.
server {
listen 80;
listen [::]:80;
server_name fixture.example www.fixture.example;
location ^~ /.well-known/acme-challenge/ {
default_type text/plain;
root /var/lib/archipelago/nginx-proxy-manager/letsencrypt-acme-challenge;
try_files $uri =404;
}
location / { return 301 https://$host$request_uri; }
}
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name fixture.example www.fixture.example;
ssl_certificate /var/lib/archipelago/nginx-proxy-manager/letsencrypt/live/npm-10/fullchain.pem;
ssl_certificate_key /var/lib/archipelago/nginx-proxy-manager/letsencrypt/live/npm-10/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
location / {
proxy_pass http://127.0.0.1:23000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header X-Forwarded-Scheme https;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 300s;
}
}
@@ -0,0 +1,29 @@
import importlib.util
import json
from pathlib import Path
import subprocess
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[2]
spec = importlib.util.spec_from_file_location('catalog_drift', ROOT / 'scripts/check-app-catalog-drift.py')
drift = importlib.util.module_from_spec(spec)
spec.loader.exec_module(drift)
class CatalogCapabilities(unittest.TestCase):
def test_generated_npm_migration_requires_gateway_support_and_preserves_old_manifest(self):
with tempfile.TemporaryDirectory() as directory:
target = Path(directory) / 'catalog.json'
subprocess.run(['bash', str(ROOT / 'scripts/generate-app-catalog.sh'), str(target)], check=True, capture_output=True)
catalog = json.loads(target.read_text())
baseline = json.loads((ROOT / 'releases/app-catalog.json').read_text())
entry = catalog['apps']['nginx-proxy-manager']
self.assertEqual(entry['manifest'], baseline['apps']['nginx-proxy-manager']['manifest'])
self.assertEqual(set(entry['manifest_variants'][0]['requires']), {'runtime-migration-backup-v1', 'npm-legacy-host-gateway-v1'})
selected = drift.load_catalog(target)['nginx-proxy-manager']
self.assertEqual(selected['container']['network'], 'slirp4netns:allow_host_loopback=true,cidr=169.254.1.0/24')
entry['manifest_variants'][0]['requires'].append('future-unknown-capability')
target.write_text(json.dumps(catalog))
self.assertEqual(drift.load_catalog(target)['nginx-proxy-manager']['container'], entry['manifest']['app']['container'])
if __name__ == '__main__': unittest.main()
@@ -0,0 +1,96 @@
import importlib.util
from pathlib import Path
import subprocess
import tempfile
import unittest
SPEC = importlib.util.spec_from_file_location('guard', Path(__file__).parents[1] / 'dashboard-public-guard.py')
guard = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(guard)
SOURCE = '''# quoted braces must not confuse the parser
server { listen 80 default_server; server_name _; location / { return 200 "{}"; } }
server { listen 443 ssl default_server; server_name _; location / { return 200 "a}"; } }
server { listen 80; server_name public.example; location / { return 200 "app"; } }
'''
class GuardTests(unittest.TestCase):
def test_active_site_copy_and_symlink_target_are_resolved(self):
for symlink in [False, True]:
with self.subTest(symlink=symlink), tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
available = root / 'sites-available/archipelago'
enabled = root / 'sites-enabled/archipelago'
available.parent.mkdir(); enabled.parent.mkdir()
available.write_text(SOURCE)
if symlink:
enabled.symlink_to(available)
else:
enabled.write_text(SOURCE + '# active custom copy\n')
active = guard.active_dashboard(root)
self.assertEqual(active, available if symlink else enabled)
before = available.read_bytes()
def command(args, **kwargs):
return subprocess.CompletedProcess(args, 0)
guard.apply(active, command, root / 'lock')
self.assertIn(guard.BEGIN, enabled.read_text())
self.assertEqual(enabled.is_symlink(), symlink)
if not symlink:
self.assertEqual(available.read_bytes(), before)
def test_all_defaults_guarded_named_apps_untouched_idempotent(self):
updated = guard.guarded(SOURCE)
self.assertEqual(updated.count(guard.CHECK), 2)
self.assertIn(SOURCE.splitlines()[-1], updated)
self.assertEqual(guard.guarded(updated), updated)
self.assertIn('geo $realip_remote_addr', updated)
def test_incomplete_and_ambiguous_config_rejected(self):
for source in [SOURCE.replace('listen 443 ssl default_server;', 'listen 8443 ssl;'),
SOURCE + '\n' + guard.BEGIN, SOURCE + '\nserver {',
guard.END + '\n' + guard.BEGIN + '\n' + SOURCE]:
with self.assertRaises(ValueError):
guard.guarded(source)
def test_legacy_address_specific_https_dashboard(self):
source = SOURCE.replace('listen 443 ssl default_server;', 'listen 192.168.1.10:443 ssl;')
updated = guard.guarded(source)
self.assertEqual(updated.count(guard.CHECK), 2)
self.assertEqual(guard.guarded(updated), updated)
self.assertIn(SOURCE.splitlines()[-1], updated)
def test_syntax_and_reload_failure_restore_exact_previous_bytes(self):
for failure in ['nginx', 'systemctl']:
with self.subTest(failure=failure), tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / 'archipelago'
path.write_text(SOURCE)
calls = []
def command(args, **kwargs):
calls.append(args)
# Only the candidate's first matching command fails.
fail = args[0] == failure and sum(x[0] == failure for x in calls) == 1
return subprocess.CompletedProcess(args, int(fail))
with self.assertRaises(RuntimeError):
guard.apply(path, command, Path(tmp) / 'nginx.lock')
self.assertEqual(path.read_text(), SOURCE)
backups = list(Path(tmp).glob('*.before-management-guard-*'))
self.assertEqual(len(backups), 1)
self.assertEqual(backups[0].read_text(), SOURCE)
self.assertEqual(backups[0].stat().st_mode & 0o777, 0o600)
self.assertEqual(calls[-1], ['systemctl', 'reload', 'nginx'])
def test_second_application_does_not_reload(self):
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / 'archipelago'
path.write_text(SOURCE)
calls = []
def command(args, **kwargs):
calls.append(args)
return subprocess.CompletedProcess(args, 0)
self.assertTrue(guard.apply(path, command, Path(tmp) / 'nginx.lock'))
self.assertFalse(guard.apply(path, command, Path(tmp) / 'nginx.lock'))
self.assertEqual(len(calls), 2)
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,61 @@
import importlib.util
from pathlib import Path
import json
import os
import tempfile
import unittest
spec = importlib.util.spec_from_file_location('filebrowser_credentials', Path(__file__).resolve().parents[1] / 'filebrowser-credentials.py')
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
class CredentialTests(unittest.TestCase):
def test_record_validation_rejects_defaults_and_malformed_credentials(self):
valid = {'schema': 1, 'username': 'archy-' + 'a' * 32, 'password': 'b' * 64}
self.assertEqual(module.credentials(valid), valid)
for values in [{**valid, 'username': 'admin'}, {**valid, 'password': 'admin'}, {**valid, 'schema': 2}, {**valid, 'password': 'x' * 64}, None]:
with self.assertRaises(module.ProvisionError):
module.credentials(values)
def test_atomic_secret_is_private_and_refuses_symlinks(self):
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / 'credential.json'
module.atomic_json(path, {'value': 'test'})
self.assertEqual(path.stat().st_mode & 0o777, 0o600)
self.assertEqual(json.loads(path.read_text()), {'value': 'test'})
target = Path(tmp) / 'target'
target.write_text('preserved')
path.unlink()
path.symlink_to(target)
with self.assertRaises(module.ProvisionError):
module.atomic_json(path, {})
self.assertEqual(target.read_text(), 'preserved')
def test_database_selection_preserves_legacy_and_configured_locations(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
self.assertEqual(module.database_path(root), '/data/filebrowser.db')
(root / 'database.db').touch()
self.assertEqual(module.database_path(root), '/data/database.db')
(root / 'filebrowser.db').touch()
with self.assertRaises(module.ProvisionError):
module.database_path(root)
(root / '.filebrowser.json').write_text(json.dumps({'database': '/data/database.db'}))
self.assertEqual(module.database_path(root), '/data/database.db')
def test_database_paths_fail_closed_on_traversal_or_symlinks(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
for database in ['/data/../outside.db', '/outside.db', None, '/data/a.db\n--flag']:
(root / '.filebrowser.json').write_text(json.dumps({'database': database}))
with self.assertRaises(module.ProvisionError):
module.database_path(root)
(root / '.filebrowser.json').write_text(json.dumps({'database': '/data/filebrowser.db'}))
(root / 'filebrowser.db').symlink_to(root / 'elsewhere')
with self.assertRaises(module.ProvisionError):
module.database_path(root)
if __name__ == '__main__':
unittest.main()
+46
View File
@@ -0,0 +1,46 @@
"""QEMU runner argument/error handling; this is not an actual ISO boot test."""
import os
from pathlib import Path
import subprocess
import tempfile
import unittest
RUNNER = Path(__file__).resolve().parents[2] / 'image-recipe/_archived/test-iso-qemu.sh'
class QemuRunnerTests(unittest.TestCase):
def test_command_survives_timeout_and_stale_logs_cannot_pass(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
iso = root / 'candidate with spaces.iso'
iso.touch()
executable = root / 'qemu-system-x86_64'
executable.write_text('''#!/usr/bin/python3
import os,pathlib,sys,time
args=sys.argv[1:]
assert args[args.index('-cdrom')+1]==os.environ['FIXTURE_ISO']
assert args[args.index('-machine')+1]=='pc'
assert 'hostfwd=tcp:127.0.0.1:2222-:22,hostfwd=tcp:127.0.0.1:8100-:80' in args[args.index('-serial')-1]
log=pathlib.Path(args[args.index('-serial')+1].removeprefix('file:'))
mode=os.environ['FIXTURE_MODE']
if mode=='login':log.write_text('Debian GNU/Linux 13 archipelago-installer ttyS0\\n')
elif mode!='silent':log.write_text('systemd[1]: boot fixture marker\\n')
if mode=='crash':sys.exit(5)
if mode=='timeout':time.sleep(10)
''')
executable.chmod(0o755)
# A dedicated empty fixture disk avoids requiring qemu-img here.
(root / 'archipelago-test-disk.qcow2').touch()
env = dict(os.environ, PATH=str(root)+':'+os.environ['PATH'],
TMPDIR=str(root), FIXTURE_ISO=str(iso))
for mode, expected in [('timeout', 0), ('crash', 5), ('silent', 1), ('login', 0)]:
with self.subTest(mode=mode):
(root / 'archipelago-qemu-serial.log').write_text('systemd[1]: stale pass\n')
result = subprocess.run(['bash', str(RUNNER), str(iso), '--bios', '1'],
env=dict(env, FIXTURE_MODE=mode),
capture_output=True, text=True, timeout=15)
self.assertEqual(result.returncode, expected, result.stdout+result.stderr)
if __name__ == '__main__':
unittest.main()
+412
View File
@@ -0,0 +1,412 @@
import contextlib
import importlib.util
import json
from pathlib import Path
import sqlite3
import subprocess
import tempfile
import unittest
from unittest.mock import patch
SPEC = importlib.util.spec_from_file_location('bridge', Path(__file__).parents[1] / 'npm-public-bridge.py')
bridge = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(bridge)
def database(path):
path.mkdir(parents=True, exist_ok=True)
with contextlib.closing(sqlite3.connect(path / 'database.sqlite')) as con:
con.executescript('''
CREATE TABLE proxy_host(id INTEGER, domain_names TEXT, certificate_id INTEGER,
enabled INTEGER, is_deleted INTEGER);
CREATE TABLE certificate(id INTEGER, provider TEXT, is_deleted INTEGER);
''')
def runtime(data, certs):
return {'Mounts': [{'Destination': '/data', 'Source': str(data)},
{'Destination': '/etc/letsencrypt', 'Source': str(certs)}]}
class StorageTests(unittest.TestCase):
def test_managed_realip_file_is_idempotent_and_preserves_operator_snippets(self):
with tempfile.TemporaryDirectory() as tmp:
data = Path(tmp)
custom = data / 'nginx/custom/http_top.conf'
custom.parent.mkdir(parents=True)
custom.write_text('# Operator configuration\n')
original_mode = custom.stat().st_mode & 0o777
path = bridge.prepare_realip({'data': str(data)})
self.assertEqual(path.stat().st_mode & 0o777, original_mode)
self.assertEqual(path.read_text().count('set_real_ip_from'), 1)
self.assertIn('set_real_ip_from 169.254.1.100;', path.read_text())
before = path.stat().st_mtime_ns
self.assertEqual(bridge.prepare_realip({'data': str(data)}), path)
self.assertEqual(path.stat().st_mtime_ns, before)
self.assertTrue(custom.read_text().startswith('# Operator configuration\n'))
path.write_text('# BEGIN ARCHY HOST BRIDGE\n# Operator override\n# END ARCHY HOST BRIDGE\n')
with self.assertRaisesRegex(ValueError, 'operator override'):
bridge.prepare_realip({'data': str(data)})
self.assertIn('# Operator override', path.read_text())
original = data / 'operator.conf'
original.write_text('# Preserved\n')
path.unlink(); path.symlink_to(original)
with self.assertRaisesRegex(ValueError, 'symlink'):
bridge.prepare_realip({'data': str(data)})
self.assertEqual(original.read_text(), '# Preserved\n')
def test_recorded_custom_mount_survives_missing_container_record(self):
with tempfile.TemporaryDirectory() as tmp:
base = Path(tmp) / 'npm'
base.mkdir()
data = Path(tmp) / 'custom-data'
database(data)
expected = {'data': str(data), 'certificates': str(Path(tmp) / 'custom-certs')}
bridge.atomic(base / '.archy-storage.json', json.dumps(expected).encode())
self.assertEqual(bridge.resolve_paths(base), expected)
(data / 'database.sqlite').unlink()
with self.assertRaisesRegex(ValueError, 'Previously initialized'):
bridge.resolve_paths(base)
def test_fresh_flat_nested_and_custom_mount_without_mutation(self):
for layout in ['fresh', 'flat', 'nested', 'custom']:
with self.subTest(layout=layout), tempfile.TemporaryDirectory() as tmp:
base = Path(tmp) / 'npm'
data = base / 'data' if layout == 'nested' else base
if layout == 'custom':
data = Path(tmp) / 'operator-data'
certs = Path(tmp) / 'operator-certs' if layout == 'custom' else base / 'letsencrypt'
if layout != 'fresh':
database(data)
before = {p: p.read_bytes() for p in Path(tmp).rglob('*') if p.is_file()}
result = bridge.resolve_paths(base, runtime(data, certs))
self.assertEqual(result, {'data': str(data), 'certificates': str(certs)})
after = {p: p.read_bytes() for p in Path(tmp).rglob('*') if p.is_file()}
self.assertEqual(before, after)
if layout != 'custom':
self.assertEqual(bridge.resolve_paths(base)['data'], str(data))
def test_ambiguity_and_wrong_active_mount_fail_without_replacing_data(self):
with tempfile.TemporaryDirectory() as tmp:
base = Path(tmp)
database(base)
original = (base / 'database.sqlite').read_bytes()
with self.assertRaisesRegex(ValueError, 'active mount differs'):
bridge.resolve_paths(base, runtime(base / 'empty', base / 'certs'))
database(base / 'data')
with self.assertRaisesRegex(ValueError, 'Multiple NPM databases'):
bridge.resolve_paths(base)
before = {path: path.read_bytes() for path in base.rglob('database.sqlite')}
for selected in [base, base / 'data']:
self.assertEqual(bridge.resolve_paths(base, runtime(selected, base / 'certs'))['data'], str(selected))
bridge.atomic(base / '.archy-storage.json', json.dumps({
'data': str(base), 'certificates': str(base / 'certs')}).encode())
self.assertEqual(bridge.resolve_paths(base)['data'], str(base))
self.assertEqual({path: path.read_bytes() for path in base.rglob('database.sqlite')}, before)
self.assertEqual((base / 'database.sqlite').read_bytes(), original)
def test_corrupt_or_uninitialized_database_is_not_recreated(self):
for value in [b'not a sqlite database', b'']:
with tempfile.TemporaryDirectory() as tmp:
base = Path(tmp)
db = base / 'database.sqlite'
db.write_bytes(value)
with self.assertRaises((sqlite3.DatabaseError, ValueError)):
bridge.resolve_paths(base)
self.assertEqual(db.read_bytes(), value)
def test_duplicate_and_missing_mounts_rejected(self):
info = runtime(Path('/data/npm'), Path('/data/certs'))
for mounts in [info['Mounts'][:1], info['Mounts'] + info['Mounts'][:1]]:
with self.assertRaises(ValueError):
bridge.resolve_paths(Path('/nonexistent-fixture'), {'Mounts': mounts})
def test_deleted_and_disabled_hosts_are_excluded(self):
with tempfile.TemporaryDirectory() as tmp:
data = Path(tmp)
database(data)
with contextlib.closing(sqlite3.connect(data / 'database.sqlite')) as con:
con.executemany('INSERT INTO proxy_host VALUES (?, ?, 0, ?, ?)', [
(1, '["active.example"]', 1, 0),
(2, '["disabled.example"]', 0, 0),
(3, '["deleted.example"]', 1, 1)])
con.commit()
self.assertEqual([row['id'] for row in bridge.hosts(data)], [1])
def test_redirect_and_dead_hosts_are_also_routed_through_npm(self):
with tempfile.TemporaryDirectory() as tmp:
data = Path(tmp)
database(data)
with contextlib.closing(sqlite3.connect(data / 'database.sqlite')) as con:
for table, domain in [('redirection_host', 'redirect.example'), ('dead_host', 'gone.example')]:
con.execute(f'CREATE TABLE {table} AS SELECT * FROM proxy_host')
con.execute(f'INSERT INTO {table} VALUES (1, ?, 0, 1, 0)', (json.dumps([domain]),))
con.commit()
self.assertEqual({r['domain_names'] for r in bridge.hosts(data)},
{'["redirect.example"]', '["gone.example"]'})
class RoutingTests(unittest.TestCase):
def test_active_dashboard_uses_enabled_copy_or_symlink_target(self):
for symlink in [False, True]:
with self.subTest(symlink=symlink), tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
enabled = root / 'sites-enabled/archipelago'
available = root / 'sites-available/archipelago'
enabled.parent.mkdir(); available.parent.mkdir()
available.write_text('available')
if symlink:
enabled.symlink_to(available)
else:
enabled.write_text('active copy')
self.assertEqual(bridge.active_dashboard(root), available if symlink else enabled)
def test_acme_flat_nested_upgrade_and_custom_override_preservation(self):
for legacy in [str(bridge.BASE), str(bridge.BASE / 'data')]:
source = ('location ^~ /.well-known/acme-challenge/ {\n'
f' root {legacy}/letsencrypt-acme-challenge; try_files $uri =404;\n' + '}\n') * 2
result = bridge.dashboard_acme_root(source, '/operator/npm-data')
self.assertEqual(result.count('root "/operator/npm-data/letsencrypt-acme-challenge";'), 2)
self.assertEqual(bridge.dashboard_acme_root(result, '/operator/npm-data'), result)
with self.assertRaisesRegex(ValueError, 'Custom dashboard ACME'):
bridge.dashboard_acme_root(source.replace(legacy, '/unrecognized'), '/operator/npm-data')
with self.assertRaisesRegex(ValueError, 'HTTP and HTTPS'):
bridge.dashboard_acme_root(source.split('}\n')[0] + '}\n', '/operator/npm-data')
def test_shipped_template_and_legacy_missing_https_acme(self):
template = (Path(__file__).parents[2] / 'image-recipe/configs/nginx-archipelago.conf').read_text()
import re
pattern = re.compile(r'location\s+\^~\s+/\.well-known/acme-challenge/\s*\{[^{}]*\}', re.S)
locations = list(pattern.finditer(template))
self.assertEqual(len(locations), 2)
legacy = template[:locations[1].start()] + template[locations[1].end():]
for source in (template, legacy):
result = bridge.dashboard_acme_root(source, '/operator/npm-data')
self.assertEqual(result.count('root "/operator/npm-data/letsencrypt-acme-challenge";'), 2)
self.assertEqual(bridge.dashboard_acme_root(result, '/operator/npm-data'), result)
self.assertEqual(result.count('try_files $uri =404;'), template.count('try_files $uri =404;'))
with self.assertRaisesRegex(ValueError, 'HTTP and HTTPS'):
bridge.dashboard_acme_root(legacy.replace('listen 443 ssl default_server;', 'listen 444 ssl;'), '/operator/npm-data')
def test_custom_duplicate_routes_block_replacement(self):
with tempfile.TemporaryDirectory() as tmp:
directory = Path(tmp) / 'conf.d'
directory.mkdir()
config = directory / 'operator.conf'
original = 'server { listen 80; server_name public.example; return 200 "operator"; }'
config.write_text(original)
rows = [{'domain_names': '["public.example"]'}]
with self.assertRaisesRegex(ValueError, 'custom configuration preserved'):
bridge.existing_route_changes(rows, {}, output=directory / 'generated.conf', directories=[directory])
self.assertEqual(config.read_text(), original)
config.write_text('server { listen 80; server_name other.example; return 200 "operator"; }')
self.assertEqual(bridge.existing_route_changes(rows, {}, directories=[directory]), [])
def test_emergency_routes_retire_transactionally_and_preserve_operator_edits(self):
paths = {'data': str(bridge.BASE), 'certificates': str(bridge.BASE / 'letsencrypt')}
fixtures = Path(__file__).parent / 'fixtures'
for app in ['indexer', 'relay', 'shop']:
with self.subTest(app=app), tempfile.TemporaryDirectory() as tmp:
directory = Path(tmp) / 'conf.d'
directory.mkdir()
route = directory / ('shop-btcpay.conf' if app == 'shop' else f'angor-{app}-npm.conf')
original = (fixtures / f'npm-emergency-{app}.conf').read_bytes()
route.write_bytes(original)
self.assertTrue(bridge.legacy_shop_route(original.decode(), paths) if app == 'shop'
else bridge.legacy_angor_route(original.decode(), paths, relay=app == 'relay'))
names = ['fixture.example', 'www.fixture.example'] if app == 'shop' else ['fixture.example']
rows = [{'domain_names': json.dumps(names), 'certificate_id': 12}]
self.assertEqual(bridge.existing_route_changes([], paths, directories=[directory]), [])
with self.assertRaisesRegex(ValueError, 'custom configuration preserved'):
bridge.existing_route_changes([{**rows[0], 'certificate_id': 0}], paths, directories=[directory])
if app == 'shop':
with self.assertRaisesRegex(ValueError, 'custom configuration preserved'):
bridge.existing_route_changes([{**rows[0], 'domain_names': '["fixture.example"]'}], paths, directories=[directory])
changes = bridge.existing_route_changes(rows, paths, directories=[directory])
self.assertEqual(len(changes), 1)
def fail(args, **kwargs):
return subprocess.CompletedProcess(args, 1 if route.read_bytes() != original else 0)
with self.assertRaisesRegex(RuntimeError, 'validation/reload failed'):
bridge.apply_files(changes, Path(tmp) / 'state', fail, Path(tmp) / 'lock')
self.assertEqual(route.read_bytes(), original)
def succeed(args, **kwargs):
return subprocess.CompletedProcess(args, 0)
self.assertTrue(bridge.apply_files(changes, Path(tmp) / 'state', succeed, Path(tmp) / 'lock'))
self.assertEqual(bridge.existing_route_changes(rows, paths, directories=[directory]), [])
modified = original.replace(b'proxy_http_version 1.1;', b'proxy_http_version 1.1; proxy_read_timeout 42s;')
route.write_bytes(modified)
with self.assertRaisesRegex(ValueError, 'custom configuration preserved'):
bridge.existing_route_changes(rows, paths, directories=[directory])
self.assertEqual(route.read_bytes(), modified)
def test_domain_injection_rejected(self):
for name in ['_', 'x; return 200;', 'x\ninclude bad;', '$host', 'a/b', '.example', 'a..b', '-a.example']:
with self.subTest(name=name), self.assertRaises(ValueError):
bridge.domains(json.dumps([name]))
self.assertEqual(bridge.domains('["EXAMPLE.COM.", "*.example.com"]'),
['*.example.com', 'example.com'])
def test_mixed_wildcard_and_loopback_publication_rejected(self):
info = {'NetworkSettings': {'Ports': {'80/tcp': [
{'HostIp': '127.0.0.1', 'HostPort': '8088'},
{'HostIp': '0.0.0.0', 'HostPort': '8088'}]}}}
with self.assertRaisesRegex(ValueError, 'non-loopback'):
bridge.local_port(info, 80)
info['NetworkSettings']['Ports']['80/tcp'].pop()
self.assertEqual(bridge.local_port(info, 80), '127.0.0.1:8088')
def test_wireguard_web_listener_preserved_but_loopback_still_required(self):
tunnel = {'HostIp': '10.55.0.2', 'HostPort': '18081'}
info = {'NetworkSettings': {'Ports': {'80/tcp': [
tunnel, {'HostIp': '127.0.0.1', 'HostPort': '8088'}]}}}
interface = [{'ifname': 'wg-web', 'linkinfo': {'info_kind': 'wireguard'},
'addr_info': [{'family': 'inet', 'local': '10.55.0.2'}]}]
with patch.object(bridge, 'run', return_value=json.dumps(interface)):
self.assertEqual(bridge.local_port(info, 80), '127.0.0.1:8088')
info['NetworkSettings']['Ports']['80/tcp'].pop()
with self.assertRaisesRegex(ValueError, 'needs a loopback'):
bridge.local_port(info, 80)
self.assertFalse(bridge.managed_tunnel_listener(tunnel, 81))
self.assertFalse(bridge.managed_tunnel_listener(dict(tunnel, HostIp='0.0.0.0'), 80))
self.assertFalse(bridge.managed_tunnel_listener(dict(tunnel, HostIp='203.0.113.1'), 80))
self.assertFalse(bridge.managed_tunnel_listener(dict(tunnel, HostIp='10.55.0.3'), 80))
self.assertFalse(bridge.managed_tunnel_listener(dict(tunnel, HostPort='18080'), 80))
interface[0]['linkinfo']['info_kind'] = 'dummy'
with patch.object(bridge, 'run', return_value=json.dumps(interface)):
self.assertFalse(bridge.managed_tunnel_listener(tunnel, 80))
with patch.object(bridge, 'run', side_effect=RuntimeError('interface missing')):
self.assertFalse(bridge.managed_tunnel_listener(tunnel, 80))
def test_bridge_routes_through_npm_without_copying_upstream(self):
rows = [{'id': 1, 'domain_names': '["public.example"]', 'certificate_id': 0,
'certificate_deleted': 0, 'provider': None, 'forward_host': 'private-backend'}]
config, trust, fingerprints = bridge.render(rows, {}, '127.0.0.1:8088', '127.0.0.1:8444',
'/acme', '/trust.pem')
self.assertIn(b'proxy_pass http://127.0.0.1:8088;', config)
self.assertNotIn(b'private-backend', config)
self.assertNotIn(b'listen 443', config)
self.assertIn(b'proxy_set_header X-Forwarded-For $remote_addr;', config)
self.assertEqual(fingerprints, [])
self.assertTrue(trust)
with self.assertRaisesRegex(ValueError, 'Duplicate'):
bridge.render(rows + rows, {}, '127.0.0.1:8088', '127.0.0.1:8444', '/acme', '/trust.pem')
class TransactionTests(unittest.TestCase):
def test_idempotent_sync_and_certificate_renewal_reload(self):
with tempfile.TemporaryDirectory() as tmp:
base = Path(tmp)
files = [(base / 'hosts.conf', b'new routes', 0o644), (base / 'trust.pem', b'chain', 0o600)]
calls = []
def command(args, **kwargs):
calls.append(args)
return subprocess.CompletedProcess(args, 0)
args = (files, base / 'state', command, base / 'lock')
self.assertTrue(bridge.apply_files(*args, renewal_fingerprint='first'))
self.assertEqual(len(calls), 2)
self.assertFalse(bridge.apply_files(*args, renewal_fingerprint='first'))
self.assertEqual(len(calls), 2)
self.assertTrue(bridge.apply_files(*args, renewal_fingerprint='renewed'))
self.assertEqual(len(calls), 4)
self.assertEqual((base / 'hosts.conf').stat().st_mode & 0o777, 0o644)
self.assertEqual((base / 'trust.pem').stat().st_mode & 0o777, 0o600)
def test_certificate_reload_failure_rolls_back_and_retry_keeps_obligation(self):
with tempfile.TemporaryDirectory() as tmp:
base = Path(tmp)
output = base / 'hosts.conf'
output.write_bytes(b'previous')
def command(args, **kwargs):
return subprocess.CompletedProcess(args, 0)
def failure():
raise RuntimeError('fixture NPM reload failure')
args = ([(output, b'candidate', 0o644)], base / 'state', command, base / 'lock')
with self.assertRaisesRegex(RuntimeError, 'fixture NPM reload failure'):
bridge.apply_files(*args, renewal_fingerprint='new', certificate_reload=failure)
self.assertEqual(output.read_bytes(), b'previous')
self.assertFalse((base / 'state/applied.json').exists())
reloaded = []
callback = lambda: reloaded.append(True)
self.assertTrue(bridge.apply_files(*args, renewal_fingerprint='new', certificate_reload=callback))
self.assertFalse(bridge.apply_files(*args, renewal_fingerprint='new', certificate_reload=callback))
self.assertEqual(reloaded, [True])
def test_validation_or_reload_failure_restores_files_and_modes(self):
for failure in ['nginx', 'systemctl']:
with self.subTest(failure=failure), tempfile.TemporaryDirectory() as tmp:
base = Path(tmp)
original = base / 'hosts.conf'
original.write_bytes(b'operator previous routes')
original.chmod(0o640)
trust = base / 'trust.pem'
calls = []
def command(args, **kwargs):
calls.append(args)
fail = args[0] == failure and sum(c[0] == failure for c in calls) == 1
return subprocess.CompletedProcess(args, int(fail))
with self.assertRaisesRegex(RuntimeError, 'validation/reload failed'):
bridge.apply_files([(original, b'candidate', 0o644), (trust, b'new trust', 0o600)],
base / 'state', command, base / 'lock')
self.assertEqual(original.read_bytes(), b'operator previous routes')
self.assertEqual(original.stat().st_mode & 0o777, 0o640)
self.assertFalse(trust.exists())
self.assertFalse((base / 'state/pending.json').exists())
backup = next((base / 'state').glob('backup-*/0'))
self.assertEqual(backup.read_bytes(), original.read_bytes())
self.assertEqual(backup.stat().st_mode & 0o777, 0o600)
def test_failed_rollback_is_recovered_before_next_sync(self):
with tempfile.TemporaryDirectory() as tmp:
base = Path(tmp)
original = base / 'hosts.conf'
original.write_bytes(b'previous')
files = [(original, b'candidate', 0o644)]
def fail(args, **kwargs):
return subprocess.CompletedProcess(args, 1)
with self.assertRaisesRegex(RuntimeError, 'rollback incomplete'):
bridge.apply_files(files, base / 'state', fail, base / 'lock')
self.assertTrue((base / 'state/pending.json').exists())
seen = []
def succeed(args, **kwargs):
seen.append(original.read_bytes())
return subprocess.CompletedProcess(args, 0)
self.assertTrue(bridge.apply_files(files, base / 'state', succeed, base / 'lock'))
self.assertEqual(seen, [b'previous', b'previous', b'candidate', b'candidate'])
self.assertFalse((base / 'state/pending.json').exists())
def test_pending_recovery_restores_inputs_before_render_and_is_idempotent(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
target = root / 'active.conf'
target.write_bytes(b'partially written candidate')
backup = root / 'backup'
backup.write_bytes(b'original active config')
state = root / 'state'
state.mkdir()
journal = state / 'pending.json'
journal.write_text(json.dumps({'files': [{'path': str(target), 'backup': str(backup), 'mode': 0o640}]}))
calls = []
def command(args, **kwargs):
calls.append(args)
self.assertEqual(target.read_bytes(), b'original active config')
return subprocess.CompletedProcess(args, 0)
self.assertTrue(bridge.recover_pending(state, command))
self.assertFalse(bridge.recover_pending(state, command))
self.assertEqual(len(calls), 2)
self.assertEqual(target.stat().st_mode & 0o777, 0o640)
def test_operator_symlink_not_replaced(self):
with tempfile.TemporaryDirectory() as tmp:
base = Path(tmp)
custom = base / 'custom.conf'
custom.write_bytes(b'operator')
output = base / 'hosts.conf'
output.symlink_to(custom)
with self.assertRaisesRegex(ValueError, 'symlink'):
bridge.apply_files([(output, b'new', 0o644)], base / 'state', lock_path=base / 'lock')
self.assertTrue(output.is_symlink())
self.assertEqual(custom.read_bytes(), b'operator')
if __name__ == '__main__':
unittest.main()
+37
View File
@@ -0,0 +1,37 @@
import contextlib
import importlib.util
import io
from pathlib import Path
import tempfile
import unittest
from unittest.mock import patch
spec = importlib.util.spec_from_file_location('release_notes', Path(__file__).resolve().parents[1] / 'sync-whats-new.py')
notes = importlib.util.module_from_spec(spec)
spec.loader.exec_module(notes)
class ReleaseNotesTests(unittest.TestCase):
def test_check_rejects_stale_content_without_modifying_the_modal(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
changelog = root / 'CHANGELOG.md'
modal = root / 'notes.vue'
changelog.write_text('## v1.9.0 (2026-10-05)\n\n- Keep uploads on their original screen.\n')
stale = notes.render_block({'ver': 'v1.9.0', 'date': 'October 2, 2026', 'bullets': ['Keep uploads across screens.']})
modal.write_text('<template>\n' + stale + '</template>\n')
with patch.object(notes, 'CHANGELOG', changelog), patch.object(notes, 'MODAL', modal):
original = modal.read_bytes()
with patch('sys.argv', ['sync-whats-new.py', '--check']), contextlib.redirect_stderr(io.StringIO()):
self.assertEqual(notes.main(), 1)
self.assertEqual(modal.read_bytes(), original)
with patch('sys.argv', ['sync-whats-new.py']), contextlib.redirect_stdout(io.StringIO()):
self.assertEqual(notes.main(), 0)
self.assertIn('original screen', modal.read_text())
self.assertIn('October 5, 2026', modal.read_text())
with patch('sys.argv', ['sync-whats-new.py', '--check']), contextlib.redirect_stdout(io.StringIO()):
self.assertEqual(notes.main(), 0)
if __name__ == '__main__':
unittest.main()