fix: harden node upgrades and prepare 1.9.0-alpha
This commit is contained in:
@@ -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 ─────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
Executable
+268
@@ -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())
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
Executable
+12
@@ -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"
|
||||
Executable
+17
@@ -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
|
||||
@@ -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
@@ -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
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user