Archipelago — open-source initial import

This commit is contained in:
Archipelago
2026-08-12 10:55:49 +00:00
commit 081dab5934
2056 changed files with 468143 additions and 0 deletions
+214
View File
@@ -0,0 +1,214 @@
#!/usr/bin/env python3
"""
Production app catalog image smoke test.
Parses local app manifests, then probes images on a target production node via
SSH. This catches catalog/image mismatches before a user clicks Install.
Checks:
- manifest YAML loads and required app/container fields exist
- production node health endpoint responds
- each non-local image can be pulled on the node
- shell-entrypoint apps reference commands that exist inside the image
Usage:
scripts/app-catalog-image-smoke-test.py \
--target archipelago@192.0.2.11 \
--ssh-key /home/archipelago/.ssh/id_ed25519
"""
from __future__ import annotations
import argparse
import json
import os
import re
import shlex
import subprocess
import sys
from pathlib import Path
import yaml
INSECURE_REGISTRIES = ("source.archipelago-foundation.org", "23.182.128.160:3000")
def run(cmd: list[str], timeout: int = 120) -> subprocess.CompletedProcess[str]:
return subprocess.run(
cmd,
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
timeout=timeout,
)
class Remote:
def __init__(self, target: str, ssh_key: str | None, extra: list[str]) -> None:
self.base = [
"ssh",
"-F",
"/dev/null",
"-o",
"ConnectTimeout=8",
"-o",
"BatchMode=yes",
"-o",
"PreferredAuthentications=publickey",
"-o",
"PasswordAuthentication=no",
"-o",
"StrictHostKeyChecking=no",
]
if ssh_key:
self.base.extend(["-i", ssh_key])
self.base.extend(extra)
self.target = target
def sh(self, script: str, timeout: int = 120) -> subprocess.CompletedProcess[str]:
return run(self.base + [self.target, script], timeout=timeout)
def load_manifests(apps_dir: Path) -> list[dict]:
manifests = []
for path in sorted(apps_dir.glob("*/manifest.yml")):
with path.open("r", encoding="utf-8") as fh:
data = yaml.safe_load(fh)
if not isinstance(data, dict):
app = None
container = None
elif isinstance(data.get("app"), dict):
app = data["app"]
container = app.get("container")
else:
app = data
container = data.get("container") if isinstance(data.get("container"), dict) else data
manifests.append({"path": path, "app": app, "container": container})
return manifests
def insecure(image: str) -> bool:
return image.startswith(INSECURE_REGISTRIES)
def shell_probe_for(app_id: str, command: str) -> str | None:
if app_id in {"bitcoin-core", "bitcoin-knots"}:
return "command -v bitcoind || find /opt -path '*/bin/bitcoind' -type f 2>/dev/null | sort | tail -n 1"
match = re.search(r"\bexec\s+([\"']?)([A-Za-z0-9_./-]+)\1", command)
if not match:
return None
binary = match.group(2)
if binary.startswith("$"):
return None
if "/" in binary:
return f"test -x {shlex.quote(binary)} && echo {shlex.quote(binary)}"
return f"command -v {shlex.quote(binary)}"
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--target", required=True)
parser.add_argument("--ssh-key", default=os.environ.get("ARCHIPELAGO_SSH_KEY"))
parser.add_argument("--apps-dir", default="apps")
parser.add_argument("--pull", action="store_true", help="pull missing images before probing")
parser.add_argument("--ssh-option", action="append", default=[])
args = parser.parse_args()
apps_dir = Path(args.apps_dir)
remote = Remote(args.target, args.ssh_key, sum((["-o", x] for x in args.ssh_option), []))
failures: list[str] = []
warnings: list[str] = []
passes = 0
health = remote.sh("curl -fsS --max-time 5 http://127.0.0.1:5678/health", timeout=15)
if health.returncode != 0:
failures.append(f"target health failed: {health.stderr.strip() or health.stdout.strip()}")
print(json.dumps({"passes": passes, "warnings": 0, "failures": len(failures)}, sort_keys=True))
for failure in failures:
print(f"FAIL {failure}")
return 1
else:
passes += 1
print(f"PASS target health {health.stdout.strip()}")
manifests = load_manifests(apps_dir)
print(f"INFO loaded {len(manifests)} manifests from {apps_dir}")
for item in manifests:
path = item["path"]
app = item["app"]
container = item["container"]
if not isinstance(app, dict) or not isinstance(container, dict):
failures.append(f"{path}: missing app.container")
continue
app_id = str(app.get("id") or "")
image = str(container.get("image") or app.get("image") or "")
if not app_id:
failures.append(f"{path}: missing app id")
continue
if not image and container.get("build"):
warnings.append(f"{app_id}: skipped locally built image")
continue
if not image:
failures.append(f"{path}: missing container image")
continue
passes += 1
if image.startswith("localhost/") or image.startswith("archipelago/"):
warnings.append(f"{app_id}: skipped local/unpublished image {image}")
continue
pull_args = ["pull"]
if insecure(image):
pull_args.append("--tls-verify=false")
pull_args.append(image)
if args.pull:
pull_cmd = "timeout 300s podman " + " ".join(shlex.quote(x) for x in pull_args)
pulled = remote.sh(pull_cmd, timeout=330)
if pulled.returncode != 0:
failures.append(f"{app_id}: pull failed for {image}: {(pulled.stderr or pulled.stdout).strip()[-500:]}")
continue
print(f"PASS {app_id}: pulled {image}")
passes += 1
else:
exists = remote.sh(f"podman image exists {shlex.quote(image)}", timeout=30)
if exists.returncode != 0:
warnings.append(f"{app_id}: image not present on target, rerun with --pull: {image}")
continue
custom_args = container.get("custom_args") or []
entrypoint = container.get("entrypoint") or []
if entrypoint == ["sh", "-lc"] and custom_args:
command = str(custom_args[0])
probe = shell_probe_for(app_id, command)
if probe:
remote_script = (
"timeout 45s podman run --rm "
f"--entrypoint sh {shlex.quote(image)} -c {shlex.quote(probe)}"
)
checked = remote.sh(remote_script, timeout=60)
found = checked.stdout.strip().splitlines()[-1:] or [""]
if checked.returncode == 0 and found[0]:
print(f"PASS {app_id}: command probe found {found[0]}")
passes += 1
else:
failures.append(
f"{app_id}: command probe failed in {image}: {(checked.stderr or checked.stdout).strip()[-500:]}"
)
print(json.dumps({"passes": passes, "warnings": len(warnings), "failures": len(failures)}, sort_keys=True))
for warning in warnings:
print(f"WARN {warning}")
for failure in failures:
print(f"FAIL {failure}")
return 1 if failures else 0
if __name__ == "__main__":
sys.exit(main())
+161
View File
@@ -0,0 +1,161 @@
#!/bin/bash
#
# App surface smoke test.
#
# Verifies that installed containers have their published host ports listening
# and that known nginx app proxy paths return a non-5xx response. This catches
# the common "container is running but UI disappeared" failure mode.
#
# Usage:
# scripts/app-surface-smoke-test.sh --target archipelago@192.0.2.10 --ssh-key /path/key
set -euo pipefail
TARGET=""
SSH_KEY="${ARCHIPELAGO_SSH_KEY:-}"
SSH_EXTRA=()
while [ "$#" -gt 0 ]; do
case "$1" in
--target) TARGET="${2:-}"; shift 2 ;;
--ssh-key) SSH_KEY="${2:-}"; shift 2 ;;
--ssh-option) SSH_EXTRA+=("-o" "${2:-}"); shift 2 ;;
-h|--help) sed -n '1,12p' "$0"; exit 0 ;;
*) echo "unknown argument: $1" >&2; exit 2 ;;
esac
done
[ -n "$TARGET" ] || { echo "--target is required" >&2; exit 2; }
SSH_OPTS=(-F /dev/null -o BatchMode=yes -o PreferredAuthentications=publickey -o PasswordAuthentication=no)
[ -n "$SSH_KEY" ] && SSH_OPTS+=(-i "$SSH_KEY")
SSH_OPTS+=("${SSH_EXTRA[@]}")
ssh_run() {
ssh "${SSH_OPTS[@]}" "$TARGET" "$@"
}
ssh_run 'bash -s' <<'REMOTE'
set -u
pass=0
fail=0
ok() { echo " PASS $*"; pass=$((pass + 1)); }
bad() { echo " FAIL $*"; fail=$((fail + 1)); }
container_exists() {
podman ps -a --format '{{.Names}}' 2>/dev/null | grep -qx "$1"
}
port_listening() {
ss -ltn 2>/dev/null | awk '{print $4}' | grep -Eq "(^|:)$1$"
}
http_code() {
local url="$1" code
for _ in 1 2 3; do
code=$(curl -ksS -o /dev/null -w '%{http_code}' --max-time 12 "$url" 2>/dev/null || true)
[ -n "$code" ] || code=000
[ "$code" != "000" ] && { echo "$code"; return; }
sleep 2
done
echo "$code"
}
http_post_code() {
local url="$1" code
for _ in 1 2 3; do
code=$(curl -ksS -o /dev/null -w '%{http_code}' --max-time 25 \
-H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"getblockchaininfo","params":[]}' \
"$url" 2>/dev/null || true)
[ -n "$code" ] || code=000
[ "$code" != "000" ] && { echo "$code"; return; }
sleep 2
done
echo "$code"
}
assert_http() {
local label="$1" url="$2" code
code=$(http_code "$url")
case "$code" in
200|204|301|302|307|308|401|403) ok "$label HTTP $code" ;;
*) bad "$label HTTP $code ($url)" ;;
esac
}
assert_http_post() {
local label="$1" url="$2" code
code=$(http_post_code "$url")
case "$code" in
200|204|401|403) ok "$label HTTP POST $code" ;;
*) bad "$label HTTP POST $code ($url)" ;;
esac
}
assert_container_ports() {
local name="$1" ports port missing=0
container_exists "$name" || return 0
ports=$(podman inspect "$name" --format '{{range $p,$bindings := .NetworkSettings.Ports}}{{if $bindings}}{{range $bindings}}{{.HostPort}}{{"\n"}}{{end}}{{end}}{{end}}' 2>/dev/null | sort -u)
[ -n "$ports" ] || return 0
while IFS= read -r port; do
[ -n "$port" ] || continue
if port_listening "$port"; then
ok "$name port $port listening"
else
bad "$name port $port missing listener"
missing=1
fi
done <<< "$ports"
return "$missing"
}
assert_env_contains() {
local name="$1" key="$2" needle="$3" val
container_exists "$name" || return 0
val=$(podman inspect "$name" --format '{{range .Config.Env}}{{println .}}{{end}}' 2>/dev/null | sed -n "s/^${key}=//p" | head -n 1)
if [ -n "$val" ] && printf '%s' "$val" | grep -qF "$needle"; then
ok "$name env $key"
else
bad "$name env $key missing $needle"
fi
}
echo "[surface] host=$(hostname) ip=$(hostname -I 2>/dev/null | awk '{print $1}')"
for c in $(podman ps -a --format '{{.Names}}' 2>/dev/null | sort); do
assert_container_ports "$c" || true
done
container_exists archy-bitcoin-ui && {
assert_http "bitcoin-ui" "http://127.0.0.1/app/bitcoin-ui/"
assert_http "bitcoin status" "http://127.0.0.1/app/bitcoin-ui/bitcoin-status"
assert_http_post "bitcoin rpc proxy" "http://127.0.0.1/app/bitcoin-ui/bitcoin-rpc/"
}
container_exists archy-electrs-ui && {
assert_http "electrumx ui" "http://127.0.0.1/app/electrumx/"
assert_http "electrumx status" "http://127.0.0.1/app/electrumx/electrs-status"
assert_http "electrs legacy status" "http://127.0.0.1/app/electrs/electrs-status"
}
container_exists mempool && assert_http "mempool ui" "http://127.0.0.1/app/mempool/"
container_exists indeedhub && assert_http "indeedhub ui" "http://127.0.0.1:7778/"
container_exists uptime-kuma && assert_http "uptime-kuma" "http://127.0.0.1/app/uptime-kuma/"
container_exists filebrowser && assert_http "filebrowser" "http://127.0.0.1/app/filebrowser/"
container_exists searxng && assert_http "searxng" "http://127.0.0.1/app/searxng/"
container_exists grafana && assert_http "grafana" "http://127.0.0.1/app/grafana/"
container_exists portainer && assert_http "portainer" "http://127.0.0.1/app/portainer/"
container_exists vaultwarden && assert_http "vaultwarden" "http://127.0.0.1/app/vaultwarden/"
container_exists nextcloud && assert_http "nextcloud" "http://127.0.0.1/app/nextcloud/"
container_exists archy-nbxplorer && assert_env_contains "archy-nbxplorer" "NBXPLORER_POSTGRES" "Database=nbxplorer"
container_exists btcpay-server && {
assert_env_contains "btcpay-server" "BTCPAY_POSTGRES" "Database=btcpay"
assert_http "btcpay" "http://127.0.0.1/app/btcpay/"
}
echo "[surface] summary: pass=$pass fail=$fail"
[ "$fail" -eq 0 ]
REMOTE
+15
View File
@@ -0,0 +1,15 @@
[Unit]
Description=Configure Nginx for Tailscale Access
After=archipelago.service
Requires=archipelago.service
ConditionPathExists=/sys/class/net/tailscale0
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/opt/archipelago/scripts/configure-tailscale-nginx.sh
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target
+58
View File
@@ -0,0 +1,58 @@
#!/bin/bash
# archipelago-wg — Privileged WireGuard helper for the Archipelago backend.
# Installed to /usr/local/bin/archipelago-wg with a sudoers rule so the
# unprivileged archipelago/debian service user can manage wg0 without
# full root or disabling NoNewPrivileges.
#
# Usage:
# archipelago-wg setup <privkey-file> — Create wg0 interface
# archipelago-wg add-peer <pubkey> <ip> — Add peer to wg0
# archipelago-wg remove-peer <pubkey> — Remove peer from wg0
set -euo pipefail
case "${1:-}" in
setup)
KEY_FILE="${2:?Usage: archipelago-wg setup <privkey-file>}"
[ -f "$KEY_FILE" ] || { echo "Key file not found: $KEY_FILE" >&2; exit 1; }
# Ensure kernel module is loaded
modprobe wireguard 2>/dev/null || true
# Create interface
ip link add dev wg0 type wireguard 2>/dev/null || true
wg set wg0 listen-port 51820 private-key "$KEY_FILE"
# Assign server address if not already set
ip address show dev wg0 | grep -q "10.44.0.1" || ip address add 10.44.0.1/16 dev wg0
ip link set up dev wg0
# NAT masquerade for VPN clients
iptables -t nat -C POSTROUTING -s 10.44.0.0/16 ! -o wg0 -j MASQUERADE 2>/dev/null ||
iptables -t nat -A POSTROUTING -s 10.44.0.0/16 ! -o wg0 -j MASQUERADE
# Open firewall port
if command -v ufw >/dev/null 2>&1 && ufw status | grep -q "Status: active"; then
ufw allow 51820/udp >/dev/null 2>&1 || true
fi
echo "wg0 configured"
;;
add-peer)
PUBKEY="${2:?Usage: archipelago-wg add-peer <pubkey> <allowed-ip>}"
ALLOWED_IP="${3:?Usage: archipelago-wg add-peer <pubkey> <allowed-ip>}"
wg set wg0 peer "$PUBKEY" allowed-ips "$ALLOWED_IP"
echo "peer added"
;;
remove-peer)
PUBKEY="${2:?Usage: archipelago-wg remove-peer <pubkey>}"
wg set wg0 peer "$PUBKEY" remove
echo "peer removed"
;;
*)
echo "Usage: archipelago-wg {setup|add-peer|remove-peer}" >&2
exit 1
;;
esac
+65
View File
@@ -0,0 +1,65 @@
#!/usr/bin/env bash
# archy-dev — Archipelago App Developer CLI
# Usage:
# archy-dev create <app-id> — scaffold a new app manifest
# archy-dev validate <manifest> — validate manifest (calls validate-app-manifest.sh)
# archy-dev test <manifest> — test app in sandbox container
# archy-dev publish <manifest> — publish to marketplace (future)
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
CMD="${1:-help}"
shift || true
case "$CMD" in
create)
APP_ID="${1:?Usage: archy-dev create <app-id>}"
MANIFEST_DIR="apps/${APP_ID}"
mkdir -p "$MANIFEST_DIR"
cat > "${MANIFEST_DIR}/manifest.yml" << YAML
id: ${APP_ID}
title: "${APP_ID^}"
version: "1.0.0"
description: "Description of ${APP_ID}"
author: "Your Name"
image: "docker.io/library/${APP_ID}:1.0.0"
ports:
- "8080:80"
environment: {}
memory: "256m"
# Security: these are enforced by Archipelago
# privileged: false (always)
# cap_drop: ALL (always)
# no_new_privileges: true (always)
YAML
echo "Created ${MANIFEST_DIR}/manifest.yml"
echo "Next: edit the manifest, then run: archy-dev validate ${MANIFEST_DIR}/manifest.yml"
;;
validate)
MANIFEST="${1:?Usage: archy-dev validate <manifest.yml>}"
exec "${SCRIPT_DIR}/../validate-app-manifest.sh" "$MANIFEST"
;;
test)
MANIFEST="${1:?Usage: archy-dev test <manifest.yml>}"
echo "Sandbox testing not yet implemented."
echo "For now, validate with: archy-dev validate $MANIFEST"
;;
publish)
echo "Marketplace publishing not yet implemented."
echo "Submit your app via PR to the Archipelago repository."
;;
help|--help|-h|"")
echo "archy-dev — Archipelago App Developer CLI"
echo ""
echo "Commands:"
echo " create <app-id> Scaffold a new app manifest"
echo " validate <manifest> Validate a manifest file"
echo " test <manifest> Test app in sandbox (future)"
echo " publish <manifest> Publish to marketplace (future)"
;;
*)
echo "Unknown command: $CMD"
echo "Run: archy-dev help"
exit 1
;;
esac
+142
View File
@@ -0,0 +1,142 @@
#!/bin/bash
set -euo pipefail
# SEC-202: Secrets audit — checks for hardcoded credentials in the codebase.
# Scans source files for common secret patterns.
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
PASS=0
FAIL=0
RESULTS=()
log() { echo -e "\033[1;34m[AUDIT]\033[0m $*"; }
pass() { echo -e "\033[1;32m[PASS]\033[0m $*"; PASS=$((PASS + 1)); RESULTS+=("PASS: $*"); }
fail() { echo -e "\033[1;31m[FAIL]\033[0m $*"; FAIL=$((FAIL + 1)); RESULTS+=("FAIL: $*"); }
# Patterns to search for (case insensitive)
PATTERNS=(
"password\s*=\s*['\"][^'\"]*['\"]"
"api_key\s*=\s*['\"][^'\"]*['\"]"
"secret\s*=\s*['\"][^'\"]*['\"]"
"private_key\s*=\s*['\"][^'\"]*['\"]"
"sk-ant-[A-Za-z0-9_-]{20,}"
"AKIA[A-Z0-9]{16}"
"ghp_[a-zA-Z0-9]{36}"
"glpat-[a-zA-Z0-9_-]{20}"
# Credentialed URLs: scheme://user:pass@host
"://[A-Za-z0-9_.-]+:[A-Za-z0-9_.@!%-]{8,}@"
# sshpass with an inline literal
"sshpass\s+-p\s*['\"][^'\"]+['\"]"
)
# Path allowlist — anchored to the PATH only, never to line content.
# The old version allow-matched the whole "file:line:content" string against
# bare words like "test" and "\.md$", so any hit whose path or text contained
# "test"/"demo"/"example" was silently dropped, and *.md was never scanned at
# all. That is why live API keys and node passwords survived this audit.
ALLOW_PATHS="(^|/)node_modules/|(^|/)(dist|target|\.git)/|\.example($|\.)|(^|/)package-lock\.json$|(^|/)Cargo\.lock$|(^|/)scripts/audit-secrets\.sh$"
# File types to scan. Markdown and YAML are in scope: docs and CI workflows are
# where the real leaks have historically lived.
SCAN_EXTS='\.(rs|ts|vue|js|mjs|cjs|json|sh|py|md|ya?ml|toml|kt|java|gradle|env)$'
main() {
log "=== Secrets Audit ==="
echo ""
# 1. Check for .env files in version control
log "1. Checking for .env files in git..."
local env_files
env_files=$(cd "$REPO_ROOT" && git ls-files | grep -E '(^|/)\.env($|[.])|(^|/)[^/]*\.env($|[.])' | grep -vE '(^|/)\.env\.example$|(^|/)[^/]*\.env\.example$' || echo "")
if [ -z "$env_files" ]; then
pass "No .env files tracked in git"
else
fail "Found .env files in git: $env_files"
fi
# 2. Check .gitignore includes sensitive patterns
log "2. Checking .gitignore coverage..."
local gitignore="$REPO_ROOT/.gitignore"
if [ -f "$gitignore" ]; then
local has_env has_key
has_env=$(grep -c '\.env' "$gitignore" || echo 0)
has_key=$(grep -c 'credentials\|\.key\|\.pem' "$gitignore" || echo 0)
if [ "$has_env" -gt 0 ]; then
pass ".gitignore covers .env files"
else
fail ".gitignore missing .env pattern"
fi
else
fail "No .gitignore found"
fi
# 3. Scan source for hardcoded credentials
log "3. Scanning source for hardcoded secrets..."
local found_secrets=0
# Scan TRACKED files only — that is exactly the set that would be published.
local scan_files
scan_files=$(cd "$REPO_ROOT" && git ls-files | grep -E "$SCAN_EXTS" | grep -vE "$ALLOW_PATHS" || echo "")
if [ -z "$scan_files" ]; then
fail "No tracked files matched the scan set (is this a git repo?)"
return 1
fi
for pattern in "${PATTERNS[@]}"; do
local matches
matches=$(cd "$REPO_ROOT" && echo "$scan_files" | tr '\n' '\0' \
| xargs -0 grep -niE "$pattern" 2>/dev/null || echo "")
if [ -n "$matches" ]; then
# Filter out false positives: empty strings, variable indirection, and
# scrubbed <PLACEHOLDER> tokens. NOTE: the previous version wrote the
# single-quote class as \x27\x27, which GNU grep does not expand in an
# ERE — so the empty-string rule silently never matched. Use a literal
# quote via a shell variable instead.
local q="'"
local real_matches
real_matches=$(echo "$matches" | grep -vE "\"\"|${q}${q}|<[A-Z_]+>|None|null|undefined|TODO|placeholder|Option<|\\\$\{[A-Za-z0-9_]+(:-[^}]*)?\}|\\\$[A-Za-z0-9_]+|TestPassword|password123|entertoexit|…|\\.\\.\\." || echo "")
if [ -n "$real_matches" ]; then
echo " WARNING: Pattern '$pattern' found:"
echo "$real_matches" | head -5 | sed 's/^/ /'
found_secrets=$((found_secrets + 1))
fi
fi
done
if [ "$found_secrets" -eq 0 ]; then
pass "No hardcoded secrets found in source"
else
fail "Found $found_secrets secret pattern matches (review above)"
fi
# 4. Check deploy-config is gitignored
log "4. Checking deploy-config.sh is gitignored..."
if cd "$REPO_ROOT" && git check-ignore scripts/deploy-config.sh > /dev/null 2>&1; then
pass "scripts/deploy-config.sh is gitignored"
elif [ -f "$REPO_ROOT/scripts/deploy-config.sh" ]; then
fail "scripts/deploy-config.sh exists but is NOT gitignored"
else
pass "scripts/deploy-config.sh does not exist (using env vars)"
fi
# 5. Check for credential files in repo
log "5. Checking for credential files..."
local cred_files
# `testdata/` holds throwaway keypairs generated for unit tests (appgate TLS);
# they are not credentials for anything real. Narrow, path-anchored exemption.
cred_files=$(cd "$REPO_ROOT" && git ls-files | grep -Ei '(\.pem$|\.key$|\.p12$|\.pfx$|\.jks$|\.keystore$|id_rsa|id_ed25519|macaroon)' | grep -vE '\.(rs|ts|sh)$|(^|/)testdata/' || echo "")
if [ -z "$cred_files" ]; then
pass "No credential files tracked in git"
else
fail "Credential files in git: $cred_files"
fi
echo ""
log "=== RESULTS ==="
for r in "${RESULTS[@]}"; do
echo " $r"
done
echo ""
log "Pass: $PASS | Fail: $FAIL"
[ $FAIL -gt 0 ] && exit 1
exit 0
}
main "$@"
+249
View File
@@ -0,0 +1,249 @@
#!/bin/bash
#
# Bitcoin stack lifecycle test.
#
# Exercises the production Bitcoin stack under repeated stop/start and
# remove/recreate cycles while asserting the actual user-facing surfaces:
# Bitcoin RPC, bitcoin-ui /bitcoin-rpc, ElectrumX status, and electrs-ui.
#
# This intentionally removes containers but not data volumes. It is safe for
# installed nodes, but it will briefly interrupt Bitcoin/ElectrumX service.
#
# Usage:
# scripts/bitcoin-stack-lifecycle-test.sh --target archipelago@192.0.2.10
# scripts/bitcoin-stack-lifecycle-test.sh --target archipelago@192.0.2.12 --cycles 5
set -euo pipefail
TARGET=""
SSH_KEY="${ARCHIPELAGO_SSH_KEY:-}"
CYCLES=3
SSH_EXTRA=()
while [ "$#" -gt 0 ]; do
case "$1" in
--target)
TARGET="${2:-}"
shift 2
;;
--ssh-key)
SSH_KEY="${2:-}"
shift 2
;;
--cycles)
CYCLES="${2:-}"
shift 2
;;
--ssh-option)
SSH_EXTRA+=("-o" "${2:-}")
shift 2
;;
-h|--help)
sed -n '1,22p' "$0"
exit 0
;;
*)
echo "unknown argument: $1" >&2
exit 2
;;
esac
done
if [ -z "$TARGET" ]; then
echo "--target is required, for example archipelago@192.0.2.10" >&2
exit 2
fi
SSH=(ssh -F /dev/null -o BatchMode=yes -o PreferredAuthentications=publickey -o PasswordAuthentication=no -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null)
if [ -n "$SSH_KEY" ]; then
SSH+=("-i" "$SSH_KEY")
fi
SSH+=("${SSH_EXTRA[@]}")
"${SSH[@]}" "$TARGET" "CYCLES='$CYCLES' bash -s" <<'REMOTE'
set -euo pipefail
PODMAN="${PODMAN:-podman}"
SCRIPTS_DIR="/opt/archipelago/scripts"
if [ ! -x "$SCRIPTS_DIR/reconcile-containers.sh" ]; then
SCRIPTS_DIR="$HOME/archy/scripts"
fi
RECONCILE="$SCRIPTS_DIR/reconcile-containers.sh"
pass_count=0
fail_count=0
log() { printf '[%s] %s\n' "$(date +%H:%M:%S)" "$*"; }
pass() { pass_count=$((pass_count + 1)); printf ' PASS %s\n' "$*"; }
fail() { fail_count=$((fail_count + 1)); printf ' FAIL %s\n' "$*" >&2; }
retry() {
local timeout="$1" label="$2"
shift 2
local end=$((SECONDS + timeout))
local out rc
while [ "$SECONDS" -lt "$end" ]; do
set +e
out=$("$@" 2>&1)
rc=$?
set -e
if [ "$rc" -eq 0 ]; then
pass "$label"
return 0
fi
sleep 2
done
fail "$label: $out"
return 1
}
rpc_pass() {
cat /var/lib/archipelago/secrets/bitcoin-rpc-password
}
json_rpc_reachable_or_warming() {
local url="$1" auth_arg=() body rc
if [ "${2:-}" = "auth" ]; then
auth_arg=(--user "archipelago:$(rpc_pass)")
fi
set +e
body=$(curl --connect-timeout 3 --max-time 20 -sS "${auth_arg[@]}" \
-H "Content-Type: application/json" \
--data-binary '{"jsonrpc":"1.0","id":"lifecycle-test","method":"getblockchaininfo","params":[]}' \
"$url" 2>&1)
rc=$?
set -e
[ "$rc" -eq 0 ] || {
echo "$body"
return 1
}
echo "$body" | grep -q '"result"' && return 0
echo "$body" | grep -q '"code":-28' && return 0
echo "$body"
return 1
}
bitcoin_status_usable() {
local url="$1"
local body
body=$(curl --connect-timeout 3 --max-time 20 -fsS "$url")
echo "$body" | grep -q '"ok":\(true\|false\)' || {
echo "$body"
return 1
}
echo "$body" | grep -q '"blockchain_info"' || echo "$body" | grep -q '"error"'
}
http_ok() {
local url="$1"
curl --connect-timeout 3 --max-time 20 -fsS -o /dev/null "$url"
}
electrs_status_ok() {
local url="${1:-http://127.0.0.1:50002/electrs-status}"
local body
body=$(curl --connect-timeout 3 --max-time 20 -fsS "$url")
echo "$body" | grep -q '"network_height":[1-9]' || {
echo "$body"
return 1
}
echo "$body" | grep -q '"status":"\(indexing\|syncing\|synced\|waiting\)"'
}
container_running() {
local name="$1"
[ "$($PODMAN inspect "$name" --format '{{.State.Status}}' 2>/dev/null || true)" = "running" ]
}
container_healthy_or_starting() {
local name="$1"
local health
health=$($PODMAN inspect "$name" --format '{{if .State.Health}}{{.State.Health.Status}}{{end}}' 2>/dev/null || true)
[ "$health" = "healthy" ] || [ "$health" = "starting" ] || [ -z "$health" ]
}
assert_bitcoin_stack() {
retry 90 "bitcoin-knots running" container_running bitcoin-knots
retry 90 "bitcoin-knots healthy/starting" container_healthy_or_starting bitcoin-knots
retry 90 "host Bitcoin RPC reachable/ready" json_rpc_reachable_or_warming http://127.0.0.1:8332/ auth
retry 90 "backend Bitcoin status bridge usable" bitcoin_status_usable http://127.0.0.1:5678/bitcoin-status
retry 90 "bitcoin-ui page" http_ok http://127.0.0.1:8334/
retry 90 "bitcoin-ui status bridge usable" bitcoin_status_usable http://127.0.0.1:8334/bitcoin-status
retry 90 "bitcoin-ui app-session status bridge usable" bitcoin_status_usable http://127.0.0.1/app/bitcoin-ui/bitcoin-status
retry 90 "bitcoin-ui RPC proxy reachable/ready" json_rpc_reachable_or_warming http://127.0.0.1:8334/bitcoin-rpc/
retry 90 "bitcoin-ui app-session RPC proxy reachable/ready" json_rpc_reachable_or_warming http://127.0.0.1/app/bitcoin-ui/bitcoin-rpc/
}
assert_electrum_stack() {
retry 120 "electrumx running" container_running electrumx
retry 120 "electrumx healthy/starting" container_healthy_or_starting electrumx
retry 90 "electrs-ui page" http_ok http://127.0.0.1:50002/
retry 120 "electrs status has network height" electrs_status_ok
retry 120 "electrs app-session status has network height" electrs_status_ok http://127.0.0.1/app/electrumx/electrs-status
retry 120 "electrs legacy app-session status has network height" electrs_status_ok http://127.0.0.1/app/electrs/electrs-status
}
reconcile_one() {
local name="$1"
"$RECONCILE" --container="$name" --force --force-recreate --create-missing
}
restart_container() {
local name="$1"
log "restart $name"
$PODMAN restart "$name" >/dev/null || {
log "podman restart failed for $name; using stop/start"
$PODMAN stop "$name" >/dev/null 2>&1 || true
sleep 3
$PODMAN start "$name" >/dev/null
}
}
remove_and_reconcile() {
local name="$1"
log "remove/recreate $name"
$PODMAN rm -f "$name" >/dev/null 2>&1 || true
reconcile_one "$name"
}
log "target $(hostname) cycles=$CYCLES"
log "using reconciler: $RECONCILE"
assert_bitcoin_stack
assert_electrum_stack
for i in $(seq 1 "$CYCLES"); do
log "cycle $i/$CYCLES: bitcoin restart"
restart_container bitcoin-knots
assert_bitcoin_stack
assert_electrum_stack
log "cycle $i/$CYCLES: bitcoin remove/reconcile"
remove_and_reconcile bitcoin-knots
assert_bitcoin_stack
assert_electrum_stack
log "cycle $i/$CYCLES: bitcoin UI remove/reconcile"
remove_and_reconcile archy-bitcoin-ui
assert_bitcoin_stack
log "cycle $i/$CYCLES: electrumx restart"
restart_container electrumx
assert_electrum_stack
log "cycle $i/$CYCLES: electrumx remove/reconcile"
remove_and_reconcile electrumx
assert_electrum_stack
log "cycle $i/$CYCLES: electrs UI remove/reconcile"
remove_and_reconcile archy-electrs-ui
assert_electrum_stack
done
log "final container state"
$PODMAN ps -a --format 'table {{.Names}}\t{{.State}}\t{{.Status}}' \
| grep -E 'bitcoin-knots|electrumx|archy-bitcoin-ui|archy-electrs-ui' || true
log "summary: pass=$pass_count fail=$fail_count"
[ "$fail_count" -eq 0 ]
REMOTE
+113
View File
@@ -0,0 +1,113 @@
#!/bin/bash
# bootstrap-switchover.sh — Switches Bitcoin-dependent services from bootstrap node to local
# Runs periodically via systemd timer. Once local Bitcoin finishes IBD, recreates
# ElectrumX/Mempool/LND/BTCPay/Fedimint containers pointing at the local node.
set -euo pipefail
BOOTSTRAP_FLAG="/var/lib/archipelago/.bootstrap-active"
LOG="/var/log/archipelago-bootstrap-switchover.log"
SECRETS_DIR="/var/lib/archipelago/secrets"
DOCKER=podman
command -v podman >/dev/null 2>&1 || DOCKER=docker
log() { echo "$(date '+%Y-%m-%d %H:%M:%S') $*" | tee -a "$LOG"; }
# Only run if bootstrap mode is active
if [ ! -f "$BOOTSTRAP_FLAG" ]; then
exit 0
fi
# Check if local Bitcoin is past IBD
RPC_PASS=$(cat "$SECRETS_DIR/bitcoin-rpc-password" 2>/dev/null)
if [ -z "$RPC_PASS" ]; then
log "No local Bitcoin RPC password — skipping"
exit 0
fi
IBD_STATUS=$($DOCKER exec bitcoin-knots bitcoin-cli -datadir=/home/bitcoin/.bitcoin getblockchaininfo 2>/dev/null | python3 -c "
import sys, json
try:
d = json.load(sys.stdin)
print(f\"{d.get('initialblockdownload', True)}|{d.get('blocks', 0)}|{d.get('headers', 0)}\")
except:
print('True|0|0')
" 2>/dev/null) || IBD_STATUS="True|0|0"
IBD=$(echo "$IBD_STATUS" | cut -d'|' -f1)
BLOCKS=$(echo "$IBD_STATUS" | cut -d'|' -f2)
HEADERS=$(echo "$IBD_STATUS" | cut -d'|' -f3)
if [ "$IBD" != "False" ]; then
log "Local Bitcoin still in IBD (blocks=$BLOCKS headers=$HEADERS) — keeping bootstrap"
exit 0
fi
log "=== Local Bitcoin synced (blocks=$BLOCKS) — switching from bootstrap to local node ==="
# Source image versions
for img_src in /opt/archipelago/scripts/image-versions.sh /home/archipelago/archy/scripts/image-versions.sh; do
[ -f "$img_src" ] && . "$img_src" && break
done
RPC_USER="archipelago"
# Helper: recreate a container with local Bitcoin config
recreate_container() {
local name="$1"
shift
log "Recreating $name..."
$DOCKER stop "$name" 2>/dev/null || true
$DOCKER rm -f "$name" 2>/dev/null || true
if $DOCKER run -d "$@" 2>>"$LOG"; then
log " $name switched to local Bitcoin"
else
log " WARNING: Failed to recreate $name"
fi
}
# ElectrumX — key service for wallet connections
if $DOCKER ps -a --format '{{.Names}}' 2>/dev/null | grep -q '^electrumx$'; then
recreate_container electrumx \
--name electrumx --restart unless-stopped \
--health-cmd="python3 -c 'import socket; socket.create_connection((\"localhost\",8000),2).close()' || exit 1" \
--health-interval=120s --health-timeout=5s --health-retries=3 \
--memory=1g --network archy-net --network-alias electrumx \
--cap-drop ALL --cap-add CHOWN --cap-add SETUID --cap-add SETGID \
--security-opt no-new-privileges:true \
-p 50001:50001 -v /var/lib/archipelago/electrumx:/data \
-e "DAEMON_URL=http://${RPC_USER}:${RPC_PASS}@bitcoin-knots:8332/" \
-e COIN=Bitcoin -e DB_DIRECTORY=/data \
-e "SERVICES=tcp://:50001,rpc://0.0.0.0:8000" \
"${ELECTRUMX_IMAGE:-source.archipelago-foundation.org/lfg2025/electrumx:v1.18.0}"
fi
# Mempool API
if $DOCKER ps -a --format '{{.Names}}' 2>/dev/null | grep -q '^mempool-api$'; then
recreate_container mempool-api \
--name mempool-api --restart unless-stopped \
--health-cmd="curl -sf http://localhost:8999/api/v1/backend-info || exit 1" \
--health-interval=120s --health-timeout=5s --health-retries=3 \
--memory=512m --network archy-net --network-alias mempool-api \
--cap-drop ALL --cap-add CHOWN --cap-add SETUID --cap-add SETGID \
--security-opt no-new-privileges:true \
-v /var/lib/archipelago/mempool-data:/backend/cache \
-e "MEMPOOL_BACKEND=electrum" \
-e "CORE_RPC_HOST=bitcoin-knots" -e "CORE_RPC_PORT=8332" \
-e "CORE_RPC_USERNAME=${RPC_USER}" -e "CORE_RPC_PASSWORD=${RPC_PASS}" \
-e "ELECTRUM_HOST=electrumx" -e "ELECTRUM_PORT=50001" -e "ELECTRUM_TLS_ENABLED=false" \
-e "DATABASE_ENABLED=true" -e "DATABASE_HOST=archy-mempool-db" \
-e "DATABASE_DATABASE=mempool" -e "DATABASE_USERNAME=mempool" \
-e "DATABASE_PASSWORD=$(cat "$SECRETS_DIR/mempool-db-password" 2>/dev/null || echo mempoolpass)" \
"${MEMPOOL_API_IMAGE:-source.archipelago-foundation.org/lfg2025/mempool-api:v3.2.0}"
fi
# Stop Tor tunnel if it was active
if systemctl is-active archipelago-bootstrap-tunnel.service >/dev/null 2>&1; then
log "Stopping bootstrap Tor tunnel..."
systemctl stop archipelago-bootstrap-tunnel.service 2>/dev/null || true
systemctl disable archipelago-bootstrap-tunnel.service 2>/dev/null || true
fi
# Done — remove bootstrap flag
rm -f "$BOOTSTRAP_FLAG"
log "=== Bootstrap switchover complete — all services now using local Bitcoin node ==="
+181
View File
@@ -0,0 +1,181 @@
#!/usr/bin/env bash
#
# build-aiui.sh — the ONE supported way to build AIUI for a node.
#
# D-19 (2026-08-03): AIUI is no longer a second repository at
# git.tx1138.com/lfg2025/AIUI. It was migrated in-repo to aiui/ via
# `git subtree`, full history intact. There is no second checkout to clone,
# no separate per-repo pin file to read or write, and no dirty-second-tree
# refusal to perform — this repo's own ordinary commit discipline is what
# keeps its history honest now, not a second-repo-specific check. D-15's
# *delivery* half still stands and is what this script enforces:
#
# - VITE_BASE_PATH must be exactly /aiui/. A wrong value produces a BLACK
# PAGE when embedded — the router base breaks, not the assets (this has
# burned this project before). Enforced here, not remembered by whoever
# runs the build.
# - A fresh checkout of this repo has no aiui/node_modules (unlike the old
# world, where a developer's separate AIUI clone was assumed already
# `pnpm install`ed) — this script installs from aiui/pnpm-lock.yaml
# with --frozen-lockfile before building, and treats a lockfile/
# package.json mismatch as a hard failure, not something to silently
# resolve.
# - The build runs AIUI's own real command (vue-tsc --noEmit && vite
# build via `pnpm build`), so a type error fails the build loudly
# instead of shipping a stale dist.
# - Before anything is copied anywhere, the emitted dist is verified:
# every local asset href carries the AIUI mount path, and this repo's
# own current commit (there is no second repo's SHA to pin — D-19 made
# them the same thing) is discoverable in the output, so a deployed
# node is attributable to a commit of THIS repo.
#
# Usage:
# bash scripts/build-aiui.sh
#
# On success, aiui/packages/app/dist/ is a fresh, verified AIUI build ready
# to be rsynced/tar'd to a node by scripts/deploy-to-target.sh or
# scripts/setup-aiui-server.sh.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
AIUI_ROOT="$PROJECT_DIR/aiui"
AIUI_APP_DIR="$AIUI_ROOT/packages/app"
AIUI_DIST="$AIUI_APP_DIR/dist"
AIUI_MOUNT_PATH="/aiui/"
timestamp() { echo "[$(date +%H:%M:%S)]"; }
# ── require_base_path ──────────────────────────────────────────────────
# Fails loudly, not silently, when VITE_BASE_PATH is unset or wrong. The
# normal case never reaches the "unset" branch below: the caller sets a
# default via `: "${VITE_BASE_PATH:=$AIUI_MOUNT_PATH}"` before this runs.
# This function's job is to catch an operator override with the WRONG
# value (T-13-57: a wrong base path ships a black page to every node).
require_base_path() {
if [ -z "${VITE_BASE_PATH:-}" ]; then
echo "FATAL: VITE_BASE_PATH is unset." >&2
echo " AIUI must be built with VITE_BASE_PATH=${AIUI_MOUNT_PATH}" >&2
echo " or every asset href in the built index.html will be" >&2
echo " wrong and AIUI will render as a BLACK PAGE when" >&2
echo " embedded at ${AIUI_MOUNT_PATH} on a node." >&2
return 1
fi
if [ "$VITE_BASE_PATH" != "$AIUI_MOUNT_PATH" ]; then
echo "FATAL: VITE_BASE_PATH='$VITE_BASE_PATH' is wrong." >&2
echo " AIUI must be built with VITE_BASE_PATH=${AIUI_MOUNT_PATH}" >&2
echo " exactly. A wrong base path breaks the router base (not" >&2
echo " the assets) and ships a BLACK PAGE to every node this" >&2
echo " dist reaches." >&2
return 1
fi
return 0
}
# ── verify_dist ─────────────────────────────────────────────────────────
# Asserts the build is safe to ship, BEFORE anything is copied anywhere.
verify_dist() {
local index="$AIUI_DIST/index.html"
if [ ! -f "$index" ]; then
echo "FATAL: $index does not exist — the build did not produce a dist." >&2
return 1
fi
# Every local (leading-"/") src=/href= must carry the AIUI mount path.
# A hand-built bundle with the wrong base path gives a black page, and
# the router base is what actually breaks, not the assets.
local bad_refs
bad_refs=$(grep -oE '(src|href)="/[^"]*"' "$index" \
| grep -v -F "=\"${AIUI_MOUNT_PATH}" || true)
if [ -n "$bad_refs" ]; then
echo "FATAL: $index references local assets outside ${AIUI_MOUNT_PATH}:" >&2
echo "$bad_refs" | sed 's/^/ /' >&2
return 1
fi
# Mock quarantine (operator decision 2026-08-07, enforced 8329b826):
# production bundles must carry NO mock content hosts. The demo site's
# content pack builds with VITE_DEMO_CONTENT=true and legitimately
# contains them — skip this check for that build.
if [ "${VITE_DEMO_CONTENT:-false}" != "true" ]; then
local mock_hits
mock_hits=$(grep -rl -e 'spotify\.com/track/example' -e 'cloud\.example\.com' \
-e 'plex://play' -e 'image\.tmdb\.org' "$AIUI_DIST" 2>/dev/null || true)
if [ -n "$mock_hits" ]; then
echo "FATAL: production bundle contains mock content hosts" >&2
echo " (mocks are demo-site-only per the 2026-08-07 operator decision):" >&2
echo "$mock_hits" | sed 's/^/ /' >&2
return 1
fi
fi
# Attribute this build to THIS repo's own current commit (D-19: no
# second-repo pin file — this repo's own commit IS the answer now).
local commit_sha
commit_sha="$(git -C "$PROJECT_DIR" rev-parse HEAD)"
{
echo "commit=$commit_sha"
echo "built_at=$(date -u +%Y-%m-%dT%H:%M:%SZ)"
echo "base_path=$VITE_BASE_PATH"
} > "$AIUI_DIST/BUILD-INFO"
if ! grep -rq "$commit_sha" "$AIUI_DIST/"; then
echo "FATAL: this repo's current commit ($commit_sha) is not" >&2
echo " discoverable anywhere under $AIUI_DIST — a deployed" >&2
echo " node would be unattributable to a source commit." >&2
return 1
fi
# Best-effort staleness check: if the source tree changed since the
# last recorded build but the emitted asset filenames are IDENTICAL,
# something didn't actually rebuild — Vite's content hashes should
# differ whenever the content they hash differs. State lives in a
# gitignored marker beside dist/ (dist/ itself gets wiped by every
# `vite build`, so it can't hold its own prior-run history).
local src_hash_file="$AIUI_APP_DIR/.build-aiui-last-src-hash"
local prev_asset_list="$AIUI_APP_DIR/.build-aiui-last-assets"
local cur_src_hash cur_assets
cur_src_hash=$(find "$AIUI_APP_DIR/src" -type f -print0 2>/dev/null \
| sort -z | xargs -0 sha256sum 2>/dev/null | sha256sum | awk '{print $1}')
cur_assets=$(ls "$AIUI_DIST/assets" 2>/dev/null | sort)
if [ -f "$src_hash_file" ] && [ -f "$prev_asset_list" ]; then
local prev_src_hash prev_assets
prev_src_hash=$(cat "$src_hash_file")
prev_assets=$(cat "$prev_asset_list")
if [ "$prev_src_hash" != "$cur_src_hash" ] && [ "$prev_assets" = "$cur_assets" ] && [ -n "$cur_assets" ]; then
echo "FATAL: source changed since the last build but the" >&2
echo " emitted asset filenames are IDENTICAL to the" >&2
echo " previous build — this looks like a stale/cached" >&2
echo " dist, not a fresh build." >&2
return 1
fi
fi
echo "$cur_src_hash" > "$src_hash_file"
echo "$cur_assets" > "$prev_asset_list"
return 0
}
echo "$(timestamp) build-aiui.sh: building AIUI from $AIUI_ROOT (in-repo, D-19)"
if [ ! -d "$AIUI_ROOT" ]; then
echo "FATAL: $AIUI_ROOT does not exist." >&2
echo " AIUI is expected in-repo at aiui/ — it is no longer a" >&2
echo " sibling checkout at ../AIUI (D-19)." >&2
exit 1
fi
: "${VITE_BASE_PATH:=$AIUI_MOUNT_PATH}"
export VITE_BASE_PATH
require_base_path
echo "$(timestamp) Installing aiui/ workspace from its committed lockfile..."
(cd "$AIUI_ROOT" && pnpm install --frozen-lockfile)
echo "$(timestamp) Building AIUI (vue-tsc --noEmit && vite build)..."
(cd "$AIUI_APP_DIR" && VITE_BASE_PATH="$VITE_BASE_PATH" pnpm build)
echo "$(timestamp) Verifying dist..."
verify_dist
echo "$(timestamp) AIUI build OK — $AIUI_DIST attributable to $(git -C "$PROJECT_DIR" rev-parse --short HEAD)"
+171
View File
@@ -0,0 +1,171 @@
#!/usr/bin/env bash
# build-bitcoin-image.sh — reproducible, verified, rootless Bitcoin image builder
# (docs/bitcoin-multi-version-design.md §3 Phase 0).
#
# Downloads an OFFICIAL upstream release tarball + SHA256SUMS(.asc), verifies the
# SHA-256 AND the OpenPGP signature (fail-closed), then builds a minimal rootless
# image and tags/pushes it to our registry as :<version>. Nodes only ever pull
# from our registry — they never fetch bitcoincore.org / bitcoinknots.org. The
# DHT Phase-0 catalog signature then carries provenance to the fleet.
#
# Usage:
# scripts/build-bitcoin-image.sh core 31.0
# scripts/build-bitcoin-image.sh knots 29.3.knots20260508
# NO_PUSH=1 scripts/build-bitcoin-image.sh core 31.0 # build + verify only
#
# Env:
# NO_PUSH=1 build + verify, do not push
# ALLOW_UNSIGNED=1 skip the GPG signature check (NOT for production)
# REQUIRE_PINNED=1 additionally require a signature from a pinned release key
# ARCHY_REGISTRY overrides the push registry (default from image-versions.sh)
set -euo pipefail
IMPL="${1:?usage: build-bitcoin-image.sh <core|knots> <version>}"
VERSION="${2:?usage: build-bitcoin-image.sh <core|knots> <version>}"
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# shellcheck disable=SC1091
source "$ROOT/scripts/image-versions.sh"
REGISTRY="${ARCHY_REGISTRY:?ARCHY_REGISTRY unset}"
# Pinned upstream release-signing fingerprints (REQUIRE_PINNED=1 enforces these).
# Bitcoin Core SHA256SUMS for 25.x31.x are signed by these maintainers; Knots by
# Luke Dashjr. Verified against the live signatures at build time.
# SHA256SUMS is a MULTI-signature file (every Guix builder signs it). We require
# a valid signature from at least one of these well-known release maintainers —
# the ones who sign every Bitcoin Core / Knots SHA256SUMS — and ignore builder
# sigs whose keys we don't hold. Both the primary fpr and the signing-subkey fpr
# that may appear in VALIDSIG are listed.
CORE_SIGNERS=(
"0CCBAAFD76A2ECE2CCD3141DE2FFD5B1D88CA97D" # fanquake (primary)
"E777299FC265DD04793070EB944D35F9AC3DB76A" # fanquake (subkey)
"152812300785C96444D3334D17565732E08E5E41" # achow101
"71A3B16735405025D447E8F274810B012346C9A6" # laanwj (older releases)
)
KNOTS_SIGNERS=(
"1A3E761F19D2CC7785C5502EA291A2C45D0C504A" # Luke Dashjr
)
case "$IMPL" in
core)
TARBALL="bitcoin-${VERSION}-x86_64-linux-gnu.tar.gz"
BASEURL="https://bitcoincore.org/bin/bitcoin-core-${VERSION}"
IMAGE_REPO="bitcoin"
SIGNERS=("${CORE_SIGNERS[@]}")
;;
knots)
MAJOR="${VERSION%%.*}"
TARBALL="bitcoin-${VERSION}-x86_64-linux-gnu.tar.gz"
BASEURL="https://bitcoinknots.org/files/${MAJOR}.x/${VERSION}"
IMAGE_REPO="bitcoin-knots"
SIGNERS=("${KNOTS_SIGNERS[@]}")
;;
*) echo "impl must be 'core' or 'knots'" >&2; exit 2 ;;
esac
TAG="${REGISTRY}/${IMAGE_REPO}:${VERSION}"
WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT
cd "$WORK"
# podman/skopeo stage image copies under TMPDIR (default /var/tmp). Point it at a
# writable dir so `podman push` works in sandboxes where /var/tmp is read-only.
export TMPDIR="$WORK/tmp"; mkdir -p "$TMPDIR"
echo "==> [$IMPL $VERSION] downloading from $BASEURL"
curl -fsSL -o "$TARBALL" "${BASEURL}/${TARBALL}"
curl -fsSL -o SHA256SUMS "${BASEURL}/SHA256SUMS"
curl -fsSL -o SHA256SUMS.asc "${BASEURL}/SHA256SUMS.asc"
echo "==> verifying SHA-256"
# SHA256SUMS lists every platform; check only our tarball line. Fail-closed.
grep " ${TARBALL}\$" SHA256SUMS | sha256sum -c - \
|| { echo "FATAL: SHA-256 mismatch for ${TARBALL}" >&2; exit 1; }
if [[ "${ALLOW_UNSIGNED:-0}" == "1" ]]; then
echo "==> WARNING: ALLOW_UNSIGNED=1 — skipping GPG verification (NOT production)"
else
echo "==> verifying OpenPGP signature on SHA256SUMS"
# A persistent, pre-seeded keyring (BITCOIN_KEYRING_DIR) makes verification
# reliable across many builds — keyserver fetches are flaky when each build
# starts from an empty keyring. Falls back to a per-build keyring + fetch.
if [[ -n "${BITCOIN_KEYRING_DIR:-}" ]]; then
export GNUPGHOME="$BITCOIN_KEYRING_DIR"
else
export GNUPGHOME="$WORK/gnupg"
fi
mkdir -p "$GNUPGHOME"; chmod 700 "$GNUPGHOME"
# Ensure each pinned maintainer key is present (best-effort fetch).
for kid in "${SIGNERS[@]}"; do
gpg --list-keys "$kid" >/dev/null 2>&1 && continue
for ks in hkps://keys.openpgp.org hkps://keyserver.ubuntu.com hkp://keyserver.ubuntu.com; do
gpg --keyserver "$ks" --recv-keys "$kid" >/dev/null 2>&1 && break || true
done
done
# SHA256SUMS carries many builder signatures; `gpg --verify`'s exit code is
# unreliable for multi-sig files (one unheld key flips it). Instead collect the
# VALIDSIG fingerprints via --status-fd and REQUIRE at least one from a pinned
# maintainer. Fail-closed otherwise.
# `|| true`: gpg exits non-zero on multi-sig files even with good sigs; we
# judge trust from VALIDSIG below, not the exit code (and set -e/pipefail would
# otherwise abort here).
VALID_FPRS="$(gpg --status-fd=1 --verify SHA256SUMS.asc SHA256SUMS 2>/dev/null \
| awk '/^\[GNUPG:\] VALIDSIG/ {print $3; print $NF}' | sort -u || true)"
ok=0; matched=""
for fpr in $VALID_FPRS; do
for want in "${SIGNERS[@]}"; do
[[ "$fpr" == "$want" ]] && { ok=1; matched="$fpr"; }
done
done
if [[ "$ok" != "1" ]]; then
echo "FATAL: no valid signature from a pinned release maintainer on SHA256SUMS" >&2
echo " valid signers seen: ${VALID_FPRS:-none}" >&2
exit 1
fi
echo " verified: valid maintainer signature ($matched)"
fi
echo "==> extracting binaries"
tar -xzf "$TARBALL"
SRC="bitcoin-${VERSION}"
[[ -x "${SRC}/bin/bitcoind" ]] || { echo "FATAL: bitcoind missing in tarball" >&2; exit 1; }
mkdir -p ctx/bin
cp "${SRC}/bin/bitcoind" "${SRC}/bin/bitcoin-cli" ctx/bin/
echo "==> building rootless image $TAG"
cat > ctx/Containerfile <<'EOF'
FROM debian:bookworm-slim
RUN set -eux; \
apt-get update; \
apt-get install -y --no-install-recommends ca-certificates; \
rm -rf /var/lib/apt/lists/*; \
useradd -m -u 1000 -s /bin/bash bitcoin; \
mkdir -p /home/bitcoin/.bitcoin; \
chown -R bitcoin:bitcoin /home/bitcoin
COPY bin/bitcoind /usr/local/bin/bitcoind
COPY bin/bitcoin-cli /usr/local/bin/bitcoin-cli
RUN chmod 0755 /usr/local/bin/bitcoind /usr/local/bin/bitcoin-cli
# Run as (container) root, exactly like the legacy hand-built :latest image.
# Rootless Podman maps container-root to the unprivileged host service user, and
# the manifest grants CAP_DAC_OVERRIDE so bitcoind can read its data dir — which
# the orchestrator chowns to the data_uid (host 100101 / container uid 102), NOT
# to this image's `bitcoin` user. A non-root USER here can't read existing chain
# data and bitcoind crash-loops with "Error initializing block database".
WORKDIR /home/bitcoin
VOLUME ["/home/bitcoin/.bitcoin"]
EXPOSE 8332 8333
ENTRYPOINT ["bitcoind"]
EOF
podman build -t "$TAG" ctx
echo "==> smoke test (bitcoind --version)"
podman run --rm --entrypoint bitcoind "$TAG" --version | head -1
if [[ "${NO_PUSH:-0}" == "1" ]]; then
echo "==> NO_PUSH=1 — built + verified $TAG (not pushed)"
else
echo "==> pushing $TAG"
# The lfg2025 registry serves plain HTTP (matches image_uses_insecure_registry
# in the Rust runtime). PODMAN_PUSH_TLS_VERIFY=true forces TLS for HTTPS regs.
podman push --tls-verify="${PODMAN_PUSH_TLS_VERIFY:-false}" "$TAG"
echo "==> pushed $TAG"
fi
+199
View File
@@ -0,0 +1,199 @@
#!/usr/bin/env bash
# Gated ISO release build — the single command that turns a signed release
# on `main` into a tested installer ISO.
#
# Stages (fail-fast, each logged with timing):
# 0. preflight — Linux, clean tree on main, version parity across
# Cargo.toml / package.json / releases/manifest.json /
# CHANGELOG / git tag, manifest signature present
# 1. gates — tests/release/run.sh (static + frontend + backend
# slice), strict catalog drift, FULL cargo test suite
# 2. artifacts — release binary embeds the version, frontend dist
# matches, AIUI present (OTA-strip regression guard)
# 3. build — image-recipe/build-debian-iso.sh (unbundled by default)
# 4. smoke — scripts/iso-smoke-test.sh (mount-level, version-checked)
# 5. qemu — headless boot test (skippable with --no-qemu)
#
# Usage:
# scripts/build-iso-release.sh [--skip-gates] [--no-qemu] [--bundled] [--rc N]
#
# The ISO is NOT signed here — run scripts/sign-iso-checksums.sh with the
# offline RELEASE_MASTER_MNEMONIC afterwards (publisher only).
set -u
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$REPO"
SKIP_GATES=0 NO_QEMU=0 UNBUNDLED=1 RC_OVERRIDE=""
while [[ $# -gt 0 ]]; do
case "$1" in
--skip-gates) SKIP_GATES=1 ;;
--no-qemu) NO_QEMU=1 ;;
--bundled) UNBUNDLED=0 ;;
--rc) RC_OVERRIDE="${2:?--rc needs a number}"; shift ;;
*) echo "unknown flag: $1" >&2; exit 2 ;;
esac
shift
done
[ -f "$HOME/.cargo/env" ] && . "$HOME/.cargo/env"
PASS=() FAIL=()
stage() { # stage <name> <cmd...>
local name="$1"; shift
local t0=$SECONDS
echo
echo "═══ [$name] $*"
if "$@"; then
echo "═══ [$name] PASS ($((SECONDS - t0))s)"
PASS+=("$name")
else
local rc=$?
echo "═══ [$name] FAIL exit=$rc ($((SECONDS - t0))s)"
FAIL+=("$name")
summary 1
fi
}
summary() {
echo
echo "──────── ISO release build summary ────────"
printf 'PASS: %s\n' "${PASS[@]:-none}"
[[ ${#FAIL[@]} -gt 0 ]] && printf 'FAIL: %s\n' "${FAIL[@]}"
exit "${1:-0}"
}
# ── Stage 0: preflight ───────────────────────────────────────────────
preflight() {
[ "$(uname -s)" = "Linux" ] || { echo "ISO builds run on Linux only"; return 1; }
local branch; branch="$(git rev-parse --abbrev-ref HEAD)"
[ "$branch" = "main" ] || { echo "must build from main (on: $branch)"; return 1; }
if [ -n "$(git status --porcelain)" ]; then
echo "working tree is not clean — release ISOs build from committed state only:"
git status --porcelain | head -20
return 1
fi
VERSION="$(grep -m1 '^version' core/archipelago/Cargo.toml | sed 's/.*"\(.*\)".*/\1/')"
local ui_ver manifest_ver
ui_ver="$(python3 -c 'import json;print(json.load(open("neode-ui/package.json"))["version"])')"
manifest_ver="$(python3 -c 'import json;print(json.load(open("releases/manifest.json"))["version"])')"
echo " Cargo.toml: $VERSION"
echo " package.json: $ui_ver"
echo " releases/manifest: $manifest_ver"
[ "$VERSION" = "$ui_ver" ] || { echo "version mismatch Cargo vs package.json"; return 1; }
[ "$VERSION" = "$manifest_ver" ] || { echo "version mismatch Cargo vs releases/manifest.json"; return 1; }
head -5 CHANGELOG.md | grep -qF "v$VERSION" \
|| { echo "CHANGELOG.md top entry is not v$VERSION"; return 1; }
git rev-parse -q --verify "refs/tags/v$VERSION" >/dev/null \
|| { echo "tag v$VERSION does not exist — cut the release first (scripts/create-release.sh)"; return 1; }
# The ISO must only ever be cut from a ceremony-signed manifest.
python3 - <<'EOF' || return 1
import json, sys
m = json.load(open("releases/manifest.json"))
sig, by = m.get("signature"), m.get("signed_by", "")
if not sig or not by.startswith("did:key:"):
print("releases/manifest.json is UNSIGNED — run the signing ceremony first")
sys.exit(1)
print(f" manifest signed by {by[:32]}…")
EOF
echo " version: $VERSION @ $(git rev-parse --short HEAD), tree clean, manifest signed"
}
stage "preflight" preflight
VERSION="$(grep -m1 '^version' core/archipelago/Cargo.toml | sed 's/.*"\(.*\)".*/\1/')"
# ── Stage 1: gates ───────────────────────────────────────────────────
if [ "$SKIP_GATES" = "0" ]; then
stage "release-gate-harness" bash tests/release/run.sh
stage "catalog-drift-strict" python3 scripts/check-app-catalog-drift.py --release --strict
# Full Rust suite — the release harness only runs a 6-module slice;
# ~1000 tests otherwise go unverified at ISO time (hardening plan §H).
stage "cargo-test-full" timeout 5400 env CARGO_INCREMENTAL=0 \
nice -n 10 cargo test --manifest-path core/Cargo.toml -p archipelago --bin archipelago
else
echo; echo "═══ [gates] SKIPPED (--skip-gates)"
fi
# ── Stage 2: artifact verification ───────────────────────────────────
verify_artifacts() {
local bin="core/target/release/archipelago"
[ -x "$bin" ] || { echo "missing release binary $bin — build it first"; return 1; }
strings "$bin" | grep -qF "$VERSION" \
|| { echo "release binary does not embed $VERSION — stale build"; return 1; }
echo " backend binary embeds $VERSION ($(du -h "$bin" | cut -f1))"
[ -f web/dist/neode-ui/index.html ] || { echo "missing frontend dist"; return 1; }
grep -rqoF "$VERSION" web/dist/neode-ui/assets/*.js \
|| { echo "frontend dist does not contain $VERSION — stale build"; return 1; }
echo " frontend dist contains $VERSION"
# AIUI must ride inside the dist BEFORE packaging or OTA upgrades
# silently strip it from nodes in the field.
[ -f web/dist/neode-ui/aiui/index.html ] \
|| { echo "AIUI missing from web/dist/neode-ui/aiui — fold it in before building"; return 1; }
echo " AIUI present in frontend dist"
}
stage "verify-artifacts" verify_artifacts
# ── Stage 3: build the ISO ───────────────────────────────────────────
build_iso() {
local env_args=(
UNBUNDLED="$UNBUNDLED"
BUILD_FROM_SOURCE=0
DEV_SERVER=localhost
ARCHIPELAGO_BIN="$REPO/core/target/release/archipelago"
)
[ -n "$RC_OVERRIDE" ] && env_args+=(RC="$RC_OVERRIDE")
sudo -E env "${env_args[@]}" nice -n 5 bash image-recipe/build-debian-iso.sh
}
stage "build-iso" build_iso
find_iso() {
ls -t "$REPO"/image-recipe/results/archipelago-installer-"$VERSION"*-x86_64_RC*.iso 2>/dev/null | head -1
}
ISO="$(find_iso)"
[ -n "$ISO" ] || { echo "FAIL: no ISO produced for $VERSION in image-recipe/results/"; FAIL+=("locate-iso"); summary 1; }
# ── Stage 4: mount-level smoke test ──────────────────────────────────
stage "iso-smoke" bash scripts/iso-smoke-test.sh "$ISO" "$VERSION"
# ── Stage 5: QEMU boot test (best-effort) ────────────────────────────
# 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.
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] PASS"
PASS+=("qemu-boot")
else
echo "═══ [qemu-boot] INCONCLUSIVE (not gating — verify on real hardware)"
PASS+=("qemu-boot(inconclusive)")
fi
else
echo; echo "═══ [qemu-boot] SKIPPED"
fi
# ── Done ─────────────────────────────────────────────────────────────
SHA_FILE="$ISO.sha256"
[ -f "$SHA_FILE" ] || (cd "$(dirname "$ISO")" && sha256sum "$(basename "$ISO")" > "$SHA_FILE")
echo
echo "════════════════════════════════════════════════════"
echo " ISO RELEASE BUILD COMPLETE — v$VERSION"
echo "════════════════════════════════════════════════════"
echo " ISO: $ISO ($(du -h "$ISO" | cut -f1))"
echo " SHA256: $(cut -d' ' -f1 "$SHA_FILE")"
echo
echo " Next steps (publisher, offline mnemonic required):"
echo " 1. scripts/sign-iso-checksums.sh $ISO"
echo " 2. upload ISO + .sha256 + signed checksum JSON alongside the"
echo " v$VERSION Gitea release assets"
summary 0
+222
View File
@@ -0,0 +1,222 @@
#!/usr/bin/env python3
"""Report drift between app-catalog/catalog.json and apps/*/manifest.yml."""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
from typing import Any
import yaml
INTERNAL_MANIFEST_IDS = {
"aiui",
"archy-btcpay-db",
"archy-mempool-db",
"archy-mempool-web",
"archy-nbxplorer",
"bitcoin-ui",
"core-lightning",
"did-wallet",
"electrs-ui",
"fips-ui",
"lightning-stack",
"lnd-ui",
"mempool-api",
"morphos-server",
"router",
"strfry",
"web5-dwn",
"immich-postgres",
"immich-redis",
"indeedhub-api",
"indeedhub-ffmpeg",
"indeedhub-minio",
"indeedhub-postgres",
"indeedhub-redis",
"indeedhub-relay",
"netbird-dashboard",
"netbird-server",
"pine-whisper",
"pine-piper",
"pine-openwakeword",
}
LEGACY_STACK_CATALOG_IDS = {
"immich",
"netbird",
"tailscale",
}
def load_catalog(path: Path) -> dict[str, dict[str, Any]]:
"""Load either catalog shape into {app_id: app-fields}.
Two formats exist and only one used to be understood here:
* app-catalog/catalog.json — `apps` is a LIST of entries carrying `id`.
* releases/app-catalog.json — `apps` is a DICT keyed by app id, and each
entry wraps the app's full manifest under `manifest.app` (the signed
release catalog; EMBED_MANIFESTS has been on since 2026-06-23).
The signed release catalog is the one nodes actually resolve apps through,
so a drift checker that only parsed the list form was checking the file
that governs nothing and crashing on the file that governs everything.
"""
with path.open("r", encoding="utf-8") as fh:
data = json.load(fh)
apps = data.get("apps", [])
if isinstance(apps, list):
return {
str(app.get("id", "")): app
for app in apps
if isinstance(app, dict) and app.get("id")
}
if isinstance(apps, dict):
out: dict[str, dict[str, Any]] = {}
for app_id, entry in apps.items():
if not isinstance(entry, dict):
continue
manifest = entry.get("manifest")
if isinstance(manifest, dict) and isinstance(manifest.get("app"), dict):
# Embedded manifest: compare against the same fields the disk
# manifests expose, plus the entry's own version.
app = dict(manifest["app"])
else:
app = {}
app.setdefault("id", app_id)
if entry.get("version") is not None:
app["version"] = entry["version"]
out[str(app_id)] = app
return out
raise ValueError(f"{path}: expected .apps to be a list or an object")
def load_manifests(apps_dir: Path) -> dict[str, dict[str, Any]]:
manifests: dict[str, dict[str, Any]] = {}
for path in sorted(apps_dir.glob("*/manifest.yml")):
with path.open("r", encoding="utf-8") as fh:
data = yaml.safe_load(fh)
if not isinstance(data, dict) or not isinstance(data.get("app"), dict):
continue
app = data["app"]
app_id = app.get("id")
if app_id:
manifests[str(app_id)] = {"path": str(path), "app": app}
return manifests
def metadata(app: dict[str, Any]) -> dict[str, Any]:
value = app.get("metadata")
return value if isinstance(value, dict) else {}
def manifest_value(app: dict[str, Any], field: str) -> Any:
meta = metadata(app)
container = app.get("container") if isinstance(app.get("container"), dict) else {}
match field:
case "title":
return app.get("name")
case "version":
return str(app.get("version", ""))
case "description":
return app.get("description")
case "dockerImage":
return container.get("image")
case "category":
return app.get("category") or meta.get("category")
case "tier":
return meta.get("tier")
case "icon":
return meta.get("icon")
case "repoUrl":
return meta.get("repo") or meta.get("repoUrl")
case _:
return None
def normalize(value: Any) -> str:
if value is None:
return ""
return str(value).strip()
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--catalog", default="app-catalog/catalog.json")
parser.add_argument("--apps-dir", default="apps")
parser.add_argument(
"--strict",
action="store_true",
help="exit non-zero when missing entries or metadata drift are found",
)
parser.add_argument(
"--release",
action="store_true",
help="suppress known internal/legacy-stack entries so output is release-actionable",
)
args = parser.parse_args()
catalog = load_catalog(Path(args.catalog))
manifests = load_manifests(Path(args.apps_dir))
catalog_ids = set(catalog)
manifest_ids = set(manifests)
missing_manifests = sorted(catalog_ids - manifest_ids)
missing_catalog = sorted(manifest_ids - catalog_ids)
if args.release:
missing_manifests = [app_id for app_id in missing_manifests if app_id not in LEGACY_STACK_CATALOG_IDS]
missing_catalog = [app_id for app_id in missing_catalog if app_id not in INTERNAL_MANIFEST_IDS]
compared_fields = [
"title",
"version",
"description",
"dockerImage",
"category",
"tier",
"icon",
"repoUrl",
]
drift: list[str] = []
for app_id in sorted(catalog_ids & manifest_ids):
catalog_app = catalog[app_id]
manifest_app = manifests[app_id]["app"]
for field in compared_fields:
catalog_val = normalize(catalog_app.get(field))
manifest_val = normalize(manifest_value(manifest_app, field))
if catalog_val and manifest_val and catalog_val != manifest_val:
drift.append(f"{app_id}: {field}: catalog={catalog_val!r} manifest={manifest_val!r}")
print(
json.dumps(
{
"catalog_apps": len(catalog),
"manifest_apps": len(manifests),
"missing_manifests": len(missing_manifests),
"missing_catalog": len(missing_catalog),
"metadata_drift": len(drift),
},
sort_keys=True,
)
)
for app_id in missing_manifests:
print(f"MISSING_MANIFEST {app_id}")
for app_id in missing_catalog:
print(f"MISSING_CATALOG {app_id}")
for item in drift:
print(f"DRIFT {item}")
if args.strict and (missing_manifests or missing_catalog or drift):
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
+163
View File
@@ -0,0 +1,163 @@
#!/usr/bin/env python3
"""Refuse to publish a catalog naming registry hosts the fleet cannot pull from.
The signed app catalog is authoritative for deployed nodes: `catalog_image_override`
makes its image reference win over the on-disk manifest. So a catalog that names a
registry host the *deployed* binaries do not trust turns every install into
"not from a trusted registry" — fleet-wide, at publish time, with no local signal.
The subtlety this guard exists for: TRUSTED_REGISTRIES in the working tree
describes a binary being built today. Nodes run what was shipped to them. Those
two lists diverge for exactly as long as it takes an OTA to reach the fleet, and
that window is when a catalog regeneration silently breaks everything.
So the floor is tracked explicitly in releases/registry-trust-floor.json and the
catalog is checked against that, never against the source tree.
Usage:
scripts/check-catalog-registry-trust.py # check the release catalog
scripts/check-catalog-registry-trust.py --catalog path.json
scripts/check-catalog-registry-trust.py --show # print current state
"""
from __future__ import annotations
import argparse
import json
import re
import sys
from pathlib import Path
from typing import Any, Iterator
DEFAULT_CATALOG = "releases/app-catalog.json"
DEFAULT_FLOOR = "releases/registry-trust-floor.json"
IMAGE_POLICY = "core/archipelago/src/container/image_policy.rs"
def iter_images(node: Any) -> Iterator[str]:
"""Yield every value stored under an `image` key, at any depth."""
if isinstance(node, dict):
for key, value in node.items():
if key == "image" and isinstance(value, str) and value:
yield value
else:
yield from iter_images(value)
elif isinstance(node, list):
for item in node:
yield from iter_images(item)
def registry_host(image: str) -> str | None:
"""Registry host of an image ref, or None for Docker Hub shorthand.
A ref's first segment is a registry only if it contains a '.' or ':'
(docker.io, host:3000). Otherwise it is a Docker Hub namespace — `nginx`,
`btcpayserver/btcpayserver` — which resolves via registries.conf, not an
attacker-controlled host. This mirrors is_valid_docker_image() in
image_policy.rs; keep the two in step.
"""
head = image.split("/", 1)[0]
if "/" not in image:
return None
if "." in head or ":" in head:
return head
return None
def source_trusted_registries(repo: Path) -> list[str]:
"""TRUSTED_REGISTRIES as the working tree currently defines it (advisory)."""
path = repo / IMAGE_POLICY
try:
text = path.read_text(encoding="utf-8")
except OSError:
return []
match = re.search(r"TRUSTED_REGISTRIES:\s*&\[&str\]\s*=\s*&\[(.*?)\];", text, re.S)
if not match:
return []
body = match.group(1)
hosts = re.findall(r'"([^"]+)"', body)
# Entries may be consts (LEGACY_REGISTRY_HOST); resolve those too.
for const in re.findall(r"\b([A-Z][A-Z0-9_]+)\b", body):
const_match = re.search(rf'{const}:\s*&str\s*=\s*"([^"]+)"', text)
if const_match:
hosts.append(const_match.group(1))
return sorted(set(hosts))
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--catalog", default=DEFAULT_CATALOG)
parser.add_argument("--floor", default=DEFAULT_FLOOR)
parser.add_argument("--repo", default=".")
parser.add_argument("--show", action="store_true",
help="print the floor, the source list and the catalog's hosts")
args = parser.parse_args()
repo = Path(args.repo)
catalog_path = repo / args.catalog
floor_path = repo / args.floor
try:
catalog = json.loads(catalog_path.read_text(encoding="utf-8"))
except OSError as exc:
print(f"ERROR: cannot read catalog: {exc}", file=sys.stderr)
return 2
try:
floor_doc = json.loads(floor_path.read_text(encoding="utf-8"))
except OSError as exc:
print(f"ERROR: cannot read trust floor: {exc}", file=sys.stderr)
return 2
floor = set(floor_doc.get("hosts") or [])
if not floor:
print(f"ERROR: {args.floor} lists no hosts; refusing to pass vacuously.",
file=sys.stderr)
return 2
hosts: dict[str, list[str]] = {}
for image in iter_images(catalog):
host = registry_host(image)
if host:
hosts.setdefault(host, []).append(image)
if args.show:
print("trust floor (deployed binaries):")
for h in sorted(floor):
print(f" {h}")
pending = floor_doc.get("pending") or {}
if pending:
print("pending (not yet in the fleet):")
for h, meta in pending.items():
print(f" {h} — trusted_from_binary={meta.get('trusted_from_binary')}")
print("working-tree TRUSTED_REGISTRIES (advisory):")
for h in source_trusted_registries(repo) or ["(could not parse)"]:
print(f" {h}")
print(f"catalog hosts ({catalog_path}):")
for h in sorted(hosts):
print(f" {h} ({len(hosts[h])} image refs)")
violations = sorted(set(hosts) - floor)
if violations:
print("")
print("REFUSING: the catalog names registry hosts the deployed fleet does not trust.")
for host in violations:
examples = hosts[host][:3]
print(f"\n {host}{len(hosts[host])} image refs, e.g.")
for ref in examples:
print(f" {ref}")
print("")
print("Publishing this would make every install fail with")
print('"not from a trusted registry" on every node in the field.')
print("")
print(f"Fix by ordering the migration — see the _comment in {args.floor}:")
print(" ship a binary that trusts the host, confirm the fleet is on it,")
print(" promote the host in the floor file, and only then regenerate.")
return 1
print(f"OK: all {len(hosts)} registry host(s) in {args.catalog} are trusted by the deployed fleet.")
return 0
if __name__ == "__main__":
sys.exit(main())
+134
View File
@@ -0,0 +1,134 @@
#!/usr/bin/env python3
"""Fail when a hardcoded installer image tag disagrees with the app manifest.
The legacy stack installers in core/archipelago/src/api/rpc/package/stacks.rs
carry image references as string literals. Those literals are a second source of
truth for a version, sitting behind the manifest and the signed catalog, and
nothing keeps them in step.
That is not cosmetic. BTCPay shipped 2.4.2 for an actively exploited 2FA bypass
on 2026-08-07 while the legacy installer still named 2.3.9, so the fallback
install path would have deployed the withdrawn release. The same shape applies
to any app whose installer literal is left behind.
The rule enforced here: if an installer literal names the same image repository
as an app manifest, the tags must match. Repositories with no manifest are
ignored, and so are floating tags, which carry no version claim.
Usage:
scripts/check-installer-image-pins.py
scripts/check-installer-image-pins.py --show
"""
from __future__ import annotations
import argparse
import re
import sys
from pathlib import Path
import yaml
# Files that pin images as literals on an install path. Test modules inside them
# are stripped before scanning: fixtures deliberately name old versions.
INSTALLER_SOURCES = [
"core/archipelago/src/api/rpc/package/stacks.rs",
]
FLOATING_TAGS = {"latest", "stable", "release", "main", "edge"}
IMAGE_RE = re.compile(r'"([a-z0-9][a-z0-9._-]*(?:\.[a-z]+|:[0-9]+)?/[a-z0-9._/-]+:[A-Za-z0-9._-]+)"')
def strip_test_modules(text: str) -> str:
"""Remove #[cfg(test)] modules so fixture literals are not treated as pins."""
marker = "#[cfg(test)]"
idx = text.find(marker)
return text if idx == -1 else text[:idx]
def repo_of(image: str) -> str:
"""Image repository without registry host or tag."""
without_tag = image.rsplit(":", 1)[0] if ":" in image.rsplit("/", 1)[-1] else image
head, _, rest = without_tag.partition("/")
if "." in head or ":" in head or head == "localhost":
return rest
return without_tag
def tag_of(image: str) -> str:
last = image.rsplit("/", 1)[-1]
return last.rsplit(":", 1)[1] if ":" in last else "latest"
def manifest_images(repo_root: Path) -> dict[str, tuple[str, str]]:
"""{image repo: (tag, manifest path)} across apps/*/manifest.yml."""
out: dict[str, tuple[str, str]] = {}
for path in sorted((repo_root / "apps").glob("*/manifest.yml")):
try:
data = yaml.safe_load(path.read_text(encoding="utf-8"))
except Exception:
continue
app = (data or {}).get("app")
if not isinstance(app, dict):
continue
image = (app.get("container") or {}).get("image")
if isinstance(image, str) and image:
out[repo_of(image)] = (tag_of(image), str(path.relative_to(repo_root)))
return out
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--repo", default=".")
parser.add_argument("--show", action="store_true")
args = parser.parse_args()
repo_root = Path(args.repo)
manifests = manifest_images(repo_root)
problems: list[str] = []
checked = 0
for rel in INSTALLER_SOURCES:
path = repo_root / rel
if not path.exists():
continue
text = strip_test_modules(path.read_text(encoding="utf-8"))
for line_no, line in enumerate(text.splitlines(), start=1):
for image in IMAGE_RE.findall(line):
repo = repo_of(image)
if repo not in manifests:
continue
tag = tag_of(image)
manifest_tag, manifest_path = manifests[repo]
checked += 1
if args.show:
print(f" {rel}:{line_no} {repo}:{tag} (manifest {manifest_tag})")
if tag in FLOATING_TAGS or manifest_tag in FLOATING_TAGS:
continue
if tag != manifest_tag:
problems.append(
f"{rel}:{line_no}\n"
f" installer pins {repo}:{tag}\n"
f" manifest wants {repo}:{manifest_tag} ({manifest_path})"
)
if problems:
print("")
print("Installer image pins disagree with their app manifests:")
for problem in problems:
print(f"\n {problem}")
print("")
print("An installer literal left behind deploys the older image on the")
print("fallback install path — which is how a withdrawn, vulnerable")
print("release gets installed after it has supposedly been replaced.")
print("Update the literal to match the manifest.")
return 1
print(f"OK: {checked} installer image pin(s) agree with their app manifests.")
return 0
if __name__ == "__main__":
sys.exit(main())
+86
View File
@@ -0,0 +1,86 @@
#!/usr/bin/env python3
"""Syntax-check the shell embedded in apps/*/manifest.yml.
A manifest can carry a whole startup script in `container.custom_args` /
`entrypoint`. Nothing validated it, so a broken one shipped through the
signed catalog and only failed on the node — as a container that exits
instantly and an app that vanishes from the UI.
Two checks, both learned from v1.7.124 (bitcoin-knots / bitcoin-core):
1. `sh -n` the snippet. The break was `sh: Syntax error: "fi" unexpected`,
which no YAML parse and no Rust test could have caught.
2. Reject `#` inside the snippet. These are YAML **folded** scalars (`>-`),
where `#` is NOT a comment — it is literal text that reaches the shell,
and because folding joins lines with spaces it comments out the rest of
the folded line. That is exactly how an `if ... then` was swallowed while
its more-indented body survived, leaving an orphan `fi`. Put explanations
above the `- >-` line, where YAML really does treat them as comments.
"""
from __future__ import annotations
import glob
import os
import subprocess
import sys
import tempfile
import yaml
# Long enough to be a script rather than a flag.
MIN_SCRIPT_LEN = 60
def snippets(path: str):
with open(path, encoding="utf-8") as fh:
data = yaml.safe_load(fh)
container = ((data or {}).get("app") or {}).get("container") or {}
for key in ("custom_args", "entrypoint"):
value = container.get(key)
if not isinstance(value, list):
continue
for i, part in enumerate(value):
if isinstance(part, str) and len(part) >= MIN_SCRIPT_LEN:
yield f"{key}[{i}]", part
def main() -> int:
failures = []
checked = 0
for path in sorted(glob.glob("apps/*/manifest.yml")):
app = os.path.basename(os.path.dirname(path))
try:
found = list(snippets(path))
except Exception as exc: # noqa: BLE001 — report, don't crash the gate
failures.append(f"{app}: manifest does not parse: {exc}")
continue
for where, script in found:
checked += 1
if "#" in script:
failures.append(
f"{app} {where}: contains '#'. In a folded YAML scalar that is not a "
f"comment — it reaches the shell and comments out the rest of the "
f"folded line. Move the explanation above the '- >-' line."
)
with tempfile.NamedTemporaryFile("w", suffix=".sh", delete=False) as tmp:
tmp.write(script)
tmp_path = tmp.name
try:
proc = subprocess.run(
["sh", "-n", tmp_path], capture_output=True, text=True, check=False
)
finally:
os.unlink(tmp_path)
if proc.returncode != 0:
failures.append(f"{app} {where}: {proc.stderr.strip()}")
for f in failures:
print(f"MANIFEST-SHELL {f}", file=sys.stderr)
print(f'{{"snippets_checked": {checked}, "failures": {len(failures)}}}')
return 1 if failures else 0
if __name__ == "__main__":
sys.exit(main())
+105
View File
@@ -0,0 +1,105 @@
#!/usr/bin/env bash
# check-release-assets.sh — prove a release's artifacts are actually fetchable
# BEFORE its manifest goes live on main.
#
# The manifest is the trigger: nodes read releases/manifest.json from branch
# main, and the moment it names a new version they try to download it. So the
# assets must resolve before the manifest lands, not after. On 2026-08-07 the
# order was reversed — the v1.7.126-alpha manifest went live while its binary
# 500'd and its frontend tarball had never uploaded — and every polling node
# would have advertised an update it could not fetch.
#
# For each component in the manifest this checks:
# 1. the download URL returns HTTP 200
# 2. the downloaded bytes match the manifest's sha256 and size
#
# It downloads each asset in full, because a HEAD 200 is not proof the body is
# intact — the corrupt binary that day passed HEAD-shaped checks and still
# served a broken stream. Slower, but this is the last gate before publish.
#
# Usage:
# scripts/check-release-assets.sh # check releases/manifest.json
# scripts/check-release-assets.sh path/to/manifest.json
#
# Exit 0 = every asset is downloadable and matches. Non-zero = do NOT publish.
set -euo pipefail
MANIFEST="${1:-releases/manifest.json}"
if [[ ! -f "$MANIFEST" ]]; then
echo "ERROR: manifest not found: $MANIFEST" >&2
exit 2
fi
command -v python3 >/dev/null 2>&1 || { echo "ERROR: python3 required" >&2; exit 2; }
command -v sha256sum >/dev/null 2>&1 || { echo "ERROR: sha256sum required" >&2; exit 2; }
TMP="$(mktemp -d)"
trap 'rm -rf "$TMP"' EXIT
# Emit "url<TAB>sha256<TAB>size<TAB>name" per component, tolerating the field
# name variations the manifest has used (download_url/url, size_bytes/size).
rows="$(python3 - "$MANIFEST" <<'PY'
import json, sys
d = json.load(open(sys.argv[1]))
comps = d.get("components") or []
if not comps:
sys.exit("manifest has no components")
for c in comps:
url = c.get("download_url") or c.get("url") or ""
sha = c.get("sha256") or ""
size = c.get("size_bytes") or c.get("size") or ""
name = c.get("name") or "(unnamed)"
if not url or not sha:
sys.exit(f"component {name!r} missing url or sha256")
print(f"{url}\t{sha}\t{size}\t{name}")
PY
)"
version="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get("version","?"))' "$MANIFEST")"
echo "Checking release assets for v${version} ($MANIFEST)"
echo ""
fail=0
n=0
while IFS=$'\t' read -r url sha size name; do
[ -z "$url" ] && continue
n=$((n + 1))
out="$TMP/asset.$n"
echo " [$name]"
echo " $url"
code="$(curl -sL -o "$out" -w '%{http_code}' "$url" || echo "000")"
if [ "$code" != "200" ]; then
echo " FAIL: HTTP $code (asset not served)"
fail=1
continue
fi
got_size="$(stat -c%s "$out")"
if [ -n "$size" ] && [ "$size" != "$got_size" ]; then
echo " FAIL: size $got_size, manifest says $size"
fail=1
continue
fi
got_sha="$(sha256sum "$out" | awk '{print $1}')"
if [ "$got_sha" != "$sha" ]; then
echo " FAIL: sha256 mismatch"
echo " served: $got_sha"
echo " manifest: $sha"
fail=1
continue
fi
echo " OK: HTTP 200, ${got_size} bytes, sha256 matches"
done <<< "$rows"
echo ""
if [ "$fail" -ne 0 ]; then
echo "REFUSING: one or more assets are not fetchable or do not match the manifest."
echo "Do NOT publish the manifest — nodes would advertise an update they cannot"
echo "apply. Upload/repair the assets, re-run this, and only then flip the"
echo "manifest live on main."
exit 1
fi
echo "OK: all $n asset(s) for v${version} download and match the manifest."
+108
View File
@@ -0,0 +1,108 @@
#!/bin/bash
# Validate releases/manifest.json:
# - version matches core/archipelago/Cargo.toml
# - changelog contains curated release notes, not raw git log output
# - every component's download_url exists on disk and matches sha256/size
#
# Run on every push from CI, and also locally before publishing a release:
# scripts/check-release-manifest.sh
#
# Exits non-zero on any mismatch so the release process fails loud.
set -eo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
MANIFEST="$REPO_ROOT/releases/manifest.json"
if [ ! -f "$MANIFEST" ]; then
echo "❌ releases/manifest.json missing"
exit 1
fi
fail() { echo "$*"; exit 1; }
ok() { echo "$*"; }
MANIFEST_VERSION=$(python3 -c "import json; print(json.load(open('$MANIFEST'))['version'])")
CARGO_VERSION=$(grep '^version' "$REPO_ROOT/core/archipelago/Cargo.toml" | head -1 | sed -E 's/.*"([^"]+)".*/\1/')
if [ "$MANIFEST_VERSION" != "$CARGO_VERSION" ]; then
fail "manifest version ($MANIFEST_VERSION) ≠ Cargo.toml ($CARGO_VERSION)"
fi
ok "version matches: $MANIFEST_VERSION"
# Release notes mandatory — ships stuff nobody can read otherwise. Require
# curated user/operator-facing notes and reject raw `git log --oneline` output.
NOTES_CHECK=$(python3 - "$MANIFEST" <<'PY'
import json
import re
import sys
manifest = sys.argv[1]
notes = json.load(open(manifest)).get("changelog", [])
if len(notes) < 3:
print(f"FAIL: changelog has {len(notes)} lines; need at least 3 curated release-note bullets")
sys.exit(0)
bad = []
for note in notes:
text = str(note).strip()
if not text:
bad.append("empty release-note entry")
if len(text) < 40:
bad.append(f"too short: {text!r}")
if re.match(r"^[0-9a-f]{7,40}\s+", text):
bad.append(f"raw commit hash entry: {text!r}")
if re.match(r"^(feat|fix|chore|docs|test|refactor|build|ci|perf)(\([^)]+\))?:\s", text):
bad.append(f"raw conventional-commit entry: {text!r}")
if bad:
print("FAIL: release notes must be curated user/operator-facing bullets, not raw git log lines:\n" + "\n".join(bad))
else:
print(f"OK: changelog has {len(notes)} curated lines")
PY
)
case "$NOTES_CHECK" in
OK:*) ok "${NOTES_CHECK#OK: }" ;;
FAIL:*) fail "${NOTES_CHECK#FAIL: }" ;;
*) fail "unexpected release-note validation output: $NOTES_CHECK" ;;
esac
# Each component: the artifact on disk under releases/v<version>/ must match
# the declared sha256 and size_bytes.
VERSION_DIR="$REPO_ROOT/releases/v${MANIFEST_VERSION}"
if [ ! -d "$VERSION_DIR" ]; then
fail "releases/v${MANIFEST_VERSION}/ missing — artifacts not staged"
fi
COMPONENT_COUNT=$(python3 -c "import json; print(len(json.load(open('$MANIFEST'))['components']))")
for i in $(seq 0 $((COMPONENT_COUNT - 1))); do
NAME=$(python3 -c "import json; print(json.load(open('$MANIFEST'))['components'][$i]['name'])")
DECLARED_SHA=$(python3 -c "import json; print(json.load(open('$MANIFEST'))['components'][$i]['sha256'])")
DECLARED_SIZE=$(python3 -c "import json; print(json.load(open('$MANIFEST'))['components'][$i]['size_bytes'])")
# Component names other than exactly "archipelago" are the tarball's
# filename; use as-is. The bare "archipelago" component maps to the
# binary file literally named `archipelago`.
FILE="$VERSION_DIR/$NAME"
if [ "$NAME" = "archipelago" ]; then
FILE="$VERSION_DIR/archipelago"
fi
if [ ! -f "$FILE" ]; then
fail "component '$NAME' file missing at $FILE"
fi
ACTUAL_SHA=$(sha256sum "$FILE" | awk '{print $1}')
ACTUAL_SIZE=$(stat -c%s "$FILE")
if [ "$ACTUAL_SHA" != "$DECLARED_SHA" ]; then
fail "component '$NAME' sha256 mismatch (declared=$DECLARED_SHA actual=$ACTUAL_SHA)"
fi
if [ "$ACTUAL_SIZE" != "$DECLARED_SIZE" ]; then
fail "component '$NAME' size mismatch (declared=$DECLARED_SIZE actual=$ACTUAL_SIZE)"
fi
ok "component '$NAME': sha256 + size match on-disk artifact"
done
echo
ok "releases/manifest.json passes all checks — safe to publish v${MANIFEST_VERSION}"
+43
View File
@@ -0,0 +1,43 @@
#!/bin/bash
# Configure Nginx to listen on Tailscale IP address
# This script should be run after Tailscale is set up and connected
set -e
echo "🔍 Detecting Tailscale IP..."
# Get Tailscale IP from tailscale0 interface
TAILSCALE_IP=$(ip -4 addr show tailscale0 2>/dev/null | grep -oP '(?<=inet\s)\d+(\.\d+){3}' || echo "")
if [ -z "$TAILSCALE_IP" ]; then
echo "❌ Tailscale interface not found. Is Tailscale running with host networking?"
exit 1
fi
echo "✅ Found Tailscale IP: $TAILSCALE_IP"
NGINX_CONFIG="/etc/nginx/sites-available/archipelago"
# Check if Tailscale IP is already in the config
if grep -q "listen $TAILSCALE_IP:80" "$NGINX_CONFIG"; then
echo "✅ Nginx already configured for Tailscale IP $TAILSCALE_IP"
exit 0
fi
echo "📝 Adding Tailscale IP to Nginx configuration..."
# Backup the config
sudo cp "$NGINX_CONFIG" "$NGINX_CONFIG.backup.$(date +%s)"
# Add Tailscale IP to listen directive (after the first "listen 80;")
sudo sed -i "0,/listen 80;/s//listen 80;\n listen $TAILSCALE_IP:80;/" "$NGINX_CONFIG"
echo "🔍 Testing Nginx configuration..."
sudo nginx -t
echo "🔄 Reloading Nginx..."
sudo systemctl reload nginx
echo "✅ Nginx configured to accept connections from Tailscale!"
echo " Access your Archipelago UI via Tailscale at:"
echo " http://$(hostname).tail<your-tailnet>.ts.net/"
+712
View File
@@ -0,0 +1,712 @@
#!/bin/bash
#
# Container Doctor — diagnose and fix common container health issues
#
# Usage:
# sudo ./scripts/container-doctor.sh # Run locally on node
# ./scripts/container-doctor.sh user@host # Run remotely via SSH
#
# Fixes:
# 1. Stale podman ps/stats processes (>10 = pileup)
# 2. Orphaned conmon/crun processes holding ports
# 3. System tor conflicting with container tor
# 4. Tor hidden service directory permissions (group/other must have no
# access; Tor's own setgid 2700 is fine, restart is backed off)
# 5. SearXNG read-only root / cap-drop ALL
# 6. Bitcoin Knots prune+txindex conflict
# 7. Containers stuck with exit code 127 (binary not found)
# 8. Stopped core containers (rootless restart policy workaround)
# 9. Missing rootless port listeners while Podman still shows published ports
# 10. Nginx Proxy Manager public hosts not mirrored into host nginx
# 11. BTCPay stores producing unpayable Lightning invoices (route hints off)
# 12. Missing catatonit (Podman init binary) — init-enabled deploys fail
#
# Safe to run multiple times (idempotent). Never blocks deploy (exit 0 always).
#
set -o pipefail
# Source pinned image versions (single source of truth)
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
[ -f "$SCRIPT_DIR/image-versions.sh" ] && . "$SCRIPT_DIR/image-versions.sh"
FIXES_APPLIED=0
CHECKS_PASSED=0
FIX_NAMES=()
log() { echo "[$(date +%H:%M:%S)] DOCTOR: $*"; }
podman_rootless() {
if [ "$(id -u)" = "0" ] && id archipelago >/dev/null 2>&1; then
local archi_uid
archi_uid=$(id -u archipelago)
sudo -u archipelago env XDG_RUNTIME_DIR="/run/user/$archi_uid" podman "$@"
else
podman "$@"
fi
}
port_is_listening() {
local port="$1"
ss -ltn 2>/dev/null | awk '{print $4}' | grep -Eq "(^|:)$port$"
}
run_fix() {
local name="$1"
shift
if "$@"; then
FIXES_APPLIED=$((FIXES_APPLIED + 1))
FIX_NAMES+=("$name")
else
CHECKS_PASSED=$((CHECKS_PASSED + 1))
fi
}
# ── Fix 1: Stale podman processes ────────────────────────────
fix_stale_podman() {
local count
count=$(pgrep -f "podman (ps|stats)" 2>/dev/null | wc -l)
count=${count:-0}
if [ "$count" -gt 10 ]; then
log "Killing $count stale podman ps/stats processes"
pkill -f "podman (ps|stats)" 2>/dev/null || true
sleep 2
local after
after=$(pgrep -f "podman (ps|stats)" 2>/dev/null | wc -l)
after=${after:-0}
log "Reduced from $count to $after"
return 0
fi
return 1
}
# ── Fix 2: Orphaned conmon holding ports ─────────────────────
fix_orphaned_conmon() {
local fixed=false
# Find conmon processes whose containers no longer exist
local pids
pids=$(pgrep -f "conmon.*--exit-command" 2>/dev/null || true)
if [ -z "$pids" ]; then
return 1
fi
# Doctor runs as root but containers are rootless under archipelago user.
# Must check container existence using the rootless podman database.
local PODMANCMD="sudo -u archipelago XDG_RUNTIME_DIR=/run/user/1000 podman"
for pid in $pids; do
# Extract container ID from conmon args
local cid
cid=$(tr '\0' ' ' < /proc/"$pid"/cmdline 2>/dev/null | grep -oP '(?<=-c )[a-f0-9]{64}' || true)
if [ -z "$cid" ]; then
continue
fi
# Check if container still exists in rootless podman
if ! $PODMANCMD inspect "$cid" &>/dev/null; then
local port_info
port_info=$(ss -tlnp 2>/dev/null | grep "pid=$pid" | grep -oP ':\K\d+' | head -3 | tr '\n' ',' | sed 's/,$//')
log "Killing orphaned conmon pid=$pid (ports: ${port_info:-none})"
kill "$pid" 2>/dev/null || kill -9 "$pid" 2>/dev/null || true
fixed=true
fi
done
$fixed && return 0 || return 1
}
# ── Fix 3: Ensure system Tor is running (preferred over container) ──
fix_system_tor_conflict() {
# System Tor is preferred over container Tor.
# If archy-tor container exists, remove it and use system Tor instead.
if podman ps -a --format '{{.Names}}' 2>/dev/null | grep -qE '^archy-tor$'; then
podman stop archy-tor 2>/dev/null || true
podman rm -f archy-tor 2>/dev/null || true
log "Removed archy-tor container (system Tor is preferred)"
fi
# Ensure system Tor is enabled and running
if command -v tor >/dev/null 2>&1; then
if ! systemctl is-active tor@default >/dev/null 2>&1; then
systemctl enable tor tor@default 2>/dev/null || true
systemctl start tor tor@default 2>/dev/null || true
log "Started system Tor"
return 0
fi
fi
return 1
}
# ── Fix 4: Tor hidden service permissions ────────────────────
# `stat -c '%a'` omits leading zeros, so a directory Tor manages itself
# reads "2700" (setgid bit set) rather than "700" -- but the setgid bit
# doesn't grant group/other any access. Comparing the full string against
# the literal "700" therefore flagged Tor's own correct setting as broken
# on every run, chmod'd it right back to what it already effectively was,
# and restarted Tor -- forever, every ~5 minutes (the doctor timer
# interval), so Tor never kept its consensus/HSDir cache long enough to
# resolve any .onion peer. Only the last 3 digits of the mode are the
# owner/group/other permission bits; anything before that is
# setuid/setgid/sticky, which is irrelevant to "is group/other access
# denied". Compare only those 3 digits so 700 and 2700 both pass, while a
# genuinely permissive mode (750, 707, 2755, ...) is still corrected.
#
# Restart backoff: even a real, legitimate fix must not restart Tor more
# than once per TOR_RESTART_BACKOFF_SECONDS, so no future defect in this
# (or any other) predicate can reproduce a restart storm. The timestamp
# file persists across doctor runs (and doctor is re-invoked from a fresh
# shell every run, so this can't just be a shell variable).
TOR_RESTART_BACKOFF_SECONDS=1800 # 30 minutes
TOR_RESTART_STATE_FILE="/var/lib/archipelago/doctor-tor-last-restart"
fix_tor_permissions() {
local fixed=false
local tor_dirs=("/var/lib/archipelago/tor" "/var/lib/tor")
for base in "${tor_dirs[@]}"; do
if [ ! -d "$base" ]; then
continue
fi
while IFS= read -r dir; do
local perms
perms=$(stat -c '%a' "$dir" 2>/dev/null)
local mode_bits="${perms: -3}"
if [ "$mode_bits" != "700" ]; then
chmod 700 "$dir"
log "Fixed permissions on $dir ($perms -> 700; group/other access was NOT fully denied)"
fixed=true
elif [ -n "$DOCTOR_DEBUG" ]; then
log "DEBUG: $dir already denies group/other access (mode $perms) — no change"
fi
done < <(find "$base" -maxdepth 1 -name "hidden_service_*" -type d 2>/dev/null)
done
# If we fixed a real permission defect, restart system Tor to pick up
# the change -- but never more than once per backoff window.
if $fixed; then
local now last_restart elapsed
now=$(date +%s)
last_restart=$(cat "$TOR_RESTART_STATE_FILE" 2>/dev/null || echo 0)
case "$last_restart" in *[!0-9]*|"") last_restart=0 ;; esac
elapsed=$((now - last_restart))
if [ "$elapsed" -ge "$TOR_RESTART_BACKOFF_SECONDS" ]; then
systemctl restart tor@default 2>/dev/null || true
mkdir -p "$(dirname "$TOR_RESTART_STATE_FILE")" 2>/dev/null
echo "$now" > "$TOR_RESTART_STATE_FILE" 2>/dev/null
log "Restarted Tor to apply hidden-service permission fix"
else
log "Skipped Tor restart: last restart was ${elapsed}s ago (backoff window ${TOR_RESTART_BACKOFF_SECONDS}s) — permissions were still corrected"
fi
return 0
fi
return 1
}
# ── Fix 5: SearXNG read-only / cap-drop ─────────────────────
fix_searxng() {
if ! podman ps -a --format '{{.Names}}' 2>/dev/null | grep -q '^searxng$'; then
return 1
fi
local state
state=$(podman inspect searxng --format '{{.State.Status}}' 2>/dev/null || true)
local readonly_root
readonly_root=$(podman inspect searxng --format '{{.HostConfig.ReadonlyRootfs}}' 2>/dev/null || true)
local cap_drop
cap_drop=$(podman inspect searxng --format '{{.HostConfig.CapDrop}}' 2>/dev/null || true)
# Fix if: exited, or has read-only root, or has cap-drop ALL
local needs_fix=false
if [ "$state" = "exited" ]; then
needs_fix=true
fi
if [ "$readonly_root" = "true" ]; then
needs_fix=true
fi
if [[ "$cap_drop" == *"ALL"* ]] || [[ "$cap_drop" == *"all"* ]]; then
needs_fix=true
fi
if ! $needs_fix; then
return 1
fi
log "Recreating SearXNG (readonly=$readonly_root, cap_drop=$cap_drop, state=$state)"
# Get current port mapping
local port
port=$(podman inspect searxng --format '{{range $k,$v := .HostConfig.PortBindings}}{{$k}}={{range $v}}{{.HostPort}}{{end}}{{println}}{{end}}' 2>/dev/null | head -1)
local host_port="${port##*=}"
host_port="${host_port:-8888}"
# Kill any stale conmon holding the port
local conmon_pid
conmon_pid=$(ss -tlnp 2>/dev/null | grep ":${host_port} " | grep -oP 'pid=\K\d+' | head -1)
podman stop searxng 2>/dev/null || true
podman rm -f searxng 2>/dev/null || true
if [ -n "$conmon_pid" ]; then
kill -9 "$conmon_pid" 2>/dev/null || true
sleep 2
fi
podman run -d \
--name searxng \
--restart=unless-stopped \
--security-opt=no-new-privileges:true \
--tmpfs /tmp:rw,noexec,nosuid,size=256m \
-v searxng-config:/etc/searxng:rw \
-v searxng-cache:/var/cache/searxng:rw \
-p "${host_port}:8080" \
--memory=512m \
"${SEARXNG_IMAGE}" 2>&1 || true
log "SearXNG recreated (no readonly, no cap-drop ALL)"
return 0
}
# ── Fix 6: Bitcoin Knots prune+txindex conflict ──────────────
fix_bitcoin_txindex() {
if ! podman ps -a --format '{{.Names}}' 2>/dev/null | grep -q '^bitcoin-knots$'; then
return 1
fi
# Check if bitcoin.conf has prune enabled
local conf="/var/lib/archipelago/bitcoin/bitcoin.conf"
if [ ! -f "$conf" ] || ! grep -q '^prune=' "$conf"; then
return 1
fi
# Check if container args include txindex
local cmd
cmd=$(podman inspect bitcoin-knots --format '{{json .Config.Cmd}}' 2>/dev/null || true)
if ! echo "$cmd" | grep -q "txindex"; then
return 1
fi
log "Bitcoin Knots: prune+txindex conflict detected"
# Get current config
local image
image=$(podman inspect bitcoin-knots --format '{{.ImageName}}' 2>/dev/null)
local network
network=$(podman inspect bitcoin-knots --format '{{.HostConfig.NetworkMode}}' 2>/dev/null)
# Read per-installation RPC password
local SECRETS_DIR="/var/lib/archipelago/secrets"
local BTC_RPC_PASS="archipelago"
if [ -f "$SECRETS_DIR/bitcoin-rpc-password" ]; then
BTC_RPC_PASS=$(cat "$SECRETS_DIR/bitcoin-rpc-password")
fi
# Ensure bitcoin.conf has all RPC settings
if ! grep -q 'rpcuser=' "$conf"; then
cat > "$conf" <<BCONF
server=1
prune=550
rpcuser=archipelago
rpcpassword=$BTC_RPC_PASS
rpcallowip=127.0.0.1/32
rpcallowip=10.88.0.0/16
listen=1
printtoconsole=0
BCONF
log "Updated bitcoin.conf with full RPC settings"
fi
# Remove stale txindex if present
if [ -d "/var/lib/archipelago/bitcoin/indexes/txindex" ]; then
find /var/lib/archipelago/bitcoin/indexes/txindex -type f -delete 2>/dev/null
rmdir /var/lib/archipelago/bitcoin/indexes/txindex 2>/dev/null || true
log "Removed stale txindex directory"
fi
# Recreate without txindex
podman stop bitcoin-knots 2>/dev/null || true
podman rm -f bitcoin-knots 2>/dev/null || true
sleep 2
# Kill stale conmon on port 8332/8333
for p in 8332 8333; do
local cpid
cpid=$(ss -tlnp 2>/dev/null | grep ":${p} " | grep -oP 'pid=\K\d+' | head -1)
if [ -n "$cpid" ]; then
kill -9 "$cpid" 2>/dev/null || true
fi
done
sleep 1
local net_arg=""
if [ -n "$network" ] && [ "$network" != "bridge" ] && [ "$network" != "host" ]; then
net_arg="--network=$network"
elif [ "$network" = "host" ]; then
net_arg="--network=host"
else
net_arg="--network=archy-net"
fi
podman run -d \
--name bitcoin-knots \
--restart=always \
$net_arg \
-p 8332:8332 \
-p 8333:8333 \
-v /var/lib/archipelago/bitcoin:/home/bitcoin/.bitcoin \
--memory=2g \
--cap-drop=ALL \
--cap-add=CHOWN \
--cap-add=FOWNER \
--cap-add=SETUID \
--cap-add=SETGID \
--cap-add=DAC_OVERRIDE \
--security-opt=no-new-privileges:true \
--health-cmd="bitcoin-cli -rpcuser=archipelago -rpcpassword=$BTC_RPC_PASS getblockchaininfo || exit 1" \
--health-interval=30s \
--health-retries=3 \
"$image" 2>&1 || true
log "Bitcoin Knots recreated without txindex (prune mode)"
return 0
}
# ── Fix 7: Exit code 127 containers ─────────────────────────
fix_exit_127() {
local containers
containers=$(podman ps -a --format '{{.Names}} {{.Status}}' 2>/dev/null | grep 'Exited (127)' | awk '{print $1}' || true)
if [ -z "$containers" ]; then
return 1
fi
local fixed_names=()
for name in $containers; do
# Skip containers handled by other fixes
if [ "$name" = "searxng" ]; then
continue
fi
log "Container $name has exit code 127 — recreating"
# Get image and create command for recreation
local image
image=$(podman inspect "$name" --format '{{.ImageName}}' 2>/dev/null || true)
local create_cmd
create_cmd=$(podman inspect "$name" --format '{{json .Config.CreateCommand}}' 2>/dev/null || true)
podman rm -f "$name" 2>/dev/null || true
if [ -n "$create_cmd" ] && [ "$create_cmd" != "null" ]; then
# Re-run the original create command (strip the leading "podman" and "run")
local recreate_args
recreate_args=$(echo "$create_cmd" | python3 -c "
import json, sys
args = json.load(sys.stdin)
# Skip 'podman' and 'run', output the rest
print(' '.join(['\"' + a + '\"' if ' ' in a else a for a in args[2:]]))
" 2>/dev/null || true)
if [ -n "$recreate_args" ]; then
eval "podman run $recreate_args" 2>&1 || true
fixed_names+=("$name")
log "Recreated $name from original args"
else
fixed_names+=("$name(removed)")
log "Removed $name — will be recreated on next deploy"
fi
else
fixed_names+=("$name(removed)")
log "Removed $name — will be recreated on next deploy"
fi
done
[ ${#fixed_names[@]} -gt 0 ] && return 0 || return 1
}
# ── Fix 8: Rootless netns egress lost ────────────────────────
# Rootless podman uses pasta to give containers internet egress. If pasta's
# tap vanishes (host link flap, mount churn, pasta dying during a boot-time
# restart storm), the rootless-netns keeps inter-container traffic working
# but silently loses outbound. Bitcoin IBD stalls at 0 peers; package pulls
# fail. The repair must rebuild the netns from scratch: merely cycling the
# containers reuses the existing (broken) netns because its holders
# (aardvark-dns, podman's pause process) survive — observed on a test node
# 2026-07-10, where the old stop/start-only cycle bounced all 35 containers
# every timer run for ~an hour without ever restoring egress. So: stop the
# containers, kill the netns holders, `podman system migrate`, clear the
# stale netns state, then start everything back up.
#
# Destructive-action latch: cycling the whole fleet is a last resort. After
# NETNS_CYCLE_MAX consecutive failed repairs we stop cycling (and log loudly)
# until a run observes egress healthy again, which resets the counter.
NETNS_CYCLE_STATE="/var/lib/archipelago/doctor-netns-cycle-failures"
NETNS_CYCLE_MAX=3
fix_rootless_netns_egress() {
# Needs root for nsenter. When doctor runs as the rootless container owner,
# a failed nsenter probe is a permissions artifact, not evidence of broken
# egress; do not cycle the fleet from that context.
[ "$(id -u)" = "0" ] || return 1
local archi_uid
archi_uid=$(id -u archipelago 2>/dev/null) || return 1
# Locate the rootless-netns via aardvark-dns (it lives inside it).
local aardvark_pid
aardvark_pid=$(pgrep -U "$archi_uid" -f '^/usr/lib/podman/aardvark-dns' 2>/dev/null | head -1)
[ -z "$aardvark_pid" ] && return 1 # no rootless network active
# Host precheck: if the host itself can't reach the internet, no point
# cycling containers — this is an upstream problem.
if ! timeout 3 bash -c '</dev/tcp/1.1.1.1/443' 2>/dev/null; then
return 1
fi
# Probe egress from inside the rootless-netns. One probe is noisy;
# require two consecutive failures 10s apart to rule out transients.
if timeout 3 nsenter -t "$aardvark_pid" -n bash -c '</dev/tcp/1.1.1.1/443' 2>/dev/null; then
rm -f "$NETNS_CYCLE_STATE" # healthy again — re-arm the latch
return 1 # first probe succeeded
fi
sleep 10
aardvark_pid=$(pgrep -U "$archi_uid" -f '^/usr/lib/podman/aardvark-dns' 2>/dev/null | head -1)
[ -z "$aardvark_pid" ] && return 1
if timeout 3 nsenter -t "$aardvark_pid" -n bash -c '</dev/tcp/1.1.1.1/443' 2>/dev/null; then
rm -f "$NETNS_CYCLE_STATE"
return 1 # recovered on its own
fi
# Latch: don't keep bouncing the fleet when the rebuild demonstrably
# isn't fixing it.
local failures
failures=$(cat "$NETNS_CYCLE_STATE" 2>/dev/null || echo 0)
case "$failures" in *[!0-9]*|"") failures=0;; esac
if [ "$failures" -ge "$NETNS_CYCLE_MAX" ]; then
log "Rootless-netns egress still broken but $failures rebuilds already failed — NOT cycling again (manual intervention needed; rm $NETNS_CYCLE_STATE to re-arm)"
return 1
fi
log "Rootless-netns egress is broken (host online, container netns unreachable) — rebuilding netns"
local PODMANCMD="sudo -u archipelago XDG_RUNTIME_DIR=/run/user/$archi_uid podman"
local running
running=$($PODMANCMD ps --format '{{.Names}}' 2>/dev/null)
if [ -z "$running" ]; then
log " No running containers to cycle — skipping"
return 1
fi
local count
count=$(echo "$running" | wc -l)
log " Stopping $count running containers (graceful, 30s)..."
$PODMANCMD stop --all --time 30 >/dev/null 2>&1
sleep 5
# Tear the broken netns down for real: kill its holders and drop the
# stale state so the first container start rebuilds pasta + aardvark-dns
# from scratch. Without this, podman re-enters the old netns and the
# missing pasta tap never comes back.
log " Rebuilding rootless netns (killing holders, clearing state)..."
pkill -U "$archi_uid" -x aardvark-dns 2>/dev/null
pkill -U "$archi_uid" -x pasta 2>/dev/null
pkill -U "$archi_uid" -x pasta.avx2 2>/dev/null
pkill -U "$archi_uid" -x slirp4netns 2>/dev/null
sleep 2
$PODMANCMD system migrate >/dev/null 2>&1
rm -rf "/run/user/$archi_uid/containers/networks"
log " Starting containers back up..."
for c in $running; do
$PODMANCMD start "$c" >/dev/null 2>&1 &
done
wait
sleep 5
aardvark_pid=$(pgrep -U "$archi_uid" -f '^/usr/lib/podman/aardvark-dns' 2>/dev/null | head -1)
if [ -n "$aardvark_pid" ] && timeout 3 nsenter -t "$aardvark_pid" -n bash -c '</dev/tcp/1.1.1.1/443' 2>/dev/null; then
log " Rootless-netns egress restored ($count containers cycled)"
rm -f "$NETNS_CYCLE_STATE"
else
failures=$((failures + 1))
echo "$failures" > "$NETNS_CYCLE_STATE"
log " WARN: egress still broken after rebuild (failure $failures/$NETNS_CYCLE_MAX) — may need manual intervention"
fi
return 0
}
# ── Fix 9: Restart stopped core containers ──────────────────
# Rootless Podman 4.x restart policies don't auto-restart on crash.
# This check restarts any exited core containers (tiers 0-2).
fix_stopped_core_containers() {
local core_containers="bitcoin-knots lnd electrumx mempool-api archy-mempool-web archy-mempool-db archy-btcpay-db archy-nbxplorer btcpay-server"
local restarted=()
# Doctor runs as root but containers are rootless under archipelago user
local PODMANCMD="sudo -u archipelago XDG_RUNTIME_DIR=/run/user/1000 podman"
for name in $core_containers; do
local state
state=$($PODMANCMD inspect "$name" --format '{{.State.Status}}' 2>/dev/null || echo "missing")
if [ "$state" = "exited" ] || [ "$state" = "stopped" ]; then
log "Restarting stopped container: $name"
$PODMANCMD start "$name" 2>/dev/null && restarted+=("$name") || true
fi
done
[ ${#restarted[@]} -gt 0 ] && return 0 || return 1
}
# ── Fix 10: Missing rootless port listeners ─────────────────
# Rootless Podman can leave a container running with PortBindings still present
# while the host-side rootlessport process has disappeared. Nginx then returns
# 502 and direct app ports refuse connections even though `podman ps` looks OK.
fix_missing_rootless_ports() {
local containers
containers=$(podman_rootless ps --format '{{.Names}}' 2>/dev/null || true)
[ -n "$containers" ] || return 1
local fixed=false
local name
for name in $containers; do
local ports
ports=$(podman_rootless inspect "$name" --format '{{range $p,$bindings := .NetworkSettings.Ports}}{{if $bindings}}{{range $bindings}}{{.HostPort}}{{"\n"}}{{end}}{{end}}{{end}}' 2>/dev/null | sort -u)
[ -n "$ports" ] || continue
local missing=()
local port
for port in $ports; do
[ -n "$port" ] || continue
if ! port_is_listening "$port"; then
missing+=("$port")
fi
done
if [ ${#missing[@]} -gt 0 ]; then
log "Restarting $name: missing rootlessport listener(s): ${missing[*]}"
if podman_rootless restart "$name" >/dev/null 2>&1; then
fixed=true
else
log "WARN: failed to restart $name for missing rootlessport listener(s)"
fi
fi
done
$fixed && return 0 || return 1
}
# ── Fix 11: Nginx Proxy Manager public host bridge ───────────
# Host nginx owns public 80/443 on Archipelago. Mirror NPM proxy hosts into
# host nginx so issued certs and public traffic reach the intended upstreams.
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"
return 0
fi
return 1
}
# ── Fix 12: BTCPay Lightning route hints ─────────────────────
# A BTCPay store whose LND node has only private (unannounced) channels
# produces BOLT11 invoices that external wallets cannot route to unless the
# store's lightningPrivateRouteHints flag is on — payers see "no way to pay
# this invoice" (observed on a test node 2026-07-10 with a Blink payer). Route
# hints are a no-op with public channels and essential with private ones, so
# the doctor enforces the flag on every store. BTCPay reads store blobs from
# Postgres per request; no restart needed.
fix_btcpay_route_hints() {
local state
state=$(podman_rootless inspect archy-btcpay-db --format '{{.State.Status}}' 2>/dev/null || echo "missing")
[ "$state" = "running" ] || return 1
local count
count=$(podman_rootless exec archy-btcpay-db psql -U btcpay -d btcpay -t -A -c \
"SELECT count(*) FROM \"Stores\" WHERE (\"StoreBlob\"->>'lightningPrivateRouteHints') = 'false';" 2>/dev/null)
[ -n "$count" ] && [ "$count" -gt 0 ] 2>/dev/null || return 1
if podman_rootless exec archy-btcpay-db psql -U btcpay -d btcpay -q -c \
"UPDATE \"Stores\" SET \"StoreBlob\" = jsonb_set(\"StoreBlob\", '{lightningPrivateRouteHints}', 'true'::jsonb) WHERE (\"StoreBlob\"->>'lightningPrivateRouteHints') = 'false';" >/dev/null 2>&1; then
log "Enabled Lightning route hints on $count BTCPay store(s) (private-channel invoices were unpayable)"
return 0
fi
return 1
}
# ── Fix 13: Missing catatonit (container init binary) ────────
# Podman resolves `--init` (and any Portainer/compose deploy with
# "init: true") through catatonit; Debian's podman package only
# Recommends it, so a node installed or upgraded without it fails those
# deploys with a missing-init error (observed on a test node 2026-07-10
# deploying sites via Portainer). install-podman.sh covers fresh ISO
# installs; this heals nodes that predate it.
fix_missing_catatonit() {
command -v catatonit >/dev/null 2>&1 && return 1
command -v apt-get >/dev/null 2>&1 || return 1
if DEBIAN_FRONTEND=noninteractive apt-get install -y catatonit >/dev/null 2>&1; then
log "Installed catatonit (init-enabled container deploys were failing)"
return 0
fi
log "WARNING: catatonit missing and apt-get install failed — init-enabled deploys will fail"
return 1
}
# ── Fix 14: archipelago user missing dialout (mesh radios) ───
# The image used to create the archipelago user with only `sudo`, so the
# backend couldn't open /dev/ttyUSB*/ttyACM* serial LoRa radios — Mesh
# never detected a plugged-in device (observed on the 1.7.99 RC install
# 2026-07-13). Group change takes effect on the next service restart; we
# restart archipelago only if a serial device is actually present.
fix_archipelago_dialout() {
id -nG archipelago 2>/dev/null | grep -qw dialout && return 1
usermod -aG dialout archipelago 2>/dev/null || return 1
log "Added archipelago to dialout (serial mesh radios were unreadable)"
if ls /dev/ttyUSB* /dev/ttyACM* >/dev/null 2>&1; then
systemctl try-restart archipelago 2>/dev/null || true
log "Restarted archipelago to pick up dialout (radio present)"
fi
return 0
}
# ── Main ─────────────────────────────────────────────────────
# If remote host provided, run via SSH
if [ -n "$1" ] && [ "$1" != "--local" ]; then
REMOTE_HOST="$1"
SSH_KEY="${ARCHIPELAGO_SSH_KEY:-$HOME/.ssh/archipelago-deploy}"
SSH_OPTS="-o StrictHostKeyChecking=no -o ServerAliveInterval=15 -o ServerAliveCountMax=4 -i $SSH_KEY"
log "Running container doctor on $REMOTE_HOST"
# Copy script to remote and execute
scp $SSH_OPTS "$0" "$REMOTE_HOST:/tmp/container-doctor.sh" 2>/dev/null
ssh $SSH_OPTS "$REMOTE_HOST" "sudo bash /tmp/container-doctor.sh --local" 2>&1
exit 0
fi
# Running locally (on the node itself)
log "Starting container health check"
run_fix "stale-podman" fix_stale_podman
run_fix "orphaned-conmon" fix_orphaned_conmon
run_fix "system-tor" fix_system_tor_conflict
run_fix "tor-permissions" fix_tor_permissions
run_fix "searxng" fix_searxng
run_fix "bitcoin-txindex" fix_bitcoin_txindex
run_fix "exit-127" fix_exit_127
run_fix "netns-egress" fix_rootless_netns_egress
run_fix "stopped-core" fix_stopped_core_containers
run_fix "rootless-ports" fix_missing_rootless_ports
run_fix "npm-public-hosts" fix_npm_public_hosts
run_fix "btcpay-route-hints" fix_btcpay_route_hints
run_fix "catatonit" fix_missing_catatonit
run_fix "dialout" fix_archipelago_dialout
echo ""
if [ $FIXES_APPLIED -gt 0 ]; then
log "Done: $FIXES_APPLIED fixes applied (${FIX_NAMES[*]}), $CHECKS_PASSED checks passed"
else
log "Done: all $CHECKS_PASSED checks passed — no fixes needed"
fi
exit 0
+664
View File
@@ -0,0 +1,664 @@
#!/bin/bash
# Container specification registry — SINGLE SOURCE OF TRUTH
# Every container's exact creation spec lives here.
# Sourced by reconcile-containers.sh, first-boot-containers.sh, deploy scripts.
#
# Usage:
# source container-specs.sh
# load_spec "bitcoin-knots" # Sets SPEC_* variables
# all_specs # Returns ordered list of all containers
[ -n "${_CONTAINER_SPECS_LOADED:-}" ] && return 0
_CONTAINER_SPECS_LOADED=1
# Source image versions
for f in /opt/archipelago/image-versions.sh \
"$(dirname "${BASH_SOURCE[0]}")/image-versions.sh" \
"$(dirname "${BASH_SOURCE[0]}")/../image-versions.sh"; do
[ -f "$f" ] && { source "$f"; break; }
done
# Source common utilities (mem_limit)
for f in "$(dirname "${BASH_SOURCE[0]}")/lib/common.sh" \
/opt/archipelago/scripts/lib/common.sh; do
[ -f "$f" ] && { source "$f"; break; }
done
# ── Environment detection ─────────────────────────────────────────────
detect_environment() {
# Measure disk where container data actually lives, not the OS partition.
# Archipelago installs mount a separate (usually-encrypted) data volume at
# /var/lib/archipelago on any host with meaningful storage, so checking /
# would always report the ~30 GB OS partition and wrongly trip prune mode
# on 2 TB boxes. Fall back to / only for first-boot before the data
# partition is mounted.
local disk_target="/var/lib/archipelago"
[ -d "$disk_target" ] || disk_target="/"
DISK_GB=$(df --output=size -BG "$disk_target" 2>/dev/null | tail -1 | tr -dc '0-9')
DISK_GB=${DISK_GB:-500}
TOTAL_MEM_MB=$(($(awk '/MemTotal/{print $2}' /proc/meminfo 2>/dev/null || echo 16000000) / 1024))
LOW_MEM=false
[ "$TOTAL_MEM_MB" -lt 12000 ] && LOW_MEM=true
# Bitcoin UTXO cache (dbcache) sized to host RAM, NOT a fixed value.
# A large dbcache on a small box pushes total memory (bitcoind + the ~20 app
# containers) past physical RAM and forces system-wide swap thrash: the disk
# saturates, bitcoind can't answer its own RPC, and the dashboard backend's
# sqlite reads stall — surfacing as fleet-wide /rpc/v1 502s and a blank
# Bitcoin UI. The old binary LOW_MEM->2048 toggle still over-committed 8 GB
# nodes. Budget ~1/16 of RAM for the cache, leaving the bulk for the OS +
# containers; floor 300 MB (bitcoind default is 450), cap 4096 MB.
BTC_DBCACHE=$(( TOTAL_MEM_MB / 16 ))
[ "$BTC_DBCACHE" -lt 300 ] && BTC_DBCACHE=300
[ "$BTC_DBCACHE" -gt 4096 ] && BTC_DBCACHE=4096
HOST_IP=$(hostname -I 2>/dev/null | awk '{print $1}')
HOST_IP=${HOST_IP:-127.0.0.1}
# Stable mDNS hostname for URLs that get baked into federation/consensus data.
# Survives DHCP churn and reinstalls-on-different-IP (which $HOST_IP does not).
# Requires avahi-daemon (shipped on all Archipelago nodes).
HOST_MDNS="$(hostname 2>/dev/null).local"
HOST_MDNS="${HOST_MDNS:-archipelago.local}"
# Secrets
SECRETS_DIR="/var/lib/archipelago/secrets"
BITCOIN_RPC_USER="archipelago"
BITCOIN_RPC_PASS=$(cat "$SECRETS_DIR/bitcoin-rpc-password" 2>/dev/null || echo "")
MEMPOOL_DB_PASS=$(cat "$SECRETS_DIR/mempool-db-password" 2>/dev/null || echo "")
BTCPAY_DB_PASS=$(cat "$SECRETS_DIR/btcpay-db-password" 2>/dev/null || echo "")
MYSQL_ROOT_PASS=$(cat "$SECRETS_DIR/mysql-root-db-password" 2>/dev/null || echo "")
FEDI_HASH=$(cat "$SECRETS_DIR/fedimint-gateway-hash" 2>/dev/null || echo "")
# Escape $ so SPEC_ENTRYPOINT survives eval in reconcile-containers.sh:build_run_cmd.
# bcrypt hashes have the form $2y$10$... and get mangled if $2 and $10 are
# interpolated as positional args at eval time.
FEDI_HASH="${FEDI_HASH//\$/\\\$}"
}
# ── Spec variables ────────────────────────────────────────────────────
# Each load_spec_* function sets these variables:
# SPEC_NAME Container name
# SPEC_IMAGE Full image reference (pinned)
# SPEC_NETWORK Network mode (archy-net, bridge, host)
# SPEC_PORTS Space-separated host:container port pairs
# SPEC_VOLUMES Space-separated host:container volume mappings
# SPEC_MEMORY Memory limit (e.g. 2g, 512m)
# SPEC_CAPS Space-separated capabilities to add
# SPEC_SECURITY Security options
# SPEC_RESTART Restart policy
# SPEC_HEALTH_CMD Health check command
# SPEC_ENV Space-separated KEY=VALUE environment variables
# SPEC_CUSTOM_ARGS Extra args appended to podman run
# SPEC_READONLY true/false for --read-only
# SPEC_TMPFS Space-separated tmpfs mounts
# SPEC_TIER 0=DB, 1=Core, 2=Service, 3=App, 4=UI
# SPEC_DATA_DIR Host data directory (for ownership fix)
# SPEC_DATA_UID Host UID:GID for data dir (rootless mapped)
# SPEC_DEPENDS Space-separated container dependencies
# SPEC_LOCAL_IMAGE true if image is built locally (don't pull)
# SPEC_OPTIONAL true if container should be skipped when image missing
reset_spec() {
SPEC_NAME="" SPEC_IMAGE="" SPEC_NETWORK="bridge" SPEC_PORTS=""
SPEC_VOLUMES="" SPEC_MEMORY="512m" SPEC_CAPS="CHOWN FOWNER SETUID SETGID DAC_OVERRIDE"
SPEC_SECURITY="no-new-privileges:true" SPEC_RESTART="unless-stopped"
SPEC_HEALTH_CMD="" SPEC_ENV="" SPEC_CUSTOM_ARGS="" SPEC_READONLY="false"
SPEC_TMPFS="" SPEC_TIER="3" SPEC_DATA_DIR="" SPEC_DATA_UID="100000:100000"
# Set by a loader (e.g. fedimint-gateway) to signal "spec is valid but this
# container must not be created/recreated right now" — e.g. a required
# per-install secret hasn't been generated yet. Empty means no skip.
SPEC_SKIP_REASON=""
# SPEC_OPTIONAL defaults true: reconcile-containers.sh only REPAIRS existing
# containers — it never creates missing ones. Baseline (filebrowser) is
# bootstrapped by first-boot-containers.sh; all other apps come from the
# install RPC. Per-spec `SPEC_OPTIONAL="true"` lines below are now redundant
# but kept for readability.
SPEC_DEPENDS="" SPEC_LOCAL_IMAGE="false" SPEC_OPTIONAL="true"
SPEC_ENTRYPOINT=""
}
if ! declare -F alloc_port >/dev/null 2>&1; then
alloc_port() { printf '%s' "$2"; }
fi
# ── Tier 0: Databases ────────────────────────────────────────────────
load_spec_archy-mempool-db() {
reset_spec
SPEC_NAME="archy-mempool-db"
SPEC_IMAGE="${MARIADB_IMAGE}"
SPEC_NETWORK="archy-net"
SPEC_MEMORY="$(mem_limit archy-mempool-db)"
SPEC_VOLUMES="/var/lib/archipelago/mysql-mempool:/var/lib/mysql"
SPEC_HEALTH_CMD="mariadb -uroot -e 'SELECT 1' || exit 1"
SPEC_ENV="MYSQL_DATABASE=mempool MYSQL_USER=mempool MYSQL_PASSWORD=$MEMPOOL_DB_PASS MYSQL_ROOT_PASSWORD=$MYSQL_ROOT_PASS"
SPEC_TIER="0"
SPEC_DATA_DIR="/var/lib/archipelago/mysql-mempool"
SPEC_DATA_UID="100999:100999"
SPEC_CAPS="CHOWN FOWNER SETUID SETGID DAC_OVERRIDE"
}
load_spec_archy-btcpay-db() {
reset_spec
SPEC_NAME="archy-btcpay-db"
SPEC_IMAGE="${BTCPAY_POSTGRES_IMAGE}"
SPEC_NETWORK="archy-net"
SPEC_MEMORY="$(mem_limit archy-btcpay-db)"
SPEC_VOLUMES="/var/lib/archipelago/postgres-btcpay:/var/lib/postgresql/data"
SPEC_HEALTH_CMD="pg_isready -U postgres || exit 1"
SPEC_ENV="POSTGRES_DB=btcpay POSTGRES_USER=btcpay POSTGRES_PASSWORD=$BTCPAY_DB_PASS"
SPEC_TIER="0"
SPEC_DATA_DIR="/var/lib/archipelago/postgres-btcpay"
SPEC_DATA_UID="100070:100070"
SPEC_CAPS="CHOWN FOWNER SETUID SETGID DAC_OVERRIDE"
}
load_spec_immich_postgres() {
reset_spec
SPEC_NAME="immich_postgres"
SPEC_IMAGE="${IMMICH_POSTGRES_IMAGE}"
SPEC_NETWORK="bridge"
SPEC_MEMORY="$(mem_limit immich_postgres)"
SPEC_VOLUMES="/var/lib/archipelago/immich-db:/var/lib/postgresql/data"
SPEC_ENV="POSTGRES_USER=postgres POSTGRES_DB=immich POSTGRES_PASSWORD=$BTCPAY_DB_PASS"
SPEC_TIER="0"
SPEC_DATA_DIR="/var/lib/archipelago/immich-db"
SPEC_DATA_UID="100070:100070"
SPEC_CAPS="CHOWN FOWNER SETUID SETGID DAC_OVERRIDE"
SPEC_OPTIONAL="true"
}
load_spec_immich_redis() {
reset_spec
SPEC_NAME="immich_redis"
SPEC_IMAGE="${VALKEY_IMAGE}"
SPEC_NETWORK="bridge"
SPEC_MEMORY="$(mem_limit immich_redis)"
SPEC_TIER="0"
SPEC_CAPS="CHOWN SETUID SETGID"
SPEC_OPTIONAL="true"
}
# ── Tier 1: Core Infrastructure ──────────────────────────────────────
load_spec_bitcoin-knots() {
reset_spec
SPEC_NAME="bitcoin-knots"
SPEC_IMAGE="${BITCOIN_KNOTS_IMAGE}"
SPEC_NETWORK="archy-net"
SPEC_PORTS="8332:8332 8333:8333"
SPEC_VOLUMES="/var/lib/archipelago/bitcoin:/home/bitcoin/.bitcoin"
SPEC_MEMORY="$(mem_limit bitcoin-knots)"
SPEC_HEALTH_CMD="bitcoin-cli -rpcuser=\$BITCOIN_RPC_USER -rpcpassword=\$BITCOIN_RPC_PASS getblockchaininfo || exit 1"
SPEC_TIER="1"
SPEC_DATA_DIR="/var/lib/archipelago/bitcoin"
SPEC_DATA_UID="100101:100101"
local btc_rpc_headroom="-rpcthreads=16 -rpcworkqueue=256"
local btc_txrelay_flags="-rpcwhitelistdefault=0"
if [ -f "$SECRETS_DIR/bitcoin-rpc-txrelay-rpcauth" ]; then
btc_txrelay_flags="$btc_txrelay_flags -rpcauth=$(cat "$SECRETS_DIR/bitcoin-rpc-txrelay-rpcauth") -rpcwhitelist=txrelay:sendrawtransaction,submitpackage,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblock,getblockheader,getrawtransaction,gettxout,gettxspendingprevout,decoderawtransaction,decodescript,estimatesmartfee,uptime,ping,getconnectioncount,getpeerinfo,getindexinfo,getdeploymentinfo,getchaintips"
fi
# Dynamic: prune on small disk
if [ "${DISK_GB:-0}" -lt 1000 ]; then
SPEC_CUSTOM_ARGS="-server=1 -prune=550 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=${BTC_DBCACHE} -par=0 -maxconnections=125 ${btc_rpc_headroom} ${btc_txrelay_flags}"
else
SPEC_CUSTOM_ARGS="-server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=${BTC_DBCACHE} -par=0 -maxconnections=125 ${btc_rpc_headroom} ${btc_txrelay_flags}"
fi
}
load_spec_electrumx() {
reset_spec
SPEC_NAME="electrumx"
SPEC_IMAGE="${ELECTRUMX_IMAGE}"
SPEC_NETWORK="archy-net"
SPEC_PORTS="50001:50001"
SPEC_VOLUMES="/var/lib/archipelago/electrumx:/data"
SPEC_MEMORY="$(mem_limit electrumx)"
SPEC_HEALTH_CMD="python3 -c 'import socket; socket.create_connection((\\\"localhost\\\",8000),2).close()' || exit 1"
SPEC_ENV="DAEMON_URL=http://$BITCOIN_RPC_USER:$BITCOIN_RPC_PASS@bitcoin-knots:8332/ COIN=Bitcoin DB_DIRECTORY=/data SERVICES=tcp://:50001,rpc://0.0.0.0:8000"
SPEC_TIER="1"
SPEC_DATA_DIR="/var/lib/archipelago/electrumx"
SPEC_DEPENDS="bitcoin-knots"
SPEC_CAPS="DAC_OVERRIDE"
}
# ── Tier 2: Services ─────────────────────────────────────────────────
load_spec_lnd() {
reset_spec
SPEC_NAME="lnd"
SPEC_IMAGE="${LND_IMAGE}"
SPEC_NETWORK="archy-net"
SPEC_PORTS="9735:9735 10009:10009 18080:8080"
SPEC_VOLUMES="/var/lib/archipelago/lnd:/root/.lnd"
SPEC_MEMORY="$(mem_limit lnd)"
SPEC_CAPS="CHOWN FOWNER SETUID SETGID DAC_OVERRIDE NET_RAW"
SPEC_HEALTH_CMD="lncli --tlscertpath /root/.lnd/tls.cert --macaroonpath /root/.lnd/data/chain/bitcoin/mainnet/readonly.macaroon --rpcserver localhost:10009 getinfo > /dev/null 2>&1 || exit 1"
SPEC_TIER="2"
SPEC_DATA_DIR="/var/lib/archipelago/lnd"
SPEC_DEPENDS="bitcoin-knots"
}
load_spec_mempool-api() {
reset_spec
SPEC_NAME="mempool-api"
SPEC_IMAGE="${MEMPOOL_BACKEND_IMAGE}"
SPEC_NETWORK="archy-net"
SPEC_PORTS="8999:8999"
SPEC_VOLUMES="/var/lib/archipelago/mempool:/data"
SPEC_MEMORY="$(mem_limit mempool-api)"
SPEC_HEALTH_CMD="curl -sf http://localhost:8999/ || exit 1"
local MYSQL_CNT="archy-mempool-db"
SPEC_ENV="MEMPOOL_BACKEND=electrum ELECTRUM_HOST=electrumx ELECTRUM_PORT=50001 ELECTRUM_TLS_ENABLED=false CORE_RPC_HOST=bitcoin-knots CORE_RPC_PORT=8332 CORE_RPC_USERNAME=$BITCOIN_RPC_USER CORE_RPC_PASSWORD=$BITCOIN_RPC_PASS DATABASE_ENABLED=true DATABASE_HOST=$MYSQL_CNT DATABASE_DATABASE=mempool DATABASE_USERNAME=mempool DATABASE_PASSWORD=$MEMPOOL_DB_PASS"
SPEC_TIER="2"
SPEC_DATA_DIR="/var/lib/archipelago/mempool"
SPEC_DEPENDS="bitcoin-knots electrumx archy-mempool-db"
SPEC_CAPS=""
}
load_spec_archy-mempool-web() {
reset_spec
SPEC_NAME="archy-mempool-web"
SPEC_IMAGE="${MEMPOOL_WEB_IMAGE}"
SPEC_NETWORK="archy-net"
SPEC_PORTS="4080:8080"
SPEC_MEMORY="$(mem_limit archy-mempool-web)"
SPEC_HEALTH_CMD="curl -sf http://localhost:8080/ || exit 1"
SPEC_ENV="FRONTEND_HTTP_PORT=8080 BACKEND_MAINNET_HTTP_HOST=mempool-api"
SPEC_TIER="2"
SPEC_DEPENDS="mempool-api"
SPEC_CAPS=""
}
load_spec_archy-nbxplorer() {
reset_spec
SPEC_NAME="archy-nbxplorer"
SPEC_IMAGE="${NBXPLORER_IMAGE}"
SPEC_NETWORK="archy-net"
SPEC_PORTS="32838:32838"
SPEC_VOLUMES="/var/lib/archipelago/nbxplorer:/data"
SPEC_MEMORY="$(mem_limit archy-nbxplorer)"
SPEC_HEALTH_CMD="curl -sf http://localhost:32838/ || exit 1"
SPEC_ENV="NBXPLORER_DATADIR=/data NBXPLORER_NETWORK=mainnet NBXPLORER_CHAINS=btc NBXPLORER_BIND=0.0.0.0:32838 NBXPLORER_BTCRPCURL=http://bitcoin-knots:8332 NBXPLORER_BTCRPCUSER=$BITCOIN_RPC_USER NBXPLORER_BTCRPCPASSWORD=$BITCOIN_RPC_PASS NBXPLORER_POSTGRES=Username=btcpay;Password=$BTCPAY_DB_PASS;Host=archy-btcpay-db;Port=5432;Database=nbxplorer"
SPEC_TIER="2"
SPEC_DATA_DIR="/var/lib/archipelago/nbxplorer"
SPEC_DEPENDS="bitcoin-knots archy-btcpay-db"
SPEC_CAPS=""
}
load_spec_btcpay-server() {
reset_spec
SPEC_NAME="btcpay-server"
SPEC_IMAGE="${BTCPAY_IMAGE}"
SPEC_NETWORK="archy-net"
SPEC_PORTS="23000:49392"
SPEC_VOLUMES="/var/lib/archipelago/btcpay:/datadir"
SPEC_MEMORY="$(mem_limit btcpay-server)"
SPEC_HEALTH_CMD="bash -ec '</dev/tcp/127.0.0.1/49392'"
SPEC_ENV="ASPNETCORE_URLS=http://0.0.0.0:49392 BTCPAY_PROTOCOL=http BTCPAY_HOST=$HOST_IP:23000 BTCPAY_CHAINS=btc BTCPAY_BTCEXPLORERURL=http://archy-nbxplorer:32838 BTCPAY_BTCRPCURL=http://bitcoin-knots:8332 BTCPAY_BTCRPCUSER=$BITCOIN_RPC_USER BTCPAY_BTCRPCPASSWORD=$BITCOIN_RPC_PASS BTCPAY_POSTGRES=Username=btcpay;Password=$BTCPAY_DB_PASS;Host=archy-btcpay-db;Port=5432;Database=btcpay"
SPEC_TIER="2"
SPEC_DATA_DIR="/var/lib/archipelago/btcpay"
SPEC_DEPENDS="archy-nbxplorer archy-btcpay-db"
}
load_spec_fedimint() {
reset_spec
SPEC_NAME="fedimint"
SPEC_IMAGE="${FEDIMINT_IMAGE}"
SPEC_NETWORK="archy-net"
SPEC_PORTS="8173:8173 8174:8174 8175:8175"
SPEC_VOLUMES="/var/lib/archipelago/fedimint:/data"
SPEC_MEMORY="$(mem_limit fedimint)"
SPEC_HEALTH_CMD="curl -sf http://localhost:8175/ || exit 1"
SPEC_ENV="FM_DATA_DIR=/data FM_BITCOIND_USERNAME=$BITCOIN_RPC_USER FM_BITCOIND_PASSWORD=$BITCOIN_RPC_PASS FM_BITCOIN_NETWORK=bitcoin FM_BIND_P2P=0.0.0.0:8173 FM_BIND_API=0.0.0.0:8174 FM_BIND_UI=0.0.0.0:8175 FM_P2P_URL=fedimint://$HOST_MDNS:8173 FM_API_URL=ws://$HOST_MDNS:8174 FM_BITCOIND_URL=http://bitcoin-knots:8332"
SPEC_TIER="2"
SPEC_DATA_DIR="/var/lib/archipelago/fedimint"
SPEC_DEPENDS="bitcoin-knots"
SPEC_OPTIONAL="true"
}
load_spec_fedimint-gateway() {
reset_spec
SPEC_NAME="fedimint-gateway"
SPEC_IMAGE="${FEDIMINT_GATEWAY_IMAGE}"
SPEC_NETWORK="archy-net"
SPEC_PORTS="8176:8176"
SPEC_VOLUMES="/var/lib/archipelago/fedimint-gateway:/data"
SPEC_MEMORY="$(mem_limit fedimint-gateway)"
SPEC_HEALTH_CMD="curl -sf http://localhost:8176/ || exit 1"
SPEC_TIER="2"
SPEC_DATA_DIR="/var/lib/archipelago/fedimint-gateway"
SPEC_DEPENDS="bitcoin-knots fedimint"
SPEC_OPTIONAL="true"
# FED-07: no shipped fallback credential. If the per-install bcrypt hash
# (fedimint-gateway-hash, generated by container::secrets::ensure_gateway_credential
# via the daemon, first-boot, or reconcile secret-generation step) doesn't
# exist yet, do not build an entrypoint with an empty --bcrypt-password-hash
# — skip creating/recreating this container and let the caller retry once
# the credential exists.
if [ -z "$FEDI_HASH" ]; then
SPEC_SKIP_REASON="fedimint-gateway credential not generated yet (no shipped default; will retry once a per-install credential exists)"
return
fi
# Custom entrypoint depends on whether LND is available
local LND_CERT=/var/lib/archipelago/lnd/tls.cert
local LND_MAC=/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/admin.macaroon
if [ -f "$LND_CERT" ] && [ -f "$LND_MAC" ]; then
SPEC_VOLUMES="$SPEC_VOLUMES $LND_CERT:/lnd/tls.cert:ro $LND_MAC:/lnd/admin.macaroon:ro"
SPEC_ENTRYPOINT="gatewayd --data-dir /data --listen 0.0.0.0:8176 --bcrypt-password-hash $FEDI_HASH --network bitcoin --bitcoind-url http://bitcoin-knots:8332 --bitcoind-username $BITCOIN_RPC_USER --bitcoind-password $BITCOIN_RPC_PASS lnd --lnd-rpc-host lnd:10009 --lnd-tls-cert /lnd/tls.cert --lnd-macaroon /lnd/admin.macaroon"
else
SPEC_PORTS="8176:8176 9737:9737"
SPEC_ENTRYPOINT="gatewayd --data-dir /data --listen 0.0.0.0:8176 --bcrypt-password-hash $FEDI_HASH --network bitcoin --bitcoind-url http://bitcoin-knots:8332 --bitcoind-username $BITCOIN_RPC_USER --bitcoind-password $BITCOIN_RPC_PASS ldk --ldk-lightning-port 9737 --ldk-alias archipelago-gateway"
fi
}
load_spec_immich_server() {
reset_spec
SPEC_NAME="immich_server"
SPEC_IMAGE="${IMMICH_SERVER_IMAGE}"
SPEC_NETWORK="bridge"
SPEC_PORTS="2283:2283"
SPEC_VOLUMES="/var/lib/archipelago/immich:/usr/src/app/upload"
SPEC_MEMORY="$(mem_limit immich_server)"
SPEC_ENV="DB_HOSTNAME=immich_postgres DB_DATABASE_NAME=immich DB_USERNAME=postgres DB_PASSWORD=$BTCPAY_DB_PASS REDIS_HOSTNAME=immich_redis UPLOAD_LOCATION=/usr/src/app/upload"
SPEC_TIER="2"
SPEC_DATA_DIR="/var/lib/archipelago/immich"
SPEC_DEPENDS="immich_postgres immich_redis"
SPEC_CAPS=""
SPEC_OPTIONAL="true"
}
# ── Tier 3: Applications ─────────────────────────────────────────────
load_spec_homeassistant() {
reset_spec
SPEC_NAME="homeassistant"
SPEC_IMAGE="${HOMEASSISTANT_IMAGE}"
SPEC_PORTS="8123:8123"
SPEC_VOLUMES="/var/lib/archipelago/home-assistant:/config"
SPEC_MEMORY="$(mem_limit homeassistant)"
SPEC_HEALTH_CMD="curl -sf http://localhost:8123/ || exit 1"
SPEC_ENV="TZ=UTC"
SPEC_TIER="3"
SPEC_DATA_DIR="/var/lib/archipelago/home-assistant"
SPEC_CAPS="CHOWN SETUID SETGID DAC_OVERRIDE NET_BIND_SERVICE"
SPEC_OPTIONAL="true"
}
load_spec_grafana() {
reset_spec
SPEC_NAME="grafana"
SPEC_IMAGE="${GRAFANA_IMAGE}"
SPEC_PORTS="3000:3000"
SPEC_VOLUMES="/var/lib/archipelago/grafana:/var/lib/grafana"
SPEC_MEMORY="$(mem_limit grafana)"
SPEC_HEALTH_CMD="curl -sf http://localhost:3000/api/health || exit 1"
SPEC_ENV="GF_PATHS_DATA=/var/lib/grafana GF_USERS_ALLOW_SIGN_UP=false"
SPEC_READONLY="true"
SPEC_TMPFS="/tmp:rw,noexec,nosuid,size=256m /run:rw,noexec,nosuid,size=64m"
SPEC_TIER="3"
SPEC_DATA_DIR="/var/lib/archipelago/grafana"
SPEC_DATA_UID="100472:100472"
SPEC_CAPS="CHOWN SETUID SETGID DAC_OVERRIDE NET_BIND_SERVICE"
SPEC_OPTIONAL="true"
}
load_spec_uptime-kuma() {
reset_spec
SPEC_NAME="uptime-kuma"
SPEC_IMAGE="${UPTIME_KUMA_IMAGE}"
SPEC_PORTS="3002:3001"
SPEC_VOLUMES="/var/lib/archipelago/uptime-kuma:/app/data"
SPEC_MEMORY="$(mem_limit uptime-kuma)"
SPEC_HEALTH_CMD="curl -sf http://localhost:3001/ || exit 1"
SPEC_ENV="TZ=UTC"
SPEC_TIER="3"
SPEC_DATA_DIR="/var/lib/archipelago/uptime-kuma"
SPEC_CAPS="CHOWN FOWNER SETUID SETGID"
SPEC_OPTIONAL="true"
}
load_spec_jellyfin() {
reset_spec
SPEC_NAME="jellyfin"
SPEC_IMAGE="${JELLYFIN_IMAGE}"
SPEC_PORTS="8096:8096"
SPEC_VOLUMES="/var/lib/archipelago/jellyfin/config:/config /var/lib/archipelago/jellyfin/cache:/cache"
SPEC_MEMORY="$(mem_limit jellyfin)"
SPEC_HEALTH_CMD="curl -sf http://localhost:8096/ || exit 1"
SPEC_TIER="3"
SPEC_DATA_DIR="/var/lib/archipelago/jellyfin"
SPEC_CAPS=""
SPEC_OPTIONAL="true"
}
load_spec_photoprism() {
reset_spec
SPEC_NAME="photoprism"
SPEC_IMAGE="${PHOTOPRISM_IMAGE}"
SPEC_PORTS="2342:2342"
SPEC_VOLUMES="/var/lib/archipelago/photoprism:/photoprism/storage"
SPEC_MEMORY="$(mem_limit photoprism)"
SPEC_HEALTH_CMD="curl -sf http://localhost:2342/ || exit 1"
SPEC_ENV="PHOTOPRISM_ADMIN_PASSWORD=archipelago PHOTOPRISM_DEFAULT_LOCALE=en"
SPEC_TIER="3"
SPEC_DATA_DIR="/var/lib/archipelago/photoprism"
SPEC_CAPS="CHOWN SETUID SETGID"
SPEC_OPTIONAL="true"
}
load_spec_vaultwarden() {
reset_spec
SPEC_NAME="vaultwarden"
SPEC_IMAGE="${VAULTWARDEN_IMAGE}"
SPEC_PORTS="8082:80"
SPEC_VOLUMES="/var/lib/archipelago/vaultwarden:/data"
SPEC_MEMORY="$(mem_limit vaultwarden)"
SPEC_HEALTH_CMD="curl -sf http://localhost:80/ || exit 1"
SPEC_TIER="3"
SPEC_DATA_DIR="/var/lib/archipelago/vaultwarden"
SPEC_CAPS="CHOWN SETUID SETGID NET_BIND_SERVICE"
SPEC_OPTIONAL="true"
}
load_spec_nextcloud() {
reset_spec
SPEC_NAME="nextcloud"
SPEC_IMAGE="${NEXTCLOUD_IMAGE}"
SPEC_PORTS="8085:80"
SPEC_VOLUMES="/var/lib/archipelago/nextcloud:/var/www/html"
SPEC_MEMORY="$(mem_limit nextcloud)"
SPEC_HEALTH_CMD="curl -sf http://localhost:80/ || exit 1"
SPEC_TIER="3"
SPEC_DATA_DIR="/var/lib/archipelago/nextcloud"
SPEC_CAPS="CHOWN SETUID SETGID DAC_OVERRIDE NET_BIND_SERVICE"
SPEC_OPTIONAL="true"
}
load_spec_searxng() {
reset_spec
SPEC_NAME="searxng"
SPEC_IMAGE="${SEARXNG_IMAGE}"
SPEC_PORTS="8888:8080"
SPEC_MEMORY="$(mem_limit searxng)"
SPEC_VOLUMES="/var/lib/archipelago/searxng:/etc/searxng"
SPEC_HEALTH_CMD="curl -sf http://localhost:8080/ || exit 1"
SPEC_READONLY="true"
SPEC_TMPFS="/tmp:rw,noexec,nosuid,size=256m /run:rw,noexec,nosuid,size=64m"
SPEC_TIER="3"
SPEC_CAPS=""
SPEC_DATA_DIR="/var/lib/archipelago/searxng"
SPEC_OPTIONAL="true"
}
load_spec_filebrowser() {
reset_spec
SPEC_NAME="filebrowser"
SPEC_IMAGE="${FILEBROWSER_IMAGE}"
SPEC_NETWORK="archy-net"
SPEC_PORTS="8083:80"
SPEC_VOLUMES="/var/lib/archipelago/filebrowser:/srv /var/lib/archipelago/filebrowser-data:/data"
SPEC_MEMORY="$(mem_limit filebrowser)"
SPEC_HEALTH_CMD="wget -q --spider http://localhost:80/health || exit 1"
SPEC_TIER="3"
SPEC_DATA_DIR="/var/lib/archipelago/filebrowser"
SPEC_DATA_UID="100000:100000"
# first-boot-containers.sh writes /data/.filebrowser.json (see filebrowser
# creation block at ~line 1128). Config path is required or filebrowser
# opens /database.db in CWD and fails with permission denied.
SPEC_CUSTOM_ARGS="--config /data/.filebrowser.json"
# Needs default caps (CHOWN FOWNER SETUID SETGID DAC_OVERRIDE) from reset_spec
# for rootless userns-root to write /data/filebrowser.db, plus NET_BIND_SERVICE
# to listen on port 80.
SPEC_CAPS="CHOWN FOWNER SETUID SETGID DAC_OVERRIDE NET_BIND_SERVICE"
SPEC_OPTIONAL="true"
}
load_spec_nginx-proxy-manager() {
reset_spec
SPEC_NAME="nginx-proxy-manager"
SPEC_IMAGE="${NPM_IMAGE}"
local admin_port http_port https_port
admin_port=$(alloc_port nginx-proxy-manager 8081 81)
http_port=$(alloc_port nginx-proxy-manager-http 8084 80)
https_port=$(alloc_port nginx-proxy-manager-https 8444 443)
SPEC_PORTS="$admin_port:81 $http_port:80 $https_port:443"
SPEC_VOLUMES="/var/lib/archipelago/nginx-proxy-manager/data:/data /var/lib/archipelago/nginx-proxy-manager/letsencrypt:/etc/letsencrypt"
SPEC_MEMORY="$(mem_limit nginx-proxy-manager)"
SPEC_HEALTH_CMD="curl -sf http://localhost:81/ || exit 1"
SPEC_TIER="3"
SPEC_DATA_DIR="/var/lib/archipelago/nginx-proxy-manager"
SPEC_CAPS="CHOWN FOWNER SETUID SETGID DAC_OVERRIDE NET_BIND_SERVICE"
SPEC_OPTIONAL="true"
}
load_spec_portainer() {
reset_spec
SPEC_NAME="portainer"
SPEC_IMAGE="${PORTAINER_IMAGE}"
SPEC_PORTS="9000:9000"
SPEC_VOLUMES="/var/lib/archipelago/portainer:/data /run/user/1000/podman/podman.sock:/var/run/docker.sock /var/lib/archipelago/portainer/compose:/data/compose"
SPEC_MEMORY="$(mem_limit portainer)"
SPEC_HEALTH_CMD="curl -sf http://localhost:9000/ || exit 1"
SPEC_TIER="3"
SPEC_DATA_DIR="/var/lib/archipelago/portainer"
SPEC_DATA_UID="1000:1000"
SPEC_OPTIONAL="true"
}
load_spec_ollama() {
reset_spec
SPEC_NAME="ollama"
SPEC_IMAGE="${OLLAMA_IMAGE}"
SPEC_PORTS="11434:11434"
SPEC_VOLUMES="/var/lib/archipelago/ollama:/root/.ollama"
SPEC_MEMORY="$(mem_limit ollama)"
SPEC_HEALTH_CMD="curl -sf http://localhost:11434/ || exit 1"
SPEC_READONLY="true"
SPEC_TMPFS="/tmp:rw,noexec,nosuid,size=256m /run:rw,noexec,nosuid,size=64m"
SPEC_TIER="3"
SPEC_DATA_DIR="/var/lib/archipelago/ollama"
SPEC_CAPS=""
SPEC_OPTIONAL="true"
}
# ── Tier 4: Frontend UIs ─────────────────────────────────────────────
load_spec_archy-bitcoin-ui() {
reset_spec
SPEC_NAME="archy-bitcoin-ui"
SPEC_IMAGE="localhost/bitcoin-ui:local"
SPEC_NETWORK="host"
SPEC_VOLUMES="/var/lib/archipelago/bitcoin-ui/nginx.conf:/etc/nginx/conf.d/default.conf:ro"
SPEC_MEMORY="$(mem_limit archy-bitcoin-ui)"
SPEC_TIER="4"
SPEC_LOCAL_IMAGE="true"
SPEC_CAPS="CHOWN SETUID SETGID"
SPEC_SECURITY="no-new-privileges:true"
}
load_spec_archy-lnd-ui() {
reset_spec
SPEC_NAME="archy-lnd-ui"
SPEC_IMAGE="localhost/lnd-ui:local"
# Host-networked, NOT bridge with 18083:80. docker/lnd-ui/nginx.conf listens
# on 18083 directly (it must, so it can proxy to the backend on
# 127.0.0.1:5678 without a cross-origin hop). This spec used to say
# SPEC_PORTS="18083:80", which published host 18083 to container port 80 —
# where nothing listens. Nobody noticed because the running containers were
# created by first-boot-containers.sh, which is host-networked and never
# consults this file; the spec is only read when self-update.sh rebuilds a
# UI image, and that only fires when a file under docker/lnd-ui/ changes.
# Verified on a test node: recreating from the old spec left :18083
# refusing connections.
SPEC_NETWORK="host"
SPEC_MEMORY="$(mem_limit archy-lnd-ui)"
SPEC_TIER="4"
SPEC_LOCAL_IMAGE="true"
SPEC_CAPS="CHOWN SETUID SETGID"
SPEC_SECURITY="no-new-privileges:true"
}
load_spec_archy-electrs-ui() {
reset_spec
SPEC_NAME="archy-electrs-ui"
SPEC_IMAGE="localhost/electrs-ui:local"
SPEC_NETWORK="host"
SPEC_MEMORY="$(mem_limit archy-electrs-ui)"
SPEC_TIER="4"
SPEC_LOCAL_IMAGE="true"
SPEC_CAPS="CHOWN SETUID SETGID"
SPEC_SECURITY="no-new-privileges:true"
}
# ── Registry ─────────────────────────────────────────────────────────
# Ordered by tier, then dependency order within tier
ALL_CONTAINER_SPECS=(
# Tier 0: Databases
archy-mempool-db
archy-btcpay-db
immich_postgres
immich_redis
# Tier 1: Core
bitcoin-knots
electrumx
# Tier 2: Services
lnd
mempool-api
archy-mempool-web
archy-nbxplorer
btcpay-server
fedimint
fedimint-gateway
immich_server
# Tier 3: Apps
homeassistant
grafana
uptime-kuma
jellyfin
photoprism
vaultwarden
nextcloud
searxng
filebrowser
nginx-proxy-manager
portainer
ollama
# Tier 4: UIs
archy-bitcoin-ui
archy-lnd-ui
archy-electrs-ui
)
# Load a spec by name. Usage: load_spec "bitcoin-knots"
load_spec() {
local fn="load_spec_${1}"
if declare -f "$fn" >/dev/null 2>&1; then
"$fn"
return 0
fi
return 1
}
# Return all spec names
all_specs() {
echo "${ALL_CONTAINER_SPECS[@]}"
}
+304
View File
@@ -0,0 +1,304 @@
#!/usr/bin/env bash
# create-release-manifest.sh — Build a release manifest for the Archipelago update system.
#
# Generates a JSON manifest with version info, changelog, and SHA256 hashes for
# each component, matching the format expected by core/archipelago/src/update.rs.
#
# Usage:
# ./scripts/create-release-manifest.sh --version 0.2.0 --date 2026-04-01
#
# The script reads built artifacts from the build output directories and produces
# a manifest.json file suitable for hosting at the UPDATE_MANIFEST_URL.
set -euo pipefail
# Defaults
VERSION=""
RELEASE_DATE=""
OUTPUT_FILE="manifest.json"
BACKEND_BINARY=""
FRONTEND_ARCHIVE=""
BASE_URL="https://source.archipelago-foundation.org/lfg2025/archy/releases/download"
usage() {
echo "Usage: $0 --version VERSION [--date DATE] [--output FILE]"
echo ""
echo "Options:"
echo " --version VERSION Release version (e.g., 0.2.0) [required]"
echo " --date DATE Release date (YYYY-MM-DD) [default: today]"
echo " --output FILE Output manifest path [default: manifest.json]"
echo " --backend PATH Path to backend binary [default: auto-detect]"
echo " --frontend PATH Path to frontend archive [default: auto-detect]"
echo " --base-url URL Base download URL [default: Gitea release attachments]"
exit 1
}
# Parse arguments
while [[ $# -gt 0 ]]; do
case "$1" in
--version) VERSION="$2"; shift 2 ;;
--date) RELEASE_DATE="$2"; shift 2 ;;
--output) OUTPUT_FILE="$2"; shift 2 ;;
--backend) BACKEND_BINARY="$2"; shift 2 ;;
--frontend) FRONTEND_ARCHIVE="$2"; shift 2 ;;
--base-url) BASE_URL="$2"; shift 2 ;;
-h|--help) usage ;;
*) echo "Unknown option: $1"; usage ;;
esac
done
if [ -z "$VERSION" ]; then
echo "Error: --version is required"
usage
fi
if [ -z "$RELEASE_DATE" ]; then
RELEASE_DATE=$(date +%Y-%m-%d)
fi
# Find project root
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
# Auto-detect backend binary
if [ -z "$BACKEND_BINARY" ]; then
BACKEND_BINARY="$PROJECT_ROOT/core/target/release/archipelago"
fi
# Auto-detect frontend archive.
# Layout: flat tarball (`./index.html`, `./assets/…`, `./aiui/…`) so the
# Rust updater can unpack it directly into /opt/archipelago/web-ui/.
# Using `-C web/dist neode-ui` would produce a `neode-ui/` prefix which
# breaks the installer and returns 403 on every fleet UI — see
# feedback_release_tarball_layout.md.
if [ -z "$FRONTEND_ARCHIVE" ]; then
FRONTEND_DIST="$PROJECT_ROOT/web/dist/neode-ui"
if [ -d "$FRONTEND_DIST" ]; then
FRONTEND_ARCHIVE="/tmp/archipelago-frontend-${VERSION}.tar.gz"
STAGING_DIR=$(mktemp -d -t archipelago-frontend.XXXXXX)
echo "Staging frontend archive in $STAGING_DIR..."
cp -r "$FRONTEND_DIST/." "$STAGING_DIR/"
# Bake AIUI in so fresh installs pick it up. OTA already
# carries-forward the existing aiui/ if the tarball lacks one
# (update.rs:922), but including it here makes the tarball
# the single source of truth instead of relying on a side-
# effect of the in-place swap.
if [ -d "$PROJECT_ROOT/demo/aiui" ] && [ -f "$PROJECT_ROOT/demo/aiui/index.html" ]; then
echo " Including AIUI from demo/aiui/"
cp -r "$PROJECT_ROOT/demo/aiui" "$STAGING_DIR/aiui"
fi
# OTA bridge for nodes running older updaters: they only know how to
# apply the backend binary and frontend archive. Carry host runtime
# assets inside the frontend tarball; the new backend promotes them
# from /opt/archipelago/web-ui/archipelago-runtime on first startup.
RUNTIME_DIR="$STAGING_DIR/archipelago-runtime"
mkdir -p "$RUNTIME_DIR"
for runtime_path in apps scripts docker; do
if [ -d "$PROJECT_ROOT/$runtime_path" ]; then
echo " Including runtime $runtime_path/"
cp -r "$PROJECT_ROOT/$runtime_path" "$RUNTIME_DIR/$runtime_path"
fi
done
# KEEP IN SYNC with the `for unit in [...]` array in
# core/archipelago/src/bootstrap.rs (run_runtime_assets). A unit that
# bootstrap installs but this list does not ship simply never reaches a
# node: bootstrap looks for it in the runtime payload, does not find
# 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
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"
fi
done
if [ -f "$PROJECT_ROOT/image-recipe/configs/nginx-archipelago.conf" ]; then
mkdir -p "$RUNTIME_DIR/image-recipe/configs"
echo " Including runtime nginx-archipelago.conf"
cp "$PROJECT_ROOT/image-recipe/configs/nginx-archipelago.conf" \
"$RUNTIME_DIR/image-recipe/configs/nginx-archipelago.conf"
fi
# Packaged radio tools ride the runtime payload: OTA-only nodes never
# get them any other way — the v1.7.117 rollout left fleet nodes with
# a stale archy-reticulum-daemon (exits on the new --enable-transport
# 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
if [ -f "$PROJECT_ROOT/reticulum-daemon/dist/$tool" ]; then
mkdir -p "$RUNTIME_DIR/radio-tools"
echo " Including radio tool $tool"
cp "$PROJECT_ROOT/reticulum-daemon/dist/$tool" "$RUNTIME_DIR/radio-tools/$tool"
else
echo " ERROR: reticulum-daemon/dist/$tool missing — run reticulum-daemon/build.sh first" >&2
rm -rf "$STAGING_DIR"
exit 1
fi
done
rm -rf "$RUNTIME_DIR/scripts/resilience/reports"
find "$RUNTIME_DIR" -type d -name '__pycache__' -prune -exec rm -rf {} +
find "$RUNTIME_DIR" -type f \( -name '*.bak' -o -name '*.bak-*' -o -name '._*' -o -name '*.log' -o -name '*.pyc' \) -delete
# Force world-readable perms on every entry BEFORE tar, so the
# archive's internal mode bits are 755/644 regardless of what
# the staging dir's umask gave us. Without this, mktemp -d
# creates the staging dir at 700, that 700 gets baked into the
# tarball's root `./` entry, and every node that extracts the
# archive ends up with /opt/archipelago/web-ui at 700 — which
# causes nginx (www-data) to return 500 "permission denied" on
# every page. Bit us on the v1.7.38 + v1.7.39 rollouts.
chmod 755 "$STAGING_DIR"
find "$STAGING_DIR" -type d -exec chmod 755 {} +
find "$STAGING_DIR" -type f -exec chmod 644 {} +
echo "Creating frontend archive $FRONTEND_ARCHIVE..."
# --mode is a belt-and-braces in case a file's on-disk perms
# drift again; forces 755 dir / 644 file in the archive too.
tar --owner=0 --group=0 \
--mode='u=rwX,go=rX' \
-czf "$FRONTEND_ARCHIVE" \
-C "$STAGING_DIR" .
# Verify the archive root entry is world-readable before we
# declare success — catches regressions in tar-flag handling
# (BSD tar, busybox tar) that might silently drop --mode.
# SIGPIPE-safe: use awk to read only the first line and exit,
# then terminate the tar pipeline explicitly so `pipefail`+SIGPIPE
# don't kill the whole `set -euo pipefail` script.
root_mode=$({ tar tvzf "$FRONTEND_ARCHIVE" 2>/dev/null || true; } | awk 'NR==1{print $1; exit}')
case "$root_mode" in
drwxr-xr-x|drwxr-x*x*)
echo " Tarball root perms OK: $root_mode"
;;
*)
echo " ERROR: tarball root perms are $root_mode (want drwxr-xr-x) — aborting release"
rm -f "$FRONTEND_ARCHIVE"
rm -rf "$STAGING_DIR"
exit 1
;;
esac
rm -rf "$STAGING_DIR"
fi
fi
# Compute SHA256 hash
sha256_of() {
if command -v sha256sum &>/dev/null; then
sha256sum "$1" | awk '{print $1}'
else
shasum -a 256 "$1" | awk '{print $1}'
fi
}
# File size in bytes
size_of() {
if [[ "$(uname)" == "Darwin" ]]; then
stat -f%z "$1"
else
stat -c%s "$1"
fi
}
# Get current version from Cargo.toml
CURRENT_VERSION=$(grep '^version' "$PROJECT_ROOT/core/archipelago/Cargo.toml" | head -1 | sed 's/.*"\(.*\)".*/\1/')
echo "Building release manifest v${VERSION}"
echo " Current version: ${CURRENT_VERSION}"
echo " Release date: ${RELEASE_DATE}"
echo " Output: ${OUTPUT_FILE}"
# Build components array
COMPONENTS="[]"
if [ -f "$BACKEND_BINARY" ]; then
HASH=$(sha256_of "$BACKEND_BINARY")
SIZE=$(size_of "$BACKEND_BINARY")
echo " Backend binary: ${BACKEND_BINARY} (${SIZE} bytes, sha256: ${HASH})"
COMPONENTS=$(echo "$COMPONENTS" | python3 -c "
import sys, json
c = json.load(sys.stdin)
c.append({
'name': 'archipelago',
'current_version': '$CURRENT_VERSION',
'new_version': '$VERSION',
'download_url': '$BASE_URL/v$VERSION/archipelago',
'sha256': '$HASH',
'size_bytes': $SIZE
})
print(json.dumps(c))
")
else
echo " Warning: Backend binary not found at $BACKEND_BINARY"
fi
if [ -n "$FRONTEND_ARCHIVE" ] && [ -f "$FRONTEND_ARCHIVE" ]; then
HASH=$(sha256_of "$FRONTEND_ARCHIVE")
SIZE=$(size_of "$FRONTEND_ARCHIVE")
ARCHIVE_NAME=$(basename "$FRONTEND_ARCHIVE")
echo " Frontend archive: ${FRONTEND_ARCHIVE} (${SIZE} bytes, sha256: ${HASH})"
COMPONENTS=$(echo "$COMPONENTS" | python3 -c "
import sys, json
c = json.load(sys.stdin)
c.append({
'name': '$ARCHIVE_NAME',
'current_version': '$CURRENT_VERSION',
'new_version': '$VERSION',
'download_url': '$BASE_URL/v$VERSION/$ARCHIVE_NAME',
'sha256': '$HASH',
'size_bytes': $SIZE
})
print(json.dumps(c))
")
else
echo " Warning: Frontend archive not found"
fi
# Read changelog from CHANGELOG.md if available
CHANGELOG="[]"
CHANGELOG_FILE="$PROJECT_ROOT/CHANGELOG.md"
if [ -f "$CHANGELOG_FILE" ]; then
# Extract entries for this version (lines between ## vVERSION and next ##)
ENTRIES=$(python3 -c "
import re, sys
content = open('$CHANGELOG_FILE').read()
pattern = r'## .*?${VERSION}.*?\n(.*?)(?=\n## |\Z)'
m = re.search(pattern, content, re.DOTALL)
if m:
for line in m.group(1).strip().split('\n')[:10]:
line = line.strip()
if line:
print(line)
" 2>/dev/null || echo "")
if [ -n "$ENTRIES" ]; then
CHANGELOG=$(echo "$ENTRIES" | python3 -c "
import sys, json
lines = [l.strip().lstrip('- ') for l in sys.stdin if l.strip()]
print(json.dumps(lines))
")
fi
fi
# If no changelog entries found, add a default
if [ "$CHANGELOG" = "[]" ]; then
CHANGELOG="[\"Update to version ${VERSION}\"]"
fi
# Generate manifest
python3 -c "
import json
manifest = {
'version': '$VERSION',
'release_date': '$RELEASE_DATE',
'changelog': $CHANGELOG,
'components': $COMPONENTS
}
print(json.dumps(manifest, indent=2))
" > "$OUTPUT_FILE"
echo ""
echo "Manifest written to: $OUTPUT_FILE"
echo ""
cat "$OUTPUT_FILE"
echo ""
echo "Next steps:"
echo " 1. Review the manifest above"
echo " 2. Upload artifacts to Gitea release v$VERSION"
echo " 3. Commit manifest.json to releases/manifest.json on main"
echo " 4. Tag the release: git tag v$VERSION && git push --tags"
+296
View File
@@ -0,0 +1,296 @@
#!/usr/bin/env bash
# create-release.sh — Full release automation for Archipelago
#
# Bumps version in Cargo.toml and package.json, generates changelog from git log,
# creates release manifest, and creates git tag.
#
# Usage:
# ./scripts/create-release.sh 1.0.0 # Release v1.0.0
# ./scripts/create-release.sh 1.0.0 --dry-run # Preview without changes
#
# Releases are tarball-only. ISO builds are archived under
# image-recipe/_archived/. Nodes OTA-update from releases/manifest.json.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
DRY_RUN=false
VERSION=""
# Parse args
for arg in "$@"; do
case "$arg" in
--dry-run) DRY_RUN=true ;;
--help|-h)
echo "Usage: $0 VERSION [--dry-run]"
echo ""
echo "Steps performed:"
echo " 1. Validate version format (SemVer)"
echo " 2. Bump version in Cargo.toml and package.json"
echo " 3. Build backend"
echo " 4. Build frontend"
echo " 5. Generate changelog from git log"
echo " 6. Create release manifest"
echo " 7. Commit version bump"
echo " 8. Create git tag v{VERSION}"
echo ""
echo "Options:"
echo " --dry-run Show what would be done without making changes"
exit 0
;;
*)
if [ -z "$VERSION" ]; then
VERSION="$arg"
else
echo "Error: Unknown argument: $arg"
exit 1
fi
;;
esac
done
if [ -z "$VERSION" ]; then
echo "Error: VERSION argument required"
echo "Usage: $0 VERSION [--dry-run]"
exit 1
fi
# Validate SemVer format
if ! echo "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?$'; then
echo "Error: Version '$VERSION' is not valid SemVer (expected: X.Y.Z or X.Y.Z-suffix)"
exit 1
fi
# Check we're on main branch
BRANCH=$(git -C "$PROJECT_ROOT" branch --show-current)
if [ "$BRANCH" != "main" ]; then
echo "Error: Must be on 'main' branch (currently on '$BRANCH')"
exit 1
fi
# Check for uncommitted changes
if ! git -C "$PROJECT_ROOT" diff --quiet HEAD; then
echo "Error: Uncommitted changes detected. Commit or stash first."
exit 1
fi
# ── Pre-flight test gate ──────────────────────────────────────────────
# A release must not ship if the static/frontend/backend checks fail. This
# runs the release gate harness (cargo fmt/check, catalog drift, vitest, and
# the focused cargo suites — incl. the receive/port-drift/secret regressions).
# Skipped on --dry-run, or set SKIP_RELEASE_TESTS=1 to bypass in an emergency.
# The lifecycle bats harness (tests/lifecycle/run-gate.sh) still runs separately
# against live nodes — see tests/lifecycle/TESTING.md.
if ! $DRY_RUN; then
if [ "${SKIP_RELEASE_TESTS:-0}" = "1" ]; then
echo "WARNING: SKIP_RELEASE_TESTS=1 — bypassing the pre-flight test gate"
elif [ -x "$PROJECT_ROOT/tests/release/run.sh" ]; then
echo "[0/7] Running release gate (tests/release/run.sh)..."
if ! "$PROJECT_ROOT/tests/release/run.sh"; then
echo "Error: release gate failed — aborting release. Fix the failing"
echo " stage, or re-run with SKIP_RELEASE_TESTS=1 to override."
exit 1
fi
else
echo "WARNING: tests/release/run.sh not found/executable — skipping test gate"
fi
fi
# Check tag doesn't already exist
if git -C "$PROJECT_ROOT" tag -l "v$VERSION" | grep -q "v$VERSION"; then
echo "Error: Tag v$VERSION already exists"
exit 1
fi
# Get current version
CURRENT_CARGO_VERSION=$(grep '^version' "$PROJECT_ROOT/core/archipelago/Cargo.toml" | head -1 | sed 's/.*"\(.*\)".*/\1/')
CURRENT_NPM_VERSION=$(node -p "require('$PROJECT_ROOT/neode-ui/package.json').version")
echo "=== Archipelago Release v${VERSION} ==="
echo " Current Cargo version: ${CURRENT_CARGO_VERSION}"
echo " Current npm version: ${CURRENT_NPM_VERSION}"
echo " Target version: ${VERSION}"
echo " Dry run: ${DRY_RUN}"
echo ""
if $DRY_RUN; then
echo "[DRY RUN] Would perform the following:"
echo " 0. Run pre-flight test gate (tests/release/run.sh) — aborts on failure"
echo " 1. Update core/archipelago/Cargo.toml version to $VERSION"
echo " 2. Update neode-ui/package.json version to $VERSION"
echo " 3. Build backend (cargo build --release -p archipelago)"
echo " 4. Build frontend (npm run build)"
echo " 5. Generate changelog from git log since v${CURRENT_CARGO_VERSION}"
echo " 6. Create release manifest"
echo " 7. Commit: 'chore: release v${VERSION}'"
echo " 8. Tag: v${VERSION}"
echo ""
echo "After this script, you would:"
echo " - Push: git push && git push --tags"
echo " - Build ISOs on server: ssh archipelago@192.0.2.10"
exit 0
fi
echo "[1/7] Bumping version in Cargo.toml..."
# Update archipelago Cargo.toml
sed -i.bak "s/^version = \".*\"/version = \"$VERSION\"/" "$PROJECT_ROOT/core/archipelago/Cargo.toml"
rm -f "$PROJECT_ROOT/core/archipelago/Cargo.toml.bak"
# Also update workspace Cargo.lock if it exists
if [ -f "$PROJECT_ROOT/core/Cargo.lock" ]; then
# Cargo will update the lock file on next build; touch the toml to trigger
true
fi
echo "[2/7] Bumping version in package.json..."
cd "$PROJECT_ROOT/neode-ui"
npm version "$VERSION" --no-git-tag-version --allow-same-version 2>/dev/null || true
cd "$PROJECT_ROOT"
echo "[3/8] Building backend..."
cd "$PROJECT_ROOT/core"
cargo build --release -p archipelago
cd "$PROJECT_ROOT"
echo "[4/8] Building frontend..."
cd "$PROJECT_ROOT/neode-ui"
npm run build 2>&1 | tail -3
cd "$PROJECT_ROOT"
# npm run build can silently no-op (vue-tsc EACCES burned us before) — a stale
# dist would ship with a perfectly valid sha256. Require the freshly built
# bundle to embed the version we just bumped to before it gets packaged.
if ! grep -rqo "${VERSION}" "$PROJECT_ROOT"/web/dist/neode-ui/assets/*.js; then
echo "Error: web/dist/neode-ui does not contain v${VERSION} — the frontend" >&2
echo " build no-opped or its output is stale. Aborting release." >&2
exit 1
fi
echo "[4b/8] Building packaged radio tools (archy-reticulum-daemon, archy-rnodeconf)..."
# These ride the frontend tarball's runtime payload (radio-tools/) and are
# promoted to /usr/local/bin by bootstrap.rs — the ONLY path that updates them
# on OTA-only nodes. Stale-dist releases re-broke fleet mesh once (v1.7.117),
# so always rebuild here; the manifest script hard-fails if they're missing.
(cd "$PROJECT_ROOT/reticulum-daemon" && ./build.sh) || {
echo "Error: reticulum-daemon/build.sh failed — radio tools are release-critical" >&2
exit 1
}
echo "[5/8] Validating curated changelog..."
CHANGELOG_FILE="$PROJECT_ROOT/CHANGELOG.md"
RELEASE_DATE=$(date +%Y-%m-%d)
if [ ! -f "$CHANGELOG_FILE" ] || ! grep -q "^## v${VERSION} (" "$CHANGELOG_FILE"; then
echo "Error: CHANGELOG.md must already contain curated notes for v${VERSION}."
echo "Add a section like:"
echo ""
echo "## v${VERSION} (${RELEASE_DATE})"
echo ""
echo "- User/operator-facing change ..."
echo "- Another concrete change ..."
echo "- Validation or operational note ..."
exit 1
fi
echo "[6/8] Creating release manifest..."
mkdir -p "$PROJECT_ROOT/releases"
"$SCRIPT_DIR/create-release-manifest.sh" --version "$VERSION" --date "$RELEASE_DATE" --output "$PROJECT_ROOT/releases/manifest.json" 2>&1 | grep -v "^$"
# §A supply-chain: the OTA manifest must carry the release-root signature.
# Nodes refuse to AUTO-apply unsigned manifests, and publish-release-assets.sh
# hard-refuses to ship one. The mnemonic is read interactively (or from
# RELEASE_MASTER_MNEMONIC) — it must never land in files or shell history.
SIGNER="$PROJECT_ROOT/core/target/release/archipelago"
if [ ! -x "$SIGNER" ]; then
echo "Error: release binary not found at $SIGNER — cannot sign manifest" >&2
exit 1
fi
if [ -n "${RELEASE_MASTER_MNEMONIC:-}" ] || [ -t 0 ]; then
echo "[6b/8] Signing release manifest (paste the release master mnemonic when prompted)..."
"$SIGNER" ceremony sign "$PROJECT_ROOT/releases/manifest.json"
"$SIGNER" ceremony verify "$PROJECT_ROOT/releases/manifest.json"
else
echo "⚠ WARNING: no TTY and RELEASE_MASTER_MNEMONIC unset — manifest left UNSIGNED."
echo " This run will ABORT before committing (step 7 refuses an unsigned"
echo " manifest), because nodes read releases/manifest.json from branch main"
echo " and would refuse to auto-apply it."
echo " Sign it, then re-run: bash scripts/sign-manifest.sh"
fi
cp "$PROJECT_ROOT/releases/manifest.json" "$PROJECT_ROOT/release-manifest.json"
echo "[6c/8] Staging release artifacts for validation..."
VERSION_DIR="$PROJECT_ROOT/releases/v${VERSION}"
FRONTEND_ARCHIVE="/tmp/archipelago-frontend-${VERSION}.tar.gz"
mkdir -p "$VERSION_DIR"
install -m 0755 "$PROJECT_ROOT/core/target/release/archipelago" "$VERSION_DIR/archipelago"
install -m 0644 "$FRONTEND_ARCHIVE" "$VERSION_DIR/archipelago-frontend-${VERSION}.tar.gz"
"$SCRIPT_DIR/check-release-manifest.sh"
# §A supply-chain gate, mirroring publish-release-assets.sh — but EARLIER,
# because publishing is not the first way an unsigned manifest reaches the
# fleet. Nodes fetch releases/manifest.json straight from branch `main`
# (see the verification URLs printed below), so the COMMIT is what exposes
# it, not the publish. publish-release-assets.sh refusing to ship is a
# backstop that arrives one step too late: by then the unsigned manifest is
# already on main and the fleet is already refusing to auto-apply.
#
# This is why every cycle needed a manual catch. The signing block above is
# conditional — no TTY and no RELEASE_MASTER_MNEMONIC means it prints a
# warning and falls through — and the commit then happened anyway. A release
# commit carrying a manifest no node will accept has no valid use, so refuse
# to create one rather than leave a tag that has to be re-cut.
# Release root ROTATED 2026-08-05. v1.7.122-alpha was the last release signed
# with the old root (z6Mkkid…q7ur) — it is the release that installed this
# pin on every node. From v1.7.123 onward the new root signs, and nodes
# running .122+ reject anything signed with the old key.
EXPECTED_DID="did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT"
if ! grep -q '"signature":' "$PROJECT_ROOT/releases/manifest.json" \
|| ! grep -q "\"signed_by\": \"$EXPECTED_DID\"" "$PROJECT_ROOT/releases/manifest.json"; then
echo "" >&2
echo "Error: releases/manifest.json is NOT signed by the release root." >&2
echo " Refusing to commit — nodes read this file from branch main and will" >&2
echo " refuse to auto-apply it, so the release would be dead on arrival." >&2
echo "" >&2
echo " Sign it, then re-run this script:" >&2
echo " bash scripts/sign-manifest.sh" >&2
echo "" >&2
echo " (Signing needs a TTY for the mnemonic prompt, or RELEASE_MASTER_MNEMONIC set.)" >&2
exit 1
fi
"$SIGNER" ceremony verify "$PROJECT_ROOT/releases/manifest.json" \
|| { echo "Error: manifest signature failed cryptographic verification — refusing to commit" >&2; exit 1; }
echo "[7/8] Committing version bump..."
git -C "$PROJECT_ROOT" add \
core/archipelago/Cargo.toml \
neode-ui/package.json \
neode-ui/package-lock.json \
CHANGELOG.md \
releases/manifest.json \
release-manifest.json \
2>/dev/null || true
git -C "$PROJECT_ROOT" commit -m "chore: release v${VERSION}"
echo "[8/8] Creating git tag..."
git -C "$PROJECT_ROOT" tag -a "v${VERSION}" -m "Release v${VERSION}"
echo ""
echo "=== Release v${VERSION} Ready ==="
echo ""
echo "Artifacts:"
echo " - Version bumped in Cargo.toml and package.json"
echo " - Changelog updated in CHANGELOG.md"
echo " - Release manifest: releases/manifest.json"
echo " - Release manifest copy: release-manifest.json"
echo " - Staged artifacts: releases/v${VERSION}/"
echo " - Git tag: v${VERSION}"
echo ""
echo "Next steps:"
echo " 1. Review: git log --oneline -5"
echo " 2. Publish commits, tag, artifacts, and verify download URLs:"
echo " scripts/publish-release-assets.sh ${VERSION} gitea-vps2"
echo " 3. Verify manifest is live on both mirrors:"
echo " curl -fsS http://localhost:3000/lfg2025/archy/raw/branch/main/releases/manifest.json"
echo " curl -fsS https://source.archipelago-foundation.org/lfg2025/archy/raw/branch/main/releases/manifest.json"
+136
View File
@@ -0,0 +1,136 @@
#!/bin/bash
#
# Complete Bitcoin Knots Deployment for Archipelago
# This script deploys Bitcoin Knots with a working web UI
#
# For production/beta releases, this needs to be captured in the auto-installer
# or provided as a one-click install in the App Store
#
set -e
# Source pinned image versions (single source of truth)
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
[ -f "$SCRIPT_DIR/image-versions.sh" ] && . "$SCRIPT_DIR/image-versions.sh"
# Read per-installation Bitcoin RPC credentials
SECRETS_DIR="/var/lib/archipelago/secrets"
sudo mkdir -p "$SECRETS_DIR" && sudo chmod 700 "$SECRETS_DIR"
if [ ! -f "$SECRETS_DIR/bitcoin-rpc-password" ]; then
openssl rand -base64 24 | sudo tee "$SECRETS_DIR/bitcoin-rpc-password" > /dev/null
sudo chmod 600 "$SECRETS_DIR/bitcoin-rpc-password"
fi
BITCOIN_RPC_USER="archipelago"
BITCOIN_RPC_PASS=$(sudo cat "$SECRETS_DIR/bitcoin-rpc-password")
echo "╔════════════════════════════════════════════════════════════════╗"
echo "║ Deploying Bitcoin Knots with Web UI ║"
echo "╚════════════════════════════════════════════════════════════════╝"
echo ""
# Step 1: Create data directory
echo "📁 Creating Bitcoin data directory..."
sudo mkdir -p /var/lib/archipelago/bitcoin
echo " ✅ Directory created"
# Step 2: Deploy Bitcoin Knots node
echo ""
echo "₿ Deploying Bitcoin Knots node..."
podman run -d \
--name bitcoin-knots \
--restart unless-stopped \
-p 8332:8332 \
-p 8333:8333 \
-v /var/lib/archipelago/bitcoin:/home/bitcoin/.bitcoin \
--label "com.archipelago.app=bitcoin-knots" \
--label "com.archipelago.title=Bitcoin Knots" \
--label "com.archipelago.version=28.1" \
--label "com.archipelago.category=bitcoin" \
--label "com.archipelago.description.short=Full Bitcoin node implementation" \
--label "com.archipelago.description.long=Bitcoin Knots is a derivative of Bitcoin Core with additional features and bug fixes. Maintain the full blockchain and validate all transactions." \
--label "com.archipelago.license=MIT" \
--label "com.archipelago.icon=/assets/img/app-icons/bitcoin-knots.webp" \
--label "com.archipelago.port=8332" \
--label "com.archipelago.repo=https://github.com/bitcoinknots/bitcoin" \
"${BITCOIN_KNOTS_IMAGE}" \
-server=1 \
-txindex=1 \
-rpcallowip=127.0.0.1/32 -rpcallowip=10.88.0.0/16 \
-rpcbind=0.0.0.0:8332 \
-rpcuser=archipelago \
-rpcpassword=$BITCOIN_RPC_PASS \
-dbcache=4096
echo " ✅ Bitcoin Knots node starting"
# Step 3: Build and deploy web UI
echo ""
echo "🌐 Building Bitcoin Knots web UI..."
# Create temporary build directory
BUILD_DIR="/tmp/bitcoin-ui-build"
rm -rf "$BUILD_DIR"
mkdir -p "$BUILD_DIR"
# Create Dockerfile
cat > "$BUILD_DIR/Dockerfile" << 'EOF'
FROM ${NGINX_ALPINE_IMAGE:-source.archipelago-foundation.org/lfg2025/nginx:1.29.6-alpine}
# Copy the static UI
COPY index.html /usr/share/nginx/html/
# Create assets directories
RUN mkdir -p /usr/share/nginx/html/assets/img/app-icons && \
mkdir -p /usr/share/nginx/html/assets/img
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
EOF
# Copy UI file from the project
# For beta: this needs to be included in the ISO or downloadable
cp /home/archipelago/archy/docker/bitcoin-ui/index.html "$BUILD_DIR/"
# Build the image
podman build -t localhost/bitcoin-ui:local "$BUILD_DIR"
# Deploy UI container
podman run -d \
--name bitcoin-ui \
--restart unless-stopped \
-p 8334:80 \
--label "com.archipelago.app=bitcoin-ui" \
--label "com.archipelago.parent=bitcoin-knots" \
localhost/bitcoin-ui:local
echo " ✅ Bitcoin UI deployed on port 8334"
# Cleanup
rm -rf "$BUILD_DIR"
# Step 4: Wait for backend to detect
echo ""
echo "⏳ Waiting for backend to detect containers..."
sleep 5
echo ""
echo "╔════════════════════════════════════════════════════════════════╗"
echo "║ ✅ BITCOIN KNOTS DEPLOYED! ║"
echo "╚════════════════════════════════════════════════════════════════╝"
echo ""
echo "📊 Status:"
podman ps | grep bitcoin
echo ""
echo "🌐 Access:"
echo " • Web UI: http://YOUR-SERVER-IP:8334"
echo " • RPC: http://localhost:8332"
echo " • Network: Port 8333 (Bitcoin P2P)"
echo ""
echo "📝 RPC Credentials:"
echo " • User: archipelago"
echo " • Pass: (stored in /var/lib/archipelago/secrets/bitcoin-rpc-password)"
echo ""
echo "⏰ Blockchain sync will take several hours to days."
echo " Check progress: podman logs -f bitcoin-knots"
echo ""
+11
View File
@@ -0,0 +1,11 @@
# Deploy config (copy to deploy-config.sh and set your password)
# deploy-config.sh is gitignored so the password is not committed.
#
# cp scripts/deploy-config.example scripts/deploy-config.sh
# Edit deploy-config.sh and set ARCHIPELAGO_PASSWORD
#
export ARCHIPELAGO_PASSWORD='your_password_here'
# Optional: central beta telemetry collector RPC endpoint.
# The reporter sends telemetry.ingest JSON-RPC requests here when users opt in.
# export TELEMETRY_COLLECTOR_URL='https://YOUR-COLLECTOR-HOST/rpc/v1'
+264
View File
@@ -0,0 +1,264 @@
#!/bin/bash
#
# Container Orchestration Dev Loop
# Fast edit-build-test cycle against real containers on .228
#
# Usage:
# ./scripts/dev-container-test.sh # Interactive loop
# ./scripts/dev-container-test.sh --once # Single run (for CI)
#
# Workflow: edit locally → rsync → build on server → restart → smoke test
#
set -o pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
SSH_KEY="${ARCHIPELAGO_SSH_KEY:-$HOME/.ssh/archipelago-deploy}"
SSH_HOST="${ARCHIPELAGO_SSH_HOST:-}"
if [ -z "$SSH_HOST" ]; then
echo "ARCHIPELAGO_SSH_HOST must be set, e.g. archipelago@<node-host>" >&2
exit 2
fi
HOST_ONLY="${SSH_HOST#*@}"
SSH_OPTS="-o StrictHostKeyChecking=no -o ServerAliveInterval=15 -i $SSH_KEY"
REMOTE_DIR="/home/archipelago/archy"
RPC_URL="http://${HOST_ONLY}/rpc/v1"
COOKIE=""
ONCE=false
[ "$1" = "--once" ] && ONCE=true
# ── Colors ──────────────────────────────────────────────────────────────
RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[0;33m'
CYAN='\033[0;36m' BOLD='\033[1m' NC='\033[0m'
pass() { echo -e " ${GREEN}${NC} $*"; }
fail() { echo -e " ${RED}${NC} $*"; FAILURES=$((FAILURES + 1)); }
info() { echo -e " ${CYAN}${NC} $*"; }
header() { echo -e "\n${BOLD}$*${NC}"; }
TESTS=0
FAILURES=0
# ── Helpers ─────────────────────────────────────────────────────────────
rpc() {
local method="$1"
local params="${2:-{}}"
local result
result=$(curl -sf -b "$COOKIE" -X POST "$RPC_URL" \
-H "Content-Type: application/json" \
-d "{\"jsonrpc\":\"2.0\",\"method\":\"$method\",\"params\":$params,\"id\":1}" \
--connect-timeout 10 --max-time 30 2>/dev/null)
echo "$result"
}
login() {
# Get session cookie
COOKIE=$(mktemp)
local resp
resp=$(curl -sf -c "$COOKIE" -X POST "$RPC_URL" \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","method":"auth.login","params":{"password":"password123"},"id":1}' \
--connect-timeout 10 2>/dev/null)
if echo "$resp" | grep -q '"result"'; then
return 0
fi
return 1
}
wait_for_health() {
local timeout=${1:-30}
for i in $(seq 1 "$timeout"); do
if curl -sf "http://${HOST_ONLY}/health" >/dev/null 2>&1; then
return 0
fi
sleep 1
done
return 1
}
# ── Sync & Build ────────────────────────────────────────────────────────
sync_and_build() {
header "Step 1: Sync code to .228"
rsync -az --delete \
--exclude='.git' --exclude='target' --exclude='node_modules' \
--exclude='dist' --exclude='*.iso' --exclude='.claude' \
-e "ssh $SSH_OPTS" \
"$PROJECT_ROOT/" "$SSH_HOST:$REMOTE_DIR/" 2>&1
pass "Code synced"
header "Step 2: Build backend (incremental)"
local build_start=$(date +%s)
if ssh $SSH_OPTS "$SSH_HOST" "cd $REMOTE_DIR/core && cargo build --release -p archipelago 2>&1 | tail -3"; then
local elapsed=$(( $(date +%s) - build_start ))
pass "Built in ${elapsed}s"
else
fail "Build failed"
return 1
fi
header "Step 3: Restart service"
ssh $SSH_OPTS "$SSH_HOST" "sudo systemctl restart archipelago"
info "Waiting for health..."
if wait_for_health 30; then
pass "Backend healthy"
else
fail "Backend failed to start (30s timeout)"
ssh $SSH_OPTS "$SSH_HOST" "journalctl -u archipelago --since '30 sec ago' --no-pager | tail -20"
return 1
fi
}
# ── Smoke Tests ─────────────────────────────────────────────────────────
run_smoke_tests() {
header "Step 4: Container Orchestration Smoke Tests"
TESTS=0
FAILURES=0
# Login
if login; then
pass "Authenticated"
else
fail "Login failed"
return 1
fi
# Test 1: Container list
TESTS=$((TESTS + 1))
local list
list=$(rpc "container.list")
if echo "$list" | grep -q '"result"'; then
local count
count=$(echo "$list" | python3 -c "import sys,json; print(len(json.load(sys.stdin).get('result',{}).get('containers',[])))" 2>/dev/null || echo "?")
pass "container.list: $count containers"
else
fail "container.list failed"
fi
# Test 2: Health status
TESTS=$((TESTS + 1))
local health
health=$(rpc "container.health")
if echo "$health" | grep -q '"result"'; then
pass "container.health: OK"
else
fail "container.health failed"
fi
# Test 3: Install a lightweight container (filebrowser — small, fast, no deps)
TESTS=$((TESTS + 1))
local install_img="source.archipelago-foundation.org/lfg2025/filebrowser:v2.27.0"
# Check if already installed
local fb_state
fb_state=$(ssh $SSH_OPTS "$SSH_HOST" "podman inspect filebrowser --format '{{.State.Status}}' 2>/dev/null || echo 'none'")
if [ "$fb_state" = "none" ]; then
info "Installing filebrowser..."
local install_result
install_result=$(rpc "package.install" "{\"id\":\"filebrowser\",\"dockerImage\":\"$install_img\"}")
if echo "$install_result" | grep -q '"success"'; then
pass "package.install filebrowser: success"
else
fail "package.install filebrowser: $(echo "$install_result" | python3 -c 'import sys,json; print(json.load(sys.stdin).get("error",{}).get("message","unknown"))' 2>/dev/null)"
fi
else
pass "filebrowser already installed ($fb_state)"
fi
# Test 4: Stop with grace period
TESTS=$((TESTS + 1))
local stop_result
stop_result=$(rpc "package.stop" '{"id":"filebrowser"}')
sleep 2
fb_state=$(ssh $SSH_OPTS "$SSH_HOST" "podman inspect filebrowser --format '{{.State.Status}}' 2>/dev/null || echo 'unknown'")
if [ "$fb_state" = "exited" ] || [ "$fb_state" = "stopped" ]; then
pass "package.stop: filebrowser → $fb_state"
else
fail "package.stop: expected stopped, got $fb_state"
fi
# Test 5: Start
TESTS=$((TESTS + 1))
rpc "package.start" '{"id":"filebrowser"}' >/dev/null
sleep 3
fb_state=$(ssh $SSH_OPTS "$SSH_HOST" "podman inspect filebrowser --format '{{.State.Status}}' 2>/dev/null || echo 'unknown'")
if [ "$fb_state" = "running" ]; then
pass "package.start: filebrowser → running"
else
fail "package.start: expected running, got $fb_state"
fi
# Test 6: Restart tracker persisted
TESTS=$((TESTS + 1))
local tracker
tracker=$(ssh $SSH_OPTS "$SSH_HOST" "cat /var/lib/archipelago/restart-tracker.json 2>/dev/null")
if [ -n "$tracker" ] && echo "$tracker" | python3 -c "import sys,json; json.load(sys.stdin)" 2>/dev/null; then
pass "restart-tracker.json: valid JSON"
else
pass "restart-tracker.json: empty (no failures — healthy)"
fi
# Test 7: Systemd timers active
TESTS=$((TESTS + 1))
local timers
timers=$(ssh $SSH_OPTS "$SSH_HOST" "systemctl list-timers --no-pager 2>/dev/null | grep -c archipelago")
if [ "${timers:-0}" -ge 2 ]; then
pass "Systemd timers: $timers active (doctor + reconcile)"
else
fail "Systemd timers: expected ≥2, got ${timers:-0}"
fi
# Test 8: Container doctor runs cleanly
TESTS=$((TESTS + 1))
local doctor_exit
ssh $SSH_OPTS "$SSH_HOST" "sudo /home/archipelago/archy/scripts/container-doctor.sh --local 2>&1 | tail -1"
doctor_exit=$?
if [ $doctor_exit -eq 0 ]; then
pass "container-doctor.sh: clean exit"
else
fail "container-doctor.sh: exit code $doctor_exit"
fi
# Summary
header "Results"
local passed=$((TESTS - FAILURES))
echo -e " ${GREEN}$passed passed${NC} / ${RED}$FAILURES failed${NC} / $TESTS total"
# Cleanup temp cookie
rm -f "$COOKIE" 2>/dev/null
return $FAILURES
}
# ── Main ────────────────────────────────────────────────────────────────
echo ""
echo "╔════════════════════════════════════════════════════════════════╗"
echo "║ Container Orchestration Dev Loop ║"
echo "╚════════════════════════════════════════════════════════════════╝"
echo ""
info "Target: $SSH_HOST"
info "Mode: $($ONCE && echo 'single run' || echo 'interactive loop')"
echo ""
# Check SSH
if ! ssh $SSH_OPTS "$SSH_HOST" "echo ok" >/dev/null 2>&1; then
fail "Cannot SSH to $SSH_HOST"
exit 1
fi
if $ONCE; then
sync_and_build && run_smoke_tests
exit $?
fi
# Interactive loop
while true; do
sync_and_build && run_smoke_tests
echo ""
echo -e "${YELLOW}Press Enter to re-sync + re-test, Ctrl+C to stop${NC}"
read -r
done
+409
View File
@@ -0,0 +1,409 @@
#!/bin/bash
# Archipelago Development Server Starter
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
FRONTEND_DIR="$PROJECT_ROOT/neode-ui"
BACKEND_DIR="$PROJECT_ROOT/core"
# Quietly kill a port — avoids EAGAIN by not piping through xargs
kill_port() {
local pids
pids=$(lsof -ti:"$1" 2>/dev/null) || true
if [ -n "$pids" ]; then
echo "$pids" | while read -r pid; do
kill -9 "$pid" 2>/dev/null || true
done
sleep 1
fi
}
cleanup_ports() {
kill_port 5959
kill_port 8100
}
ensure_deps() {
cd "$FRONTEND_DIR"
if [ ! -d "node_modules" ]; then
echo " Installing dependencies..."
npm install
fi
}
if [ ! -d "$FRONTEND_DIR" ]; then
echo "Frontend directory not found: $FRONTEND_DIR"
exit 1
fi
echo ""
echo "Archipelago Dev Server"
echo ""
# Detect if running on a Linux dev machine (production-like mode available)
IS_LINUX=false
if [[ "$OSTYPE" == "linux"* ]]; then
IS_LINUX=true
fi
echo " 0) Boot branding dev (GRUB theme, Plymouth, installer — patch + QEMU)"
echo " 1) Mock backend (UI dev — fastest, no Docker/Podman needed)"
echo " 2) Full stack (Rust backend + frontend)"
echo " 3) Setup mode (first-time password setup — mock)"
echo " 4) Onboarding mode (onboarding flow — mock)"
echo " 5) Existing user (login screen — mock)"
echo " 6) Boot mode (simulated 25s startup — mock)"
echo " 7) Testnet stack (signet Bitcoin + LND + ThunderHub via Podman)"
echo " 8) Manual instructions"
echo " 9) Container orchestration dev (live testing on .228)"
if [ "$IS_LINUX" = true ]; then
echo " 10) Production build (Linux only — build, install, restart all services)"
echo " Mirrors ISO exactly: backend + frontend + Tor + WG + NostrVPN + nginx"
fi
echo ""
read -p "Enter choice [0-10]: " choice
case $choice in
0)
echo ""
echo "Boot Branding Dev"
echo ""
# Find an ISO to patch
ISO=$(ls -t ~/Desktop/archipelago-dev-*.iso 2>/dev/null | head -1)
if [ -z "$ISO" ]; then
ISO=$(ls -t "$PROJECT_ROOT/image-recipe/results/archipelago-"*.iso 2>/dev/null | head -1)
fi
DEV_BRANDING="$PROJECT_ROOT/image-recipe/dev-branding.sh"
if [ -z "$ISO" ] || [ ! -f "$ISO" ]; then
echo " No ISO found to patch. Options:"
echo ""
echo " a) Preview GRUB background only (instant):"
echo " python3 image-recipe/branding/generate-grub-background.py /tmp/grub-bg.png && open /tmp/grub-bg.png"
echo ""
echo " b) Download an ISO from FileBrowser (http://192.0.2.10:8083)"
echo " then drop it on your Desktop and re-run this option."
echo ""
echo " Files you can edit:"
echo " image-recipe/branding/grub-theme/background.png — GRUB boot background"
echo " image-recipe/branding/grub-theme/theme.txt — GRUB menu colors/layout"
echo " image-recipe/branding/plymouth-theme/logo.png — Plymouth boot logo"
echo " image-recipe/branding/plymouth-theme/*.script — Plymouth animation"
echo ""
exit 0
fi
echo " ISO: $ISO"
echo " Edit these files, then this script patches and boots in QEMU:"
echo " branding/grub-theme/background.png — GRUB background"
echo " branding/grub-theme/theme.txt — GRUB menu theme"
echo " branding/plymouth-theme/logo.png — Plymouth logo"
echo ""
if [ -f "$DEV_BRANDING" ]; then
exec bash "$DEV_BRANDING" "$ISO"
else
echo " dev-branding.sh not found at: $DEV_BRANDING"
exit 1
fi
;;
1)
echo ""
echo "Starting frontend with mock backend..."
cleanup_ports
ensure_deps
exec npm run dev:mock
;;
2)
echo ""
echo "Starting full stack (Rust backend + frontend)..."
cleanup_ports
if [ ! -d "$BACKEND_DIR" ]; then
echo "Backend directory not found: $BACKEND_DIR"
exit 1
fi
cd "$BACKEND_DIR"
if ! cargo check --bin archipelago > /tmp/archipelago-backend-check.log 2>&1; then
echo "Backend build check failed. See /tmp/archipelago-backend-check.log"
echo "Falling back to mock backend."
ensure_deps
exec npm run dev:mock
fi
echo " Starting Rust backend..."
export ARCHIPELAGO_DATA_DIR=/tmp/archipelago-dev
export ARCHIPELAGO_DEV_DATA_DIR=/tmp/archipelago-dev
export ARCHIPELAGO_DEV_MODE=true
export ARCHIPELAGO_BIND=127.0.0.1:5959
export ARCHIPELAGO_LOG_LEVEL=debug
export ARCHIPELAGO_BITCOIN_SIMULATION=mock
cargo run --bin archipelago > /tmp/archipelago-backend.log 2>&1 &
BACKEND_PID=$!
echo " Backend PID: $BACKEND_PID (logs: /tmp/archipelago-backend.log)"
echo " Waiting for backend on port 5959..."
for i in $(seq 1 60); do
if lsof -ti:5959 >/dev/null 2>&1; then break; fi
sleep 1
done
if ! lsof -ti:5959 >/dev/null 2>&1; then
echo "Backend did not start. Falling back to mock."
kill "$BACKEND_PID" 2>/dev/null || true
ensure_deps
exec npm run dev:mock
fi
echo " Backend ready."
trap "kill $BACKEND_PID 2>/dev/null" EXIT
ensure_deps
exec npm run dev
;;
3)
echo ""
echo "Starting setup mode..."
cleanup_ports
ensure_deps
VITE_DEV_MODE=setup exec npm run dev:mock
;;
4)
echo ""
echo "Starting onboarding mode..."
cleanup_ports
ensure_deps
VITE_DEV_MODE=onboarding exec npm run dev:mock
;;
5)
echo ""
echo "Starting existing user mode..."
cleanup_ports
ensure_deps
VITE_DEV_MODE=existing exec npm run dev:mock
;;
6)
echo ""
echo "Starting boot mode (25s simulated startup)..."
cleanup_ports
ensure_deps
VITE_DEV_MODE=boot exec npm run dev:mock
;;
7)
echo ""
echo "Starting testnet stack (signet) via Podman/Docker..."
# Check for a working container runtime (binary exists AND daemon responds)
RUNTIME=""
COMPOSE=""
if command -v docker &>/dev/null && docker ps &>/dev/null; then
RUNTIME="docker"
COMPOSE="docker compose"
elif command -v podman &>/dev/null && podman ps &>/dev/null; then
if command -v podman-compose &>/dev/null; then
RUNTIME="podman"
COMPOSE="podman-compose"
else
RUNTIME="podman"
COMPOSE="podman compose"
fi
fi
if [ -z "$RUNTIME" ]; then
if command -v podman &>/dev/null; then
echo " Podman machine not running — starting it..."
if ! podman machine ls --format '{{.Name}}' 2>/dev/null | grep -q .; then
echo " No Podman machine found — initializing..."
podman machine init
fi
podman machine start
if podman ps &>/dev/null; then
if command -v podman-compose &>/dev/null; then
RUNTIME="podman"
COMPOSE="podman-compose"
else
RUNTIME="podman"
COMPOSE="podman compose"
fi
else
echo " Failed to start Podman machine."
exit 1
fi
elif command -v docker &>/dev/null; then
echo ""
echo "Docker is installed but the daemon isn't running."
echo "Start Docker Desktop and try again."
exit 1
else
echo ""
echo "No container runtime found. Install one:"
echo " brew install podman podman-compose"
echo " # or"
echo " brew install --cask docker"
exit 1
fi
fi
echo " Using: $RUNTIME"
cd "$PROJECT_ROOT"
echo " Starting signet Bitcoin + LND + ThunderHub + Fedimint..."
$COMPOSE -f docker-compose.testnet.yml up -d
echo ""
echo " Testnet stack starting. Services:"
echo " ThunderHub: http://localhost:3010 (password: thunderhub)"
echo " Fedimint Guardian: http://localhost:18175"
echo " LND REST: http://localhost:8080"
echo " Bitcoin RPC: localhost:38332"
echo ""
echo " Get signet coins: https://signetfaucet.com"
echo ""
echo " Also starting mock frontend..."
cleanup_ports
ensure_deps
exec npm run dev:mock
;;
8)
echo ""
echo "Manual Instructions"
echo ""
echo "UI development (mock backend, no Docker):"
echo " cd $FRONTEND_DIR"
echo " npm install && npm run dev:mock"
echo ""
echo "Dev modes (prepend to command):"
echo " VITE_DEV_MODE=setup First-time setup flow"
echo " VITE_DEV_MODE=onboarding Onboarding flow"
echo " VITE_DEV_MODE=existing Login screen"
echo " VITE_DEV_MODE=boot Boot sequence"
echo ""
echo "Testnet stack (requires Podman or Docker):"
echo " podman compose -f docker-compose.testnet.yml up -d"
echo ""
echo "Full stack (requires Rust toolchain):"
echo " Terminal 1: cd $BACKEND_DIR && cargo run --bin archipelago"
echo " Terminal 2: cd $FRONTEND_DIR && npm run dev"
echo ""
echo "Access: http://localhost:8100 (password: password123)"
;;
9)
echo ""
echo "Container Orchestration Dev (live testing on .228)"
echo "Syncs code, builds on server, runs orchestration smoke tests."
echo ""
exec "$SCRIPT_DIR/dev-container-test.sh"
;;
10)
if [ "$IS_LINUX" != true ]; then
echo "Production build is only available on Linux dev machines."
exit 1
fi
echo ""
echo "Production Build — mirrors ISO install exactly"
echo ""
FAILED=0
# Step 1: Build backend
echo "[1/5] Building Rust backend (release)..."
cd "$BACKEND_DIR/archipelago"
if cargo build --release 2>&1 | tail -3; then
RELEASE_BIN="$BACKEND_DIR/target/release/archipelago"
sudo cp "$RELEASE_BIN" /usr/local/bin/archipelago
sudo chmod +x /usr/local/bin/archipelago
echo " Backend installed: $(ls -lh /usr/local/bin/archipelago | awk '{print $5}')"
else
echo " FAILED: cargo build --release"
FAILED=1
fi
# Step 2: Type-check + build frontend
echo "[2/5] Building frontend..."
cd "$FRONTEND_DIR"
if [ ! -d "node_modules" ]; then
npm install
fi
if npx vue-tsc -b --noEmit 2>&1 | tail -3; then
npm run build 2>&1 | tail -3
sudo cp -r "$PROJECT_ROOT/web/dist/neode-ui/"* /opt/archipelago/web-ui/
# Deploy AIUI (pre-built demo or source build)
if [ -d "$PROJECT_ROOT/../AIUI/packages/app/dist" ]; then
sudo cp -r "$PROJECT_ROOT/../AIUI/packages/app/dist/"* /opt/archipelago/web-ui/aiui/
echo " AIUI deployed from source build"
elif [ -d "$PROJECT_ROOT/demo/aiui" ]; then
sudo mkdir -p /opt/archipelago/web-ui/aiui/
sudo cp -r "$PROJECT_ROOT/demo/aiui/"* /opt/archipelago/web-ui/aiui/
echo " AIUI deployed from demo/"
fi
echo " Frontend deployed to /opt/archipelago/web-ui/"
else
echo " FAILED: vue-tsc type check"
FAILED=1
fi
# Step 3: Sync configs from repo
echo "[3/5] Syncing configs..."
sudo cp "$PROJECT_ROOT/image-recipe/configs/archipelago.service" /etc/systemd/system/archipelago.service
sudo cp "$PROJECT_ROOT/image-recipe/configs/nginx-archipelago.conf" /etc/nginx/sites-available/archipelago
sudo cp "$PROJECT_ROOT/image-recipe/configs/snippets/"*.conf /etc/nginx/snippets/ 2>/dev/null
for unit in archipelago-tor-helper.service archipelago-tor-helper.path archipelago-wg.service archipelago-wg-address.service nostr-relay.service nostr-vpn.service; do
sudo cp "$PROJECT_ROOT/image-recipe/configs/$unit" "/etc/systemd/system/$unit"
done
sudo cp "$PROJECT_ROOT/scripts/tor-helper.sh" /opt/archipelago/scripts/tor-helper.sh
sudo chmod +x /opt/archipelago/scripts/tor-helper.sh
sudo cp "$PROJECT_ROOT/scripts/archipelago-wg" /usr/local/bin/archipelago-wg
sudo chmod +x /usr/local/bin/archipelago-wg
echo " Configs synced"
# Step 4: Sync Tor hostnames
echo "[4/5] Syncing Tor hostnames..."
for svc in archipelago bitcoin electrumx lnd btcpay mempool fedimint; do
dir="/var/lib/archipelago/tor/hidden_service_$svc"
if [ -f "$dir/hostname" ]; then
sudo cp "$dir/hostname" "/var/lib/archipelago/tor-hostnames/$svc"
fi
done
sudo chown -R "$(whoami)":"$(whoami)" /var/lib/archipelago/tor-hostnames 2>/dev/null
# Step 5: Reload and restart all services
echo "[5/5] Restarting services..."
sudo systemctl daemon-reload
sudo nginx -t 2>&1 && sudo systemctl reload nginx
sudo systemctl restart archipelago
# Verify
echo ""
echo "Service Status:"
for svc in tor@default archipelago-wg archipelago-wg-address nostr-relay nostr-vpn archipelago-tor-helper.path archipelago nginx; do
STATUS=$(systemctl is-active "$svc" 2>/dev/null)
if [ "$STATUS" = "active" ]; then
printf " %-30s active\n" "$svc"
else
printf " %-30s FAILED\n" "$svc"
FAILED=1
fi
done
echo ""
if [ "$FAILED" -eq 0 ]; then
HOST_IP=$(hostname -I 2>/dev/null | awk '{print $1}')
ONION=$(cat /var/lib/archipelago/tor-hostnames/archipelago 2>/dev/null || echo "generating...")
echo "All services running. Access:"
echo " LAN: http://$HOST_IP"
echo " Tor: http://$ONION"
echo " WG: 10.44.0.1"
echo " RPC: http://127.0.0.1:5678/rpc/v1"
else
echo "Some services failed. Check: journalctl -u <service> --no-pager -n 20"
fi
;;
*)
echo "Invalid choice"
exit 1
;;
esac
+1498
View File
File diff suppressed because it is too large Load Diff
+45
View File
@@ -0,0 +1,45 @@
#!/usr/bin/env bash
# One-shot node-side repair: pull the current companion-UI manifests
# (session_passthrough on the gated ports) from the public repo, install
# them into every location the daemon reads, restart, and report.
#
# Run on a node:
# curl -sf https://source.archipelago-foundation.org/lfg2025/archy/raw/branch/main/scripts/fix-companion-manifests.sh | bash
#
# Idempotent and safe to re-run. Needs passwordless sudo (fleet default).
set -u
BASE="https://source.archipelago-foundation.org/lfg2025/archy/raw/branch/main/apps"
RUNTIME="/opt/archipelago/web-ui/archipelago-runtime/apps"
updated=0
for app in lnd-ui bitcoin-ui electrs-ui fips-ui; do
tmp="/tmp/${app}-manifest.yml"
if ! curl -sf --max-time 30 "$BASE/$app/manifest.yml" -o "$tmp"; then
echo "$app: download failed"; continue
fi
if ! grep -q session_passthrough "$tmp"; then
echo "$app: fetched file missing session_passthrough — refusing"; continue
fi
sudo cp "$tmp" "/opt/archipelago/apps/$app/manifest.yml" || { echo "$app: install failed"; continue; }
# The frontend's runtime payload is restored over /opt/archipelago/apps at
# every daemon boot on nodes that carry it — update it too or the fix
# reverts on the next restart.
if [ -d "$RUNTIME/$app" ]; then
sudo cp "$tmp" "$RUNTIME/$app/manifest.yml"
fi
echo "$app updated"
updated=$((updated + 1))
done
if [ "$updated" -eq 0 ]; then
echo "Nothing updated — not restarting."
exit 1
fi
sudo systemctl restart archipelago
echo "Daemon restarted; waiting for the gate…"
sleep 15
ip=$(hostname -I | tr ' ' '\n' | grep '^100\.' | head -1)
code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 "http://$ip:18083/" 2>/dev/null)
echo "ext :18083 -> $code (401 = gate holds the port: CORRECT)"
+243
View File
@@ -0,0 +1,243 @@
#!/bin/bash
set -e
# Source pinned image versions (single source of truth)
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
[ -f "$SCRIPT_DIR/image-versions.sh" ] && . "$SCRIPT_DIR/image-versions.sh"
# Fix corrupted IndeedHub containers + SearXNG
# All images were exported as the same (wrong) image during multi-node deploy.
# This script: stops broken containers, removes them, recreates with correct images.
echo "=== IndeedHub Container Fix Script ==="
PODMAN_IMAGE_CHECK_TIMEOUT="${PODMAN_IMAGE_CHECK_TIMEOUT:-10}"
# Detect node IP (Tailscale or LAN)
NODE_IP=$(hostname -I | awk '{for(i=1;i<=NF;i++) if($i ~ /^100\./) print $i}')
if [ -z "$NODE_IP" ]; then
NODE_IP=$(hostname -I | awk '{print $1}')
fi
echo "Node IP: $NODE_IP"
NETWORK="indeedhub-build_indeedhub-network"
# Load custom images if tar exists
if [ -f /tmp/indeedhub-images.tar ]; then
echo "Loading custom images from tar..."
podman load < /tmp/indeedhub-images.tar 2>&1 | tail -5
fi
# Verify correct images are available
echo "Verifying images..."
for img in "${INDEEDHUB_REDIS_IMAGE}" "${MINIO_IMAGE}" "${INDEEDHUB_POSTGRES_IMAGE}" "${NOSTR_RS_RELAY_IMAGE}" "${SEARXNG_IMAGE}" "localhost/indeedhub:local" "localhost/indeedhub-build_api:local" "localhost/indeedhub-build_ffmpeg-worker:local"; do
if ! timeout --kill-after=2s "${PODMAN_IMAGE_CHECK_TIMEOUT}s" podman image exists "$img" 2>/dev/null; then
echo "ERROR: Missing image $img"
exit 1
fi
done
echo "All images verified."
# Ensure network exists
if ! podman network exists "$NETWORK" 2>/dev/null; then
echo "Creating network $NETWORK..."
podman network create "$NETWORK" 2>/dev/null || true
fi
# Stop all affected containers
echo "Stopping containers..."
for c in indeedhub indeedhub-build_api_1 indeedhub-build_ffmpeg-worker_1 indeedhub-relay indeedhub-redis indeedhub-minio indeedhub-postgres searxng; do
podman stop "$c" 2>/dev/null || true
done
# Remove all affected containers
echo "Removing containers..."
for c in indeedhub indeedhub-build_api_1 indeedhub-build_ffmpeg-worker_1 indeedhub-relay indeedhub-redis indeedhub-minio indeedhub-postgres searxng; do
podman rm -f "$c" 2>/dev/null || true
done
# 1. PostgreSQL (must start first — others depend on it)
echo "Creating postgres..."
podman run -d --name indeedhub-postgres \
--restart unless-stopped \
--network "$NETWORK" --network-alias postgres \
-v indeedhub-postgres-data:/var/lib/postgresql/data \
-e POSTGRES_USER=indeedhub \
-e POSTGRES_PASSWORD=indeehhub-archy-2026 \
-e POSTGRES_DB=indeedhub \
"$INDEEDHUB_POSTGRES_IMAGE"
# Wait for postgres to be ready
echo "Waiting for postgres..."
for i in $(seq 1 15); do
if podman exec indeedhub-postgres pg_isready -U indeedhub 2>/dev/null; then
echo "Postgres ready."
break
fi
sleep 2
done
# 2. Redis
echo "Creating redis..."
podman run -d --name indeedhub-redis \
--restart unless-stopped \
--network "$NETWORK" --network-alias redis \
-v indeedhub-redis-data:/data \
"$INDEEDHUB_REDIS_IMAGE" \
redis-server --appendonly yes
# 3. MinIO
echo "Creating minio..."
podman run -d --name indeedhub-minio \
--restart unless-stopped \
--network "$NETWORK" --network-alias minio \
-v indeedhub-minio-data:/data \
-e MINIO_ROOT_USER=indeeadmin \
-e MINIO_ROOT_PASSWORD=indeeadmin2026 \
"${MINIO_IMAGE}" \
server /data --console-address ":9001"
# 4. Nostr Relay
echo "Creating relay..."
podman run -d --name indeedhub-relay \
--restart unless-stopped \
--network "$NETWORK" --network-alias relay \
-v indeedhub-relay-data:/usr/src/app/db \
"${NOSTR_RS_RELAY_IMAGE}"
# 5. API
echo "Creating api..."
podman run -d --name indeedhub-build_api_1 \
--restart unless-stopped \
--network "$NETWORK" --network-alias api \
-e ENVIRONMENT=production \
-e PORT=4000 \
-e DOMAIN="$NODE_IP" \
-e FRONTEND_URL="http://$NODE_IP" \
-e DATABASE_HOST=postgres \
-e DATABASE_PORT=5432 \
-e DATABASE_USER=indeedhub \
-e DATABASE_PASSWORD=indeehhub-archy-2026 \
-e DATABASE_NAME=indeedhub \
-e QUEUE_HOST=redis \
-e QUEUE_PORT=6379 \
-e "QUEUE_PASSWORD=" \
-e S3_ENDPOINT=http://minio:9000 \
-e AWS_REGION=us-east-1 \
-e AWS_ACCESS_KEY=indeeadmin \
-e AWS_SECRET_KEY=indeeadmin2026 \
-e S3_PRIVATE_BUCKET_NAME=indeedhub-private \
-e S3_PUBLIC_BUCKET_NAME=indeedhub-public \
-e S3_PUBLIC_BUCKET_URL=/storage \
-e "BTCPAY_URL=" \
-e "BTCPAY_API_KEY=" \
-e "BTCPAY_STORE_ID=" \
-e "BTCPAY_WEBHOOK_SECRET=" \
-e NOSTR_JWT_SECRET=archipelago-indeehhub-jwt-secret-2026 \
-e NOSTR_JWT_EXPIRES_IN=7d \
-e AES_MASTER_SECRET=0123456789abcdef0123456789abcdef \
-e "ADMIN_API_KEY=" \
-e NODE_OPTIONS=--max-old-space-size=1024 \
--health-cmd "wget --no-verbose --tries=1 --spider http://localhost:4000/nostr-auth/health || exit 1" \
--health-interval 60s \
--health-timeout 30s \
--health-retries 5 \
--health-start-period 60s \
localhost/indeedhub-build_api:local \
sh -c "echo 'Running database migrations...' && npx typeorm migration:run -d dist/database/ormconfig.js && echo 'Migrations complete.' && npm run start:prod"
# 6. FFmpeg Worker
echo "Creating ffmpeg-worker..."
podman run -d --name indeedhub-build_ffmpeg-worker_1 \
--restart unless-stopped \
--network "$NETWORK" --network-alias ffmpeg-worker \
-e ENVIRONMENT=production \
-e DATABASE_HOST=postgres \
-e DATABASE_PORT=5432 \
-e DATABASE_USER=indeedhub \
-e DATABASE_PASSWORD=indeehhub-archy-2026 \
-e DATABASE_NAME=indeedhub \
-e QUEUE_HOST=redis \
-e QUEUE_PORT=6379 \
-e "QUEUE_PASSWORD=" \
-e S3_ENDPOINT=http://minio:9000 \
-e AWS_REGION=us-east-1 \
-e AWS_ACCESS_KEY=indeeadmin \
-e AWS_SECRET_KEY=indeeadmin2026 \
-e S3_PRIVATE_BUCKET_NAME=indeedhub-private \
-e S3_PUBLIC_BUCKET_NAME=indeedhub-public \
-e S3_PUBLIC_BUCKET_URL=/storage \
-e AES_MASTER_SECRET=0123456789abcdef0123456789abcdef \
localhost/indeedhub-build_ffmpeg-worker:local
# 7. IndeedHub Frontend
echo "Creating indeedhub frontend..."
podman run -d --name indeedhub \
--restart unless-stopped \
--network "$NETWORK" \
-p 7778:7777 \
--label "com.archipelago.app=indeedhub" \
--label "com.archipelago.title=IndeedHub" \
--label "com.archipelago.version=0.1.0" \
--label "com.archipelago.category=media" \
--label "com.archipelago.port=7777" \
localhost/indeedhub:local
# Fix IndeedHub for iframe: remove X-Frame-Options, inject nostr-provider, hardcode container IPs
sleep 3
if podman ps --format '{{.Names}}' 2>/dev/null | grep -q "^indeedhub$"; then
podman exec indeedhub sed -i "/X-Frame-Options/d" /etc/nginx/conf.d/default.conf 2>/dev/null || true
# Inject nostr-provider.js if available
if [ -f /opt/archipelago/web-ui/nostr-provider.js ]; then
podman cp /opt/archipelago/web-ui/nostr-provider.js indeedhub:/usr/share/nginx/html/nostr-provider.js 2>/dev/null || true
fi
# Add nostr-provider location block + sub_filter
if ! podman exec indeedhub grep -q "nostr-provider" /etc/nginx/conf.d/default.conf 2>/dev/null; then
podman exec indeedhub cat /etc/nginx/conf.d/default.conf > /tmp/ih-nginx.conf 2>/dev/null
sed -i "/location = \/sw.js {/i\\ location = /nostr-provider.js {\n add_header Cache-Control \"no-cache, no-store, must-revalidate\";\n expires off;\n }\n" /tmp/ih-nginx.conf
sed -i "/try_files.*index.html/a\\ sub_filter_once on;\n sub_filter '</head>' '<script src=\"/nostr-provider.js\"></script></head>';" /tmp/ih-nginx.conf
podman cp /tmp/ih-nginx.conf indeedhub:/etc/nginx/conf.d/default.conf 2>/dev/null || true
rm -f /tmp/ih-nginx.conf
fi
# Fix X-Forwarded-Prefix for NIP-98 URL reconstruction in iframe context
# The outer Archipelago nginx sets X-Forwarded-Prefix to /app/indeedhub;
# the inner nginx must pass it through (appending /api) instead of hardcoding /api
podman exec indeedhub sed -i 's|proxy_set_header X-Forwarded-Prefix /api;|proxy_set_header X-Forwarded-Prefix $http_x_forwarded_prefix/api;|' /etc/nginx/conf.d/default.conf 2>/dev/null || true
# Replace DNS-based upstream resolution with hardcoded container IPs
# (podman DNS resolver 127.0.0.11 is unreliable, causing 502 errors)
API_IP=$(podman inspect indeedhub-build_api_1 --format "{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}" 2>/dev/null)
MINIO_IP=$(podman inspect indeedhub-minio --format "{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}" 2>/dev/null)
RELAY_IP=$(podman inspect indeedhub-relay --format "{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}" 2>/dev/null)
if [ -n "$API_IP" ] && [ -n "$MINIO_IP" ] && [ -n "$RELAY_IP" ]; then
podman exec indeedhub cat /etc/nginx/conf.d/default.conf > /tmp/ih-nginx.conf 2>/dev/null
sed -i "s|resolver 127.0.0.11 valid=30s ipv6=off;||g" /tmp/ih-nginx.conf
sed -i "s|set \$api_upstream http://api:4000;|set \$api_upstream http://$API_IP:4000;|g" /tmp/ih-nginx.conf
sed -i "s|set \$minio_upstream http://minio:9000;|set \$minio_upstream http://$MINIO_IP:9000;|g" /tmp/ih-nginx.conf
sed -i "s|set \$relay_upstream http://relay:8080;|set \$relay_upstream http://$RELAY_IP:8080;|g" /tmp/ih-nginx.conf
sed -i "s|proxy_set_header Host \$host;|proxy_set_header Host \$http_host;|g" /tmp/ih-nginx.conf
podman cp /tmp/ih-nginx.conf indeedhub:/etc/nginx/conf.d/default.conf 2>/dev/null || true
rm -f /tmp/ih-nginx.conf
echo "Patched IndeedHub nginx with container IPs (API=$API_IP MINIO=$MINIO_IP RELAY=$RELAY_IP)"
fi
podman exec indeedhub nginx -s reload 2>/dev/null || true
echo "Applied IndeedHub iframe fix."
fi
# 8. SearXNG (standalone — no cap-drop ALL, searxng needs write access to /etc/searxng/)
echo "Creating searxng..."
podman run -d --name searxng \
--restart unless-stopped \
-p 8888:8080 \
"${SEARXNG_IMAGE}"
echo ""
echo "=== Verifying container status ==="
sleep 5
podman ps -a --filter name=indeedhub --filter name=searxng --format "table {{.Names}}\t{{.Status}}" 2>&1
echo ""
echo "=== FIX COMPLETE ==="
+254
View File
@@ -0,0 +1,254 @@
#!/usr/bin/env python3
"""Sync public app catalog metadata from apps/*/manifest.yml.
Manifests are the source of truth for fields the runtime already needs
(`name`, `version`, `description`, container image, category, tier, icon,
repo URL). The catalog still owns presentation-only fields that manifests do
not carry yet, such as `author`, `requires`, `featured`, and rich
`containerConfig` notes.
"""
from __future__ import annotations
import argparse
import json
from pathlib import Path
from typing import Any
import yaml
SYNC_FIELDS = ("title", "version", "description", "dockerImage", "category", "tier", "icon", "repoUrl")
def load_manifests(apps_dir: Path) -> dict[str, dict[str, Any]]:
manifests: dict[str, dict[str, Any]] = {}
for path in sorted(apps_dir.glob("*/manifest.yml")):
with path.open("r", encoding="utf-8") as fh:
data = yaml.safe_load(fh)
if not isinstance(data, dict) or not isinstance(data.get("app"), dict):
continue
app = data["app"]
app_id = app.get("id")
if app_id:
manifests[str(app_id)] = app
return manifests
def metadata(app: dict[str, Any]) -> dict[str, Any]:
value = app.get("metadata")
return value if isinstance(value, dict) else {}
def manifest_catalog_values(app: dict[str, Any]) -> dict[str, str]:
meta = metadata(app)
container = app.get("container") if isinstance(app.get("container"), dict) else {}
values = {
"title": app.get("name"),
"version": app.get("version"),
"description": app.get("description"),
"dockerImage": container.get("image"),
"category": app.get("category") or meta.get("category"),
"tier": meta.get("tier"),
"icon": meta.get("icon"),
"repoUrl": meta.get("repo") or meta.get("repoUrl") or meta.get("source"),
}
return {key: str(value) for key, value in values.items() if value is not None and str(value).strip()}
def manifest_launch_port(app: dict[str, Any]) -> int | None:
"""Return the manifest-owned public UI port, when it is unambiguous."""
interfaces = app.get("interfaces")
if isinstance(interfaces, dict):
main = interfaces.get("main")
if isinstance(main, dict) and main.get("type") == "ui":
port = main.get("port")
if isinstance(port, int):
return port
if isinstance(port, str) and port.isdigit():
return int(port)
health_check = app.get("health_check")
if not isinstance(health_check, dict) or str(health_check.get("type", "")).lower() != "http":
return None
ports = app.get("ports")
if not isinstance(ports, list):
return None
tcp_ports = [
item.get("host")
for item in ports
if isinstance(item, dict) and str(item.get("protocol", "tcp")).lower() == "tcp"
]
if len(tcp_ports) != 1:
return None
port = tcp_ports[0]
if isinstance(port, int):
return port
if isinstance(port, str) and port.isdigit():
return int(port)
return None
def manifest_opens_in_new_tab(app: dict[str, Any]) -> bool:
"""Return whether manifest launch metadata opts the app out of iframe launch."""
launch = metadata(app).get("launch")
if not isinstance(launch, dict):
return False
return launch.get("open_in_new_tab") is True
def ts_string(value: str) -> str:
return json.dumps(value, ensure_ascii=True)
def render_app_session_config(manifests: dict[str, dict[str, Any]]) -> str:
ports: dict[str, int] = {}
titles: dict[str, str] = {}
new_tab_apps: list[str] = []
for app_id, app in sorted(manifests.items()):
name = app.get("name")
if isinstance(name, str) and name.strip():
titles[app_id] = name.strip()
port = manifest_launch_port(app)
if port:
ports[app_id] = port
if manifest_opens_in_new_tab(app):
new_tab_apps.append(app_id)
lines = [
"/** Generated by scripts/generate-app-catalog.py. Do not edit manually. */",
"",
"export const GENERATED_APP_PORTS: Record<string, number> = {",
]
for app_id, port in ports.items():
lines.append(f" {ts_string(app_id)}: {port},")
lines.extend([
"}",
"",
"export const GENERATED_APP_TITLES: Record<string, string> = {",
])
for app_id, title in titles.items():
lines.append(f" {ts_string(app_id)}: {ts_string(title)},")
lines.extend([
"}",
"",
"export const GENERATED_NEW_TAB_APPS = new Set<string>([",
])
for app_id in new_tab_apps:
lines.append(f" {ts_string(app_id)},")
lines.extend(["])", ""])
return "\n".join(lines)
def render_rust_ports(ports: dict[str, int], extra_ports: list[int]) -> str:
"""Rust constant of catalog launch ports for the fips0 firewall drop-in
(core/archipelago/src/fips/app_ports.rs). Extra ports cover the frontend's
APP_PORTS overrides (companions/aliases) that have no manifest of their own.
"""
distinct = sorted(set(list(ports.values()) + extra_ports))
lines = [
"//! Generated by scripts/generate-app-catalog.py. Do not edit manually.",
"//!",
"//! Catalog app launch ports (the web UIs the companion opens by direct",
"//! port). Used to write the fips0 firewall allowance drop-in so app UIs",
"//! are reachable over the mesh; ports of apps that aren\'t installed have",
"//! no listener, so allowing them is inert.",
"",
"pub const APP_LAUNCH_PORTS: &[u16] = &[",
]
lines.extend(f" {port}," for port in distinct)
lines.extend(["];", ""])
return "\n".join(lines)
# Keep in lockstep with APP_PORTS overrides in
# neode-ui/src/views/appSession/appSessionConfig.ts.
RUST_EXTRA_PORTS = [8334, 50002, 18083, 11434, 8081, 8240, 8175, 8176, 8080]
def sync_catalog(path: Path, manifests: dict[str, dict[str, Any]]) -> int:
with path.open("r", encoding="utf-8") as fh:
catalog = json.load(fh)
apps = catalog.get("apps")
if not isinstance(apps, list):
raise ValueError(f"{path}: expected .apps to be a list")
changed = 0
for catalog_app in apps:
if not isinstance(catalog_app, dict):
continue
app_id = catalog_app.get("id")
if not app_id or str(app_id) not in manifests:
continue
values = manifest_catalog_values(manifests[str(app_id)])
for field in SYNC_FIELDS:
if field not in values:
continue
old = catalog_app.get(field)
new = values[field]
if old != new:
catalog_app[field] = new
changed += 1
path.write_text(json.dumps(catalog, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
return changed
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--apps-dir", default="apps")
parser.add_argument(
"--catalog",
action="append",
default=[],
help="Catalog JSON path to update. May be passed multiple times.",
)
parser.add_argument(
"--rust-app-ports",
default="core/archipelago/src/fips/app_ports.rs",
help="Generated Rust launch-port list for the fips0 firewall drop-in. Empty string to skip.",
)
parser.add_argument(
"--app-session-config",
default="neode-ui/src/views/appSession/generatedAppSessionConfig.ts",
help="Generated TypeScript app-session metadata path. Pass an empty string to skip.",
)
args = parser.parse_args()
catalogs = args.catalog or ["app-catalog/catalog.json", "neode-ui/public/catalog.json"]
manifests = load_manifests(Path(args.apps_dir))
total = 0
for catalog in catalogs:
changed = sync_catalog(Path(catalog), manifests)
total += changed
print(f"{catalog}: updated {changed} fields")
if args.app_session_config:
path = Path(args.app_session_config)
content = render_app_session_config(manifests)
old = path.read_text(encoding="utf-8") if path.exists() else ""
if old != content:
path.write_text(content, encoding="utf-8")
print(f"{path}: updated")
else:
print(f"{path}: updated 0 fields")
if args.rust_app_ports:
ports = {
app_id: port
for app_id, app in manifests.items()
if (port := manifest_launch_port(app))
}
rust_path = Path(args.rust_app_ports)
rust_content = render_rust_ports(ports, RUST_EXTRA_PORTS)
rust_old = rust_path.read_text(encoding="utf-8") if rust_path.exists() else ""
if rust_old != rust_content:
rust_path.write_text(rust_content, encoding="utf-8")
print(f"{rust_path}: updated")
else:
print(f"{rust_path}: updated 0 fields")
print(f"total_updated={total}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+282
View File
@@ -0,0 +1,282 @@
#!/usr/bin/env bash
# Generate releases/app-catalog.json — the REMOTE per-app version catalog that
# decouples app updates from the binary OTA (see
# core/.../container/app_catalog.rs and docs/dht-distribution-design.md).
#
# Nodes fetch this file over HTTP from the OVH origin (same host as the OTA
# manifest), compare each app's catalog version against the running container
# tag, and light up the per-app "Update" button — no node release required.
#
# The app_id -> image-variable mapping below MIRRORS
# core/archipelago/src/container/image_versions.rs (image_var_for_app +
# containers_for_stack). image_versions.rs is the canonical mapping; keep this in
# sync when you add an app there.
#
# Usage:
# scripts/generate-app-catalog.sh [output-path]
# EMBED_MANIFESTS=0 scripts/generate-app-catalog.sh # version/image only (legacy)
# # then publish: push releases/app-catalog.json to the OVH gitea (raw URL).
#
# EMBED_MANIFESTS (default ON, 2026-06-23): embed each app's full
# apps/<id>/manifest.yml into its catalog entry's `manifest` field, so nodes
# install from the signed registry alone (no OTA-shipped disk manifest). Consumed
# by container::app_catalog + the orchestrator's load_manifests overlay
# (origin-wins, disk = fallback). See docs/registry-manifest-design.md. The
# migration window is over — every regen now embeds; set EMBED_MANIFESTS=0 only
# to reproduce the old version/image-only catalog.
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
OUT="${1:-$ROOT/releases/app-catalog.json}"
# Export every *_IMAGE var (and ARCHY_REGISTRY) so python can read them.
set -a
# shellcheck disable=SC1091
source "$ROOT/scripts/image-versions.sh"
set +a
UPDATED="$(date -u +%Y-%m-%d)" OUT="$OUT" APPS_DIR="$ROOT/apps" \
EMBED_MANIFESTS="${EMBED_MANIFESTS:-1}" python3 - <<'PY'
import glob
import json, os
try:
import yaml
except ImportError:
yaml = None
def img(var):
v = os.environ.get(var)
return v if v else None
def tag(image):
# version = tag after the LAST colon that follows the last slash
if not image:
return None
tail = image.rsplit('/', 1)[-1]
return tail.rsplit(':', 1)[1] if ':' in tail else 'latest'
# Single-container apps: app_id -> primary image variable.
SINGLE = {
"bitcoin-knots": "BITCOIN_KNOTS_IMAGE",
"lnd": "LND_IMAGE",
"electrumx": "ELECTRUMX_IMAGE",
"bitcoin-ui": "BITCOIN_UI_IMAGE",
"lnd-ui": "LND_UI_IMAGE",
"electrs-ui": "ELECTRS_UI_IMAGE",
"homeassistant": "HOMEASSISTANT_IMAGE",
"grafana": "GRAFANA_IMAGE",
"uptime-kuma": "UPTIME_KUMA_IMAGE",
"jellyfin": "JELLYFIN_IMAGE",
"photoprism": "PHOTOPRISM_IMAGE",
"ollama": "OLLAMA_IMAGE",
"vaultwarden": "VAULTWARDEN_IMAGE",
"nextcloud": "NEXTCLOUD_IMAGE",
"searxng": "SEARXNG_IMAGE",
"cryptpad": "CRYPTPAD_IMAGE",
"filebrowser": "FILEBROWSER_IMAGE",
"nginx-proxy-manager": "NPM_IMAGE",
"portainer": "PORTAINER_IMAGE",
"tailscale": "TAILSCALE_IMAGE",
"fedimint": "FEDIMINT_IMAGE",
"fedimint-gateway": "FEDIMINT_GATEWAY_IMAGE",
"nostr-rs-relay": "NOSTR_RS_RELAY_IMAGE",
"nostr-vpn": "NOSTR_VPN_IMAGE",
"fips": "FIPS_IMAGE",
"routstr": "ROUTSTR_IMAGE",
"adguardhome": "ADGUARDHOME_IMAGE",
}
# Stack apps: app_id -> {container_name: image variable}. The FIRST entry is the
# primary (its version drives the badge); it is also emitted as `image`.
STACK = {
"indeedhub": {
"indeedhub": "INDEEDHUB_IMAGE",
"indeedhub-api": "INDEEDHUB_API_IMAGE",
"indeedhub-ffmpeg": "INDEEDHUB_FFMPEG_IMAGE",
},
"immich": {
"immich_server": "IMMICH_SERVER_IMAGE",
"immich_postgres": "IMMICH_POSTGRES_IMAGE",
"immich_redis": "REDIS_IMAGE",
},
"penpot": {
"penpot-frontend": "PENPOT_FRONTEND_IMAGE",
"penpot-backend": "PENPOT_BACKEND_IMAGE",
"penpot-exporter": "PENPOT_EXPORTER_IMAGE",
"penpot-postgres": "PENPOT_POSTGRES_IMAGE",
"penpot-valkey": "PENPOT_VALKEY_IMAGE",
},
"mempool": {
"archy-mempool-web": "MEMPOOL_WEB_IMAGE",
"mempool-api": "MEMPOOL_BACKEND_IMAGE",
"archy-mempool-db": "MARIADB_IMAGE",
},
"btcpay": {
"btcpay-server": "BTCPAY_IMAGE",
"archy-nbxplorer": "NBXPLORER_IMAGE",
"archy-btcpay-db": "BTCPAY_POSTGRES_IMAGE",
},
}
apps = {}
for app_id, var in SINGLE.items():
image = img(var)
if image:
apps[app_id] = {"version": tag(image), "image": image}
for app_id, comps in STACK.items():
images = {name: img(var) for name, var in comps.items() if img(var)}
if not images:
continue
primary_name = next(iter(comps)) # first listed = primary
primary_image = img(comps[primary_name])
entry = {"version": tag(primary_image)}
if primary_image:
entry["image"] = primary_image
entry["images"] = images
apps[app_id] = entry
# Opt-in (EMBED_MANIFESTS): embed each app's full manifest so nodes install from
# the registry alone. The whole manifest document is embedded under `manifest`
# (top-level `app:` preserved) — that is exactly what the Rust side deserializes
# into an AppManifest. Apps not already in SINGLE/STACK get a new entry whose
# version comes from the manifest. A bad embed is harmless: the node validates and
# falls back to its disk manifest.
# Embedded manifests must name a registry the DEPLOYED fleet trusts, which is
# not necessarily the one the repo names. apps/*/manifest.yml moved to the public
# domain in 8e814ca0, but releases/registry-trust-floor.json still lists only the
# OVH host — the migration is ship-binary -> confirm-fleet -> promote-floor ->
# regenerate, and the later steps have not happened. Embedding the repo's host
# verbatim produced a catalog naming 78 untrusted refs, which would have made
# every install in the field fail with "not from a trusted registry". The signer
# refused it, which is how this was caught.
#
# So rewrite OUR registry host to whatever REGISTRY is generating against, and
# leave every other host (docker.io, ghcr.io, ...) untouched. When the floor is
# promoted, generating against the domain becomes a no-op here.
REGISTRY = os.environ.get("ARCHY_REGISTRY", "source.archipelago-foundation.org/lfg2025")
_KNOWN_ARCHY_REGISTRY_HOSTS = (
"source.archipelago-foundation.org/lfg2025",
"146.59.87.168:3000/lfg2025",
)
def _retarget_registry(node):
if isinstance(node, dict):
return {k: _retarget_registry(v) for k, v in node.items()}
if isinstance(node, list):
return [_retarget_registry(v) for v in node]
if isinstance(node, str):
for host in _KNOWN_ARCHY_REGISTRY_HOSTS:
if host != REGISTRY and node.startswith(host + "/"):
return REGISTRY + node[len(host):]
return node
return node
embedded = 0
apps_dir = os.environ.get("APPS_DIR")
if os.environ.get("EMBED_MANIFESTS") and apps_dir:
if yaml is None:
raise SystemExit("EMBED_MANIFESTS set but PyYAML is not available")
for path in sorted(glob.glob(os.path.join(apps_dir, "*", "manifest.yml"))):
with open(path) as fh:
data = yaml.safe_load(fh)
if not isinstance(data, dict) or not isinstance(data.get("app"), dict):
continue
app = data["app"]
app_id = app.get("id")
if not app_id:
continue
entry = apps.setdefault(str(app_id), {})
entry.setdefault("version", str(app.get("version", "")) or "0")
entry["manifest"] = _retarget_registry(data)
embedded += 1
# Multi-version support (docs/bitcoin-multi-version-design.md §3 Phase 1):
# curated, bounded `versions[]` a runner may install or switch to. The entry
# marked default:true MUST equal the app's top-level catalog `version` (the
# manifest version for embedded apps) so selecting it un-pins / tracks latest.
#
# ONLY list versions whose tagged image is actually published to the registry —
# an unbuilt tag 404s on install. Extend each list as scripts/build-bitcoin-
# image.sh (Phase 0) publishes more tagged images, e.g.:
# {"version": "30.0", "image": f"{REGISTRY}/bitcoin:30.0"},
# {"version": "27.2", "image": f"{REGISTRY}/bitcoin:27.2", "deprecated": True, "eol": "2026-12-31"},
VERSIONS = {
# Curated Core set (latest patch per major, current → 25). Images built +
# verified (SHA-256 + OpenPGP, fail-closed) and pushed by
# scripts/build-bitcoin-image.sh. `28.4.0` is the default (== the manifest's
# top-level version) so existing/new installs are undisturbed; runners switch
# up to 31.0 (e.g. for BIP-110 signalling) or down to 25.2 from the app's
# "Version & Updates" card. Add the next release by building its image then
# prepending it here.
"bitcoin-core": [
{"version": "latest", "image": f"{REGISTRY}/bitcoin:latest", "default": True},
{"version": "31.0", "image": f"{REGISTRY}/bitcoin:31.0"},
{"version": "30.2", "image": f"{REGISTRY}/bitcoin:30.2"},
{"version": "29.3", "image": f"{REGISTRY}/bitcoin:29.3"},
{"version": "29.2", "image": f"{REGISTRY}/bitcoin:29.2"},
{"version": "28.4.0", "image": f"{REGISTRY}/bitcoin:28.4"},
{"version": "27.2", "image": f"{REGISTRY}/bitcoin:27.2"},
{"version": "26.2", "image": f"{REGISTRY}/bitcoin:26.2", "deprecated": True},
{"version": "25.2", "image": f"{REGISTRY}/bitcoin:25.2", "deprecated": True},
],
# Knots: a real tagged build is now published, so it's selectable + pinnable
# in the Knots app interface. `latest` is the default (== the manifest's
# floating tag) so selecting it un-pins / tracks latest — this MUST match the
# top-level catalog version (L167-168) or the card can't reach "latest" and
# selecting the highlighted default would instead pin+recreate. Pinning
# 29.3.knots20260508 moves a runner off the floating tag.
# `latest` is the default and points at the NEWEST published dated image
# (not the bare :latest tag) so "Always use the latest version" installs the
# newest build on fixed-binary nodes, while UNPINNED nodes still resolve via
# the manifest's floating :latest tag (kept on the legacy image until the
# entrypoint-render fix is fleet-deployed — see
# the bitcoin multi-version design).
# NO "latest" pseudo-version here, and the default is pinned. The entry
# marked default:true used to be {"version": "latest"} pointing at
# 29.3.knots20260508 — so a fresh install, or anyone picking "latest",
# silently got the BIP110/RDTS build. That build HALTS until an operator
# sets consensusrules=rdts: node 100.64.204.114 runs it and is frozen at
# block 961,692 (blocks AND headers static, 11 peers, unpruned) while
# reporting itself synced, whereas the nodes on 20260210 sit at the tip.
# A default that can move across a consensus boundary is a fleet-wide
# stall waiting to happen, so the default is an explicit, non-RDTS build
# and moving it is a deliberate consensus decision.
"bitcoin-knots": [
{"version": "29.3.knots20260210",
"image": f"{REGISTRY}/bitcoin-knots:29.3.knots20260210", "default": True},
{"version": "29.3.knots20260508",
"image": f"{REGISTRY}/bitcoin-knots:29.3.knots20260508"},
{"version": "29.3.knots20260507",
"image": f"{REGISTRY}/bitcoin-knots:29.3.knots20260507"},
{"version": "29.2.knots20251110",
"image": f"{REGISTRY}/bitcoin-knots:29.2.knots20251110"},
],
}
for app_id, versions in VERSIONS.items():
if app_id in apps and versions:
apps[app_id]["versions"] = versions
# The default/latest entry MUST equal the app's top-level catalog
# `version` (commit 169ff2e2) so selecting the highlighted default
# un-pins / tracks latest instead of pinning+recreating. Enforce it here
# rather than relying on the manifest version matching.
default_entry = next((v for v in versions if v.get("default")), None)
if default_entry:
apps[app_id]["version"] = default_entry["version"]
catalog = {
"schema": 1,
"updated": os.environ["UPDATED"],
"apps": dict(sorted(apps.items())),
}
with open(os.environ["OUT"], "w") as f:
json.dump(catalog, f, indent=2)
f.write("\n")
suffix = f" (embedded {embedded} manifests)" if embedded else ""
print(f"Wrote {os.environ['OUT']} with {len(apps)} apps{suffix}")
PY
+143
View File
@@ -0,0 +1,143 @@
#!/bin/bash
# Container image versions — single source of truth
# Source this file from all scripts that create containers
#
# Usage: source /opt/archipelago/image-versions.sh 2>/dev/null || true
# source "$(dirname "$0")/image-versions.sh" 2>/dev/null || true
#
# Tags MUST match what's actually in the registry at source.archipelago-foundation.org/lfg2025/
# Run: podman images --format '{{.Repository}}:{{.Tag}}' | grep 'source.archipelago-foundation.org' | sort
# to verify against the registry.
# Archipelago app registries (primary + fallback)
#
# Honour a caller-supplied ARCHY_REGISTRY instead of overwriting it. Catalog
# generation MUST be able to target a host inside releases/registry-trust-floor.json
# — the hosts binaries already deployed to the fleet are known to trust. This
# file's default is the public domain, which the fleet does NOT trust yet: the
# migration is ship-binary -> confirm-fleet -> promote-in-floor -> regenerate,
# and only step 0 has happened. Generating the catalog against an untrusted host
# makes every install in the field fail with "not from a trusted registry"; the
# signer refuses to sign such a catalog, which is how this was caught.
ARCHY_REGISTRY="${ARCHY_REGISTRY:-source.archipelago-foundation.org/lfg2025}"
# No fallback registry: the old tx1138 registry host was retired (2026-06-13); empty disables the fallback path.
ARCHY_REGISTRY_FALLBACK=""
# Bitcoin stack
# Pinned, not :latest — see apps/bitcoin-knots/manifest.yml. Knots 20260508
# halts pending the BIP110/RDTS consensus decision, so a moving tag can freeze
# the fleet's chain sync. Bumping this is a consensus decision.
BITCOIN_KNOTS_IMAGE="$ARCHY_REGISTRY/bitcoin-knots:29.3.knots20260210"
LND_IMAGE="$ARCHY_REGISTRY/lnd:v0.18.4-beta"
ELECTRUMX_IMAGE="$ARCHY_REGISTRY/electrumx:v1.18.0"
# Mempool stack
MEMPOOL_BACKEND_IMAGE="$ARCHY_REGISTRY/mempool-backend:v3.0.0"
MEMPOOL_WEB_IMAGE="$ARCHY_REGISTRY/mempool-frontend:v3.0.1"
MARIADB_IMAGE="$ARCHY_REGISTRY/mariadb:11.4.10"
# BTCPay
BTCPAY_IMAGE="docker.io/btcpayserver/btcpayserver:2.4.2"
NBXPLORER_IMAGE="$ARCHY_REGISTRY/nbxplorer:2.6.0"
POSTGRES_IMAGE="$ARCHY_REGISTRY/postgres:15.17"
BTCPAY_POSTGRES_IMAGE="$ARCHY_REGISTRY/postgres:15.17"
# Apps
HOMEASSISTANT_IMAGE="$ARCHY_REGISTRY/home-assistant:2026.7.3"
GRAFANA_IMAGE="$ARCHY_REGISTRY/grafana:10.2.0"
UPTIME_KUMA_IMAGE="$ARCHY_REGISTRY/uptime-kuma:1"
JELLYFIN_IMAGE="$ARCHY_REGISTRY/jellyfin:10.8.13"
PHOTOPRISM_IMAGE="$ARCHY_REGISTRY/photoprism:240915"
OLLAMA_IMAGE="$ARCHY_REGISTRY/ollama:latest"
VAULTWARDEN_IMAGE="$ARCHY_REGISTRY/vaultwarden:1.30.0-alpine"
NEXTCLOUD_IMAGE="$ARCHY_REGISTRY/nextcloud:29"
SEARXNG_IMAGE="$ARCHY_REGISTRY/searxng:latest"
# OnlyOffice removed — incompatible with rootless Podman (internal postgres/rabbitmq fail)
# Replaced by CryptPad (single Node.js process, e2e encrypted)
CRYPTPAD_IMAGE="$ARCHY_REGISTRY/cryptpad:2024.12.0"
FILEBROWSER_IMAGE="$ARCHY_REGISTRY/filebrowser:v2.27.0"
NPM_IMAGE="$ARCHY_REGISTRY/nginx-proxy-manager:latest"
# 2.39.1 is what the fleet has actually been running via the moving :latest
# tag, and it is the version that wrote their databases. Pinning back to
# 2.19.4 (2 years older) made Portainer refuse to start the moment a
# container was recreated: "database schema version does not align with the
# server version" — it migrates a DB forward, never backward. Pinned
# forward and published as a concrete tag so this is reproducible.
PORTAINER_IMAGE="$ARCHY_REGISTRY/portainer:2.39.1"
# Networking
TAILSCALE_IMAGE="$ARCHY_REGISTRY/tailscale:stable"
NETBIRD_DASHBOARD_IMAGE="docker.io/netbirdio/dashboard:v2.38.0"
NETBIRD_SERVER_IMAGE="docker.io/netbirdio/netbird-server:0.71.2"
NETBIRD_PROXY_IMAGE="docker.io/library/nginx:1.27-alpine"
ALPINE_TOR_IMAGE="$ARCHY_REGISTRY/alpine-tor:0.4.8.13"
ADGUARDHOME_IMAGE="$ARCHY_REGISTRY/adguardhome:v0.107.55"
# Fedimint
FEDIMINT_IMAGE="$ARCHY_REGISTRY/fedimintd:v0.10.0"
FEDIMINT_GATEWAY_IMAGE="$ARCHY_REGISTRY/gatewayd:v0.10.0"
# fmcd = Fedimint client daemon (iroh-capable, fedimint-client 0.8.2). Built
# from minmoto/fmcd. Bundled on the ISO in BOTH modes (full CONTAINER_IMAGES
# list and the unbundled core bundle) and auto-created by first-boot as a
# baseline app so ecash works offline out of the box.
# See docs/dual-ecash-design.md.
FMCD_IMAGE="$ARCHY_REGISTRY/fmcd:0.8.1"
# Ark (bark)
# barkd = Ark wallet daemon, packaged from the pinned upstream release binary
# (apps/barkd/Dockerfile). Signet-only default config; keep the tag in
# lockstep with core/archipelago/src/wallet/ark_client.rs REST shapes. Not in
# the bundled CONTAINER_IMAGES list — install via the barkd app manifest.
BARKD_IMAGE="$ARCHY_REGISTRY/barkd:0.3.0"
# Media
REDIS_IMAGE="$ARCHY_REGISTRY/redis:7.4.8"
# Valkey (general purpose)
VALKEY_IMAGE="$ARCHY_REGISTRY/valkey:8.1.6"
# Nostr
NOSTR_RS_RELAY_IMAGE="$ARCHY_REGISTRY/nostr-rs-relay:0.9.0"
STRFRY_IMAGE="$ARCHY_REGISTRY/strfry:1.0.4"
NOSTR_VPN_IMAGE="$ARCHY_REGISTRY/nostr-vpn:v0.3.7"
NOSTR_VPN_UI_IMAGE="$ARCHY_REGISTRY/nostr-vpn-ui:latest"
FIPS_IMAGE="$ARCHY_REGISTRY/fips:v0.1.0"
FIPS_UI_IMAGE="$ARCHY_REGISTRY/fips-ui:1.7.123-alpha"
# AI / Routing
ROUTSTR_IMAGE="$ARCHY_REGISTRY/routstr:v0.4.3"
# Community / Gaming
BOTFIGHTS_IMAGE="$ARCHY_REGISTRY/botfights:1.2.11"
# IndeedHub stack
INDEEDHUB_IMAGE="$ARCHY_REGISTRY/indeedhub:1.0.0"
INDEEDHUB_API_IMAGE="$ARCHY_REGISTRY/indeedhub-api:1.0.0"
INDEEDHUB_FFMPEG_IMAGE="$ARCHY_REGISTRY/indeedhub-ffmpeg:1.0.0"
MINIO_IMAGE="$ARCHY_REGISTRY/minio:RELEASE.2024-11-07T00-52-20Z"
INDEEDHUB_POSTGRES_IMAGE="$ARCHY_REGISTRY/postgres:16.13-alpine"
INDEEDHUB_REDIS_IMAGE="$ARCHY_REGISTRY/redis:7.4.8-alpine"
# Gitea (Git + Container Registry)
GITEA_IMAGE="docker.io/gitea/gitea:1.23"
# DWN (Decentralized Web Node)
# Immich stack
IMMICH_POSTGRES_IMAGE="$ARCHY_REGISTRY/immich-postgres:14-vectorchord0.4.3-pgvectors0.2.0"
IMMICH_SERVER_IMAGE="$ARCHY_REGISTRY/immich-server:release"
# Penpot stack
PENPOT_POSTGRES_IMAGE="$ARCHY_REGISTRY/postgres:15"
PENPOT_VALKEY_IMAGE="$ARCHY_REGISTRY/valkey:8.1"
PENPOT_BACKEND_IMAGE="$ARCHY_REGISTRY/penpot-backend:2.4"
PENPOT_EXPORTER_IMAGE="$ARCHY_REGISTRY/penpot-exporter:2.4"
PENPOT_FRONTEND_IMAGE="$ARCHY_REGISTRY/penpot-frontend:2.4"
# Custom UI containers (built from docker/ dirs, pushed to registry)
BITCOIN_UI_IMAGE="$ARCHY_REGISTRY/bitcoin-ui:1.7.123-alpha"
LND_UI_IMAGE="$ARCHY_REGISTRY/lnd-ui:1.7.123-alpha"
ELECTRS_UI_IMAGE="$ARCHY_REGISTRY/electrs-ui:1.7.123-alpha"
# Base images
NGINX_ALPINE_IMAGE="$ARCHY_REGISTRY/nginx:1.27.4-alpine"
+633
View File
@@ -0,0 +1,633 @@
#!/usr/bin/env bash
# ─────────────────────────────────────────────────────────────────
# Archipelago Install TUI Demo — 80s Hacker Edition
# Run: bash scripts/install-tui-demo.sh
# Ctrl+C to exit at any time.
# ─────────────────────────────────────────────────────────────────
set -euo pipefail
# ── Colors — everything orange unless noted ─────────────────────
ORANGE=$'\033[38;5;208m'
ORANGE_DIM=$'\033[38;5;130m'
ORANGE_BRIGHT=$'\033[38;5;214m'
ORANGE_GLOW=$'\033[38;5;220m'
GREEN=$'\033[32m'
GREEN_DIM=$'\033[38;5;22m'
GREEN_BRIGHT=$'\033[38;5;46m'
WHITE=$'\033[1;37m'
DIM=$'\033[38;5;242m'
DIMMER=$'\033[38;5;238m'
DARK=$'\033[38;5;235m'
NC=$'\033[0m'
BOLD=$'\033[1m'
# ── Terminal setup ──────────────────────────────────────────────
TW=$(tput cols 2>/dev/null || echo 80)
TH=$(tput lines 2>/dev/null || echo 24)
[[ $TW -gt 100 ]] && TW=100
BW=56
[[ $BW -gt $((TW - 4)) ]] && BW=$((TW - 4))
INNER=$((BW - 2))
PAD=$(( (TW - BW) / 2 ))
[[ $PAD -lt 0 ]] && PAD=0
PADS=$(printf "%*s" "$PAD" "")
LOGO_W=43
LOGO_PAD=$(( (TW - LOGO_W) / 2 ))
[[ $LOGO_PAD -lt 0 ]] && LOGO_PAD=0
LOGO_PADS=$(printf "%*s" "$LOGO_PAD" "")
cleanup() {
tput cnorm 2>/dev/null
tput sgr0 2>/dev/null
echo ""
}
trap cleanup EXIT INT TERM
# ── Primitives ──────────────────────────────────────────────────
hide_cursor() { tput civis 2>/dev/null || true; }
show_cursor() { tput cnorm 2>/dev/null || true; }
goto() { printf "\033[%d;%dH" "$1" "$2"; }
clear_line() { printf "\033[K"; }
p() { printf "%s%b\n" "$PADS" "$1"; }
pn() { printf "%s%b" "$PADS" "$1"; }
hrule() {
local len=$((INNER < 50 ? INNER : 50))
local hr=""
for _ in $(seq 1 "$len"); do hr="${hr}*"; done
p "${ORANGE_DIM}${hr}${NC}"
}
# ── Hacker glyphs ──────────────────────────────────────────────
HEXCHARS='0123456789abcdef'
SPIN_FRAMES='⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏'
rand_hex() {
local len="${1:-8}" out=""
for _ in $(seq 1 "$len"); do
out="${out}${HEXCHARS:RANDOM % 16:1}"
done
echo -n "$out"
}
# ── Boot scan effect ───────────────────────────────────────────
boot_scan() {
clear
hide_cursor
local messages=(
"POST: memory check ............ 16384MB OK"
"BIOS: AES-NI .................. detected"
"UEFI: secure boot ............. disabled"
"SATA: TOSHIBA MQ01ACF0 ........ 465.8G"
"USB: boot media .............. verified"
"NET: interface enp0s31f6 ...... link up"
"INIT: loading archipelago ....."
)
for i in $(seq 1 8); do
local addr data
addr=$(rand_hex 8)
data=$(rand_hex 32)
goto $i 1
printf "%s%b0x%s %s%b" "$PADS" "$DARK" "$addr" "$data" "$NC"
sleep 0.02
done
local row=3
for msg in "${messages[@]}"; do
goto $row 1; clear_line
pn "${ORANGE_DIM}"
local i=0
while [[ $i -lt ${#msg} ]]; do
printf "%s" "${msg:$i:1}"
i=$((i + 1))
if [[ "${msg:$i:1}" == "." ]]; then sleep 0.005; else sleep 0.012; fi
done
printf "%b\n" "$NC"
row=$((row + 1))
sleep 0.05
done
sleep 0.3
for r in $(seq 1 $((row + 2))); do goto $r 1; clear_line; done
sleep 0.2
}
# ── ASCII Logo: A R C H I P E L A G O ─────────────────────────
# 43 chars wide, 3 lines tall. Correct spelling!
LOGO_FRONT=(
'▄▀█ █▀▄ █▀▀ █ █ █ █▀█ █▀▀ █ ▄▀█ █▀▀ █▀█'
'█▀█ █▀▄ █ █▀█ █ █▀▀ ██▀ █ █▀█ █ █ █ █'
'▀ ▀ ▀ ▀ ▀▀▀ ▀ ▀ ▀ ▀ ▀▀▀ ▀▀▀ ▀ ▀ ▀▀▀ ▀▀▀'
)
# 3D shadow: draw shadow (dark, offset +1,+2) then front on top
draw_logo_3d_at() {
local row="$1" color="${2:-$ORANGE}"
local front_col=$((LOGO_PAD + 1))
local shadow_col=$((LOGO_PAD + 3))
for li in 0 1 2; do
goto $((row + li + 1)) "$shadow_col"
printf "%b%s%b" "$DARK" "${LOGO_FRONT[$li]}" "$NC"
done
for li in 0 1 2; do
goto $((row + li)) "$front_col"
printf "%b%s%b" "$color" "${LOGO_FRONT[$li]}" "$NC"
done
}
draw_logo_flat() {
for line in "${LOGO_FRONT[@]}"; do
printf "%s%b%s%b\n" "$LOGO_PADS" "${1:-$ORANGE}" "$line" "$NC"
done
}
# Decrypt reveal with 3D shadow
logo_decrypt_reveal() {
local row="$1"
local iterations=7
local scramble_chars='█▓▒░╳◆▀▄▌▐┃━╋╬╪'
local front_col=$((LOGO_PAD + 1))
local shadow_col=$((LOGO_PAD + 3))
# Draw shadow layer first (static, dark)
for li in 0 1 2; do
goto $((row + li + 1)) "$shadow_col"
printf "%b%s%b" "$DARK" "${LOGO_FRONT[$li]}" "$NC"
done
# Decrypt front layer
for iter in $(seq 1 "$iterations"); do
for li in 0 1 2; do
local real="${LOGO_FRONT[$li]}"
local out=""
local len=${#real}
local resolve=$(( iter * len / iterations ))
local ci=0
while [[ $ci -lt $len ]]; do
local ch="${real:$ci:1}"
if [[ $ci -lt $resolve ]]; then
out="${out}${ch}"
elif [[ "$ch" == " " ]]; then
out="${out} "
else
out="${out}${scramble_chars:RANDOM % ${#scramble_chars}:1}"
fi
ci=$((ci + 1))
done
local color="$DARK"
case $iter in
1) color="$DARK" ;; 2) color="$DIMMER" ;; 3) color="$DIM" ;;
4) color="$ORANGE_DIM" ;; 5) color="$ORANGE_DIM" ;;
6) color="$ORANGE" ;; 7) color="$ORANGE" ;;
esac
goto $((row + li)) "$front_col"
printf "%b%s%b" "$color" "$out" "$NC"
done
sleep 0.07
done
# Glow pulse
for color in "$ORANGE_BRIGHT" "$ORANGE_GLOW" "$ORANGE_BRIGHT" "$ORANGE"; do
for li in 0 1 2; do
goto $((row + li)) "$front_col"
printf "\033[K%b%s%b" "$color" "${LOGO_FRONT[$li]}" "$NC"
done
sleep 0.05
done
}
# Quick glow pulse on existing logo
logo_glow_pulse() {
local row="$1" cycles="${2:-2}"
local col=$((LOGO_PAD + 1))
for _ in $(seq 1 "$cycles"); do
for color in "$ORANGE" "$ORANGE_BRIGHT" "$ORANGE_GLOW" "$ORANGE_BRIGHT" "$ORANGE"; do
for li in 0 1 2; do
goto $((row + li)) "$col"
printf "\033[K%b%s%b" "$color" "${LOGO_FRONT[$li]}" "$NC"
done
sleep 0.04
done
done
}
# Celebration: logo strobes with color party
logo_celebrate() {
local row="$1"
local col=$((LOGO_PAD + 1))
local party_colors=("$ORANGE" "$ORANGE_GLOW" "$WHITE" "$ORANGE_BRIGHT" "$GREEN_BRIGHT" "$ORANGE_GLOW" "$ORANGE")
for color in "${party_colors[@]}"; do
for li in 0 1 2; do
goto $((row + li)) "$col"
printf "\033[K%b%s%b" "$color" "${LOGO_FRONT[$li]}" "$NC"
done
sleep 0.06
done
}
# Screen wipe transition
screen_transition() {
for r in $(seq 1 "$TH"); do
goto "$r" 1
printf "%b%*s%b" "$ORANGE_DIM" "$TW" "" "$NC"
sleep 0.005
done
sleep 0.05
clear
}
# CRT power-on scan line
crt_on() {
hide_cursor; clear
for r in $(seq 1 "$TH"); do
goto "$r" 1
printf "%b%*s%b" "$ORANGE_DIM" "$TW" "" "$NC"
if [[ $r -gt 1 ]]; then goto $((r - 1)) 1; clear_line; fi
sleep 0.008
done
goto "$TH" 1; clear_line
sleep 0.1; clear
}
# ── Phase data ─────────────────────────────────────────────────
PHASE_NAMES=(
"Checking tools"
"Detecting disks"
"Creating partitions"
"Formatting partitions"
"Installing base system"
"Encrypting data partition"
"Installing bootloader"
)
PHASE_DETAILS=(
"parted, mkfs, cryptsetup"
"/dev/sda (465.8G) — TOSHIBA MQ01ACF0"
"BIOS boot + EFI + root + data"
"FAT32, ext4, LUKS2"
"debootstrap → Debian 13 minimal"
"AES-256-XTS (AES-NI detected)"
"GRUB: BIOS + UEFI hybrid"
)
PHASE_DURATIONS=(8 6 12 10 40 15 10)
# ── Header (logo + right-aligned subtitle) ────────────────────
# "bitcoin node os" right-aligned to match logo's right edge
SUBTITLE_PAD=$(printf "%*s" $((LOGO_PAD + LOGO_W - 15)) "")
HEADER_LINES=6 # 3 logo + shadow row + subtitle + blank
draw_header() {
draw_logo_flat
printf "%s%b%s%b\n" "$SUBTITLE_PAD" "$ORANGE_DIM" "bitcoin node os" "$NC"
p ""
}
draw_header_3d() {
local start_row="$1"
local logo_row=$((start_row))
# Blank for logo + shadow + subtitle
goto "$start_row" 1
for _ in $(seq 1 5); do p ""; done
printf "%s%b%s%b\n" "$SUBTITLE_PAD" "$ORANGE_DIM" "bitcoin node os" "$NC"
sleep 0.15
logo_decrypt_reveal "$logo_row"
}
# ── Phase drawing (all orange) ─────────────────────────────────
draw_phase_pending() {
p " ${DIMMER}[${1}/7] ${PHASE_NAMES[$(($1-1))]}${NC}"
}
draw_phase_running() {
p " ${ORANGE}[${1}/7] ${PHASE_NAMES[$(($1-1))]}${NC} ${ORANGE_BRIGHT}${NC}"
}
draw_phase_done() {
local name="${PHASE_NAMES[$(($1-1))]}"
local dot_count=$((34 - ${#name}))
[[ $dot_count -lt 2 ]] && dot_count=2
local dots=""
for _ in $(seq 1 "$dot_count"); do dots="${dots}."; done
p " ${ORANGE_DIM}[${1}/7] ${name} ${DARK}${dots}${NC} ${ORANGE_BRIGHT}${NC}"
}
draw_phase_done_compact() {
p " ${ORANGE_DIM}[${1}/7] ${PHASE_NAMES[$(($1-1))]}${NC} ${ORANGE_BRIGHT}${NC}"
}
simulate_work() {
local ticks=$1 row=$2 phase=$3 fi=0
local col=$((PAD + 2 + 10 + ${#PHASE_NAMES[$((phase-1))]} + 2))
for _ in $(seq 1 "$ticks"); do
goto "$row" "$col"
printf "%b%s%b" "$ORANGE" "${SPIN_FRAMES:fi%10:1}" "$NC"
fi=$((fi + 1))
sleep 0.1
done
}
simulate_work_with_bar() {
local ticks=$1 row=$2 phase=$3 bar_row=$4 fi=0
local col=$((PAD + 2 + 10 + ${#PHASE_NAMES[$((phase-1))]} + 2))
local bar_width=36
# Bouncing ₿ — DVD screensaver style
local b_row=$((bar_row + 3)) b_col=$((PAD + 4))
local b_dr=1 b_dc=1
local b_min_row=$((bar_row + 3))
local b_max_row=$((TH - 2))
[[ $b_max_row -lt $((b_min_row + 3)) ]] && b_max_row=$((b_min_row + 3))
local b_min_col=$((PAD + 2))
local b_max_col=$((PAD + BW - 2))
local b_colors=("$ORANGE" "$ORANGE_BRIGHT" "$ORANGE_GLOW" "$ORANGE_DIM" "$WHITE")
local b_ci=0
local b_prev_row=$b_row b_prev_col=$b_col
for t in $(seq 1 "$ticks"); do
goto "$row" "$col"
printf "%b%s%b" "$ORANGE" "${SPIN_FRAMES:fi%10:1}" "$NC"
fi=$((fi + 1))
local pct=$(( t * 100 / ticks ))
local filled=$(( pct * bar_width / 100 ))
local empty=$(( bar_width - filled ))
local bar_f="" bar_e=""
for _ in $(seq 1 "$filled" 2>/dev/null); do bar_f="${bar_f}"; done
for _ in $(seq 1 "$empty" 2>/dev/null); do bar_e="${bar_e}"; done
goto "$bar_row" 1; clear_line
p " ${ORANGE}${bar_f}${DARK}${bar_e}${NC} ${ORANGE_DIM}${pct}%%${NC}"
goto "$b_prev_row" "$b_prev_col"; printf " "
b_row=$((b_row + b_dr)); b_col=$((b_col + b_dc))
if [[ $b_row -ge $b_max_row ]] || [[ $b_row -le $b_min_row ]]; then
b_dr=$(( -b_dr )); b_row=$((b_row + b_dr))
b_ci=$(( (b_ci + 1) % ${#b_colors[@]} ))
fi
if [[ $b_col -ge $b_max_col ]] || [[ $b_col -le $b_min_col ]]; then
b_dc=$(( -b_dc )); b_col=$((b_col + b_dc))
b_ci=$(( (b_ci + 1) % ${#b_colors[@]} ))
fi
goto "$b_row" "$b_col"
printf "%b₿%b" "${b_colors[$b_ci]}" "$NC"
b_prev_row=$b_row; b_prev_col=$b_col
sleep 0.1
done
goto "$b_prev_row" "$b_prev_col"; printf " "
goto "$bar_row" 1; clear_line
for r in $(seq "$b_min_row" "$b_max_row"); do goto "$r" 1; clear_line; done
}
typewrite() {
local text="$1" delay="${2:-0.025}"
pn "${ORANGE}"
local i=0
while [[ $i -lt ${#text} ]]; do
printf "%s" "${text:$i:1}"
i=$((i + 1))
sleep "$delay"
done
printf "%b\n" "$NC"
}
# ── SCREEN 1: Welcome ─────────────────────────────────────────
screen_welcome() {
crt_on
boot_scan
clear
hide_cursor
local start_row=$(( (TH - 16) / 2 ))
[[ $start_row -lt 2 ]] && start_row=2
draw_header_3d "$start_row"
local prompt_row=$((start_row + HEADER_LINES + 2))
goto "$prompt_row" 1
local prompt_text=" Press Enter to install │ Ctrl+C for shell"
pn "${ORANGE_DIM}"
local i=0
while [[ $i -lt ${#prompt_text} ]]; do
printf "%s" "${prompt_text:$i:1}"
i=$((i + 1))
sleep 0.018
done
printf "%b" "$NC"
# Logo breathing while cursor blinks
local logo_at=$((start_row + 1))
local front_col=$((LOGO_PAD + 1))
for _ in $(seq 1 3); do
goto "$prompt_row" $((PAD + ${#prompt_text} + 2))
printf "%b▌%b" "$ORANGE" "$NC"
for li in 0 1 2; do
goto $((logo_at + li)) "$front_col"
printf "\033[K%b%s%b" "$ORANGE_BRIGHT" "${LOGO_FRONT[$li]}" "$NC"
done
sleep 0.4
goto "$prompt_row" $((PAD + ${#prompt_text} + 2))
printf " "
for li in 0 1 2; do
goto $((logo_at + li)) "$front_col"
printf "\033[K%b%s%b" "$ORANGE" "${LOGO_FRONT[$li]}" "$NC"
done
sleep 0.4
done
}
# ── SCREEN 2: Disk Detection ──────────────────────────────────
screen_disk_detect() {
screen_transition
hide_cursor
local row=2
goto $row 1
draw_header
row=$((row + HEADER_LINES))
goto $row 1
draw_phase_running 1
simulate_work 8 $row 1
goto $row 1; draw_phase_done 1
row=$((row + 2))
goto $row 1
draw_phase_running 2
simulate_work 6 $row 2
goto $row 1; draw_phase_done 2
row=$((row + 2))
goto $row 1
typewrite " Found: /dev/sda (465.8G) — TOSHIBA MQ01ACF0" 0.02
row=$((row + 2))
goto $row 1; hrule; row=$((row + 2))
goto $row 1
p "${ORANGE} ⚠ All data on /dev/sda will be erased.${NC}"
row=$((row + 2))
goto $row 1
p "${ORANGE_DIM} Press Enter to install │ Ctrl+C to cancel${NC}"
for _ in $(seq 1 4); do
goto $row $((PAD + 49))
printf "%b▌%b" "$ORANGE" "$NC"
sleep 0.4
goto $row $((PAD + 49))
printf " "
sleep 0.4
done
}
# ── SCREEN 3: Installation ────────────────────────────────────
screen_install() {
screen_transition
hide_cursor
local row=2
goto $row 1
draw_header
local phase_start=$((row + HEADER_LINES))
for i in $(seq 0 6); do
goto $((phase_start + i * 2)) 1
draw_phase_pending $((i + 1))
done
local bar_row=$((phase_start + 14 + 1))
goto $bar_row 1; hrule
local status_row=$((bar_row + 2))
for i in $(seq 0 6); do
local pr=$((phase_start + i * 2))
local pnum=$((i + 1))
local dur=${PHASE_DURATIONS[$i]}
goto $pr 1; clear_line
draw_phase_running $pnum
goto $status_row 1; clear_line
p " ${ORANGE_DIM}${PHASE_DETAILS[$i]}${NC}"
if [[ $dur -gt 15 ]]; then
simulate_work_with_bar "$dur" "$pr" "$pnum" "$((status_row - 1))"
else
simulate_work "$dur" "$pr" "$pnum"
fi
goto $pr 1; clear_line
draw_phase_done $pnum
done
goto $((bar_row + 1)) 1; clear_line
goto $status_row 1; clear_line
logo_glow_pulse 3 2
sleep 0.3
}
# ── SCREEN 4: Complete ─────────────────────────────────────────
screen_complete() {
screen_transition
hide_cursor
local row=2
goto $row 1
draw_header
logo_celebrate 3
row=$((row + HEADER_LINES))
for i in $(seq 1 7); do
goto $row 1
draw_phase_done_compact $i
row=$((row + 1))
done
row=$((row + 1))
goto $row 1; hrule; row=$((row + 2))
# Success flash
for color in "$ORANGE_DIM" "$ORANGE" "$ORANGE_BRIGHT" "$ORANGE"; do
goto $row 1; clear_line
p " ${color}✓ Installation Complete${NC}"
sleep 0.06
done
row=$((row + 2))
goto $row 1
typewrite " After reboot, access from any device:" 0.02
row=$((row + 2))
# URL in orange
goto $row 1
p " ${ORANGE}http://192.0.2.11${NC}"
row=$((row + 2))
# Credentials — white, NOT orange (user request)
goto $row 1
p " ${WHITE}SSH ssh archipelago@192.0.2.11${NC}"
row=$((row + 1))
goto $row 1
p " ${WHITE}Password archipelago${NC}"
row=$((row + 1))
goto $row 1
p " ${WHITE}Web Login create your password on first visit${NC}"
row=$((row + 2))
goto $row 1; hrule; row=$((row + 2))
for _ in $(seq 1 5); do
goto $row 1; clear_line
p "${ORANGE}${BOLD} >>> REMOVE THE USB DRIVE NOW <<<${NC}"
sleep 0.5
goto $row 1; clear_line
p "${ORANGE_DIM} >>> REMOVE THE USB DRIVE NOW <<<${NC}"
sleep 0.5
done
goto $row 1; clear_line
p "${ORANGE}${BOLD} >>> REMOVE THE USB DRIVE NOW <<<${NC}"
goto $((row + 2)) 1
p "${ORANGE_DIM} Press Enter to reboot${NC}"
sleep 3
}
# ── Main ───────────────────────────────────────────────────────
main() {
echo ""
echo " ${ORANGE}${NC} ${ORANGE}Archipelago Install TUI Demo${NC}"
echo " ${ORANGE_DIM} Each screen auto-advances. Ctrl+C to exit.${NC}"
echo ""
sleep 2
screen_welcome
sleep 0.3
screen_disk_detect
sleep 0.3
screen_install
sleep 0.3
screen_complete
show_cursor
echo ""
p "${ORANGE_DIM}** Demo complete **${NC}"
echo ""
}
main "$@"
+131
View File
@@ -0,0 +1,131 @@
#!/usr/bin/env bash
# Mount-level smoke test for an Archipelago installer ISO.
#
# Verifies boot plumbing (BIOS + UEFI + live-boot), the auto-installer
# payload, and — the check that has bitten before — that the backend
# binary inside the ISO actually embeds the version the filename claims.
#
# Usage:
# scripts/iso-smoke-test.sh <path-to-iso> [expected-version]
#
# expected-version defaults to core/archipelago/Cargo.toml. Needs sudo
# (loop mount). Exits non-zero on the first hard failure; prints a
# PASS/FAIL table either way.
set -u
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
ISO="${1:-}"
EXPECTED_VERSION="${2:-$(grep -m1 '^version' "$REPO/core/archipelago/Cargo.toml" | sed 's/.*"\(.*\)".*/\1/')}"
if [ -z "$ISO" ] || [ ! -f "$ISO" ]; then
echo "usage: $0 <path-to-iso> [expected-version]" >&2
exit 2
fi
FAIL=0
ok() { echo " OK: $*"; }
bad() { echo " FAIL: $*"; FAIL=1; }
warn() { echo " WARN: $*"; }
echo "ISO smoke test"
echo " ISO: $ISO ($(du -h "$ISO" | cut -f1))"
echo " Version: $EXPECTED_VERSION (expected)"
# ── Filename ↔ version parity (gap: ISO version can silently drift) ──
case "$(basename "$ISO")" in
*"$EXPECTED_VERSION"*) ok "filename contains $EXPECTED_VERSION" ;;
*) bad "filename does not contain expected version $EXPECTED_VERSION" ;;
esac
MNT="$(mktemp -d)"
INITRD_DIR=""
cleanup() {
sudo umount "$MNT" 2>/dev/null || true
rmdir "$MNT" 2>/dev/null || true
[ -n "$INITRD_DIR" ] && sudo rm -rf "$INITRD_DIR" 2>/dev/null
}
trap cleanup EXIT
if ! sudo mount -o loop,ro "$ISO" "$MNT"; then
echo " FAIL: could not loop-mount ISO" >&2
exit 1
fi
# ── Required boot + installer files ──────────────────────────────────
for f in live/vmlinuz live/initrd.img live/filesystem.squashfs \
isolinux/isolinux.bin isolinux/isolinux.cfg \
boot/grub/grub.cfg EFI/BOOT/BOOTX64.EFI \
archipelago/auto-install.sh archipelago/rootfs.tar; do
if [ -e "$MNT/$f" ]; then
ok "$f ($(sudo du -h "$MNT/$f" 2>/dev/null | cut -f1))"
else
bad "missing $f"
fi
done
# ── GRUB must boot the live system ───────────────────────────────────
if grep -q "boot=live" "$MNT/boot/grub/grub.cfg" 2>/dev/null; then
ok "grub.cfg has boot=live"
else
bad "grub.cfg missing boot=live"
fi
# ── initrd must contain live-boot scripts ────────────────────────────
if command -v unmkinitramfs >/dev/null 2>&1; then
INITRD_DIR="$(mktemp -d)"
sudo unmkinitramfs "$MNT/live/initrd.img" "$INITRD_DIR" 2>/dev/null
if [ -e "$INITRD_DIR/scripts/live" ] || [ -e "$INITRD_DIR/main/scripts/live" ]; then
ok "initrd has live-boot scripts"
else
bad "initrd missing live-boot scripts"
fi
else
warn "unmkinitramfs not installed — skipping initrd live-boot check"
fi
# ── Backend binary inside the ISO embeds the expected version ────────
# (the v1.4.0-binary-in-a-v1.5-ISO incident: a stale captured binary
# shipped and the fleet rejected its fips.yaml on Activate)
BIN_IN_ISO=""
if [ -f "$MNT/archipelago/bin/archipelago" ]; then
BIN_IN_ISO="$MNT/archipelago/bin/archipelago"
if sudo strings "$BIN_IN_ISO" 2>/dev/null | grep -qF "$EXPECTED_VERSION"; then
ok "payload backend binary embeds $EXPECTED_VERSION"
else
bad "payload backend binary does NOT embed $EXPECTED_VERSION (stale binary)"
fi
else
# Fall back to the copy inside rootfs.tar
TMPBIN="$(mktemp -d)"
if sudo tar -xf "$MNT/archipelago/rootfs.tar" -C "$TMPBIN" \
usr/local/bin/archipelago 2>/dev/null; then
if sudo strings "$TMPBIN/usr/local/bin/archipelago" | grep -qF "$EXPECTED_VERSION"; then
ok "rootfs backend binary embeds $EXPECTED_VERSION"
else
bad "rootfs backend binary does NOT embed $EXPECTED_VERSION (stale binary)"
fi
else
bad "no backend binary found at archipelago/bin/ or in rootfs.tar"
fi
sudo rm -rf "$TMPBIN"
fi
# ── Frontend payload present ─────────────────────────────────────────
if [ -f "$MNT/archipelago/web-ui/index.html" ]; then
ok "frontend payload (archipelago/web-ui/index.html)"
if [ -f "$MNT/archipelago/web-ui/aiui/index.html" ]; then
ok "AIUI included in frontend payload"
else
warn "AIUI missing from archipelago/web-ui (verify rootfs copy before shipping)"
fi
else
warn "no archipelago/web-ui payload on ISO (frontend may live in rootfs.tar only)"
fi
echo
if [ "$FAIL" = "1" ]; then
echo "ISO SMOKE TEST: FAILED"
exit 1
fi
echo "ISO SMOKE TEST: PASSED"
+225
View File
@@ -0,0 +1,225 @@
#!/bin/bash
# Shared utility functions for Archipelago scripts
#
# Source this from any script:
# source "$(dirname "$0")/lib/common.sh"
#
# Provides: logging, SSH helpers, health checks, disk checks, memory limits
# Guard against double-sourcing
[ -n "${_ARCHY_COMMON_LOADED:-}" ] && return 0
_ARCHY_COMMON_LOADED=1
# ── Colored logging ─────────────────────────────────────────────────────
log_info() { echo -e "\033[0;32m[INFO]\033[0m $(date '+%H:%M:%S') $*"; }
log_warn() { echo -e "\033[0;33m[WARN]\033[0m $(date '+%H:%M:%S') $*"; }
log_error() { echo -e "\033[0;31m[ERROR]\033[0m $(date '+%H:%M:%S') $*"; }
# ── SSH wrapper with deploy key ─────────────────────────────────────────
# Usage: ssh_cmd <host> <command...>
# Uses the standard deploy key and safe defaults.
ssh_cmd() {
local host="$1"; shift
local key="${ARCHIPELAGO_SSH_KEY:-$HOME/.ssh/archipelago-deploy}"
ssh -i "$key" \
-o StrictHostKeyChecking=no \
-o ConnectTimeout=10 \
-o ServerAliveInterval=15 \
-o ServerAliveCountMax=4 \
"archipelago@${host}" "$@"
}
# Usage: scp_cmd <src> <dest>
# Wraps scp with the same deploy key and options.
scp_cmd() {
local key="${ARCHIPELAGO_SSH_KEY:-$HOME/.ssh/archipelago-deploy}"
scp -i "$key" \
-o StrictHostKeyChecking=no \
-o ConnectTimeout=10 \
"$@"
}
# ── Health check ────────────────────────────────────────────────────────
# Wait for an HTTP health endpoint to respond successfully.
# Usage: wait_for_health <host> [max_wait_seconds] [path]
wait_for_health() {
local host="$1" max_wait="${2:-60}" path="${3:-/health}"
local waited=0
while [ $waited -lt $max_wait ]; do
if curl -sf "http://${host}${path}" >/dev/null 2>&1; then
log_info "Health check passed for ${host}"
return 0
fi
sleep 2
waited=$((waited + 2))
done
log_error "Health check failed for ${host} after ${max_wait}s"
return 1
}
# ── Disk space check ───────────────────────────────────────────────────
# Check that disk usage on a remote host is below a threshold.
# Usage: check_disk_space <host> [max_percent]
check_disk_space() {
local host="$1" max_pct="${2:-85}"
local pct
pct=$(ssh_cmd "$host" "df / | tail -1 | awk '{print \$(NF-1)}' | tr -d '%'" 2>/dev/null)
if [ -n "$pct" ] && [ "$pct" -gt "$max_pct" ] 2>/dev/null; then
log_error "Disk at ${pct}% on ${host} (max ${max_pct}%)"
return 1
fi
return 0
}
# ── Memory limit calculator ────────────────────────────────────────────
# Returns the memory limit for a container by name.
# Checks /etc/archipelago/memory-limits.conf first (override), then falls
# back to built-in defaults. Mirrors the pattern in first-boot-containers.sh.
#
# Low-memory mode: set LOW_MEM=true before calling to get reduced limits
# on certain heavy containers.
#
# Usage: mem_limit <container-name>
mem_limit() {
local name="$1"
# Allow per-host overrides via config file
local limit
limit=$(grep "^${name}=" /etc/archipelago/memory-limits.conf 2>/dev/null | cut -d= -f2)
if [ -n "$limit" ]; then
echo "$limit"
return
fi
# Built-in defaults (keep in sync with first-boot-containers.sh and Rust package config)
local low="${LOW_MEM:-false}"
case "$name" in
bitcoin|bitcoin-core|bitcoin-knots) $low && echo "4g" || echo "8g" ;;
ollama) $low && echo "1g" || echo "4g" ;;
lnd) echo "512m" ;;
electrumx|mempool-electrs|electrs) echo "4g" ;;
nextcloud) echo "1g" ;;
immich_server) echo "1g" ;;
btcpay-server|btcpayserver) echo "1g" ;;
homeassistant) echo "512m" ;;
fedimint) echo "512m" ;;
fedimint-gateway) echo "512m" ;;
photoprism) $low && echo "512m" || echo "1g" ;;
mempool-api) echo "512m" ;;
jellyfin) echo "1g" ;;
searxng) echo "512m" ;;
archy-btcpay-db) echo "512m" ;;
archy-nbxplorer) echo "512m" ;;
archy-mempool-db) echo "512m" ;;
archy-mempool-web) echo "256m" ;;
grafana) echo "256m" ;;
vaultwarden) echo "256m" ;;
uptime-kuma) echo "256m" ;;
filebrowser) echo "256m" ;;
portainer) echo "256m" ;;
nginx-proxy-manager) echo "256m" ;;
immich_postgres) echo "256m" ;;
immich_redis) echo "128m" ;;
tailscale) echo "256m" ;;
penpot-postgres) echo "256m" ;;
penpot-valkey) echo "128m" ;;
penpot-backend) echo "512m" ;;
penpot-exporter) echo "256m" ;;
penpot-frontend) echo "256m" ;;
nostr-rs-relay) echo "256m" ;;
strfry) echo "256m" ;;
indeedhub|archy-bitcoin-ui|archy-lnd-ui|archy-electrs-ui) echo "128m" ;;
*) echo "512m" ;;
esac
}
# ── Wait for container readiness ───────────────────────────────────────
# Wait for a container health check command to succeed.
# Usage: wait_for_container <name> <check_cmd> [max_wait_seconds]
wait_for_container() {
local name="$1" check_cmd="$2" max_wait="${3:-30}"
local waited=0
while [ $waited -lt $max_wait ]; do
if eval "$check_cmd" 2>/dev/null; then
log_info "$name is ready (${waited}s)"
return 0
fi
sleep 2
waited=$((waited + 2))
done
log_warn "$name not ready after ${max_wait}s"
return 1
}
# ── Same-host deploy safety guard (2026-07-31 incident, widened 13-09) ──
# Refuse a same-host deploy whose resolved source and destination differ.
#
# The 2026-07-31 incident: a same-host `rsync --delete` deploy whose source
# was INSIDE the destination (a worktree under the main checkout) mirrored
# the worktree onto the main checkout and deleted ~1810 tracked files, a
# running dev server, and two sessions' uncommitted work. The original fix
# refused only that containment shape (source-in-destination or
# destination-in-source). It missed SIBLING directories that share a parent
# but neither contains the other — e.g. archy-phase13 (this worktree) as
# source and archy (the main checkout, TARGET_DIR's resolved symlink
# target) as destination — which is the identical rsync --delete hazard
# through a shape the old two-case guard let through.
#
# This function takes two ALREADY-RESOLVED (`readlink -f`) absolute paths
# and makes no SSH calls itself — same-host detection stays in the caller
# (deploy-to-target.sh already does it via /etc/machine-id). It returns 0
# only when the two paths are equal; every other case is refused, not just
# the two containment shapes. Any resolved-path mismatch on the same host
# is the same rsync --delete hazard regardless of shape, so "refuse unless
# equal" is both the widening and a simplification.
#
# Usage: assert_safe_same_host_deploy <local_src> <remote_dst>
assert_safe_same_host_deploy() {
local local_src="$1" remote_dst="$2"
if [ -z "$local_src" ] || [ -z "$remote_dst" ]; then
log_error "assert_safe_same_host_deploy: both paths are required (local_src='$local_src' remote_dst='$remote_dst')"
return 1
fi
if [ "$local_src" = "$remote_dst" ]; then
return 0
fi
echo "FATAL: refusing to deploy. Source '$local_src' and destination '$remote_dst' are on the same host but resolve to DIFFERENT paths." >&2
echo " 'rsync --delete' would mirror one onto the other and delete whatever the source lacks — the 2026-07-31 data-loss incident (and the sibling-directory shape its original fix missed)." >&2
echo " Run this script from the deploy destination itself ('$remote_dst'), not from a worktree, sibling checkout, or copy." >&2
return 1
}
# ── Section timing ─────────────────────────────────────────────────────
# Track elapsed time for deploy sections.
# Usage:
# section_start "Building frontend"
# ... do work ...
# section_end
_SECTION_START=0
_SECTION_NAME=""
section_start() {
_SECTION_NAME="${1:-}"
_SECTION_START=$(date +%s)
[ -n "$_SECTION_NAME" ] && log_info "$_SECTION_NAME"
}
section_end() {
local elapsed=$(( $(date +%s) - _SECTION_START ))
if [ -n "$_SECTION_NAME" ]; then
log_info "$_SECTION_NAME done (${elapsed}s)"
else
echo " (${elapsed}s)"
fi
}
+292
View File
@@ -0,0 +1,292 @@
#!/bin/bash
# ─────────────────────────────────────────────────────────────────
# Archipelago Install TUI Library
# Sourced by auto-install.sh to add animations to the installer.
# If not sourced, installer falls back to plain text output.
# ─────────────────────────────────────────────────────────────────
# Revert: remove the "source" line in auto-install.sh and
# this file. Installer reverts to plain step/ok/fail output.
# ─────────────────────────────────────────────────────────────────
[ -n "${_INSTALL_TUI_LOADED:-}" ] && return 0
_INSTALL_TUI_LOADED=1
# ── Extra colors (plain installer only has basic set) ──────────
ORANGE_GLOW=$'\033[38;5;220m'
GREEN_DIM=$'\033[38;5;22m'
GREEN_BRIGHT=$'\033[38;5;46m'
DARK=$'\033[38;5;235m'
# ── Terminal setup ─────────────────────────────────────────────
TW=$(tput cols 2>/dev/null || echo 80)
TH=$(tput lines 2>/dev/null || echo 24)
[[ $TW -gt 100 ]] && TW=100
LOGO_W=43
LOGO_PAD=$(( (TW - LOGO_W) / 2 ))
[[ $LOGO_PAD -lt 0 ]] && LOGO_PAD=0
LOGO_PADS=$(printf "%*s" "$LOGO_PAD" "")
# ── Primitives ─────────────────────────────────────────────────
hide_cursor() { tput civis 2>/dev/null || true; }
show_cursor() { tput cnorm 2>/dev/null || true; }
goto() { printf "\033[%d;%dH" "$1" "$2"; }
clear_line() { printf "\033[K"; }
# ── Hacker glyphs ─────────────────────────────────────────────
HEXCHARS='0123456789abcdef'
SPIN_FRAMES='⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏'
tui_rand_hex() {
local len="${1:-8}" out=""
for _ in $(seq 1 "$len"); do
out="${out}${HEXCHARS:RANDOM % 16:1}"
done
echo -n "$out"
}
# ── ASCII Logo ─────────────────────────────────────────────────
LOGO_FRONT=(
'▄▀█ █▀▄ █▀▀ █ █ █ █▀█ █▀▀ █ ▄▀█ █▀▀ █▀█'
'█▀█ █▀▄ █ █▀█ █ █▀▀ ██▀ █ █▀█ █ █ █ █'
'▀ ▀ ▀ ▀ ▀▀▀ ▀ ▀ ▀ ▀ ▀▀▀ ▀▀▀ ▀ ▀ ▀▀▀ ▀▀▀'
)
# ── Boot scan effect ──────────────────────────────────────────
tui_boot_scan() {
clear
hide_cursor
local messages=(
"POST: memory check ............ OK"
"BIOS: AES-NI .................. detected"
"UEFI: secure boot ............. disabled"
"USB: boot media .............. verified"
"NET: interface ............... link up"
"INIT: loading archipelago ....."
)
for i in $(seq 1 6); do
local addr data
addr=$(tui_rand_hex 8)
data=$(tui_rand_hex 32)
goto $i 1
printf "%s%b0x%s %s%b" "$PADS" "$DARK" "$addr" "$data" "$NC"
sleep 0.02
done
local row=3
for msg in "${messages[@]}"; do
goto $row 1; clear_line
printf "%s%b" "$PADS" "$ORANGE_DIM"
local i=0
while [[ $i -lt ${#msg} ]]; do
printf "%s" "${msg:$i:1}"
i=$((i + 1))
sleep 0.01
done
printf "%b\n" "$NC"
row=$((row + 1))
sleep 0.04
done
sleep 0.3
for r in $(seq 1 $((row + 2))); do goto $r 1; clear_line; done
sleep 0.2
}
# ── Logo decrypt reveal ───────────────────────────────────────
tui_logo_decrypt_reveal() {
local row="${1:-3}"
local iterations=6
local scramble_chars='█▓▒░╳◆▀▄▌▐┃━╋╬╪'
local front_col=$((LOGO_PAD + 1))
local shadow_col=$((LOGO_PAD + 3))
# Draw shadow layer (static, dark)
for li in 0 1 2; do
goto $((row + li + 1)) "$shadow_col"
printf "%b%s%b" "$DARK" "${LOGO_FRONT[$li]}" "$NC"
done
# Decrypt front layer
for iter in $(seq 1 "$iterations"); do
local color
case $iter in
1|2) color="$ORANGE_DIM" ;;
3|4) color="$ORANGE" ;;
*) color="$ORANGE_BRIGHT" ;;
esac
for li in 0 1 2; do
local real="${LOGO_FRONT[$li]}"
local out=""
local len=${#real}
local resolve=$(( iter * len / iterations ))
local ci=0
while [[ $ci -lt $len ]]; do
local ch="${real:$ci:1}"
if [[ $ci -lt $resolve ]]; then
out="${out}${ch}"
elif [[ "$ch" == " " ]]; then
out="${out} "
else
out="${out}${scramble_chars:RANDOM % ${#scramble_chars}:1}"
fi
ci=$((ci + 1))
done
goto $((row + li)) "$front_col"
printf "%b%s%b" "$color" "$out" "$NC"
done
sleep 0.07
done
# Glow pulse
for color in "$ORANGE_BRIGHT" "$ORANGE_GLOW" "$ORANGE_BRIGHT" "$ORANGE"; do
for li in 0 1 2; do
goto $((row + li)) "$front_col"
printf "\033[K%b%s%b" "$color" "${LOGO_FRONT[$li]}" "$NC"
done
sleep 0.05
done
}
# ── Logo celebration strobe ───────────────────────────────────
tui_logo_celebrate() {
local row="${1:-3}"
local col=$((LOGO_PAD + 1))
local party_colors=("$ORANGE" "$ORANGE_GLOW" "$WHITE" "$ORANGE_BRIGHT" "$GREEN_BRIGHT" "$ORANGE_GLOW" "$ORANGE")
for color in "${party_colors[@]}"; do
for li in 0 1 2; do
goto $((row + li)) "$col"
printf "\033[K%b%s%b" "$color" "${LOGO_FRONT[$li]}" "$NC"
done
sleep 0.06
done
}
# ── CRT power-on scan line ────────────────────────────────────
tui_crt_on() {
hide_cursor; clear
for r in $(seq 1 "$TH"); do
goto "$r" 1
printf "%b%*s%b" "$ORANGE_DIM" "$TW" "" "$NC"
if [[ $r -gt 1 ]]; then goto $((r - 1)) 1; clear_line; fi
sleep 0.008
done
goto "$TH" 1; clear_line
sleep 0.1; clear
}
# ── Progress bar with bouncing Bitcoin symbol ─────────────────
# Usage: tui_progress_bar <pid> <message>
# Runs until process $pid exits. Shows progress bar + bouncing ₿
tui_progress_bar() {
local pid=$1 msg=$2
local frames='⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏'
local bar_width=36
local fi=0 tick=0
local est_ticks=200 # rough estimate for progress
# Bouncing ₿ setup
local term_h=$TH
local b_row=12 b_col=10
local b_dr=1 b_dc=1
local b_min_row=10 b_max_row=$((term_h - 2))
[[ $b_max_row -lt 14 ]] && b_max_row=14
local b_min_col=4 b_max_col=$((TW - 4))
local b_colors=("$ORANGE" "$ORANGE_BRIGHT" "$ORANGE_GLOW" "$ORANGE_DIM" "$WHITE")
local b_ci=0
local b_prev_row=$b_row b_prev_col=$b_col
while kill -0 "$pid" 2>/dev/null; do
# Spinner
printf "\r%s %b%s %s%b" "$PADS" "$ORANGE" "${frames:fi%10:1}" "$msg" "$NC"
fi=$((fi + 1))
tick=$((tick + 1))
# Progress bar (estimate-based since we don't know real progress)
local pct=$(( tick * 95 / est_ticks ))
[[ $pct -gt 95 ]] && pct=95
local filled=$(( pct * bar_width / 100 ))
local empty=$(( bar_width - filled ))
local bar_f="" bar_e=""
for _ in $(seq 1 "$filled" 2>/dev/null); do bar_f="${bar_f}"; done
for _ in $(seq 1 "$empty" 2>/dev/null); do bar_e="${bar_e}"; done
# Draw bar below spinner
goto 22 1; clear_line
printf "%s %b%s%b%s%b %b%d%%%b" "$PADS" "$ORANGE" "$bar_f" "$DARK" "$bar_e" "$NC" "$ORANGE_DIM" "$pct" "$NC"
# Bouncing ₿
goto "$b_prev_row" "$b_prev_col"; printf " "
b_row=$((b_row + b_dr)); b_col=$((b_col + b_dc))
if [[ $b_row -ge $b_max_row ]] || [[ $b_row -le $b_min_row ]]; then
b_dr=$(( -b_dr )); b_row=$((b_row + b_dr))
b_ci=$(( (b_ci + 1) % ${#b_colors[@]} ))
fi
if [[ $b_col -ge $b_max_col ]] || [[ $b_col -le $b_min_col ]]; then
b_dc=$(( -b_dc )); b_col=$((b_col + b_dc))
b_ci=$(( (b_ci + 1) % ${#b_colors[@]} ))
fi
goto "$b_row" "$b_col"
printf "%b₿%b" "${b_colors[$b_ci]}" "$NC"
b_prev_row=$b_row; b_prev_col=$b_col
sleep 0.1
done
# Clean up
goto "$b_prev_row" "$b_prev_col"; printf " "
goto 22 1; clear_line
# Clear bouncing area
for r in $(seq "$b_min_row" "$b_max_row"); do goto "$r" 1; clear_line; done
printf "\r%s %b✓ %s%b\n" "$PADS" "$ORANGE_BRIGHT" "$msg" "$NC"
}
# ── Flashing completion message ───────────────────────────────
tui_flash_remove_usb() {
local row="${1:-20}"
for _ in $(seq 1 5); do
goto "$row" 1; clear_line
printf "%s%b%b >>> REMOVE THE USB DRIVE NOW <<<%b\n" "$PADS" "$ORANGE" "$BOLD" "$NC"
sleep 0.5
goto "$row" 1; clear_line
printf "%s%b >>> REMOVE THE USB DRIVE NOW <<<%b\n" "$PADS" "$ORANGE_DIM" "$NC"
sleep 0.5
done
goto "$row" 1; clear_line
printf "%s%b%b >>> REMOVE THE USB DRIVE NOW <<<%b\n" "$PADS" "$ORANGE" "$BOLD" "$NC"
}
# ── Override: replace plain spinner with progress bar ─────────
# Call this to replace the default spinner() with the animated version.
# Only for long operations (base system install, bootloader).
tui_enable_progress_spinner() {
spinner() {
tui_progress_bar "$1" "$2"
}
}
# ── Welcome screen (replaces plain logo) ─────────────────────
tui_welcome() {
tui_crt_on
tui_boot_scan
clear
hide_cursor
tui_logo_decrypt_reveal 3
# Subtitle
local sub_pad=$(printf "%*s" $((LOGO_PAD + LOGO_W - 15)) "")
goto 7 1
printf "%s%b%s%b\n" "$sub_pad" "$ORANGE_DIM" "bitcoin node os" "$NC"
echo ""
}
# ── Completion screen (replaces plain message) ────────────────
tui_complete() {
tui_logo_celebrate 3
show_cursor
}
# Mark as loaded — installer checks this
TUI_AVAILABLE=1
+48
View File
@@ -0,0 +1,48 @@
# Proxy apps that set X-Frame-Options - strip header so iframe works (Nextcloud, Vaultwarden, Immich)
location /app/nextcloud/ {
proxy_pass http://127.0.0.1:8085/;
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_hide_header X-Frame-Options;
proxy_hide_header Content-Security-Policy;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
}
location /app/vaultwarden/ {
proxy_pass http://127.0.0.1:8082/;
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_hide_header X-Frame-Options;
proxy_hide_header Content-Security-Policy;
}
location /app/immich/ {
proxy_pass http://127.0.0.1:2283/;
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_hide_header X-Frame-Options;
proxy_hide_header Content-Security-Policy;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
}
location /app/penpot/ {
proxy_pass http://127.0.0.1:9001/;
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_hide_header X-Frame-Options;
proxy_hide_header Content-Security-Policy;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
}
+6
View File
@@ -0,0 +1,6 @@
location /archipelago/ {
proxy_pass http://127.0.0.1:5678;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
+211
View File
@@ -0,0 +1,211 @@
# App proxies for HTTPS - avoids mixed content when embedding apps from HTTPS page
# Complete list for all apps that may be launched from the UI
location /app/grafana/ {
proxy_pass http://127.0.0.1:3000/;
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_hide_header X-Frame-Options;
proxy_hide_header Content-Security-Policy;
}
location = /app/uptime-kuma/ {
return 302 /app/uptime-kuma/dashboard;
}
location /app/uptime-kuma/ {
proxy_pass http://127.0.0.1:3002/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Prefix /app/uptime-kuma;
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_redirect / /app/uptime-kuma/;
proxy_hide_header X-Frame-Options;
proxy_hide_header Content-Security-Policy;
}
location /app/gitea/ {
proxy_pass http://127.0.0.1:3001/;
proxy_http_version 1.1;
proxy_set_header Host $http_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_hide_header X-Frame-Options;
proxy_hide_header Content-Security-Policy;
}
location /app/searxng/ {
proxy_pass http://127.0.0.1:8888/;
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_hide_header X-Frame-Options;
proxy_hide_header Content-Security-Policy;
}
location /app/portainer/ {
proxy_pass http://127.0.0.1:9000/;
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_hide_header X-Frame-Options;
proxy_hide_header Content-Security-Policy;
}
location /app/filebrowser/ {
client_max_body_size 10G;
proxy_pass http://127.0.0.1:8083/;
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_hide_header X-Frame-Options;
proxy_hide_header Content-Security-Policy;
proxy_request_buffering off;
}
location /app/endurain/ {
proxy_pass http://127.0.0.1:8080/;
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_hide_header X-Frame-Options;
proxy_hide_header Content-Security-Policy;
}
location /app/lnd/ {
proxy_pass http://127.0.0.1:18083/;
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_hide_header X-Frame-Options;
proxy_hide_header Content-Security-Policy;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
}
location /app/jellyfin/ {
proxy_pass http://127.0.0.1:8096/;
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_hide_header X-Frame-Options;
proxy_hide_header Content-Security-Policy;
}
location /app/photoprism/ {
proxy_pass http://127.0.0.1:2342/;
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_hide_header X-Frame-Options;
proxy_hide_header Content-Security-Policy;
}
location /app/mempool/ {
proxy_pass http://127.0.0.1:4080/;
proxy_http_version 1.1;
# mempool's UI is websocket-driven (/api/v1/ws). Without forwarding the
# upgrade, the page loads but never connects — the backend can be fully
# healthy and every REST probe green while the user sees a dead UI
# (2026-08-09). 101 through this proxy is the only honest health signal.
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
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_hide_header X-Frame-Options;
proxy_hide_header Content-Security-Policy;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
}
location /app/fedimint/ {
proxy_pass http://127.0.0.1:8175/;
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_hide_header X-Frame-Options;
proxy_hide_header Content-Security-Policy;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
}
location /app/tailscale/ {
proxy_pass http://127.0.0.1:8240/;
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_hide_header X-Frame-Options;
proxy_hide_header Content-Security-Policy;
}
location /app/ollama/ {
proxy_pass http://127.0.0.1:11434/;
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_hide_header X-Frame-Options;
proxy_hide_header Content-Security-Policy;
}
location /app/bitcoin-ui/ {
proxy_pass http://127.0.0.1:8334/;
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_hide_header X-Frame-Options;
proxy_hide_header Content-Security-Policy;
}
location /app/electrs/ {
proxy_pass http://127.0.0.1:50002/;
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_hide_header X-Frame-Options;
proxy_hide_header Content-Security-Policy;
}
location /app/electrumx/ {
proxy_pass http://127.0.0.1:50002/;
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_hide_header X-Frame-Options;
proxy_hide_header Content-Security-Policy;
}
location /app/electrs-ui/ {
proxy_pass http://127.0.0.1:50002/;
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_hide_header X-Frame-Options;
proxy_hide_header Content-Security-Policy;
}
location /app/nginx-proxy-manager/ {
proxy_pass http://127.0.0.1:8081/;
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_hide_header X-Frame-Options;
proxy_hide_header Content-Security-Policy;
}
+12
View File
@@ -0,0 +1,12 @@
location /app/penpot/ {
proxy_pass http://127.0.0.1:9001/;
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_hide_header X-Frame-Options;
proxy_hide_header Content-Security-Policy;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
}
+16
View File
@@ -0,0 +1,16 @@
# PWA installability - required for Install (not just Add to Home Screen) on Android
# Manifest MUST be served with application/manifest+json - Chrome rejects otherwise
location = /manifest.webmanifest {
default_type application/manifest+json;
add_header Cache-Control "public, max-age=0, must-revalidate";
}
# Service worker - no cache so updates apply
location ~ ^/(sw\.js|workbox-.*\.js|registerSW\.js)$ {
add_header Content-Type application/javascript;
add_header Service-Worker-Allowed /;
add_header Cache-Control "no-cache, no-store, must-revalidate";
}
# index.html - avoid aggressive cache for PWA updates
location = /index.html {
add_header Cache-Control "public, max-age=0, must-revalidate";
}
+87
View File
@@ -0,0 +1,87 @@
#!/bin/bash
# Debian Linux optimization script for Archipelago
# Optimizes system settings for container workloads
set -e
echo "⚡ Optimizing Debian Linux for container workloads..."
# CPU Governor - set to performance for better container performance
if [ -f /sys/devices/system/cpu/cpu0/cpufreq/scaling_governor ]; then
echo "performance" > /sys/devices/system/cpu/cpu*/cpufreq/scaling_governor 2>/dev/null || true
fi
# I/O Scheduler - use none for NVMe or mq-deadline for SATA
if command -v lsblk >/dev/null 2>&1; then
for disk in $(lsblk -d -o NAME -n); do
if [ -f "/sys/block/$disk/queue/scheduler" ]; then
# Prefer none (for NVMe) or mq-deadline (for SATA SSD)
if grep -q "none" "/sys/block/$disk/queue/scheduler"; then
echo none > "/sys/block/$disk/queue/scheduler" 2>/dev/null || true
elif grep -q "mq-deadline" "/sys/block/$disk/queue/scheduler"; then
echo mq-deadline > "/sys/block/$disk/queue/scheduler" 2>/dev/null || true
fi
fi
done
fi
# Increase file descriptor limits
cat >> /etc/security/limits.conf <<EOF
* soft nofile 65536
* hard nofile 65536
root soft nofile 65536
root hard nofile 65536
EOF
# Optimize network settings for container networking
cat >> /etc/sysctl.d/99-archipelago.conf <<EOF
# Container networking optimizations
net.core.somaxconn = 4096
net.ipv4.tcp_max_syn_backlog = 4096
net.core.netdev_max_backlog = 5000
net.ipv4.ip_local_port_range = 1024 65535
# Container storage optimizations
vm.swappiness = 10
vm.dirty_ratio = 15
vm.dirty_background_ratio = 5
# Enable IP forwarding for containers
net.ipv4.ip_forward = 1
EOF
# Apply sysctl settings
sysctl --system >/dev/null 2>&1 || true
# Remove policy-rc.d if present — leftover from chroot build, blocks service starts
rm -f /usr/sbin/policy-rc.d 2>/dev/null || true
# Ensure NTP time sync via chrony (more reliable than systemd-timesyncd)
if ! dpkg -l chrony >/dev/null 2>&1; then
echo "🕐 Installing chrony for NTP time sync..."
apt-get update -qq && apt-get install -y chrony 2>/dev/null || true
fi
systemctl enable chrony 2>/dev/null || true
systemctl start chrony 2>/dev/null || true
timedatectl set-ntp true 2>/dev/null || true
# Ensure swap exists — prevents OOM kills on memory-constrained nodes
TOTAL_MEM_KB=$(grep MemTotal /proc/meminfo | awk '{print $2}')
TOTAL_MEM_GB=$((TOTAL_MEM_KB / 1024 / 1024))
SWAP_SIZE_GB=$((TOTAL_MEM_GB > 8 ? 8 : TOTAL_MEM_GB))
if [ ! -f /swapfile ]; then
echo "💾 Creating ${SWAP_SIZE_GB}G swap file..."
fallocate -l ${SWAP_SIZE_GB}G /swapfile
chmod 600 /swapfile
mkswap /swapfile
swapon /swapfile
if ! grep -q '/swapfile' /etc/fstab; then
echo '/swapfile none swap sw 0 0' >> /etc/fstab
fi
echo "✅ Swap created: ${SWAP_SIZE_GB}G"
else
echo "✅ Swap file already exists"
swapon /swapfile 2>/dev/null || true
fi
echo "✅ Debian optimization complete!"
+72
View File
@@ -0,0 +1,72 @@
#!/bin/bash
# OTA crash-loop guard — runs as root from ExecStartPre=+- on archipelago.service.
#
# Covers the failure mode verify_pending_update() cannot: a freshly-applied
# binary that can't even start (SEGV/ENOEXEC — e.g. the truncated 17MB binary
# .198 installed on the v1.7.103 OTA, which crash-looped 236 times with a
# perfectly good backup sitting in update-backup/). The in-binary probe never
# runs because the binary never runs, so this guard counts start attempts from
# outside and restores the backup binary once the new one has clearly failed.
#
# Scope is deliberately narrow: it acts ONLY while the post-OTA pending-verify
# marker exists (written by apply_update just before the restart, deleted by
# the new binary once it boots and passes its probes). A crash loop with no
# marker is not an OTA gone wrong, and this script stays out of it.
#
# Always exits 0 — a guard must never be the reason the service can't start.
set -u
DATA_DIR=/var/lib/archipelago
MARKER="$DATA_DIR/update-pending-verify.json"
COUNT_FILE="$DATA_DIR/ota-crash-guard.count"
BACKUP="$DATA_DIR/update-backup/archipelago"
BINARY=/usr/local/bin/archipelago
MAX_ATTEMPTS=5
log() {
echo "$*" | systemd-cat -t ota-crash-guard -p warning 2>/dev/null || true
}
# No pending OTA verification -> nothing to guard; clear any stale counter.
if [ ! -f "$MARKER" ]; then
rm -f "$COUNT_FILE"
exit 0
fi
# Count this start attempt. The counter only accumulates while the marker
# exists; a healthy new binary deletes the marker on its first successful
# boot, and the next start clears the counter above.
count=$(cat "$COUNT_FILE" 2>/dev/null || echo 0)
case "$count" in ''|*[!0-9]*) count=0 ;; esac
count=$((count + 1))
echo "$count" > "$COUNT_FILE" 2>/dev/null || true
if [ "$count" -lt "$MAX_ATTEMPTS" ]; then
exit 0
fi
if [ ! -f "$BACKUP" ]; then
log "OTA crash guard: $count failed start attempts but no backup binary at $BACKUP — cannot roll back"
exit 0
fi
# Already restored (or the OTA never replaced the binary)? Don't loop.
if cmp -s "$BACKUP" "$BINARY"; then
exit 0
fi
# Restore via copy-to-temp + atomic rename; never truncate the live path.
tmp="$BINARY.rollback.$$"
if cp "$BACKUP" "$tmp" && chown root:root "$tmp" && chmod 755 "$tmp" && mv "$tmp" "$BINARY"; then
# Leave a tombstone for the UI/logs instead of the marker so the restored
# binary doesn't run the post-OTA probe against the rolled-back version.
mv "$MARKER" "$DATA_DIR/update-rolled-back.json" 2>/dev/null || rm -f "$MARKER"
rm -f "$COUNT_FILE"
log "OTA crash guard: restored previous binary after $count failed start attempts of the updated one"
else
rm -f "$tmp" 2>/dev/null
log "OTA crash guard: failed to restore backup binary (cp/mv error)"
fi
exit 0
+94
View File
@@ -0,0 +1,94 @@
#!/usr/bin/env bash
# Build the Archipelago companion debug APK and stage it as the served download
# at neode-ui/public/packages/archipelago-companion.apk (a plain APK, so a phone
# can install it straight from the link — no unzip step).
#
# Run manually, or automatically via the pre-push hook (.githooks/pre-push).
#
# Hardened (2026-06-26) so a broken APK can never ship again:
# 1. Aborts on stray resource dirs whose names contain spaces (these break a
# clean build with "Invalid resource directory name"). Empty ones — junk
# left by some icon-export tools — are auto-removed; non-empty ones error.
# 2. Always a CLEAN build (incremental builds masked the bad resource dirs).
# 3. Forces v1 + v2 + v3 signing with zipalign + apksigner. AGP's
# `enableV1Signing = true` flag is silently ignored for minSdk>=24, which
# shipped a v2-only APK that some OEM installers reject ("App not installed").
# 4. VERIFIES all three schemes and ABORTS if any is missing — no silent ship.
set -euo pipefail
ROOT="$(git rev-parse --show-toplevel)"
cd "$ROOT"
JAVA="${JAVA_HOME:-/opt/homebrew/opt/openjdk@17}"
SDK="${ANDROID_HOME:-$HOME/Library/Android/sdk}"
if [ ! -x "$JAVA/bin/java" ] || [ ! -d "$SDK" ]; then
echo "publish-companion-apk: JDK or Android SDK not found — skipping." >&2
echo " (set JAVA_HOME and ANDROID_HOME to build the companion APK)" >&2
exit 0
fi
export JAVA_HOME="$JAVA"
export PATH="$JAVA/bin:$PATH"
RES="Android/app/src/main/res"
APK="Android/app/build/outputs/apk/debug/app-debug.apk"
SIGNED="Android/app/build/outputs/apk/debug/app-debug-signed.apk"
DEST="neode-ui/public/packages/archipelago-companion.apk"
OLD_ZIP="neode-ui/public/packages/archipelago-companion.apk.zip"
KS="Android/app/debug.keystore"
# 1. Guard against resource dirs with spaces (Android forbids them; a clean
# build aborts on them). Empty ones are removed; non-empty ones are fatal.
while IFS= read -r d; do
[ -n "$d" ] || continue
if [ -n "$(ls -A "$d" 2>/dev/null)" ]; then
echo "publish-companion-apk: ERROR — resource dir with a space is not empty:" >&2
echo " $d" >&2
echo " Rename it (Android resource dir names cannot contain spaces)." >&2
exit 1
fi
rmdir "$d" && echo "publish-companion-apk: removed stray empty resource dir: $d" >&2
done < <(find "$RES" -type d -name '* *' 2>/dev/null)
# 2. Clean build.
echo "publish-companion-apk: clean build of debug APK…" >&2
( cd Android && ./gradlew -q --console=plain :app:clean :app:assembleDebug )
[ -f "$APK" ] || { echo "publish-companion-apk: ERROR — APK not produced at $APK" >&2; exit 1; }
# 3. Force v1 + v2 + v3 signing (AGP's enableV1Signing flag is ignored here).
BT="$(ls -d "$SDK"/build-tools/*/ | sort -V | tail -1)"
ZIPALIGN="${BT}zipalign"; APKSIGNER="${BT}apksigner"
[ -x "$ZIPALIGN" ] && [ -x "$APKSIGNER" ] || {
echo "publish-companion-apk: ERROR — zipalign/apksigner not found under $BT" >&2; exit 1; }
[ -f "$KS" ] || { echo "publish-companion-apk: ERROR — keystore missing at $KS" >&2; exit 1; }
echo "publish-companion-apk: zipalign + sign (v1+v2+v3)…" >&2
"$ZIPALIGN" -p -f 4 "$APK" "$SIGNED"
"$APKSIGNER" sign \
--ks "$KS" --ks-pass pass:android \
--ks-key-alias androiddebugkey --key-pass pass:android \
--v1-signing-enabled true --v2-signing-enabled true --v3-signing-enabled true \
"$SIGNED"
# 4. Verify all three schemes (min-sdk 21 forces the v1 path to be exercised).
VERIFY="$("$APKSIGNER" verify -v --min-sdk-version 21 "$SIGNED" 2>&1)"
for scheme in "v1 scheme" "v2 scheme" "v3 scheme"; do
if ! printf '%s\n' "$VERIFY" | grep -iq "$scheme.*: true"; then
echo "publish-companion-apk: ERROR — $scheme NOT present after signing. Aborting." >&2
printf '%s\n' "$VERIFY" | grep -iE "scheme" >&2
exit 1
fi
done
echo "publish-companion-apk: verified v1 + v2 + v3 signatures." >&2
# 5. Publish.
mkdir -p "$(dirname "$DEST")"
cp "$SIGNED" "$DEST"
# Drop the legacy zipped artifact so the served download is the raw APK only.
if [ -f "$OLD_ZIP" ]; then
git rm -q --ignore-unmatch "$OLD_ZIP" 2>/dev/null || rm -f "$OLD_ZIP"
fi
git add "$DEST"
echo "publish-companion-apk: staged $DEST" >&2
+133
View File
@@ -0,0 +1,133 @@
#!/usr/bin/env bash
# Publish an Archipelago OTA release to a Gitea remote and verify downloads.
set -euo pipefail
VERSION="${1:-}"
REMOTE="${2:-gitea-vps2}"
if [ -z "$VERSION" ]; then
echo "Usage: $0 VERSION [remote]"
exit 1
fi
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
VERSION_DIR="$PROJECT_ROOT/releases/v${VERSION}"
BACKEND="$VERSION_DIR/archipelago"
FRONTEND="$VERSION_DIR/archipelago-frontend-${VERSION}.tar.gz"
fail() { echo "Error: $*" >&2; exit 1; }
[ -f "$PROJECT_ROOT/releases/manifest.json" ] || fail "releases/manifest.json missing"
[ -f "$BACKEND" ] || fail "backend artifact missing: $BACKEND"
[ -f "$FRONTEND" ] || fail "frontend artifact missing: $FRONTEND"
"$SCRIPT_DIR/check-release-manifest.sh"
# §A supply-chain gate: never publish an unsigned OTA manifest. Fleet nodes
# with the pinned release-root anchor refuse to auto-apply unsigned manifests,
# and enforcement will tighten to hard-reject — an unsigned publish would
# strand them. Grep proves presence; ceremony verify proves the crypto.
# Release root ROTATED 2026-08-05; see create-release.sh. New root from
# v1.7.123 onward.
EXPECTED_DID="did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT"
grep -q '"signature":' "$PROJECT_ROOT/releases/manifest.json" \
&& grep -q "\"signed_by\": \"$EXPECTED_DID\"" "$PROJECT_ROOT/releases/manifest.json" \
|| fail "releases/manifest.json is not signed by the release root — run: bash scripts/sign-manifest.sh"
if [ -x "$PROJECT_ROOT/core/target/release/archipelago" ]; then
"$PROJECT_ROOT/core/target/release/archipelago" ceremony verify "$PROJECT_ROOT/releases/manifest.json" \
|| fail "manifest signature failed cryptographic verification"
fi
remote_url=$(git -C "$PROJECT_ROOT" remote get-url "$REMOTE")
# https is accepted as well as http. Requiring http:// meant the only remote
# whose credential actually works for git push (the https one) was rejected,
# while the http remote it forced you to use had a dead token — so publishing
# failed on auth after the manifest had already passed every check
# (v1.7.121-alpha, 2026-08-04). The scheme is carried through to the API URL
# rather than assumed.
case "$remote_url" in
http://*@*|https://*@*) ;;
*) fail "$REMOTE must be an authenticated http(s):// Gitea remote URL for API uploads" ;;
esac
scheme=${remote_url%%://*}
rest=${remote_url#*://}
auth=${rest%%@*}
host_path=${rest#*@}
host=${host_path%%/*}
repo_path=${host_path#*/}
repo_path=${repo_path%.git}
api="$scheme://$host/api/v1/repos/$repo_path"
release_url="$api/releases/tags/v${VERSION}"
# ORDER MATTERS. The manifest is the trigger — nodes read releases/manifest.json
# from branch main and try to download the named version the moment it appears.
# So main (which carries the live manifest) must be pushed LAST, only after the
# assets are uploaded and their bytes verified against the manifest. The tag is
# pushed first because the Gitea release and its asset download URLs hang off it,
# but the tag alone changes nothing for nodes.
#
# This used to push main and the tag together, up front, then upload assets. That
# left the manifest live for the entire upload+verify window — and on 2026-08-07
# an upload failed inside that window, so every polling node briefly advertised a
# v1.7.126-alpha update whose binary 500'd and whose tarball did not exist.
echo "Pushing tag v${VERSION} to $REMOTE (not main yet)..."
git -C "$PROJECT_ROOT" push "$REMOTE" "refs/tags/v${VERSION}"
release_json=$(curl -fsS -u "$auth" "$release_url" || true)
if [ -z "$release_json" ]; then
echo "Creating Gitea release v${VERSION}..."
release_body=$(python3 - "$VERSION" <<'PY'
import json
import sys
version = sys.argv[1]
print(json.dumps({
"tag_name": f"v{version}",
"target_commitish": "main",
"name": f"v{version}",
"body": f"Archipelago v{version} release artifacts for OTA updates.",
"draft": False,
"prerelease": True,
}))
PY
)
release_json=$(curl -fsS -u "$auth" -H 'Content-Type: application/json' -d "$release_body" "$api/releases")
fi
release_id=$(printf '%s' "$release_json" | python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])')
asset_names=$(curl -fsS -u "$auth" "$api/releases/$release_id/assets" | python3 -c 'import json,sys; print("\n".join(a["name"] for a in json.load(sys.stdin)))')
upload_asset() {
local path="$1"
local name="$2"
if printf '%s\n' "$asset_names" | grep -Fxq "$name"; then
echo "Asset $name already exists; leaving it in place."
return
fi
echo "Uploading $name..."
curl --fail --show-error --silent --http1.1 --connect-timeout 20 --max-time 900 \
-u "$auth" \
-F "attachment=@$path" \
"$api/releases/$release_id/assets?name=$name" >/dev/null
asset_names=$(printf '%s\n%s\n' "$asset_names" "$name")
}
upload_asset "$BACKEND" "archipelago"
upload_asset "$FRONTEND" "archipelago-frontend-${VERSION}.tar.gz"
echo "Verifying public download URLs (full GET + size + sha256)..."
# Delegated to check-release-assets.sh so the same verifier is used here and by
# hand during recovery. It fails hard on the first bad asset — the previous
# inline `while read` ran in a pipe subshell, where a `fail` (exit) killed only
# the subshell and let this script march on to "published and verified".
"$PROJECT_ROOT/scripts/check-release-assets.sh" "$PROJECT_ROOT/releases/manifest.json" \
|| fail "asset verification failed — NOT pushing main. The manifest stays off the branch nodes read, so no node sees a version it cannot fetch. Repair the assets and re-run."
# Assets are proven fetchable — only now does the manifest become live.
echo "Assets verified. Pushing main to $REMOTE (this makes v${VERSION} live)..."
git -C "$PROJECT_ROOT" push "$REMOTE" main
echo "Release v${VERSION} published and verified on $REMOTE."
+923
View File
@@ -0,0 +1,923 @@
#!/bin/bash
#
# Archipelago Container Reconciler
# Ensures every container matches the canonical spec from container-specs.sh.
# Safe to run repeatedly (idempotent). Run on any node.
#
# Usage:
# sudo ./reconcile-containers.sh # Fix everything
# sudo ./reconcile-containers.sh --check-only # Audit only, no changes
# sudo ./reconcile-containers.sh --force # Override user-stopped
# sudo ./reconcile-containers.sh --force-recreate # Recreate matched containers
# sudo ./reconcile-containers.sh --tier=2 # Only reconcile tier 2
# sudo ./reconcile-containers.sh --container=lnd # Only reconcile lnd
#
set -o pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# ── Parse arguments ──────────────────────────────────────────────────
CHECK_ONLY=false
FORCE=false
FORCE_RECREATE=false
CREATE_MISSING=false
FILTER_TIER=""
FILTER_CONTAINER=""
for arg in "$@"; do
case "$arg" in
--check-only) CHECK_ONLY=true ;;
--force) FORCE=true ;;
--force-recreate) FORCE_RECREATE=true ;;
--create-missing) CREATE_MISSING=true ;;
--tier=*) FILTER_TIER="${arg#*=}" ;;
--container=*) FILTER_CONTAINER="${arg#*=}" ;;
-h|--help)
echo "Usage: $0 [--check-only] [--force] [--force-recreate] [--create-missing] [--tier=N] [--container=NAME]"
echo ""
echo " --check-only Audit only, no changes."
echo " --force Override user-stopped state."
echo " --force-recreate Recreate matched existing containers even if they"
echo " otherwise match the spec. Use with --container or"
echo " --tier for scoped image/config refreshes."
echo " --create-missing Override SPEC_OPTIONAL for containers that have on-disk"
echo " data but no live container (recovery from failed updates)."
echo " --tier=N Only reconcile containers in tier N."
echo " --container=NAME Only reconcile the named container (spec key)."
exit 0 ;;
esac
done
# ── Colors ───────────────────────────────────────────────────────────
RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[0;33m'
BLUE='\033[0;34m' CYAN='\033[0;36m' BOLD='\033[1m'
NC='\033[0m'
ok() { echo -e " ${GREEN}[OK]${NC} $*"; }
fixed() { echo -e " ${CYAN}[FIXED]${NC} $*"; }
skip() { echo -e " ${YELLOW}[SKIP]${NC} $*"; }
fail() { echo -e " ${RED}[FAIL]${NC} $*"; }
info() { echo -e " ${BLUE}[INFO]${NC} $*"; }
header(){ echo -e "\n${BOLD}$*${NC}"; }
# ── Source specs ─────────────────────────────────────────────────────
source "$SCRIPT_DIR/container-specs.sh" || { echo "Cannot source container-specs.sh"; exit 1; }
detect_environment
PORT_ALLOC_FILE="/var/lib/archipelago/port-allocations.env"
[ -f "$PORT_ALLOC_FILE" ] && . "$PORT_ALLOC_FILE"
port_available() {
local port="$1"
ss -ltn 2>/dev/null | awk -v p=":$port" '$4 == p || $4 ~ p "$" { found=1 } END { exit found ? 1 : 0 }'
}
alloc_port() {
local key="$1" preferred="$2" var="PORT_${key//[^A-Za-z0-9]/_}" cur=""
eval "cur=\${$var:-}"
if [ -n "$cur" ] && port_available "$cur"; then
printf '%s' "$cur"
return
fi
if port_available "$preferred"; then
cur="$preferred"
else
cur=""
for p in $(seq 8085 9999); do
if port_available "$p"; then cur="$p"; break; fi
done
fi
[ -n "$cur" ] || cur="$preferred"
sudo mkdir -p "$(dirname "$PORT_ALLOC_FILE")" 2>/dev/null || true
if ! grep -q "^$var=" "$PORT_ALLOC_FILE" 2>/dev/null; then
printf '%s=%s\n' "$var" "$cur" | sudo tee -a "$PORT_ALLOC_FILE" >/dev/null
fi
printf '%s' "$cur"
}
# ── Podman command ───────────────────────────────────────────────────
# Run as archipelago user — podman sees rootless containers directly.
# Use sudo only for chown/mkdir operations.
PODMAN="podman"
PODMAN_IMAGE_CHECK_TIMEOUT="${PODMAN_IMAGE_CHECK_TIMEOUT:-10}"
podman_bounded() {
timeout --kill-after=2s "${PODMAN_IMAGE_CHECK_TIMEOUT}s" "$PODMAN" "$@"
}
# ── Pre-flight ───────────────────────────────────────────────────────
header "╔══════════════════════════════════════════════════╗"
header "║ ARCHIPELAGO CONTAINER RECONCILER ║"
header "╚══════════════════════════════════════════════════╝"
echo ""
info "Host: $(hostname) ($HOST_IP)"
info "Disk: ${DISK_GB}GB | RAM: ${TOTAL_MEM_MB}MB | Low-mem: $LOW_MEM"
info "Mode: $($CHECK_ONLY && echo 'CHECK ONLY (no changes)' || echo 'APPLY FIXES')"
echo ""
# Ensure archy-net exists
if ! $PODMAN network exists archy-net 2>/dev/null; then
if $CHECK_ONLY; then
info "archy-net missing (would create)"
else
$PODMAN network create archy-net 2>/dev/null && info "Created archy-net" || fail "Cannot create archy-net"
fi
fi
# Load user-stopped list
USER_STOPPED_FILE="/var/lib/archipelago/user-stopped.json"
USER_STOPPED=""
if [ -f "$USER_STOPPED_FILE" ]; then
USER_STOPPED=$(cat "$USER_STOPPED_FILE" 2>/dev/null)
fi
is_user_stopped() {
[ "$FORCE" = "true" ] && return 1
echo "$USER_STOPPED" | grep -q "\"$1\"" 2>/dev/null
}
# ── Inspection helpers ───────────────────────────────────────────────
container_exists() {
# Avoid SIGPIPE-from-grep-q failing under `set -o pipefail`.
local names
names=$($PODMAN ps -a --format '{{.Names}}' 2>/dev/null)
echo "$names" | grep -qx "$1"
}
container_running() {
local names
names=$($PODMAN ps --format '{{.Names}}' 2>/dev/null)
echo "$names" | grep -qx "$1"
}
container_image() {
$PODMAN inspect "$1" --format '{{.ImageName}}' 2>/dev/null
}
container_image_id() {
$PODMAN inspect "$1" --format '{{.Image}}' 2>/dev/null
}
spec_image_id() {
podman_bounded image inspect "$SPEC_IMAGE" --format '{{.Id}}' 2>/dev/null
}
container_network() {
# Use actual Networks map — NetworkMode is unreliable (always shows 'bridge' in rootless)
local nets
nets=$($PODMAN inspect "$1" --format '{{range $k,$v := .NetworkSettings.Networks}}{{$k}} {{end}}' 2>/dev/null)
# Return first network name, trimmed
echo "$nets" | awk '{print $1}'
}
container_memory() {
$PODMAN inspect "$1" --format '{{.HostConfig.Memory}}' 2>/dev/null
}
container_health_cmd() {
$PODMAN inspect "$1" --format '{{with .Config.Healthcheck}}{{range .Test}}{{println .}}{{end}}{{end}}' 2>/dev/null \
| awk 'NR > 1 { print }' \
| paste -sd ' ' -
}
normalize_health_cmd() {
printf '%s' "$1" | sed 's/\\"/"/g; s/[[:space:]][[:space:]]*/ /g; s/^ //; s/ $//'
}
host_port_listening() {
local port="$1"
ss -ltn 2>/dev/null | awk -v p=":$port" '
$4 == p || $4 ~ p "$" { found=1 }
END { exit found ? 0 : 1 }
'
}
prepare_bind_source() {
local source="$1"
[ -n "$source" ] || return 0
case "$source" in
/run/user/*/podman/podman.sock)
if [ ! -S "$source" ]; then
local runtime_dir="${source%/podman/podman.sock}"
XDG_RUNTIME_DIR="$runtime_dir" systemctl --user start podman.socket 2>/dev/null || true
for _ in 1 2 3 4 5 6 7 8 9 10; do
[ -S "$source" ] && return 0
sleep 0.25
done
fi
;;
esac
case "$source" in
/var/lib/archipelago/*)
sudo mkdir -p "$source" 2>/dev/null
;;
*)
# Non-data bind mounts can be files/sockets/devices. Creating the full
# path would turn e.g. podman.sock into a directory and break Portainer.
if [ -e "$source" ]; then
return 0
fi
fail "bind source missing: $source"
return 1
;;
esac
}
ensure_catatonit() {
command -v catatonit >/dev/null 2>&1 && return 0
$CHECK_ONLY && { info "catatonit missing (would install)"; return 0; }
if command -v apt-get >/dev/null 2>&1; then
sudo apt-get update >/dev/null 2>&1 || true
sudo apt-get install -y catatonit >/dev/null 2>&1 || true
elif command -v dnf >/dev/null 2>&1; then
sudo dnf install -y catatonit >/dev/null 2>&1 || true
elif command -v apk >/dev/null 2>&1; then
sudo apk add catatonit >/dev/null 2>&1 || true
fi
command -v catatonit >/dev/null 2>&1 || { fail "catatonit missing; Portainer compose builds may fail"; return 1; }
}
ensure_portainer_host_paths() {
ensure_catatonit
if $CHECK_ONLY; then
[ -d /var/lib/archipelago/portainer/compose ] || info "Portainer compose dir missing (would create)"
[ -e /data ] || info "/data host path missing (would link to /var/lib/archipelago/portainer)"
return 0
fi
sudo mkdir -p /var/lib/archipelago/portainer/compose 2>/dev/null || true
sudo chown -R 1000:1000 /var/lib/archipelago/portainer 2>/dev/null || true
if [ ! -e /data ]; then
sudo ln -s /var/lib/archipelago/portainer /data 2>/dev/null || true
elif [ -d /data ] && [ ! -L /data ] && [ ! -e /data/compose ]; then
sudo ln -s /var/lib/archipelago/portainer/compose /data/compose 2>/dev/null || true
fi
}
container_has_mount() {
local name="$1" source="$2" target="$3"
$PODMAN inspect "$name" --format '{{range .Mounts}}{{println .Source "|" .Destination}}{{end}}' 2>/dev/null \
| awk -F'|' -v src="$source" -v dst="$target" '
{ gsub(/[[:space:]]+$/, "", $1); gsub(/^[[:space:]]+/, "", $2); }
$1 == src && $2 == dst { found=1 }
END { exit found ? 0 : 1 }
'
}
# Read one environment variable's current value from a running/stopped container.
# Returns empty string if the var is not set.
container_env_val() {
local name="$1" key="$2"
$PODMAN inspect "$name" --format '{{range .Config.Env}}{{println .}}{{end}}' 2>/dev/null \
| awk -F= -v k="$key" '$1==k { sub(/^[^=]+=/, ""); print; exit }'
}
# Env keys whose values bake network topology into the container. If the spec's
# value for one of these keys ever differs from the running container's value
# (host IP changed, DHCP lease rotated, LAN re-subnetted, container dependency
# moved between archy-net and bridge), the container MUST be recreated.
# This is the systemic fix for the fedimint April-11 stale-IP class of bug
# where a container's URL env was never reconciled after network changes.
#
# Match by suffix to keep the list small. Covers:
# *_URL (FM_P2P_URL, FM_API_URL, FM_BITCOIND_URL, NBXPLORER_BTCRPCURL, ...)
# *_HOST (BTCPAY_HOST, CORE_RPC_HOST, ...)
# *_ENDPOINT (NBXPLORER_BTCNODEENDPOINT, ...)
URL_ENV_SUFFIXES="_URL _HOST _ENDPOINT"
image_exists() {
podman_bounded image exists "$1" >/dev/null 2>&1
}
resolve_spec_image() {
image_exists "$SPEC_IMAGE" && return
local image_path image_name image_tag candidate repo
image_path="${SPEC_IMAGE#*/}"
image_name="${SPEC_IMAGE##*/}"
image_tag="${image_name#*:}"
image_name="${image_name%%:*}"
for candidate in \
"${ARCHY_REGISTRY_FALLBACK:-}/${image_path}" \
"80.71.235.15:3000/archipelago/${image_name}:${image_tag}" \
"80.71.235.15:3000/lfg2025/${image_name}:${image_tag}"; do
case "$candidate" in /*) continue;; esac
if image_exists "$candidate"; then
info "$SPEC_NAME — using local image alias $candidate"
SPEC_IMAGE="$candidate"
return
fi
done
repo=$(podman_bounded images --format '{{.Repository}}:{{.Tag}}' 2>/dev/null \
| grep -E "/${image_name}:${image_tag}$" \
| head -1 || true)
if [ -n "$repo" ]; then
info "$SPEC_NAME — using local image alias $repo"
SPEC_IMAGE="$repo"
fi
}
# Convert memory string to bytes for comparison
mem_to_bytes() {
local m="$1"
case "$m" in
*g|*G) echo $(( ${m%[gG]} * 1073741824 )) ;;
*m|*M) echo $(( ${m%[mM]} * 1048576 )) ;;
*) echo "$m" ;;
esac
}
# ── Build podman run command from spec ───────────────────────────────
build_run_cmd() {
local cmd="$PODMAN run -d --name $SPEC_NAME"
cmd+=" --restart $SPEC_RESTART"
# Network
if [ "$SPEC_NETWORK" = "host" ]; then
cmd+=" --network=host"
elif [ "$SPEC_NETWORK" = "archy-net" ]; then
cmd+=" --network archy-net"
fi
# Memory
[ -n "$SPEC_MEMORY" ] && cmd+=" --memory=$SPEC_MEMORY"
# Capabilities
cmd+=" --cap-drop ALL"
for cap in $SPEC_CAPS; do
cmd+=" --cap-add $cap"
done
# Security
[ -n "$SPEC_SECURITY" ] && cmd+=" --security-opt $SPEC_SECURITY"
# Read-only
[ "$SPEC_READONLY" = "true" ] && cmd+=" --read-only"
# Tmpfs
for t in $SPEC_TMPFS; do
cmd+=" --tmpfs $t"
done
# Health check
if [ -n "$SPEC_HEALTH_CMD" ]; then
cmd+=" --health-cmd=\"$SPEC_HEALTH_CMD\" --health-interval=120s --health-timeout=10s --health-retries=3"
fi
# Ports
for p in $SPEC_PORTS; do
cmd+=" -p $p"
done
# Volumes
for v in $SPEC_VOLUMES; do
cmd+=" -v $v"
done
# Environment
for e in $SPEC_ENV; do
cmd+=" -e \"$e\""
done
# Image
cmd+=" $SPEC_IMAGE"
# Custom args
[ -n "$SPEC_CUSTOM_ARGS" ] && cmd+=" $SPEC_CUSTOM_ARGS"
# Entrypoint override
[ -n "$SPEC_ENTRYPOINT" ] && cmd+=" $SPEC_ENTRYPOINT"
echo "$cmd"
}
# ── Counters ─────────────────────────────────────────────────────────
COUNT_OK=0 COUNT_FIXED=0 COUNT_CREATED=0 COUNT_SKIPPED=0 COUNT_FAILED=0
FAILED_LIST=""
# ── Reconcile one container ──────────────────────────────────────────
reconcile() {
local name="$1"
if ! load_spec "$name"; then
skip "$name — no spec defined"
COUNT_SKIPPED=$((COUNT_SKIPPED + 1))
return
fi
if [ -n "$SPEC_SKIP_REASON" ]; then
skip "$name$SPEC_SKIP_REASON"
COUNT_SKIPPED=$((COUNT_SKIPPED + 1))
return
fi
[ "$name" = "portainer" ] && ensure_portainer_host_paths
# Filter by tier
[ -n "$FILTER_TIER" ] && [ "$SPEC_TIER" != "$FILTER_TIER" ] && return
# User-stopped
if is_user_stopped "$name"; then
skip "$name — user-stopped"
COUNT_SKIPPED=$((COUNT_SKIPPED + 1))
fix_ownership "$name"
return
fi
# Optional apps: only reconcile if already installed (container exists).
# The install RPC creates the container; the reconciler just keeps it running.
# --create-missing overrides this so we can recover from failed-update rollbacks
# that deleted a container without restoring it (on-disk data still present).
if [ "$SPEC_OPTIONAL" = "true" ] && ! container_exists "$name" && ! $CREATE_MISSING; then
skip "$name — not installed"
COUNT_SKIPPED=$((COUNT_SKIPPED + 1))
return
fi
# Resolve registry aliases before create/recreate. ISOs and older installers
# may seed the same image under a fallback registry tag.
resolve_spec_image
# Local images: skip if image doesn't exist and container doesn't exist
if [ "$SPEC_LOCAL_IMAGE" = "true" ]; then
if ! image_exists "$SPEC_IMAGE" && ! container_exists "$name"; then
skip "$name — image not available"
COUNT_SKIPPED=$((COUNT_SKIPPED + 1))
return
fi
fi
# Check dependencies
for dep in $SPEC_DEPENDS; do
if ! container_running "$dep"; then
skip "$name — dependency $dep not running"
COUNT_SKIPPED=$((COUNT_SKIPPED + 1))
return
fi
done
local action="OK"
local reasons=""
if container_exists "$name"; then
local cur_image cur_image_id want_image_id cur_network cur_memory
cur_image=$(container_image "$name")
cur_image_id=$(container_image_id "$name")
want_image_id=$(spec_image_id)
cur_network=$(container_network "$name")
cur_memory=$(container_memory "$name")
local spec_memory_bytes expected_network
spec_memory_bytes=$(mem_to_bytes "$SPEC_MEMORY")
if [ "$FORCE_RECREATE" = "true" ]; then
action="RECREATE"
reasons+="force-recreate "
fi
# Same-tag local rebuilds leave running containers on the old image ID.
# Recreate when the currently tagged spec image points at a different ID.
if [ "$action" = "OK" ] && [ -n "$want_image_id" ] && [ -n "$cur_image_id" ] && [ "$cur_image_id" != "$want_image_id" ]; then
action="RECREATE"
reasons+="image-id "
fi
# Check network mismatch
# For archy-net and host: exact match required
# For bridge/default: accept any non-archy-net, non-host network
if [ "$SPEC_NETWORK" = "archy-net" ]; then
if [ "$cur_network" != "archy-net" ]; then
action="RECREATE"
reasons+="network($cur_network→archy-net) "
fi
elif [ "$SPEC_NETWORK" = "host" ]; then
if [ "$cur_network" != "host" ]; then
action="RECREATE"
reasons+="network($cur_network→host) "
fi
else
# Default/bridge: anything that isn't archy-net or host is fine
if [ "$cur_network" = "archy-net" ] || [ "$cur_network" = "host" ]; then
action="RECREATE"
reasons+="network($cur_network→bridge) "
fi
fi
# Check memory limit (0 = no limit)
if [ "${cur_memory:-0}" = "0" ] && [ "${spec_memory_bytes:-0}" != "0" ]; then
action="RECREATE"
reasons+="memory(none→$SPEC_MEMORY) "
fi
# Healthcheck drift matters: a stale check can leave an otherwise working
# service permanently unhealthy (for example ElectrumX images do not ship
# curl, so the healthcheck must use python's socket module).
if [ "$action" = "OK" ] && [ -n "$SPEC_HEALTH_CMD" ]; then
local cur_health spec_health
cur_health=$(normalize_health_cmd "$(container_health_cmd "$name")")
spec_health=$(normalize_health_cmd "$SPEC_HEALTH_CMD")
if [ "$cur_health" != "$spec_health" ]; then
action="RECREATE"
reasons+="healthcheck "
fi
fi
# Check URL/HOST env drift — catches stale network topology baked into
# container env (fedimint April-11 bug: FM_P2P_URL pointed at old IP).
# Only checks URL-shaped keys; other env drift (passwords rotated, etc.)
# is intentionally ignored to avoid thrashing.
if [ "$action" = "OK" ] && [ -n "$SPEC_ENV" ]; then
for kv in $SPEC_ENV; do
local env_key="${kv%%=*}"
local env_val_spec="${kv#*=}"
local is_url_key=false
for suffix in $URL_ENV_SUFFIXES; do
case "$env_key" in *"$suffix") is_url_key=true; break ;; esac
done
[ "$is_url_key" = "true" ] || continue
local env_val_cur
env_val_cur=$(container_env_val "$name" "$env_key")
if [ "$env_val_cur" != "$env_val_spec" ]; then
action="RECREATE"
reasons+="env($env_key:$env_val_cur$env_val_spec) "
break
fi
done
fi
# Check bind mounts. This catches companion UIs recreated from older specs,
# especially bitcoin-ui: its image intentionally does not bake nginx.conf,
# so the rendered RPC proxy config must be mounted from the host.
if [ "$action" = "OK" ] && [ -n "$SPEC_VOLUMES" ]; then
for v in $SPEC_VOLUMES; do
local mount_source mount_rest mount_target
mount_source="${v%%:*}"
mount_rest="${v#*:}"
mount_target="${mount_rest%%:*}"
[ -n "$mount_source" ] && [ -n "$mount_target" ] || continue
if ! container_has_mount "$name" "$mount_source" "$mount_target"; then
action="RECREATE"
reasons+="mount($mount_target) "
break
fi
done
fi
# Rootless Podman can occasionally leave a container running while its
# rootlessport listener is gone. The container still looks healthy in
# `podman ps`, but host-network UIs and backend status probes fail against
# 127.0.0.1. Treat missing host listeners as spec drift.
if [ "$action" = "OK" ] && [ -n "$SPEC_PORTS" ]; then
for p in $SPEC_PORTS; do
local host_port="${p%%:*}"
[ -n "$host_port" ] || continue
if ! host_port_listening "$host_port"; then
action="RECREATE"
reasons+="port($host_port-not-listening) "
break
fi
done
fi
# Check if running
if ! container_running "$name" && [ "$action" = "OK" ]; then
action="START"
reasons+="not-running "
fi
else
action="CREATE"
reasons+="missing "
fi
# Fix ownership regardless
fix_ownership "$name"
case "$action" in
OK)
ok "$name"
COUNT_OK=$((COUNT_OK + 1))
;;
START)
if $CHECK_ONLY; then
info "$name — would start ($reasons)"
else
if $PODMAN start "$name" >/dev/null 2>&1; then
fixed "$name — started ($reasons)"
else
fail "$name — start failed"
COUNT_FAILED=$((COUNT_FAILED + 1))
FAILED_LIST+=" $name"
return
fi
fi
COUNT_FIXED=$((COUNT_FIXED + 1))
;;
RECREATE)
if $CHECK_ONLY; then
info "$name — would recreate ($reasons)"
else
info "$name — recreating ($reasons)"
$PODMAN stop "$name" >/dev/null 2>&1
$PODMAN rm "$name" >/dev/null 2>&1
if eval "$(build_run_cmd)" >/dev/null 2>&1; then
fixed "$name — recreated ($reasons)"
else
fail "$name — recreate failed: $(eval "$(build_run_cmd)" 2>&1 | tail -1)"
COUNT_FAILED=$((COUNT_FAILED + 1))
FAILED_LIST+=" $name"
return
fi
fi
COUNT_FIXED=$((COUNT_FIXED + 1))
;;
CREATE)
if $CHECK_ONLY; then
info "$name — would create ($reasons)"
else
for v in $SPEC_VOLUMES; do
local host_dir="${v%%:*}"
prepare_bind_source "$host_dir" || {
COUNT_FAILED=$((COUNT_FAILED + 1))
FAILED_LIST+=" $name"
return
}
done
if eval "$(build_run_cmd)" >/dev/null 2>&1; then
fixed "$name — created"
else
fail "$name — create failed"
COUNT_FAILED=$((COUNT_FAILED + 1))
FAILED_LIST+=" $name"
return
fi
fi
COUNT_CREATED=$((COUNT_CREATED + 1))
;;
esac
}
# ── Fix ownership ────────────────────────────────────────────────────
fix_ownership() {
local name="$1"
[ -z "$SPEC_DATA_DIR" ] && return
[ ! -d "$SPEC_DATA_DIR" ] && return
[ "$SPEC_DATA_UID" = "100000:100000" ] && return
local expected_uid="${SPEC_DATA_UID%%:*}"
local current_uid
current_uid=$(stat -c '%u' "$SPEC_DATA_DIR" 2>/dev/null)
if [ "$current_uid" != "$expected_uid" ]; then
if $CHECK_ONLY; then
info "$name — ownership: $current_uid$SPEC_DATA_UID"
else
sudo chown -R "$SPEC_DATA_UID" "$SPEC_DATA_DIR" 2>/dev/null
info "$name — fixed ownership → $SPEC_DATA_UID"
fi
fi
}
# ── Ensure secrets exist ─────────────────────────────────────────────
ensure_secrets() {
local SECRETS_DIR="/var/lib/archipelago/secrets"
sudo mkdir -p "$SECRETS_DIR" 2>/dev/null
sudo chmod 700 "$SECRETS_DIR" 2>/dev/null
for svc in bitcoin-rpc-password mempool-db-password btcpay-db-password mysql-root-db-password; do
if [ ! -f "$SECRETS_DIR/$svc" ]; then
if $CHECK_ONLY; then
info "Would generate secret: $svc"
else
openssl rand -hex 16 | sudo tee "$SECRETS_DIR/$svc" >/dev/null
sudo chmod 600 "$SECRETS_DIR/$svc"
info "Generated secret: $svc"
fi
fi
done
# FED-07: the gateway's bcrypt credential is generated by the daemon's
# container::secrets::ensure_gateway_credential (bcrypt, 0600, idempotent,
# self-healing) — this script no longer generates it locally (that removed
# the htpasswd host dependency AND the shipped-default fallback that used
# to fire when htpasswd was absent). It never shipped, and never ships,
# a fallback value.
#
# Legacy migration only: earlier builds wrote the plaintext under
# fedimint-gateway-password instead of the manifest's canonical
# fedimint-gateway-hash.pw name. Carry a pre-existing value forward under
# the canonical name rather than regenerating (migrations never destroy
# data or rotate a working credential); the legacy file is left in place —
# plan 01-16 owns its retirement.
if [ -f "$SECRETS_DIR/fedimint-gateway-password" ] && [ ! -f "$SECRETS_DIR/fedimint-gateway-hash.pw" ]; then
if $CHECK_ONLY; then
info "Would migrate legacy fedimint-gateway-password -> fedimint-gateway-hash.pw"
else
sudo cp "$SECRETS_DIR/fedimint-gateway-password" "$SECRETS_DIR/fedimint-gateway-hash.pw"
sudo chmod 600 "$SECRETS_DIR/fedimint-gateway-hash.pw"
info "Migrated legacy fedimint-gateway-password -> fedimint-gateway-hash.pw"
fi
fi
if [ ! -f "$SECRETS_DIR/fedimint-gateway-hash" ]; then
info "fedimint-gateway credential not yet generated — the daemon will generate a per-install bcrypt hash (no shipped default); the gateway container is skipped until then"
fi
# Reload after generation
detect_environment
}
# ── Ensure bitcoin.conf ─────────────────────────────────────────────
ensure_bitcoin_conf() {
local BITCOIN_CONF="/var/lib/archipelago/bitcoin/bitcoin.conf"
sudo mkdir -p /var/lib/archipelago/bitcoin 2>/dev/null
if [ ! -f "$BITCOIN_CONF" ] || ! sudo grep -q "^rpcauth=" "$BITCOIN_CONF" 2>/dev/null; then
if ! $CHECK_ONLY && [ -n "$BITCOIN_RPC_PASS" ]; then
local salt hash rpcauth
salt=$(openssl rand -hex 16)
hash=$(echo -n "$BITCOIN_RPC_PASS" | openssl dgst -sha256 -hmac "$salt" -hex 2>/dev/null | awk '{print $NF}')
rpcauth="${BITCOIN_RPC_USER}:${salt}\$${hash}"
# Only rpcauth + printtoconsole here — all other options are in SPEC_CUSTOM_ARGS
# to avoid duplicate bind conflicts. printtoconsole=0: datadir debug.log
# already has everything; console duplication spammed journald during IBD.
sudo tee "$BITCOIN_CONF" >/dev/null << BTCEOF
rpcauth=${rpcauth}
printtoconsole=0
BTCEOF
info "Generated bitcoin.conf"
fi
fi
# Strip duplicate server/rpc/listen lines from existing conf files to avoid
# conflicts with custom args. Knots can persist runtime args in
# bitcoin_rw.conf, so clean both files.
for conf in "$BITCOIN_CONF" "/var/lib/archipelago/bitcoin/bitcoin_rw.conf"; do
if [ -f "$conf" ]; then
sudo sed -i '/^server=/d; /^txindex=/d; /^rpcbind=/d; /^rpcallowip=/d; /^rpcport=/d; /^listen=/d; /^bind=/d; /^dbcache=/d; /^rpcthreads=/d; /^rpcworkqueue=/d' "$conf" 2>/dev/null
fi
done
sudo chown -R 100101:100101 /var/lib/archipelago/bitcoin 2>/dev/null
}
# ── Ensure lnd.conf ─────────────────────────────────────────────────
ensure_lnd_conf() {
local LND_CONF="/var/lib/archipelago/lnd/lnd.conf"
sudo mkdir -p /var/lib/archipelago/lnd 2>/dev/null
if [ ! -f "$LND_CONF" ] && [ -n "$BITCOIN_RPC_PASS" ]; then
if ! $CHECK_ONLY; then
sudo tee "$LND_CONF" >/dev/null << LNDEOF
[Application Options]
listen=0.0.0.0:9735
rpclisten=0.0.0.0:10009
restlisten=0.0.0.0:8080
debuglevel=info
noseedbackup=true
[Bitcoin]
bitcoin.mainnet=true
bitcoin.node=bitcoind
[Bitcoind]
bitcoind.rpchost=bitcoin-knots:8332
bitcoind.rpcuser=$BITCOIN_RPC_USER
bitcoind.rpcpass=$BITCOIN_RPC_PASS
bitcoind.rpcpolling=true
bitcoind.estimatemode=ECONOMICAL
[autopilot]
autopilot.active=false
LNDEOF
info "Generated lnd.conf"
fi
fi
}
# ── Ensure bitcoin-ui nginx.conf ────────────────────────────────────
ensure_bitcoin_ui_nginx_conf() {
local CONF_DIR="/var/lib/archipelago/bitcoin-ui"
local CONF_PATH="$CONF_DIR/nginx.conf"
[ -n "$BITCOIN_RPC_PASS" ] || return
if $CHECK_ONLY; then
[ -f "$CONF_PATH" ] || info "Would generate bitcoin-ui nginx.conf"
return
fi
local auth_b64 tmp
auth_b64=$(printf '%s' "${BITCOIN_RPC_USER}:${BITCOIN_RPC_PASS}" | base64 | tr -d '\n')
sudo mkdir -p "$CONF_DIR" 2>/dev/null
tmp="${CONF_PATH}.tmp.$$"
sudo tee "$tmp" >/dev/null << EOF
server {
# Loopback ONLY — this is the fourth copy of this declaration (the others
# are the Rust template in container/bitcoin_ui_nginx.conf.template, the
# image, and the manifest). Host networking means this nginx binds the
# HOST's address, so \`listen 8334;\` served the Bitcoin screen on every
# interface with no login. The app gate owns the external addresses now.
listen 127.0.0.1:8334;
server_name _;
root /usr/share/nginx/html;
index index.html;
location /bitcoin-rpc/ {
proxy_pass http://127.0.0.1:8332/;
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 Authorization "Basic ${auth_b64}";
add_header Access-Control-Allow-Origin *;
add_header Access-Control-Allow-Methods "POST, GET, OPTIONS";
add_header Access-Control-Allow-Headers "Content-Type, Authorization";
if (\$request_method = OPTIONS) { return 204; }
}
location /bitcoin-status {
proxy_pass http://127.0.0.1:5678/bitcoin-status;
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;
add_header Cache-Control "no-store";
}
location / {
try_files \$uri \$uri/ /index.html;
}
}
EOF
if ! sudo cmp -s "$tmp" "$CONF_PATH" 2>/dev/null; then
sudo mv "$tmp" "$CONF_PATH"
sudo chmod 644 "$CONF_PATH"
info "Generated bitcoin-ui nginx.conf"
else
sudo rm -f "$tmp"
fi
}
# ── Ensure BTCPay databases ─────────────────────────────────────────
ensure_btcpay_db() {
if container_running "archy-btcpay-db"; then
$PODMAN exec archy-btcpay-db psql -U postgres -tc \
"SELECT 1 FROM pg_database WHERE datname='nbxplorer'" 2>/dev/null | grep -q 1 || \
$PODMAN exec archy-btcpay-db psql -U postgres -c \
"CREATE DATABASE nbxplorer;" 2>/dev/null || true
fi
}
# ══════════════════════════════════════════════════════════════════════
# MAIN
# ══════════════════════════════════════════════════════════════════════
START_TIME=$(date +%s)
header "Phase 0: Prerequisites"
ensure_secrets
detect_environment
ensure_bitcoin_conf
ensure_lnd_conf
ensure_bitcoin_ui_nginx_conf
TIER_NAMES=("Databases" "Core Infrastructure" "Services" "Applications" "Frontend UIs")
for tier in 0 1 2 3 4; do
[ -n "$FILTER_TIER" ] && [ "$FILTER_TIER" != "$tier" ] && continue
header "Tier $tier: ${TIER_NAMES[$tier]}"
for name in "${ALL_CONTAINER_SPECS[@]}"; do
[ -n "$FILTER_CONTAINER" ] && [ "$name" != "$FILTER_CONTAINER" ] && continue
# Load spec to check tier before reconciling
if load_spec "$name" && [ "$SPEC_TIER" = "$tier" ]; then
reconcile "$name"
fi
done
# After databases, ensure BTCPay DB schemas exist
[ "$tier" = "0" ] && ensure_btcpay_db
# Brief pause between tiers
[ "$tier" -lt 4 ] && ! $CHECK_ONLY && sleep 2
done
# ── Summary ──────────────────────────────────────────────────────────
ELAPSED=$(( $(date +%s) - START_TIME ))
TOTAL=$((COUNT_OK + COUNT_FIXED + COUNT_CREATED + COUNT_SKIPPED + COUNT_FAILED))
echo ""
header "╔══════════════════════════════════════════════════╗"
header "║ RECONCILIATION REPORT ║"
header "╚══════════════════════════════════════════════════╝"
echo ""
echo -e " Total: ${BOLD}$TOTAL${NC}"
echo -e " OK: ${GREEN}$COUNT_OK${NC}"
echo -e " Fixed: ${CYAN}$COUNT_FIXED${NC}"
echo -e " Created: ${CYAN}$COUNT_CREATED${NC}"
echo -e " Skipped: ${YELLOW}$COUNT_SKIPPED${NC}"
echo -e " Failed: ${RED}$COUNT_FAILED${NC}"
[ -n "$FAILED_LIST" ] && echo -e " Failed: ${RED}$FAILED_LIST${NC}"
echo -e " Duration: ${ELAPSED}s"
echo ""
[ "$COUNT_FAILED" -gt 0 ] && exit 1
exit 0
+109
View File
@@ -0,0 +1,109 @@
# Resilience Harness
Black-box state-machine tester for archipelago app containers.
Drives the live RPC against a real archipelago + podman runtime on a target
host. For each app in `app-catalog/catalog.json`, runs every state transition
a user could trigger and asserts the system stays in the expected state.
## Why this exists
We shipped v1.7.43-alpha on .228 with three independent bugs that no unit test
caught:
1. `indeedhub-api` crashlooped 8500+ times because `stacks.rs` was missing 5
env vars (`QUEUE_HOST`/`QUEUE_PORT`/`DATABASE_PORT`/`S3_PRIVATE_BUCKET_NAME`/
`AES_MASTER_SECRET`) — the install "succeeded" (containers running) but the
API never became healthy.
2. `bitcoin-ui` shipped with a stale baked-in `Authorization: Basic …` header
from the registry image, so every `/bitcoin-rpc/` call returned 401.
3. The container-absence scanner evicted apps from the UI 14 seconds into
install (before image pull finished).
All three were exactly the kind of bug a "did the user-visible flow actually
work end to end?" test would catch — and the kind a single-file unit test
will never catch. This harness is the gate.
## Running
Against the .228 test node:
scripts/resilience/resilience.sh archipelago@192.0.2.10
Or non-interactive (CI):
RESILIENCE_SSH_PASS=… RESILIENCE_UI_PASS=… \
scripts/resilience/resilience.sh archipelago@192.0.2.10
Filters:
# Smoke test (3 apps, no reboot, ~15min)
scripts/resilience/resilience.sh archipelago@192.0.2.10 smoke
# Single app
scripts/resilience/resilience.sh archipelago@192.0.2.10 bitcoin-knots
# Subset
scripts/resilience/resilience.sh archipelago@192.0.2.10 bitcoin-knots,lnd
Without a filter, the harness sweeps **every** app in the catalog
(~24 apps × 7 per-app transitions + 2 batch transitions) and runs the
batch transitions (archipelago.service restart, host reboot) at the end.
Full sweep is ~3-4 hours and **reboots the target host** as part of the
run — only point it at a dedicated test node.
## What it tests
Per-app transitions:
| # | Transition | Pass criteria |
|---|----------------------|------------------------------------------------|
| 1 | install | All containers reach `running` within 10 min |
| 2 | ui_probe | HTTP 2xx/3xx via `https://<host>/app/<id>/` |
| 3 | auth_probe | (bitcoin-rpc only) returns 200 not 401 |
| 4 | stop | All containers reach `exited` state |
| 5 | start | All containers reach `running` state |
| 6 | restart | All containers `running` after restart |
| 7 | uninstall | All containers absent, no residue |
Batch transitions (full sweep only):
| # | Transition | Pass criteria |
|---|-------------------------------|-------------------------------------|
| 8 | archipelago.service restart | Container set unchanged across |
| 9 | host reboot | Container set unchanged across |
Coverage by design — discovery rather than encoded metadata. The harness
snapshots `podman ps -a` before install, again after install stabilizes,
and the difference IS this app's container set. Works equally well for
single-container apps and 7-container stacks (indeedhub) without per-app
configuration.
## Output
JSON-lines results at `scripts/resilience/reports/<run_ts>/results.jsonl`:
{"ts":"…","app":"bitcoin-knots","transition":"install","status":"PASS","detail":"bitcoin-knots,archy-bitcoin-ui"}
{"ts":"…","app":"bitcoin-knots","transition":"auth_probe","status":"PASS","detail":"bitcoin-rpc HTTP 200"}
Exit code: `0` if every cell green, `1` if any red, `2` if setup failed
before tests began. Use as a release gate — refuse to tag if any cell red.
## Auth flow
The harness uses the same `auth.login` RPC that the UI uses, then carries
`session=…` and `csrf_token=…` cookies plus the `X-CSRF-Token` header on
every subsequent call. Re-logs in after archipelago.service restart and
host reboot.
## Caveats / known gaps
- App proxy probe (`/app/<id>/`) only validates the proxy responds — for
apps with deeper protocol behavior (lnd, fedimint, mempool) this only
catches "container alive, proxy reachable", not "the protocol is healthy".
- Multi-container stack assertions: the harness checks **every** new
container is `running`, so it would catch the indeedhub-api restart loop
while postgres/redis/minio looked fine.
- Host reboot test is destructive and slow — runs once at end of full sweep.
- `package.start`/`stop`/`restart` RPC methods may not exist for all apps;
failures are recorded and the harness continues.
+297
View File
@@ -0,0 +1,297 @@
#!/bin/bash
# Resilience harness shared helpers.
# Sourced by resilience.sh — do not invoke directly.
# Required env (set by resilience.sh before sourcing):
# TARGET — ssh target, e.g. archipelago@192.0.2.10
# RPC_URL — http://<host>:5678/rpc/v1
# COOKIE_JAR — path for curl cookie store
# SSH_PASS — sshpass password
# UI_PASS — archipelago UI password
# OUT_DIR — report output dir
# ── ssh ─────────────────────────────────────────────────────────
ssh_run() {
# -n: redirect stdin from /dev/null so ssh doesn't gobble up our parent's
# stdin. Without this, ssh inside a `while read … done <<< "$LIST"`
# consumes the heredoc on the first call, ending the loop after one
# iteration. Cost us a smoke run that only tested filebrowser instead
# of all three smoke apps.
sshpass -p "$SSH_PASS" ssh -n -o StrictHostKeyChecking=accept-new \
-o ConnectTimeout=10 -o LogLevel=ERROR "$TARGET" "$@"
}
# Run a command and tolerate ssh failure (host rebooting, etc.).
ssh_try() {
sshpass -p "$SSH_PASS" ssh -n -o StrictHostKeyChecking=accept-new \
-o ConnectTimeout=5 -o LogLevel=ERROR "$TARGET" "$@" 2>/dev/null || echo "__SSH_FAIL__"
}
ssh_wait_ready() {
local deadline=$(($(date +%s) + ${1:-180}))
while [ "$(date +%s)" -lt "$deadline" ]; do
if [ "$(ssh_try 'echo OK')" = "OK" ]; then return 0; fi
sleep 3
done
return 1
}
# ── rpc ─────────────────────────────────────────────────────────
rpc_login() {
local resp
resp=$(curl -ksS -c "$COOKIE_JAR" -H "Content-Type: application/json" \
-d "{\"jsonrpc\":\"2.0\",\"method\":\"auth.login\",\"params\":{\"password\":\"$UI_PASS\"},\"id\":1}" \
"$RPC_URL")
if echo "$resp" | jq -e '.error' >/dev/null 2>&1; then
echo "ERROR: login failed: $(echo "$resp" | jq -c .)" >&2
return 1
fi
CSRF_TOKEN=$(awk '/csrf_token/ {print $7}' "$COOKIE_JAR" | head -1)
[ -n "$CSRF_TOKEN" ] || { echo "ERROR: no CSRF token after login" >&2; return 1; }
export CSRF_TOKEN
}
# Make an RPC call. Args: method, json_params, timeout_secs (optional, default 90).
# Prints raw JSON response. Caller asserts success via jq.
#
# CSRF rotates per-response: the server may issue a new csrf_token on every
# state-changing call, so we re-read it from the cookie jar before each call
# rather than caching the value from login. Also retries once on nginx-served
# BACKEND_UNAVAILABLE (5xx fallback) for transient stalls.
rpc_call() {
local method="$1"
# NOTE: don't use ${2:-{}} — bash matches the first unescaped `}` as the
# end of the expansion, so the trailing `}` becomes a literal char and
# corrupts every params value into invalid JSON. Use an if-check instead.
local params="${2-}"
[ -z "$params" ] && params='{}'
local timeout="${3:-90}"
local attempt
for attempt in 1 2 3 4; do
local csrf
csrf=$(awk '/^[^#]/ && /csrf_token/ {print $7; exit}' "$COOKIE_JAR")
local resp
resp=$(curl -ksS -b "$COOKIE_JAR" -c "$COOKIE_JAR" \
-H "Content-Type: application/json" \
-H "X-CSRF-Token: $csrf" \
-d "{\"jsonrpc\":\"2.0\",\"method\":\"$method\",\"params\":$params,\"id\":1}" \
--max-time "$timeout" \
"$RPC_URL")
# Retry on transient errors:
# BACKEND_UNAVAILABLE — nginx 5xx fallback (archipelago briefly stalled)
# 429 — nginx rate limiter exceeded (burst=40 in /etc/nginx/sites-enabled/*)
if echo "$resp" | jq -e '.error.code == "BACKEND_UNAVAILABLE" or .error.code == 429' >/dev/null 2>&1; then
[ "$attempt" -eq 4 ] && { echo "$resp"; return; }
# Exponential-ish backoff: 5s, 15s, 30s. Plenty of time for the
# nginx rate window (1s) and any archipelago restart to clear.
sleep $((attempt * 10))
continue
fi
echo "$resp"
return
done
}
# After a service restart the session may need re-establishing.
rpc_relogin_if_needed() {
local probe
probe=$(rpc_call "package.list" '{}' 2>/dev/null)
if echo "$probe" | jq -e '.error.code == -32001' >/dev/null 2>&1; then
rpc_login || return 1
fi
}
# ── per-app metadata ────────────────────────────────────────────
# Mappings the harness needs that aren't expressible from catalog.json alone:
# multi-container stack rosters, alias/variant container names (bitcoin-knots
# vs bitcoin-core install the same slots), and the actual nginx UI proxy path
# (which often differs from /app/<id>/, e.g. `bitcoin-knots` → `/app/bitcoin-ui/`).
#
# Keep these tables in sync with the install code in package/stacks.rs and
# the `*_IMAGE` companion handling in install.rs (the `archy-<x>-ui` set).
# Containers an app installs. Used for app_already_installed detection AND
# for state assertions when the snapshot-diff falls back (variant apps don't
# create new containers when their alternate is already present).
expected_containers_for() {
case "$1" in
bitcoin-knots) echo "bitcoin-knots archy-bitcoin-ui" ;;
bitcoin-core) echo "bitcoin-core archy-bitcoin-ui" ;;
lnd) echo "lnd archy-lnd-ui" ;;
electrumx|electrs|mempool-electrs)
echo "electrs archy-electrs-ui" ;;
btcpay-server) echo "archy-btcpay-server archy-btcpay-db archy-nbxplorer archy-btcpay-ui" ;;
mempool) echo "mempool archy-mempool-web archy-mempool-db" ;;
immich) echo "immich_server immich_machine_learning immich_postgres immich_redis" ;;
penpot|penpot-frontend)
echo "penpot-frontend penpot-backend penpot-exporter penpot-postgres penpot-redis" ;;
indeedhub) echo "indeedhub indeedhub-api indeedhub-ffmpeg indeedhub-postgres indeedhub-redis indeedhub-minio indeedhub-relay" ;;
*) echo "$1" ;;
esac
}
# UI proxy URL path on the HTTPS frontend. Most apps live at /app/<id>/ but
# Bitcoin/LND/Electrs proxy through their UI companion containers, and BTCPay
# uses its own short path.
ui_proxy_path_for() {
case "$1" in
bitcoin-knots|bitcoin-core) echo "/app/bitcoin-ui/" ;;
electrumx|electrs) echo "/app/electrumx/" ;;
lnd) echo "/app/lnd-ui/" ;;
btcpay-server) echo "/app/btcpay/" ;;
*) echo "/app/$1/" ;;
esac
}
# Authenticated probe for credentialed UIs. Echoes the HTTP status code if
# defined, otherwise returns 1 (caller records SKIP). PASS = code in
# {200,401,403} for endpoints that prove the proxy reaches the backend
# (401/403 from app's own auth ≠ 502 from broken proxy).
auth_probe_for() {
local app="$1"
local host; host="$(echo "$TARGET" | cut -d@ -f2)"
case "$app" in
bitcoin-knots|bitcoin-core)
# Direct bitcoin-rpc proxy on :8334 inside .228 — credential
# plumbing is the .228 bug we just shipped, must return 200.
ssh_run 'curl -s -o /dev/null -w "%{http_code}" --max-time 5 -X POST http://127.0.0.1:8334/bitcoin-rpc/ -H "Content-Type: application/json" -d "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"getblockchaininfo\",\"params\":[]}"'
;;
btcpay-server)
# BTCPay's own auth returns 401 for unauthenticated API calls;
# 502 means proxy broken / backend down.
curl -ks -o /dev/null -w "%{http_code}" --max-time 5 \
"https://$host/app/btcpay/api/v1/server/info"
;;
lnd)
# LND has a /lnd-connect-info passthrough on archipelago itself —
# returns lndconnect URI when LND is up. 200 = backend reachable.
curl -ks -o /dev/null -w "%{http_code}" --max-time 5 \
"https://$host/lnd-connect-info"
;;
electrumx|electrs)
# ElectrumX is plain TCP (electrum protocol) — no HTTPS auth path.
# archipelago exposes /electrs-status which queries the daemon.
curl -ks -o /dev/null -w "%{http_code}" --max-time 5 \
"https://$host/electrs-status"
;;
*)
return 1
;;
esac
}
# Whether an auth_probe HTTP code counts as a pass.
auth_probe_pass_codes() {
case "$1" in
bitcoin-knots|bitcoin-core) echo "200" ;;
btcpay-server) echo "200 401 403" ;;
lnd|electrumx|electrs) echo "200" ;;
*) echo "200" ;;
esac
}
# ── probes (state assertions) ───────────────────────────────────
# Returns container Status string ("running","exited","absent",…).
probe_container_state() {
local name="$1"
ssh_run "podman inspect '$name' --format '{{.State.Status}}' 2>/dev/null || echo absent"
}
# Returns RestartCount as integer.
probe_container_restart_count() {
local name="$1"
ssh_run "podman inspect '$name' --format '{{.RestartCount}}' 2>/dev/null || echo -1"
}
# Probe the app's UI proxy on the HTTPS frontend. Returns HTTP code.
# Uses ui_proxy_path_for so apps with non-default proxy paths (bitcoin-ui,
# lnd-ui, electrs-ui, btcpay) get probed at the right URL.
probe_app_proxy() {
local app_id="$1"
local host
host="$(echo "$TARGET" | cut -d@ -f2)"
local path
path=$(ui_proxy_path_for "$app_id")
curl -ks -o /dev/null -w "%{http_code}" --max-time 5 "https://$host$path" || echo "000"
}
# Check that ZERO containers are leftover for this app — catches uninstall residue.
probe_no_residue() {
local prefix="$1"
ssh_run "podman ps -a --format '{{.Names}}' | grep -E '^${prefix}(-|$)' | wc -l"
}
# ── waiters ─────────────────────────────────────────────────────
# Wait for the package's state in the RPC list to match expected, with timeout.
wait_for_package_state() {
local pkg="$1"; local want="$2"; local timeout="${3:-300}"
local deadline=$(($(date +%s) + timeout))
while [ "$(date +%s)" -lt "$deadline" ]; do
local got
got=$(rpc_call "package.list" '{}' \
| jq -r ".result.package_data[\"$pkg\"].state // \"absent\"")
case "$want" in
Running) [ "$got" = "Running" ] && return 0 ;;
Stopped) [ "$got" = "Stopped" ] && return 0 ;;
absent) [ "$got" = "absent" ] && return 0 ;;
esac
sleep 4
done
echo "TIMEOUT waiting for $pkg$want (last seen: $got)" >&2
return 1
}
# Wait for podman state of a specific container.
wait_for_container_state() {
local name="$1"; local want="$2"; local timeout="${3:-180}"
local deadline=$(($(date +%s) + timeout))
while [ "$(date +%s)" -lt "$deadline" ]; do
local got
got=$(probe_container_state "$name")
[ "$got" = "$want" ] && return 0
sleep 3
done
echo "TIMEOUT waiting for container $name$want (last seen: $got)" >&2
return 1
}
# Wait until restart count is stable for `stable_secs` seconds — proxy for "no crashloop".
wait_restart_count_stable() {
local name="$1"; local stable_secs="${2:-30}"; local timeout="${3:-180}"
local deadline=$(($(date +%s) + timeout))
local last; local last_change_ts
last=$(probe_container_restart_count "$name")
last_change_ts=$(date +%s)
while [ "$(date +%s)" -lt "$deadline" ]; do
sleep 5
local now
now=$(probe_container_restart_count "$name")
if [ "$now" != "$last" ]; then
last="$now"
last_change_ts=$(date +%s)
elif [ $(( $(date +%s) - last_change_ts )) -ge "$stable_secs" ]; then
return 0
fi
done
echo "TIMEOUT waiting for $name restart-count stable (last=$last)" >&2
return 1
}
# ── result recording ────────────────────────────────────────────
# Append a result row to the JSON-lines report.
# Args: app_id, transition, status (PASS/FAIL/SKIP), detail
record() {
local app="$1"; local transition="$2"; local status="$3"; local detail="${4:-}"
local ts
ts=$(date -u +%Y-%m-%dT%H:%M:%SZ)
jq -nc --arg ts "$ts" --arg app "$app" --arg t "$transition" --arg s "$status" --arg d "$detail" \
'{ts:$ts, app:$app, transition:$t, status:$s, detail:$d}' >> "$OUT_DIR/results.jsonl"
local marker
case "$status" in
PASS) marker="✅" ;;
FAIL) marker="❌" ;;
SKIP) marker="⏭" ;;
*) marker="•" ;;
esac
printf '%s [%-15s] %-30s %s%s\n' "$marker" "$app" "$transition" "$status" "${detail:+ — $detail}"
}
+523
View File
@@ -0,0 +1,523 @@
#!/bin/bash
# Archipelago resilience harness — black-box state-machine tester for app containers.
#
# Drives the live archipelago RPC against a real podman runtime on a target
# host. For each app in the catalog, runs every state transition a user could
# trigger (install / probe / stop / start / restart / archipelago-restart /
# host-reboot / uninstall / reinstall / vanish-watch) and asserts the system
# remains in the expected state at every step.
#
# Usage:
# scripts/resilience/resilience.sh archipelago@192.0.2.10 [filter]
#
# `filter` is a comma-separated list of app IDs (or "smoke" for the curated
# fast subset). Default: every app in app-catalog/catalog.json.
#
# Exit codes:
# 0 every cell green
# 1 any cell red — release should not ship
# 2 setup/auth error before tests began
set -uo pipefail
# ── args ─────────────────────────────────────────────────────────
TARGET="${1:?usage: $0 <user@host> [filter]}"
FILTER="${2:-}"
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
HERE="$ROOT/scripts/resilience"
RUN_TS="$(date -u +%Y%m%dT%H%M%SZ)"
OUT_DIR="$HERE/reports/$RUN_TS"
mkdir -p "$OUT_DIR"
COOKIE_JAR="$OUT_DIR/cookies.txt"
HOST="$(echo "$TARGET" | cut -d@ -f2)"
# RPC reaches archipelago through nginx on 443 (which proxies to localhost:5678).
# Direct :5678 is bound to 127.0.0.1 on the target so we can't curl it from here.
RPC_URL="https://$HOST/rpc/v1"
export TARGET RPC_URL COOKIE_JAR OUT_DIR
# shellcheck source=lib.sh
. "$HERE/lib.sh"
# ── credentials ──────────────────────────────────────────────────
# Pull from env first (so this script can be called from CI). Fall back to
# interactive prompts.
SSH_PASS="${RESILIENCE_SSH_PASS:-}"
UI_PASS="${RESILIENCE_UI_PASS:-}"
if [ -z "$SSH_PASS" ]; then
read -rsp "SSH password for $TARGET: " SSH_PASS; echo
fi
if [ -z "$UI_PASS" ]; then
read -rsp "Archipelago UI password: " UI_PASS; echo
fi
export SSH_PASS UI_PASS
command -v sshpass >/dev/null || { echo "sshpass required"; exit 2; }
command -v jq >/dev/null || { echo "jq required"; exit 2; }
ssh_run 'echo ok' >/dev/null || { echo "ssh to $TARGET failed"; exit 2; }
rpc_login || exit 2
echo "Resilience harness — target $TARGET, run $RUN_TS"
echo "Output: $OUT_DIR/results.jsonl"
echo "─────────────────────────────────────────────────────────────"
# ── catalog & filter ─────────────────────────────────────────────
CATALOG="$ROOT/app-catalog/catalog.json"
ALL_APPS=$(jq -r '.apps[].id' "$CATALOG")
# Topo-sort the catalog by `requires`. Outputs app IDs in install order
# (deps first, then dependents). Kahn's algorithm via python — keeps the
# bash side simple and the deps logic obvious for next-time-readers.
topo_order() {
python3 -c "
import json
with open('$CATALOG') as f: c = json.load(f)
deps = {a['id']: list(a.get('requires', [])) for a in c['apps']}
order = []
remaining = set(deps)
while remaining:
ready = sorted(a for a in remaining if all(d not in remaining for d in deps[a]))
if not ready: # cycle (shouldn't happen) — emit whatever's left
order.extend(sorted(remaining)); break
order.extend(ready); remaining.difference_update(ready)
print('\n'.join(order))
"
}
apps_to_test() {
local order; order=$(topo_order)
if [ -z "$FILTER" ]; then
# Full sweep — but skip bitcoin-core since it shares container slots
# with bitcoin-knots; testing both back-to-back would just churn the
# same containers. bitcoin-knots is the canonical entry.
echo "$order" | grep -v '^bitcoin-core$'
elif [ "$FILTER" = "smoke" ]; then
# Fast subset exercising the bug classes we just fixed:
# single-container, multi-container stack, credentialed UI.
echo -e "filebrowser\nbitcoin-knots\nindeedhub"
else
echo "$order" | grep -E "^($(echo "$FILTER" | tr ',' '|'))$"
fi
}
# Resolve `requires` chain for $1 in install-order (deps first).
deps_for_app() {
local app="$1"
python3 -c "
import json
with open('$CATALOG') as f: c = json.load(f)
deps_map = {a['id']: list(a.get('requires', [])) for a in c['apps']}
visited, order = set(), []
def visit(x):
if x in visited or x not in deps_map: return
visited.add(x)
for d in deps_map.get(x, []): visit(d)
order.append(x)
for d in deps_map.get('$app', []): visit(d)
print('\n'.join(order))
"
}
# ── per-app transitions ──────────────────────────────────────────
# Diff helper: capture container names matching a sane prefix for $app_id.
# Approach: snapshot before install, snapshot after, take the difference =
# this app's containers.
snapshot_containers() {
ssh_run "podman ps -a --format '{{.Names}}' | sort"
}
# Whether $app currently has ALL of its expected containers running. Uses
# the per-app metadata table in lib.sh (expected_containers_for) so variant
# apps (bitcoin-knots/bitcoin-core sharing slots) and stacks are detected
# correctly. Falls back to name-prefix match for apps the table doesn't know.
#
# Returns true only when every expected container is present. Earlier
# versions returned true on ANY match — that caused dep installs (e.g.
# bitcoin-knots required by btcpay) to be declared "installed" as soon as
# the backend container appeared, before the UI companion (archy-bitcoin-ui)
# was up. The before-snapshot then missed the companion, the after-snapshot
# caught it, and it leaked into the dependent app's "new containers" set,
# false-positive-FAILing stop/uninstall when the companion (correctly) did
# not respond to the dependent app's package.stop.
app_already_installed() {
local app="$1"
local snap; snap=$(snapshot_containers)
local expected
expected=$(expected_containers_for "$app")
if [ -n "$expected" ] && [ "$expected" != "$app" ]; then
local c missing=0
for c in $expected; do
echo "$snap" | grep -qxF "$c" || missing=1
done
[ "$missing" -eq 0 ] && return 0
# Fall through to prefix match if the expected_containers list has
# gaps; a partial install still counts as "installed enough" for
# preclean purposes.
fi
# Generic prefix fallback for apps not in the expected_containers_for table.
echo "$snap" | grep -qE "^(${app}|${app}-|archy-${app}|archy-${app}-)"
}
# Install missing deps for $app via the regular install path. Idempotent —
# already-installed deps are skipped. Records dep_install per dep so we can
# tell from the report whether the bitcoin pre-req was actually green by the
# time lnd's matrix started.
ensure_deps_installed() {
local app="$1"
local dep
for dep in $(deps_for_app "$app"); do
if app_already_installed "$dep"; then
continue
fi
echo " · dep install: $dep (required by $app)"
local img ver resp
img=$(jq -r --arg id "$dep" '.apps[] | select(.id==$id) | .dockerImage // ""' "$CATALOG")
ver=$(jq -r --arg id "$dep" '.apps[] | select(.id==$id) | .version // ""' "$CATALOG")
if [ -z "$img" ]; then
record "$app" "dep_$dep" FAIL "no dockerImage in catalog for dep $dep"
return 1
fi
resp=$(rpc_call "package.install" "$(jq -nc \
--arg id "$dep" --arg img "$img" --arg ver "$ver" \
'{id:$id, dockerImage:$img, version:$ver}')")
if echo "$resp" | jq -e '.error' >/dev/null 2>&1; then
record "$app" "dep_$dep" FAIL "rpc error: $(echo "$resp" | jq -c '.error')"
return 1
fi
# Wait for at least one expected container to appear running.
local deadline=$(($(date +%s) + 600))
while [ "$(date +%s)" -lt "$deadline" ]; do
if app_already_installed "$dep"; then
record "$app" "dep_$dep" PASS "installed"
break
fi
sleep 5
done
if ! app_already_installed "$dep"; then
record "$app" "dep_$dep" FAIL "containers did not appear within 10min"
return 1
fi
done
return 0
}
# Pre-clean: if the app is currently installed, uninstall it and wait for
# all containers to disappear. We can't measure install correctness without
# starting from a clean slate. Fail-soft — if the uninstall RPC errors we
# log but proceed; the install step will catch any residual state.
preclean_app() {
local app="$1"
if ! app_already_installed "$app"; then
return 0
fi
echo " · pre-clean: $app already installed, uninstalling first"
local resp; resp=$(rpc_call "package.uninstall" "{\"id\":\"$app\"}")
if echo "$resp" | jq -e '.error' >/dev/null 2>&1; then
echo " pre-clean uninstall RPC error: $(echo "$resp" | jq -c '.error')"
fi
# Multi-container stacks (indeedhub: 7, immich: 5, mempool: 3, btcpay: 6)
# take noticeably longer to tear down than single-container apps. 240s was
# too tight for indeedhub's 7-container teardown — bump to 10 min for
# safety; per-container timeout is still bounded inside archipelago itself.
local deadline=$(($(date +%s) + 600))
while [ "$(date +%s)" -lt "$deadline" ]; do
if ! app_already_installed "$app"; then return 0; fi
sleep 5
done
echo " pre-clean: timeout waiting for $app to uninstall"
return 1
}
# Run the full per-app matrix. Records a row per transition.
run_app_matrix() {
local app="$1"
echo
echo "═══ $app ═══"
if ! ensure_deps_installed "$app"; then
record "$app" install FAIL "dep install failed; skipping rest of matrix"
return
fi
preclean_app "$app" || record "$app" preclean FAIL "uninstall before test did not complete"
# ── 01 install ───────────────────────────────────────────────
local before after new_containers
before=$(snapshot_containers)
# The install handler requires `id` + `dockerImage` from the catalog
# entry. Match what the UI passes (Discover.vue / MarketplaceAppDetails.vue).
local docker_image version
docker_image=$(jq -r --arg id "$app" '.apps[] | select(.id==$id) | .dockerImage // ""' "$CATALOG")
version=$(jq -r --arg id "$app" '.apps[] | select(.id==$id) | .version // ""' "$CATALOG")
if [ -z "$docker_image" ]; then
record "$app" install FAIL "no dockerImage in catalog for $app"
return
fi
local install_resp
install_resp=$(rpc_call "package.install" "$(jq -nc \
--arg id "$app" --arg img "$docker_image" --arg ver "$version" \
'{id:$id, dockerImage:$img, version:$ver}')")
if echo "$install_resp" | jq -e '.error' >/dev/null 2>&1; then
record "$app" install FAIL "rpc error: $(echo "$install_resp" | jq -c '.error')"
return # cannot continue this app
fi
# Wait for the EXPECTED containers (per expected_containers_for) to all
# appear. The old "snapshot stable for 10s + count > before" heuristic
# terminated early on apps with deps: e.g. mempool's wait would break
# when archy-electrs-ui (electrumx dep companion) appeared, long before
# mempool's own containers were created (those take ~10min to pull and
# start). Waiting on the expected-set is exact, not heuristic.
#
# Cap at 15 minutes — mempool stack with cold image cache needs ~12 min.
local expected; expected=$(expected_containers_for "$app")
local deadline=$(($(date +%s) + 900))
while [ "$(date +%s)" -lt "$deadline" ]; do
after=$(snapshot_containers)
local missing=0
for c in $expected; do
echo "$after" | grep -qxF "$c" || missing=1
done
[ "$missing" -eq 0 ] && break
sleep 5
done
new_containers=$(comm -13 <(echo "$before") <(echo "$after"))
if [ -z "$new_containers" ]; then
record "$app" install FAIL "no containers created within 10min"
return
fi
# Assert each new container is in 'running' state.
local install_ok=1; local detail=""
while read -r c; do
[ -z "$c" ] && continue
local s
s=$(probe_container_state "$c")
if [ "$s" != "running" ]; then
install_ok=0
detail="$detail $c=$s"
fi
done <<< "$new_containers"
if [ "$install_ok" -eq 1 ]; then
record "$app" install PASS "$(echo "$new_containers" | tr '\n' ',' | sed 's/,$//')"
else
record "$app" install FAIL "containers not running:$detail"
fi
# ── 02 ui_probe ──────────────────────────────────────────────
# Retry with backoff — install just finished, but the app's backend
# (fedimint, immich, mempool stack) may take 30+s to be ready to serve
# HTTP. Probing immediately false-positive-FAILed those apps; pass on
# first 2xx/3xx within 60s.
local code
local ui_deadline=$(($(date +%s) + 60))
while :; do
code=$(probe_app_proxy "$app")
[[ "$code" =~ ^(2[0-9][0-9]|3[0-9][0-9])$ ]] && break
[ "$(date +%s)" -ge "$ui_deadline" ] && break
sleep 5
done
# Accept all 2xx/3xx — proxy reaches backend, app may redirect to login,
# serve OAuth flow (307), or use 308 permanent. 401/403 still fail because
# those mean "backend reached, app rejected request" which is the
# credential-plumbing failure mode we DO want to catch.
if [[ "$code" =~ ^(2[0-9][0-9]|3[0-9][0-9])$ ]]; then
record "$app" ui_probe PASS "HTTP $code"
else
record "$app" ui_probe FAIL "HTTP $code (expected 2xx/3xx, retried 60s)"
fi
# ── 03 auth_probe (only for apps with a credentialed/data endpoint) ──
# Same backoff treatment: bitcoin-ui's nginx config bind-mount is
# picked up at start, but the bitcoin-core backend may not have
# accepted RPC connections yet on a fresh install.
local probe_code; local pass_codes
pass_codes=$(auth_probe_pass_codes "$app")
if probe_code=$(auth_probe_for "$app" 2>/dev/null) && [ -n "$probe_code" ]; then
local auth_deadline=$(($(date +%s) + 60))
while :; do
echo " $pass_codes " | grep -qF " $probe_code " && break
[ "$(date +%s)" -ge "$auth_deadline" ] && break
sleep 5
probe_code=$(auth_probe_for "$app" 2>/dev/null) || break
done
if echo " $pass_codes " | grep -qF " $probe_code "; then
record "$app" auth_probe PASS "HTTP $probe_code"
else
record "$app" auth_probe FAIL "HTTP $probe_code (expected one of: $pass_codes; retried 60s — credential plumbing broken)"
fi
else
record "$app" auth_probe SKIP "no authenticated probe defined"
fi
# ── 04 stop ──────────────────────────────────────────────────
local stop_resp
stop_resp=$(rpc_call "package.stop" "{\"id\":\"$app\"}")
if echo "$stop_resp" | jq -e '.error' >/dev/null 2>&1; then
record "$app" stop FAIL "rpc error: $(echo "$stop_resp" | jq -c '.error')"
else
local all_stopped=1
while read -r c; do
[ -z "$c" ] && continue
wait_for_container_state "$c" "exited" 60 || all_stopped=0
done <<< "$new_containers"
if [ "$all_stopped" -eq 1 ]; then
record "$app" stop PASS
else
record "$app" stop FAIL "not all containers reached exited state"
fi
fi
# ── 05 start ─────────────────────────────────────────────────
local start_resp
start_resp=$(rpc_call "package.start" "{\"id\":\"$app\"}")
if echo "$start_resp" | jq -e '.error' >/dev/null 2>&1; then
record "$app" start FAIL "rpc error: $(echo "$start_resp" | jq -c '.error')"
else
local all_started=1
while read -r c; do
[ -z "$c" ] && continue
wait_for_container_state "$c" "running" 90 || all_started=0
done <<< "$new_containers"
if [ "$all_started" -eq 1 ]; then
record "$app" start PASS
else
record "$app" start FAIL "not all containers reached running state"
fi
fi
# ── 06 restart_container ─────────────────────────────────────
# `package.restart` returns immediately and spawns the actual restart.
# `podman restart -t <stop_timeout>` blocks for up to stop_timeout
# seconds (e.g. 600s for bitcoin-core). Polling once after sleep 5
# races on slow-stopping apps and false-positive-FAILs them. Poll
# each container up to 90s for "running" instead.
local restart_resp
restart_resp=$(rpc_call "package.restart" "{\"id\":\"$app\"}")
if echo "$restart_resp" | jq -e '.error' >/dev/null 2>&1; then
record "$app" restart FAIL "rpc error: $(echo "$restart_resp" | jq -c '.error')"
else
local all_running=1
while read -r c; do
[ -z "$c" ] && continue
wait_for_container_state "$c" "running" 90 || all_running=0
done <<< "$new_containers"
if [ "$all_running" -eq 1 ]; then
record "$app" restart PASS
else
record "$app" restart FAIL "container not running 90s after restart"
fi
fi
# ── 09 uninstall (skip 07 archipelago-restart and 08 host-reboot
# here — those are batch tests run once across all installed apps) ─
local uninst_resp
uninst_resp=$(rpc_call "package.uninstall" "{\"id\":\"$app\"}")
if echo "$uninst_resp" | jq -e '.error' >/dev/null 2>&1; then
record "$app" uninstall FAIL "rpc error: $(echo "$uninst_resp" | jq -c '.error')"
else
# Wait for all this-app containers to be absent.
local all_gone=1
while read -r c; do
[ -z "$c" ] && continue
wait_for_container_state "$c" "absent" 120 || all_gone=0
done <<< "$new_containers"
if [ "$all_gone" -eq 1 ]; then
record "$app" uninstall PASS
else
record "$app" uninstall FAIL "not all containers removed"
fi
fi
}
# ── batch transitions (run after per-app loop) ───────────────────
batch_archipelago_service_restart() {
echo
echo "═══ batch: archipelago.service restart ═══"
local before; before=$(snapshot_containers)
if ! ssh_run 'sudo systemctl restart archipelago'; then
record "_batch" archipelago_restart FAIL "systemctl restart errored"
return
fi
ssh_wait_ready 60 || { record "_batch" archipelago_restart FAIL "ssh did not return"; return; }
sleep 30 # let containers re-stabilize
rpc_login || { record "_batch" archipelago_restart FAIL "rpc relogin failed"; return; }
local after; after=$(snapshot_containers)
if [ "$before" = "$after" ]; then
record "_batch" archipelago_restart PASS "container set unchanged"
else
record "_batch" archipelago_restart FAIL "container set drifted across restart"
fi
}
batch_host_reboot() {
echo
echo "═══ batch: host reboot ═══"
local before; before=$(snapshot_containers)
ssh_run 'sudo systemctl reboot' || true # ssh disconnects immediately
sleep 30
# 5 min was too short — .228 took ~9min for full BIOS+kernel+systemd+
# rootless-podman boot. 12 min gives margin for slower hardware.
ssh_wait_ready 720 || { record "_batch" host_reboot FAIL "host did not come back in 12min"; return; }
sleep 60 # let containers auto-restart
rpc_login || { record "_batch" host_reboot FAIL "rpc unreachable after reboot"; return; }
local after; after=$(snapshot_containers)
if [ "$before" = "$after" ]; then
record "_batch" host_reboot PASS "all containers came back"
else
local missing
missing=$(comm -23 <(echo "$before") <(echo "$after") | tr '\n' ',' | sed 's/,$//')
record "_batch" host_reboot FAIL "missing: $missing"
fi
# ── L3 per-boot health gate ──────────────────────────────────
# Container-set equality proves the right containers exist; os-audit proves
# the node is actually *healthy* after the reboot: RPC up, OTA not wedged
# (FM12), every app reachable with valid launch metadata, FM-guards green.
# This is the per-boot building block os-audit.sh was written to be.
if [ -x "$ROOT/tests/lifecycle/os-audit.sh" ]; then
echo "── per-boot os-audit gate ──"
if ARCHY_HOST="$HOST" ARCHY_SCHEME=https ARCHY_PASSWORD="$UI_PASS" ARCHY_LOCAL=0 \
"$ROOT/tests/lifecycle/os-audit.sh" >"$OUT_DIR/os-audit-postboot.log" 2>&1; then
record "_batch" host_reboot_osaudit PASS "os-audit green after reboot"
else
record "_batch" host_reboot_osaudit FAIL "os-audit not green after reboot (see $OUT_DIR/os-audit-postboot.log)"
fi
fi
}
# ── main ─────────────────────────────────────────────────────────
APPS_LIST=$(apps_to_test)
if [ -z "$APPS_LIST" ]; then
echo "no apps match filter '$FILTER'" >&2; exit 2
fi
while read -r app; do
[ -z "$app" ] && continue
run_app_matrix "$app"
done <<< "$APPS_LIST"
# Batch transitions only run on full sweep (skip in filtered/smoke mode).
if [ -z "$FILTER" ]; then
batch_archipelago_service_restart
batch_host_reboot
fi
# ── summary ──────────────────────────────────────────────────────
echo
echo "═══ summary ═══"
count_status() {
local pat="$1"
[ -s "$OUT_DIR/results.jsonl" ] || { echo 0; return; }
awk -v pat="$pat" '$0 ~ pat { n++ } END { print n+0 }' "$OUT_DIR/results.jsonl"
}
PASS=$(count_status '"status":"PASS"')
FAIL=$(count_status '"status":"FAIL"')
SKIP=$(count_status '"status":"SKIP"')
TOTAL=$((PASS + FAIL + SKIP))
echo "PASS: $PASS / FAIL: $FAIL / SKIP: $SKIP / TOTAL: $TOTAL"
echo "Report: $OUT_DIR/results.jsonl"
[ "$FAIL" -eq 0 ] || exit 1
exit 0
+251
View File
@@ -0,0 +1,251 @@
#!/usr/bin/env bash
# E2E test suite for all Archipelago RPC endpoints.
# Uses correct method names from the dispatch table.
# Run on the server: bash run-e2e-tests.sh
set -u
BASE="http://127.0.0.1:5678"
JAR="/tmp/test-cookies.txt"
rm -f "$JAR"
PC=0; FC=0; SC=0
pass() { PC=$((PC + 1)); printf "\033[32m✓ %s\033[0m\n" "$1"; }
fail() { FC=$((FC + 1)); printf "\033[31m✗ %s\033[0m\n" "$1"; }
skip() { SC=$((SC + 1)); printf "\033[33m⊘ %s\033[0m\n" "$1"; }
rpc() {
sleep 0.3
local method="$1"
local params="${2:-"{}"}"
curl -s -b "$JAR" -c "$JAR" \
-H "Content-Type: application/json" \
-d "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"$method\",\"params\":$params}" \
"${BASE}/rpc/v1" 2>/dev/null
}
# Check if RPC response is successful (error field is null or absent)
rpc_ok() {
local resp="$1"
[ -z "$resp" ] && return 1
echo "$resp" | grep -q '"error":null' && return 0
echo "$resp" | grep -q '"error"' && return 1
return 0
}
echo ""
echo "━━━ Auth ━━━"
# Warmup: first request after server restart may get empty response
curl -s "${BASE}/health" > /dev/null 2>&1
sleep 1
# Login with retry
LOGIN=""
for attempt in 1 2 3; do
LOGIN=$(rpc "auth.login" '{"password":"password123"}')
if [ -n "$LOGIN" ]; then break; fi
sleep 0.5
done
rpc_ok "$LOGIN" && pass "auth.login" || fail "auth.login: $LOGIN"
echo ""
echo "━━━ Identity ━━━"
ID_LIST=$(rpc "identity.list")
rpc_ok "$ID_LIST" && pass "identity.list" || fail "identity.list: $ID_LIST"
FIRST_ID=$(echo "$ID_LIST" | python3 -c "import sys,json; r=json.load(sys.stdin); ids=r.get('result',{}).get('identities',[]); print(ids[0]['id'] if ids else '')" 2>/dev/null)
if [ -n "$FIRST_ID" ]; then
# sign
SIGN=$(rpc "identity.sign" "{\"id\":\"$FIRST_ID\",\"message\":\"hello\"}")
rpc_ok "$SIGN" && pass "identity.sign" || fail "identity.sign: $SIGN"
DID=$(echo "$SIGN" | python3 -c "import sys,json; print(json.load(sys.stdin)['result']['did'])" 2>/dev/null)
SIG_HEX=$(echo "$SIGN" | python3 -c "import sys,json; print(json.load(sys.stdin)['result']['signature'])" 2>/dev/null)
# verify (valid)
VER=$(rpc "identity.verify" "{\"did\":\"$DID\",\"message\":\"hello\",\"signature\":\"$SIG_HEX\"}")
echo "$VER" | python3 -c "import sys,json; r=json.load(sys.stdin); assert r['result']['valid']" 2>/dev/null && pass "identity.verify (valid)" || fail "identity.verify: $VER"
# verify (bad)
VER_BAD=$(rpc "identity.verify" "{\"did\":\"$DID\",\"message\":\"nope\",\"signature\":\"$SIG_HEX\"}")
echo "$VER_BAD" | python3 -c "import sys,json; r=json.load(sys.stdin); assert not r['result']['valid']" 2>/dev/null && pass "identity.verify (bad rejected)" || fail "identity.verify bad: $VER_BAD"
# get
R=$(rpc "identity.get" "{\"id\":\"$FIRST_ID\"}")
rpc_ok "$R" && pass "identity.get" || fail "identity.get: $R"
# set-default
R=$(rpc "identity.set-default" "{\"id\":\"$FIRST_ID\"}")
rpc_ok "$R" && pass "identity.set-default" || fail "identity.set-default: $R"
# nostr key
NOSTR=$(rpc "identity.create-nostr-key" "{\"id\":\"$FIRST_ID\"}")
rpc_ok "$NOSTR" && pass "identity.create-nostr-key" || {
echo "$NOSTR" | grep -q "already exists" && pass "identity.create-nostr-key (exists)" || fail "nostr-key: $NOSTR"
}
# nostr sign
HASH=$(python3 -c "import hashlib; print(hashlib.sha256(b'test').hexdigest())")
R=$(rpc "identity.nostr-sign" "{\"id\":\"$FIRST_ID\",\"event_hash\":\"$HASH\"}")
rpc_ok "$R" && pass "identity.nostr-sign" || fail "identity.nostr-sign: $R"
else
fail "no identity found"
fi
# Create + nostr + delete
CREATE=$(rpc "identity.create" '{"name":"TmpTest","purpose":"anonymous"}')
rpc_ok "$CREATE" && pass "identity.create" || fail "identity.create: $CREATE"
TEMP_ID=$(echo "$CREATE" | python3 -c "import sys,json; print(json.load(sys.stdin).get('result',{}).get('id',''))" 2>/dev/null)
if [ -n "$TEMP_ID" ]; then
R=$(rpc "identity.create-nostr-key" "{\"id\":\"$TEMP_ID\"}")
rpc_ok "$R" && pass "nostr-key (new identity)" || fail "nostr-key (new): $R"
R=$(rpc "identity.delete" "{\"id\":\"$TEMP_ID\"}")
rpc_ok "$R" && pass "identity.delete" || fail "identity.delete: $R"
fi
echo ""
echo "━━━ Names (identity.*-name) ━━━"
R=$(rpc "identity.list-names")
rpc_ok "$R" && pass "identity.list-names" || fail "identity.list-names: $(echo $R | head -c 120)"
if [ -n "$FIRST_ID" ]; then
R=$(rpc "identity.register-name" "{\"name\":\"e2e\",\"domain\":\"archipelago.local\",\"identity_id\":\"$FIRST_ID\",\"did\":\"$DID\"}")
rpc_ok "$R" && pass "identity.register-name" || fail "identity.register-name: $(echo $R | head -c 120)"
REG_NAME_ID=$(echo "$R" | python3 -c "import sys,json; print(json.load(sys.stdin).get('result',{}).get('id',''))" 2>/dev/null)
R=$(rpc "identity.resolve-name" '{"identifier":"e2e@archipelago.local"}')
rpc_ok "$R" && pass "identity.resolve-name" || fail "identity.resolve-name: $(echo $R | head -c 120)"
if [ -n "$REG_NAME_ID" ]; then
R=$(rpc "identity.remove-name" "{\"id\":\"$REG_NAME_ID\"}")
rpc_ok "$R" && pass "identity.remove-name" || fail "identity.remove-name: $(echo $R | head -c 120)"
fi
fi
echo ""
echo "━━━ Credentials (identity.*-credential) ━━━"
R=$(rpc "identity.list-credentials")
rpc_ok "$R" && pass "identity.list-credentials" || fail "identity.list-credentials: $(echo $R | head -c 120)"
if [ -n "$FIRST_ID" ]; then
R=$(rpc "identity.issue-credential" "{\"issuer_id\":\"$FIRST_ID\",\"subject_did\":\"did:key:z6MkTest\",\"type\":\"TestCred\",\"claims\":{\"name\":\"E2E\"}}")
rpc_ok "$R" && pass "identity.issue-credential" || fail "identity.issue-credential: $(echo $R | head -c 120)"
fi
echo ""
echo "━━━ Lightning ━━━"
R=$(rpc "lnd.getinfo")
rpc_ok "$R" && pass "lnd.getinfo" || fail "lnd.getinfo: $R"
R=$(rpc "lnd.listchannels")
rpc_ok "$R" && pass "lnd.listchannels" || fail "lnd.listchannels: $R"
R=$(rpc "lnd.newaddress")
rpc_ok "$R" && pass "lnd.newaddress" || fail "lnd.newaddress: $R"
R=$(rpc "lnd.createinvoice" '{"amount_sats":0,"memo":"zero amount test"}')
rpc_ok "$R" && pass "lnd.createinvoice (0 sats)" || fail "lnd.createinvoice (0): $R"
R=$(rpc "lnd.createinvoice" '{"amount_sats":1000,"memo":"test"}')
rpc_ok "$R" && pass "lnd.createinvoice (1000 sats)" || fail "lnd.createinvoice (1000): $R"
R=$(rpc "bitcoin.getinfo")
rpc_ok "$R" && pass "bitcoin.getinfo" || fail "bitcoin.getinfo: $R"
echo ""
echo "━━━ Tor ━━━"
R=$(rpc "tor.list-services")
rpc_ok "$R" && pass "tor.list-services" || fail "tor.list-services: $R"
R=$(rpc "tor.create-service" '{"name":"test-e2e","local_port":9999}')
rpc_ok "$R" && pass "tor.create-service" || fail "tor.create-service: $(echo $R | head -c 150)"
R=$(rpc "tor.delete-service" '{"name":"test-e2e"}')
rpc_ok "$R" && pass "tor.delete-service" || fail "tor.delete-service: $R"
R=$(rpc "tor.get-onion-address" '{"name":"archipelago"}')
rpc_ok "$R" && pass "tor.get-onion-address" || fail "tor.get-onion-address: $R"
echo ""
echo "━━━ Ecash Wallet ━━━"
R=$(rpc "wallet.ecash-balance")
rpc_ok "$R" && pass "wallet.ecash-balance" || skip "wallet.ecash-balance"
R=$(rpc "wallet.ecash-history")
rpc_ok "$R" && pass "wallet.ecash-history" || skip "wallet.ecash-history"
R=$(rpc "wallet.networking-profits")
rpc_ok "$R" && pass "wallet.networking-profits" || skip "wallet.networking-profits"
echo ""
echo "━━━ Content ━━━"
R=$(rpc "content.list-mine")
rpc_ok "$R" && pass "content.list-mine" || fail "content.list-mine: $R"
echo ""
echo "━━━ Network ━━━"
R=$(rpc "network.get-visibility")
rpc_ok "$R" && pass "network.get-visibility" || fail "network.get-visibility: $R"
R=$(rpc "network.diagnostics")
rpc_ok "$R" && pass "network.diagnostics" || fail "network.diagnostics: $R"
R=$(rpc "network.list-requests")
rpc_ok "$R" && pass "network.list-requests" || fail "network.list-requests: $R"
R=$(rpc "node-list-peers")
rpc_ok "$R" && pass "node-list-peers" || fail "node-list-peers: $R"
echo ""
echo "━━━ Nostr Relays ━━━"
R=$(rpc "nostr.list-relays")
rpc_ok "$R" && pass "nostr.list-relays" || fail "nostr.list-relays: $R"
R=$(rpc "nostr.get-stats")
rpc_ok "$R" && pass "nostr.get-stats" || fail "nostr.get-stats: $R"
echo ""
echo "━━━ DWN ━━━"
R=$(rpc "dwn.status")
rpc_ok "$R" && pass "dwn.status" || fail "dwn.status: $R"
echo ""
echo "━━━ Update ━━━"
R=$(rpc "update.status")
rpc_ok "$R" && pass "update.status" || fail "update.status: $R"
R=$(rpc "update.check")
rpc_ok "$R" && pass "update.check" || skip "update.check"
echo ""
echo "━━━ Router ━━━"
R=$(rpc "router.info")
rpc_ok "$R" && pass "router.info" || skip "router.info"
R=$(rpc "router.list-forwards")
rpc_ok "$R" && pass "router.list-forwards" || skip "router.list-forwards"
echo ""
echo "━━━ Health & HTTP endpoints ━━━"
HC=$(curl -s -o /dev/null -w "%{http_code}" "${BASE}/health")
[ "$HC" = "200" ] && pass "/health (200)" || fail "/health ($HC)"
EC=$(curl -s -o /dev/null -w "%{http_code}" "${BASE}/electrs-status")
[ "$EC" = "200" ] && pass "/electrs-status (200)" || fail "/electrs-status ($EC)"
echo ""
echo "━━━ Container Management ━━━"
R=$(rpc "container-list")
rpc_ok "$R" && pass "container-list" || fail "container-list: $R"
R=$(rpc "container-status" '{"app_id":"bitcoin-knots"}')
rpc_ok "$R" && pass "container-status (bitcoin-knots)" || fail "container-status: $R"
echo ""
echo "━━━━━━━━━━━━━ RESULTS ━━━━━━━━━━━━━"
printf "\033[32m Passed: %d\033[0m\n" "$PC"
printf "\033[31m Failed: %d\033[0m\n" "$FC"
printf "\033[33m Skipped: %d\033[0m\n" "$SC"
T=$((PC + FC + SC))
if [ "$FC" -eq 0 ]; then
printf "\n\033[1;32m🎉 ALL %d PASSED (%d skipped)\033[0m\n" "$PC" "$SC"
else
printf "\n\033[1;31m⚠ %d/%d FAILED\033[0m\n" "$FC" "$T"
fi
rm -f "$JAR"
+521
View File
@@ -0,0 +1,521 @@
#!/usr/bin/env bash
# Post-install + onboarding + container lifecycle E2E tests.
# Run on an installed Archipelago node (SSH or local).
#
# Usage: bash run-post-install-tests.sh --password-stdin # read password from stdin (preferred)
# bash run-post-install-tests.sh [password] # argv form; visible in `ps`, local use only
# bash run-post-install-tests.sh --phase1-only # Install checks only (no auth)
#
# Tests:
# Phase 1: Install verification (services, files, logs) — safe, no side effects
# Phase 2: Onboarding (password setup, auth flow) — creates user account
# Phase 3: Container lifecycle (install 3 apps, start/stop/health) — needs auth
set -u
PHASE1_ONLY=false
PASSWORD=""
for arg in "$@"; do
case "$arg" in
--phase1-only) PHASE1_ONLY=true ;;
--password-stdin) IFS= read -r PASSWORD || true ;;
*) PASSWORD="$arg" ;;
esac
done
if [ "$PHASE1_ONLY" = false ] && [ -z "$PASSWORD" ]; then
echo "ERROR: no password supplied. Use --password-stdin, pass one as an argument," >&2
echo " or run --phase1-only for the no-auth install checks." >&2
exit 2
fi
BASE="http://127.0.0.1:5678"
JAR="/tmp/e2e-cookies.txt"
rm -f "$JAR"
PC=0; FC=0; SC=0
pass() { PC=$((PC + 1)); printf "\033[32m ✓ %s\033[0m\n" "$1"; }
fail() { FC=$((FC + 1)); printf "\033[31m ✗ %s — %s\033[0m\n" "$1" "${2:-}"; }
skip() { SC=$((SC + 1)); printf "\033[33m ⊘ %s\033[0m\n" "$1"; }
section() { printf "\n\033[1m━━━ %s ━━━\033[0m\n" "$1"; }
# Extract CSRF token from cookie jar
get_csrf() {
grep 'csrf_token' "$JAR" 2>/dev/null | awk '{print $NF}'
}
rpc() {
local method="$1"
local params="${2:-"{}"}"
local csrf
csrf=$(get_csrf)
local csrf_header=""
if [ -n "$csrf" ]; then
csrf_header="-H X-CSRF-Token:${csrf}"
fi
curl -s -b "$JAR" -c "$JAR" \
-H "Content-Type: application/json" \
$csrf_header \
-d "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"$method\",\"params\":$params}" \
"${BASE}/rpc/v1" 2>/dev/null
}
rpc_ok() {
local resp="$1"
[ -z "$resp" ] && return 1
echo "$resp" | grep -q '"error":null' && return 0
echo "$resp" | grep -q '"error"' && return 1
return 0
}
rpc_result() {
echo "$1" | python3 -c "import sys,json; d=json.load(sys.stdin); print(json.dumps(d.get('result','')))" 2>/dev/null
}
wait_for_server() {
local max_wait=60
local waited=0
while [ $waited -lt $max_wait ]; do
if curl -sf "${BASE}/health" >/dev/null 2>&1; then
return 0
fi
sleep 2
waited=$((waited + 2))
done
return 1
}
echo ""
echo "╔══════════════════════════════════════════════╗"
echo "║ Archipelago Post-Install E2E Test Suite ║"
echo "╚══════════════════════════════════════════════╝"
echo ""
echo "Target: ${BASE}"
echo "Date: $(date -u '+%Y-%m-%d %H:%M:%S UTC')"
# ═══════════════════════════════════════════
# PHASE 1: Install Verification
# ═══════════════════════════════════════════
section "Phase 1: Install Verification"
# 1.1 — Critical files exist
for f in /usr/local/bin/archipelago \
/opt/archipelago/web-ui/index.html \
/etc/nginx/sites-available/archipelago \
/etc/archipelago/ssl/archipelago.crt \
/opt/archipelago/scripts/image-versions.sh; do
if [ -f "$f" ]; then
pass "File exists: $f"
else
fail "File missing" "$f"
fi
done
# 1.1b — Every enabled archipelago/nostr unit must have an existing ExecStart
# payload. v1.7.104 shipped nostr-relay.service without its binary (3s
# crash-loop forever) and archipelago-diag.service without its script
# (203/EXEC) — catch that class of ISO defect on first boot, by generic rule.
for unit in /etc/systemd/system/archipelago*.service /etc/systemd/system/nostr*.service; do
[ -f "$unit" ] || continue
systemctl is-enabled "$(basename "$unit")" >/dev/null 2>&1 || continue
exec_bin=$(grep -m1 '^ExecStart=' "$unit" | sed 's/^ExecStart=[-+@!:]*//' | awk '{print $1}')
case "$exec_bin" in
/*) if [ -e "$exec_bin" ]; then
pass "Unit payload exists: $(basename "$unit")$exec_bin"
else
fail "Unit payload missing" "$(basename "$unit")$exec_bin"
fi ;;
esac
done
# 1.2 — Critical services active
for svc in archipelago nginx; do
if systemctl is-active "$svc" >/dev/null 2>&1; then
pass "Service active: $svc"
else
fail "Service not active" "$svc"
fi
done
# 1.3 — Services enabled
for svc in archipelago nginx archipelago-load-images archipelago-first-boot-containers; do
if systemctl is-enabled "$svc" >/dev/null 2>&1; then
pass "Service enabled: $svc"
else
fail "Service not enabled" "$svc"
fi
done
# 1.4 — Podman available for archipelago user
if runuser -u archipelago -- bash -c 'export XDG_RUNTIME_DIR=/run/user/1000 && podman --version' >/dev/null 2>&1; then
pass "Podman available (rootless, archipelago user)"
else
fail "Podman not available" "rootless podman for archipelago user"
fi
# 1.5 — Linger enabled
if [ -f /var/lib/systemd/linger/archipelago ]; then
pass "Linger enabled for archipelago"
else
fail "Linger not enabled" "/var/lib/systemd/linger/archipelago missing"
fi
# 1.6 — Backend not in dev mode
if systemctl cat archipelago 2>/dev/null | grep -q 'DEV_MODE=true'; then
fail "DEV_MODE enabled" "ARCHIPELAGO_DEV_MODE=true found in service file"
else
pass "DEV_MODE disabled (production mode)"
fi
# 1.7 — Backend running as correct user
SVC_USER=$(systemctl show -p User archipelago 2>/dev/null | cut -d= -f2)
if [ "$SVC_USER" = "archipelago" ]; then
pass "Backend runs as user: archipelago"
elif [ "$SVC_USER" = "root" ]; then
fail "Backend runs as root" "Should be User=archipelago"
else
skip "Cannot determine backend user ($SVC_USER)"
fi
# 1.8 — Health endpoint responds
if curl -sf "${BASE}/health" >/dev/null 2>&1; then
pass "Health endpoint responds"
else
fail "Health endpoint" "No response from ${BASE}/health"
fi
# 1.9 — Web UI loads via nginx
HTTP_CODE=$(curl -sk -o /dev/null -w "%{http_code}" "https://localhost/" 2>/dev/null)
if [ "$HTTP_CODE" = "200" ]; then
pass "Web UI loads via nginx (HTTPS)"
else
fail "Web UI not accessible" "HTTPS returned $HTTP_CODE"
fi
# 1.10 — Nginx config test
if nginx -t 2>/dev/null; then
pass "Nginx config valid"
else
fail "Nginx config" "nginx -t failed"
fi
# ── Phase 1 exit point ──
if [ "$PHASE1_ONLY" = "true" ]; then
section "Results (Phase 1 only)"
TOTAL=$((PC + FC + SC))
printf "\n \033[32mPassed: %d\033[0m \033[31mFailed: %d\033[0m \033[33mSkipped: %d\033[0m Total: %d\n\n" "$PC" "$FC" "$SC" "$TOTAL"
[ "$FC" -gt 0 ] && echo " Phase 1: SOME CHECKS FAILED" && exit 1
echo " Phase 1: ALL CHECKS PASSED"
echo " Run without --phase1-only to test onboarding + containers"
exit 0
fi
# ═══════════════════════════════════════════
# PHASE 2: Onboarding & Auth
# ═══════════════════════════════════════════
section "Phase 2: Onboarding & Auth"
# Wait for server
if ! wait_for_server; then
fail "Server not ready" "Timed out after 60s"
section "Results"
echo " Passed: $PC Failed: $FC Skipped: $SC"
exit 1
fi
# 2.1 — Check setup status (should be false on fresh install)
SETUP_RESP=$(rpc "auth.isSetup")
if rpc_ok "$SETUP_RESP"; then
IS_SETUP=$(echo "$SETUP_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin).get('result',False))" 2>/dev/null)
if [ "$IS_SETUP" = "True" ] || [ "$IS_SETUP" = "true" ]; then
pass "auth.isSetup returns true (user exists)"
# Already set up — just login
LOGIN=$(rpc "auth.login" "{\"password\":\"$PASSWORD\"}")
if rpc_ok "$LOGIN"; then
pass "auth.login (existing user)"
else
# Try default dev password
LOGIN=$(rpc "auth.login" '{"password":"password123"}')
if rpc_ok "$LOGIN"; then
pass "auth.login (dev password)"
PASSWORD="password123"
else
fail "auth.login" "Cannot authenticate"
fi
fi
else
pass "auth.isSetup returns false (fresh install)"
# 2.2 — Set up password
SETUP=$(rpc "auth.setup" "{\"password\":\"$PASSWORD\"}")
if rpc_ok "$SETUP"; then
pass "auth.setup (password created)"
else
fail "auth.setup" "$SETUP"
fi
# 2.3 — Login with new password
LOGIN=$(rpc "auth.login" "{\"password\":\"$PASSWORD\"}")
if rpc_ok "$LOGIN"; then
pass "auth.login (new password)"
else
fail "auth.login" "$LOGIN"
fi
fi
else
fail "auth.isSetup" "$SETUP_RESP"
fi
# 2.4 — Onboarding status
OB_RESP=$(rpc "auth.isOnboardingComplete")
if rpc_ok "$OB_RESP"; then
pass "auth.isOnboardingComplete responds"
else
fail "auth.isOnboardingComplete" "$OB_RESP"
fi
# 2.5 — Node DID available
DID_RESP=$(rpc "node.did")
if rpc_ok "$DID_RESP"; then
pass "node.did (DID generated)"
else
fail "node.did" "$DID_RESP"
fi
# 2.6 — Server info
INFO_RESP=$(rpc "server.info")
if rpc_ok "$INFO_RESP"; then
pass "server.info responds"
else
# Try alternate method name
INFO_RESP=$(rpc "system.info")
if rpc_ok "$INFO_RESP"; then
pass "system.info responds"
else
skip "server.info / system.info (may not exist)"
fi
fi
# 2.7 — Mark onboarding complete
OB_COMPLETE=$(rpc "auth.onboardingComplete")
if rpc_ok "$OB_COMPLETE"; then
pass "auth.onboardingComplete"
else
skip "auth.onboardingComplete (may already be done)"
fi
# ═══════════════════════════════════════════
# PHASE 3: Container Lifecycle
# ═══════════════════════════════════════════
section "Phase 3: Container Lifecycle"
# Source image versions for dockerImage URLs
source /opt/archipelago/scripts/image-versions.sh 2>/dev/null || true
# Test with 3 lightweight standalone containers
# package.install expects: {"id": "app_id", "dockerImage": "registry/image:tag"}
# container-start/stop/status expect: {"app_id": "name"}
declare -a APPS=("filebrowser" "searxng" "grafana")
declare -a IMAGES=("${FILEBROWSER_IMAGE:-}" "${SEARXNG_IMAGE:-}" "${GRAFANA_IMAGE:-}")
# 3.1 — List containers (baseline)
LIST_RESP=$(rpc "container-list")
if rpc_ok "$LIST_RESP"; then
pass "container-list (baseline)"
else
fail "container-list" "$LIST_RESP"
fi
for i in 0 1 2; do
APP="${APPS[$i]}"
IMAGE="${IMAGES[$i]}"
section "Container: $APP"
if [ -z "$IMAGE" ]; then
fail "$APP — image variable empty" "image-versions.sh missing or incomplete"
continue
fi
# 3.2 — Install container via package.install RPC
# Check if already exists first
EXISTING=$(rpc "container-list")
if echo "$EXISTING" | grep -q "\"$APP\""; then
pass "$APP already installed (skipping install)"
else
INSTALL_RESP=$(rpc "package.install" "{\"id\":\"$APP\",\"dockerImage\":\"$IMAGE\"}")
if rpc_ok "$INSTALL_RESP"; then
pass "$APP installed"
else
ERR_MSG=$(echo "$INSTALL_RESP" | python3 -c "import sys,json; d=json.load(sys.stdin); e=d.get('error',{}); print(e.get('message','unknown') if isinstance(e,dict) else str(e))" 2>/dev/null)
fail "$APP install" "$ERR_MSG"
continue
fi
fi
# Wait for container to start (pull + create + start)
echo " ... waiting for $APP to start"
for attempt in $(seq 1 15); do
sleep 2
STATUS_RESP=$(rpc "container-list")
if echo "$STATUS_RESP" | grep -q "\"$APP\"" && echo "$STATUS_RESP" | grep -q '"running"'; then
break
fi
done
# 3.3 — Verify running
LIST_NOW=$(rpc "container-list")
if echo "$LIST_NOW" | grep -q "\"$APP\""; then
if echo "$LIST_NOW" | python3 -c "
import sys,json
data = json.load(sys.stdin).get('result',[])
if isinstance(data, list):
for c in data:
if c.get('name','') == '$APP' or '$APP' in c.get('name',''):
print(c.get('state','unknown'))
sys.exit(0)
print('not-found')
" 2>/dev/null | grep -q "running"; then
pass "$APP running after install"
else
fail "$APP not running" "Check container-list output"
fi
else
fail "$APP not in container list" ""
continue
fi
# 3.4 — Stop container
STOP_RESP=$(rpc "container-stop" "{\"app_id\":\"$APP\"}")
if rpc_ok "$STOP_RESP"; then
pass "$APP stopped"
else
fail "$APP stop" "$STOP_RESP"
fi
sleep 3
# 3.5 — Verify stopped
LIST_NOW=$(rpc "container-list")
STATE=$(echo "$LIST_NOW" | python3 -c "
import sys,json
data = json.load(sys.stdin).get('result',[])
if isinstance(data, list):
for c in data:
if c.get('name','') == '$APP' or '$APP' in c.get('name',''):
print(c.get('state','unknown'))
sys.exit(0)
print('not-found')
" 2>/dev/null)
if [ "$STATE" = "exited" ] || [ "$STATE" = "stopped" ]; then
pass "$APP confirmed stopped"
else
fail "$APP not stopped" "State: $STATE"
fi
# 3.6 — Restart container
START_RESP=$(rpc "container-start" "{\"app_id\":\"$APP\"}")
if rpc_ok "$START_RESP"; then
pass "$APP restarted"
else
fail "$APP restart" "$START_RESP"
fi
sleep 5
# 3.7 — Verify running again
LIST_NOW=$(rpc "container-list")
STATE=$(echo "$LIST_NOW" | python3 -c "
import sys,json
data = json.load(sys.stdin).get('result',[])
if isinstance(data, list):
for c in data:
if c.get('name','') == '$APP' or '$APP' in c.get('name',''):
print(c.get('state','unknown'))
sys.exit(0)
print('not-found')
" 2>/dev/null)
if [ "$STATE" = "running" ]; then
pass "$APP running after restart"
else
fail "$APP not running after restart" "State: $STATE"
fi
# 3.8 — Health check
HEALTH_RESP=$(rpc "container-health" "{\"app_id\":\"$APP\"}")
if rpc_ok "$HEALTH_RESP"; then
pass "$APP health responds"
else
skip "$APP health (may need warm-up time)"
fi
done
# 3.9 — Final container list (should show all 3)
LIST_RESP=$(rpc "container-list")
if rpc_ok "$LIST_RESP"; then
COUNT=$(echo "$LIST_RESP" | python3 -c "import sys,json; r=json.load(sys.stdin).get('result',[]); print(len(r) if isinstance(r,list) else 0)" 2>/dev/null)
if [ "${COUNT:-0}" -ge 3 ]; then
pass "container-list shows $COUNT containers (>= 3)"
else
fail "container-list" "Only $COUNT containers (expected >= 3)"
fi
else
fail "container-list (final)" "$LIST_RESP"
fi
# ═══════════════════════════════════════════
# PHASE 4: Log Verification
# ═══════════════════════════════════════════
section "Phase 4: Log Verification"
# 4.1 — First-boot log exists and completed
if [ -f /var/log/archipelago-first-boot.log ]; then
if grep -q "first-boot complete" /var/log/archipelago-first-boot.log 2>/dev/null; then
pass "First-boot log: completed"
else
fail "First-boot log" "Did not complete — check /var/log/archipelago-first-boot.log"
fi
else
fail "First-boot log" "/var/log/archipelago-first-boot.log missing"
fi
# 4.2 — Diagnostics log exists
if [ -f /var/log/archipelago-first-boot-diag.log ]; then
pass "Diagnostics log exists"
else
skip "Diagnostics log (/var/log/archipelago-first-boot-diag.log)"
fi
# 4.3 — No critical errors in backend journal
CRIT_ERRORS=$(journalctl -u archipelago --no-pager -p err -b 2>/dev/null | grep -v "Failed to read LND\|Failed to query getblockchain\|Cannot connect to Podman" | head -5)
if [ -z "$CRIT_ERRORS" ]; then
pass "No unexpected backend errors in journal"
else
fail "Backend errors in journal" "$(echo "$CRIT_ERRORS" | head -1)"
fi
# 4.4 — image-versions.sh is accessible
if [ -f /opt/archipelago/scripts/image-versions.sh ]; then
if source /opt/archipelago/scripts/image-versions.sh 2>/dev/null && [ -n "$FILEBROWSER_IMAGE" ]; then
pass "image-versions.sh loads correctly"
else
fail "image-versions.sh" "Cannot source or FILEBROWSER_IMAGE empty"
fi
else
fail "image-versions.sh" "Not found at /opt/archipelago/scripts/"
fi
# ═══════════════════════════════════════════
# Results
# ═══════════════════════════════════════════
section "Results"
TOTAL=$((PC + FC + SC))
echo ""
printf " \033[32mPassed: %d\033[0m \033[31mFailed: %d\033[0m \033[33mSkipped: %d\033[0m Total: %d\n" "$PC" "$FC" "$SC" "$TOTAL"
echo ""
if [ "$FC" -gt 0 ]; then
echo " ❌ SOME TESTS FAILED"
exit 1
else
echo " ✅ ALL TESTS PASSED"
exit 0
fi
+85
View File
@@ -0,0 +1,85 @@
#!/usr/bin/env bash
#
# Run Archipelago tests.
#
# By default this runs frontend tests and local backend Rust tests. Set
# ARCHIPELAGO_SSH_HOST and ARCHIPELAGO_SSH_KEY to run backend tests on a Linux
# target instead.
#
set -euo pipefail
SSH_KEY="${ARCHIPELAGO_SSH_KEY:-}"
SSH_HOST="${ARCHIPELAGO_SSH_HOST:-}"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
FRONTEND_OK=0
BACKEND_OK=0
echo "========================================="
echo " Archipelago Test Runner"
echo "========================================="
echo ""
# --- Frontend Tests ---
echo "--- Frontend Tests (local) ---"
if (cd "$PROJECT_DIR/neode-ui" && npm test 2>&1); then
echo "✅ Frontend tests PASSED"
FRONTEND_OK=1
else
echo "❌ Frontend tests FAILED"
fi
echo ""
# --- Backend Tests ---
if [[ -n "$SSH_HOST" && -n "$SSH_KEY" ]]; then
echo "--- Backend Tests (Linux target: $SSH_HOST) ---"
echo "Syncing source to target..."
rsync -az --exclude 'target' --exclude 'node_modules' --exclude '.git' \
-e "ssh -i $SSH_KEY" \
"$PROJECT_DIR/core/" "$SSH_HOST:~/archy/core/" 2>&1
if ssh -i "$SSH_KEY" "$SSH_HOST" \
"source ~/.cargo/env && cd ~/archy/core && cargo test --all-features 2>&1"; then
echo "✅ Backend tests PASSED"
BACKEND_OK=1
else
echo "❌ Backend tests FAILED"
fi
else
echo "--- Backend Tests (local) ---"
if (cd "$PROJECT_DIR/core" && cargo test --all-features 2>&1); then
echo "✅ Backend tests PASSED"
BACKEND_OK=1
else
echo "❌ Backend tests FAILED"
fi
fi
echo ""
echo "========================================="
echo " Results"
echo "========================================="
if [ "$FRONTEND_OK" -eq 1 ]; then
echo " Frontend: ✅ PASS"
else
echo " Frontend: ❌ FAIL"
fi
if [ "$BACKEND_OK" -eq 1 ]; then
echo " Backend: ✅ PASS"
else
echo " Backend: ❌ FAIL"
fi
echo "========================================="
if [ "$FRONTEND_OK" -eq 1 ] && [ "$BACKEND_OK" -eq 1 ]; then
echo "All tests passed!"
exit 0
else
echo "Some tests failed."
exit 1
fi
+567
View File
@@ -0,0 +1,567 @@
#!/bin/bash
# host-secrets-audit.sh — does THIS node run the fleet-shared, image-baked SSH
# host keys and TLS private key, or its own?
#
# Audit finding F-03 / phase 10 KEY-02, deployed half (decision D-06).
#
# 10-03 fixed the ISO builder: the rootfs no longer carries identity material
# and first-boot regeneration fails closed. Nodes already in the field never
# receive any of that — the first-boot script is installed by the installer,
# not shipped by OTA — and a node that hit the old fail-open path
# (`WARNING: TLS regeneration failed, keeping baked key` plus an unconditional
# `touch $MARKER`) is running key material that every downloader of that ISO
# also holds, and will never try again. This script is how such a node is
# found, and how it is fixed.
#
# ── SAFETY MODEL (D-06: detect-report-then-apply) ────────────────────────────
# --detect (default) read-only. Writes only its own verdict file. Always
# exits 0: detection is informational and must never fail
# a boot.
# --apply prints what it WOULD do and exits 0 having touched
# nothing. A mistyped invocation is inert.
# --apply --yes rotates — and only if the detect pass returned `shared`.
# A node whose verdict is `per-node` cannot have its keys
# rotated by this script even by explicit command.
#
# The boot unit (image-recipe/configs/archipelago-host-secrets-audit.service)
# runs --detect only and contains no apply path.
#
# ── THIS IS A SANCTIONED KEY PRODUCER. THERE ARE NOW THREE. ─────────────────
# Do not unify them, and do not let their parameters drift apart:
# 1. gen_tls()/gen_ssh() in image-recipe/_archived/build-auto-installer-iso.sh
# — first boot, on the node, from the ISO.
# 2. TlsMaterial::regenerate() in core/archipelago/src/api/rpc/system/handlers.rs
# — TLS only, re-minted after `server.set-name` so the SAN matches.
# 3. rotate_tls()/rotate_ssh() below — deployed nodes, operator-driven, once.
# All three: rsa:2048, 3650 days, the same subject and the same SAN set, stage
# to `.new` siblings of the destination (same directory, so the final mv is a
# rename(2) and therefore atomic), parse both halves back AND prove they are a
# matching pair, then swap. A key from one generation beside a cert from
# another passes both individual parse checks and still breaks nginx.
#
# Producer 3 has to exist separately: producer 1 lives inside an ISO build
# script that is not present on a deployed node, and producer 2 does TLS only —
# nothing in the daemon has ever rotated an SSH host key.
#
# ── TEST SEAM ───────────────────────────────────────────────────────────────
# HOST_SECRETS_ROOT prefixes every absolute path, exactly as
# FIRST_BOOT_SECRETS_ROOT does for 10-03's first-boot script. Unset in
# production the expansion is empty and behaviour is byte-identical; set, it is
# what makes tests/first-boot-secrets/rotation-tests.sh able to force a
# `shared` node into existence and drive a real rotation against it.
#
# Usage:
# host-secrets-audit.sh [--detect] [--json] [--quiet]
# host-secrets-audit.sh --apply [--yes]
set -euo pipefail
ROOT="${HOST_SECRETS_ROOT:-}"
MARKER="$ROOT/var/lib/archipelago/.secrets-regenerated"
FAILED_RECORD="$ROOT/var/lib/archipelago/first-boot-secrets.failed"
FIRST_BOOT_LOG="$ROOT/var/log/archipelago-first-boot-secrets.log"
STRIPPED_MARKER="$ROOT/opt/archipelago/rootfs-identity-stripped"
LUKS_KEY="$ROOT/root/.luks-archipelago.key"
MACHINE_ID="$ROOT/etc/machine-id"
SSH_DIR="$ROOT/etc/ssh"
SSL_DIR="$ROOT/etc/archipelago/ssl"
TLS_KEY="$SSL_DIR/archipelago.key"
TLS_CRT="$SSL_DIR/archipelago.crt"
STATE_DIR="$ROOT/var/lib/archipelago"
AUDIT_JSON="$STATE_DIR/host-secrets-audit.json"
ROTATION_JSON="$STATE_DIR/host-key-rotation.json"
CONSOLE="$ROOT/dev/console"
# A key regenerated at first boot carries an mtime within seconds of the
# anchor. A key baked into the image carries the image build time — days or
# weeks earlier. 300s absorbs the spread between the anchor being touched and
# the last key being written, without being wide enough to hide a build-time
# key.
ANCHOR_SKEW_SECONDS=300
MODE="detect"
CONFIRMED=0
QUIET=0
EMIT_JSON=0
while [ $# -gt 0 ]; do
case "$1" in
--detect) MODE="detect" ;;
--apply) MODE="apply" ;;
--yes) CONFIRMED=1 ;;
--json) EMIT_JSON=1 ;;
--quiet) QUIET=1 ;;
-h|--help)
sed -n '2,50p' "$0"
exit 0
;;
*)
echo "host-secrets-audit: unknown argument: $1" >&2
exit 2
;;
esac
shift
done
say() { [ "$QUIET" = 1 ] || echo "$*"; }
# Evidence must name production paths, not the harness's temp root.
disp() { printf '%s' "${1#"$ROOT"}"; }
json_escape() { printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g'; }
json_array() {
local first=1 item
printf '['
for item in "$@"; do
[ "$first" = 1 ] || printf ', '
first=0
printf '"%s"' "$(json_escape "$item")"
done
printf ']'
}
mtime_of() { stat -c %Y "$1" 2>/dev/null || true; }
now_iso() { date -u +%Y-%m-%dT%H:%M:%SZ; }
# ── Fingerprints ────────────────────────────────────────────────────────────
# Fingerprints of PUBLIC keys are public data (T-10-35: accept). The private
# keys are never read by this script except by the generators that replace
# them.
ssh_fingerprints() {
local f
for f in "$SSH_DIR"/ssh_host_*_key.pub; do
[ -e "$f" ] || continue
ssh-keygen -lf "$f" 2>/dev/null | sed "s|^|$(disp "$f"): |" || true
done
}
tls_fingerprint() {
[ -s "$TLS_CRT" ] || return 0
openssl x509 -in "$TLS_CRT" -noout -fingerprint -sha256 2>/dev/null \
| sed 's/^.*=//' || true
}
# ── Detection ───────────────────────────────────────────────────────────────
# Outputs (globals, so --apply can reuse the pass without re-running it):
# VERDICT per-node | shared | fail-closed-missing | unknown
# EVIDENCE[] one string per signal that fired, each naming its file
# SSH_SHARED 1 when this node's SSH host keys are believed image-baked
# TLS_SHARED 1 when this node's TLS key is believed image-baked
VERDICT="unknown"
EVIDENCE=()
SSH_SHARED=0
TLS_SHARED=0
detect() {
VERDICT="unknown"
EVIDENCE=()
SSH_SHARED=0
TLS_SHARED=0
local ssh_keys=() f
for f in "$SSH_DIR"/ssh_host_*_key; do
[ -e "$f" ] || continue
ssh_keys+=("$f")
done
local have_ssh=0 have_tls=0
[ "${#ssh_keys[@]}" -gt 0 ] && have_ssh=1
[ -s "$TLS_KEY" ] && have_tls=1
# Signal 4 — rootfs provenance. Recorded on every run because it changes
# what missing material MEANS, and a reader of the JSON needs that context
# regardless of the verdict.
local stripped=0
if [ -e "$STRIPPED_MARKER" ]; then
stripped=1
EVIDENCE+=("provenance: $(disp "$STRIPPED_MARKER") present — this rootfs shipped identity-free (10-03 or later ISO)")
else
EVIDENCE+=("provenance: $(disp "$STRIPPED_MARKER") absent — this rootfs predates the 10-03 identity strip, so baked material is possible")
fi
# Signal 3 — 10-03's durable failure record.
local failed_record=0
if [ -e "$FAILED_RECORD" ]; then
failed_record=1
EVIDENCE+=("failure record: $(disp "$FAILED_RECORD") present — first-boot generation reported failure and did not silently continue")
fi
# ── Precedence step 1: is the material even there? ──────────────────────
# Missing material can never be SHARED material. On a stripped rootfs this
# is fail-closed working as designed; without the provenance marker it is
# still missing, and saying so is more honest than guessing.
if [ "$have_ssh" = 0 ] || [ "$have_tls" = 0 ]; then
[ "$have_ssh" = 0 ] && EVIDENCE+=("missing: no $(disp "$SSH_DIR")/ssh_host_*_key on this node")
[ "$have_tls" = 0 ] && EVIDENCE+=("missing: $(disp "$TLS_KEY") is absent or empty")
if [ "$stripped" = 0 ]; then
EVIDENCE+=("note: provenance marker absent, so 'fail-closed' is inferred from the absence itself, not from a build-time guarantee")
fi
VERDICT="fail-closed-missing"
return 0
fi
# ── Precedence step 2: the fail-open fingerprint ────────────────────────
# `.secrets-regenerated` present AND a WARNING: line in the first-boot log
# is precisely what the pre-10-03 fail-open path produced (builder :1647,
# :1659, :1663). This is direct evidence, not an inference from timestamps,
# so it outranks the mtime signal — and the two WARNING strings name which
# class survived, so the rotation can be narrowed to it.
if [ -e "$MARKER" ] && [ -f "$FIRST_BOOT_LOG" ] && grep -q 'WARNING:' "$FIRST_BOOT_LOG" 2>/dev/null; then
local tls_warn=0 ssh_warn=0
grep -q 'WARNING: TLS regeneration failed' "$FIRST_BOOT_LOG" 2>/dev/null && tls_warn=1
grep -q 'WARNING: ssh-keygen -A failed' "$FIRST_BOOT_LOG" 2>/dev/null && ssh_warn=1
if [ "$tls_warn" = 0 ] && [ "$ssh_warn" = 0 ]; then
# An unrecognised WARNING. Do not narrow on a guess.
tls_warn=1
ssh_warn=1
EVIDENCE+=("fail-open fingerprint: $(disp "$MARKER") present and $(disp "$FIRST_BOOT_LOG") carries an unrecognised WARNING: line — both key classes treated as shared")
else
EVIDENCE+=("fail-open fingerprint: $(disp "$MARKER") present and $(disp "$FIRST_BOOT_LOG") records the first-boot generator giving up and keeping the baked key")
fi
[ "$tls_warn" = 1 ] && { TLS_SHARED=1; EVIDENCE+=("shared: $(disp "$TLS_KEY") — the first-boot log says TLS regeneration failed and the baked key was kept"); }
[ "$ssh_warn" = 1 ] && { SSH_SHARED=1; EVIDENCE+=("shared: $(disp "$SSH_DIR")/ssh_host_*_key — the first-boot log says ssh-keygen -A failed and the baked host keys were kept"); }
VERDICT="shared"
return 0
fi
# ── Precedence step 3: the mtime anchor ─────────────────────────────────
local anchor="" anchor_kind=""
if [ -e "$MARKER" ]; then
anchor="$MARKER"; anchor_kind="first-boot regeneration marker"
elif [ -e "$LUKS_KEY" ]; then
anchor="$LUKS_KEY"; anchor_kind="LUKS key written by the installer with dd if=/dev/urandom"
elif [ -s "$MACHINE_ID" ]; then
anchor="$MACHINE_ID"; anchor_kind="machine-id, populated on this node's first boot"
fi
if [ -z "$anchor" ]; then
EVIDENCE+=("no anchor: none of $(disp "$MARKER"), $(disp "$LUKS_KEY"), $(disp "$MACHINE_ID") is usable, so this node's first boot cannot be dated")
VERDICT="unknown"
return 0
fi
local anchor_mtime
anchor_mtime=$(mtime_of "$anchor")
if [ -z "$anchor_mtime" ]; then
EVIDENCE+=("no anchor: $(disp "$anchor") exists but could not be stat'd")
VERDICT="unknown"
return 0
fi
EVIDENCE+=("anchor: $(disp "$anchor") ($anchor_kind), mtime $(date -u -d "@$anchor_mtime" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || echo "$anchor_mtime")")
older_than_anchor() {
local file="$1" m age
m=$(mtime_of "$file")
[ -n "$m" ] || return 1
age=$((anchor_mtime - m))
[ "$age" -gt "$ANCHOR_SKEW_SECONDS" ]
}
for f in "${ssh_keys[@]}"; do
if older_than_anchor "$f"; then
SSH_SHARED=1
EVIDENCE+=("shared: $(disp "$f") mtime is $(( anchor_mtime - $(mtime_of "$f") ))s older than the anchor (threshold ${ANCHOR_SKEW_SECONDS}s) — it came from the image, not from this node's first boot")
fi
done
if older_than_anchor "$TLS_KEY"; then
TLS_SHARED=1
EVIDENCE+=("shared: $(disp "$TLS_KEY") mtime is $(( anchor_mtime - $(mtime_of "$TLS_KEY") ))s older than the anchor (threshold ${ANCHOR_SKEW_SECONDS}s) — it came from the image, not from this node's first boot")
fi
if [ "$SSH_SHARED" = 1 ] || [ "$TLS_SHARED" = 1 ]; then
VERDICT="shared"
return 0
fi
# Never claim per-node while the node's own generator's last word was
# failure. A clean-looking mtime is not evidence that generation succeeded.
if [ "$failed_record" = 1 ]; then
EVIDENCE+=("withholding per-node: every key is newer than the anchor, but $(disp "$FAILED_RECORD") stands, so success is not established")
VERDICT="unknown"
return 0
fi
EVIDENCE+=("per-node: every SSH host key and the TLS key is newer than the anchor, so all of it was generated on this node")
VERDICT="per-node"
return 0
}
write_audit_json() {
local fps=() fp tls_fp
while IFS= read -r fp; do [ -n "$fp" ] && fps+=("$fp"); done < <(ssh_fingerprints)
tls_fp=$(tls_fingerprint)
mkdir -p "$STATE_DIR" 2>/dev/null || true
local tmp="$AUDIT_JSON.tmp.$$"
{
printf '{\n'
printf ' "verdict": "%s",\n' "$(json_escape "$VERDICT")"
printf ' "checked_at": "%s",\n' "$(now_iso)"
printf ' "evidence": %s,\n' "$(json_array "${EVIDENCE[@]}")"
printf ' "ssh_host_key_fingerprints": %s,\n' "$(json_array "${fps[@]+"${fps[@]}"}")"
printf ' "tls_cert_sha256": "%s"\n' "$(json_escape "$tls_fp")"
printf '}\n'
} > "$tmp"
chmod 0644 "$tmp"
mv -f "$tmp" "$AUDIT_JSON"
}
human_line() {
case "$VERDICT" in
per-node)
say "host-secrets: per-node — this node's SSH host keys and TLS key were generated here." ;;
shared)
say "host-secrets: SHARED — this node is running image-baked key material that every downloader of its ISO also holds. Rotate it: host-secrets-audit.sh --apply --yes" ;;
fail-closed-missing)
say "host-secrets: fail-closed-missing — key material is absent. Generation never succeeded; this node is not serving on a shared key, it is not serving." ;;
*)
say "host-secrets: unknown — not enough on-disk evidence to date this node's first boot." ;;
esac
}
# ── Rotation ────────────────────────────────────────────────────────────────
# Same pair check as both other producers. Parsing each half back proves each
# is well-formed; it does NOT prove they belong together, and a key from one
# generation beside a cert from another passes both individual checks and then
# breaks nginx.
tls_pair_matches() {
local key="$1" crt="$2" kp cp
kp=$(openssl pkey -in "$key" -pubout 2>/dev/null) || return 1
cp=$(openssl x509 -in "$crt" -noout -pubkey 2>/dev/null) || return 1
[ -n "$kp" ] || return 1
[ "$kp" = "$cp" ]
}
TLS_STAGE_KEY="$SSL_DIR/archipelago.key.rotnew"
TLS_STAGE_CRT="$SSL_DIR/archipelago.crt.rotnew"
SSH_STAGE_DIR=""
cleanup_staging() {
rm -f "$TLS_STAGE_KEY" "$TLS_STAGE_CRT" 2>/dev/null || true
[ -n "$SSH_STAGE_DIR" ] && rm -rf "$SSH_STAGE_DIR" 2>/dev/null || true
}
# STAGE ONLY. Touches nothing live. Parameters kept identical to the other two
# producers — see the header. Do not let rsa:2048/3650 drift here alone.
stage_tls() {
local node_name
node_name=$(hostname 2>/dev/null || echo archipelago)
mkdir -p "$SSL_DIR" || return 1
rm -f "$TLS_STAGE_KEY" "$TLS_STAGE_CRT"
openssl req -x509 -nodes -days 3650 -newkey rsa:2048 \
-keyout "$TLS_STAGE_KEY" -out "$TLS_STAGE_CRT" \
-subj "/C=XX/ST=Bitcoin/L=Node/O=Archipelago/CN=${node_name}" \
-addext "subjectAltName=DNS:${node_name},DNS:${node_name}.local,DNS:archipelago,DNS:archipelago.local,DNS:localhost,IP:127.0.0.1" \
>/dev/null 2>&1 || return 1
[ -s "$TLS_STAGE_KEY" ] && [ -s "$TLS_STAGE_CRT" ] || return 1
tls_pair_matches "$TLS_STAGE_KEY" "$TLS_STAGE_CRT" || return 1
chmod 600 "$TLS_STAGE_KEY"
return 0
}
stage_ssh() {
SSH_STAGE_DIR=$(mktemp -d) || return 1
mkdir -p "$SSH_STAGE_DIR/etc/ssh"
ssh-keygen -A -f "$SSH_STAGE_DIR" >/dev/null 2>&1 || return 1
ls "$SSH_STAGE_DIR"/etc/ssh/ssh_host_*_key >/dev/null 2>&1 || return 1
return 0
}
swap_tls() {
mv -f "$TLS_STAGE_KEY" "$TLS_KEY" || return 1
mv -f "$TLS_STAGE_CRT" "$TLS_CRT" || return 1
chmod 600 "$TLS_KEY"
return 0
}
# Overwrite in place rather than rm-then-mv. rm-then-mv opens a window — small,
# but real — in which the node has ZERO host keys on disk; sshd restarting into
# that window is unrecoverable on a remote machine. mv onto the existing path
# is a rename(2), so each key is replaced atomically and the directory is never
# empty. Only after every staged key has landed are leftovers of key types the
# new set does not include removed — leaving a stale ssh_host_dsa_key behind
# would leave shared material behind, which is the whole point of rotating.
swap_ssh() {
local f base staged=()
for f in "$SSH_STAGE_DIR"/etc/ssh/ssh_host_*; do
[ -e "$f" ] || continue
base=$(basename "$f")
mv -f "$f" "$SSH_DIR/$base" || return 1
staged+=("$base")
done
[ "${#staged[@]}" -gt 0 ] || return 1
for f in "$SSH_DIR"/ssh_host_*; do
[ -e "$f" ] || continue
base=$(basename "$f")
local keep=0 s
for s in "${staged[@]}"; do [ "$s" = "$base" ] && keep=1; done
[ "$keep" = 0 ] && rm -f "$f"
done
return 0
}
# reload, NEVER restart. THIS IS THE SINGLE MOST IMPORTANT LINE IN THIS FILE:
# a reload re-execs the sshd listener while already-forked session children
# keep running, so the operator's own SSH session survives its own rotation. A
# restart kills every session, and on a remote node reached only over SSH that
# is unrecoverable without physical console access.
reload_sshd() {
systemctl reload ssh >/dev/null 2>&1 || systemctl reload sshd >/dev/null 2>&1 || true
}
reload_nginx() {
systemctl reload nginx >/dev/null 2>&1 || true
}
shout() {
echo "$*"
[ -w "$CONSOLE" ] && printf '%s\n' "$*" > "$CONSOLE" 2>/dev/null || true
}
write_rotation_json() {
# $1 = "pre" (old only) or "post" (old + new)
local phase="$1"
mkdir -p "$STATE_DIR" 2>/dev/null || true
local tmp="$ROTATION_JSON.tmp.$$"
{
printf '{\n'
printf ' "rotated_at": "%s",\n' "$(json_escape "$ROTATED_AT")"
printf ' "old_ssh_fingerprints": %s,\n' "$(json_array "${OLD_SSH_FPS[@]+"${OLD_SSH_FPS[@]}"}")"
if [ "$phase" = "pre" ]; then
printf ' "old_tls_sha256": "%s"\n' "$(json_escape "$OLD_TLS_FP")"
else
printf ' "old_tls_sha256": "%s",\n' "$(json_escape "$OLD_TLS_FP")"
printf ' "new_ssh_fingerprints": %s,\n' "$(json_array "${NEW_SSH_FPS[@]+"${NEW_SSH_FPS[@]}"}")"
printf ' "new_tls_sha256": "%s"\n' "$(json_escape "$NEW_TLS_FP")"
fi
printf '}\n'
} > "$tmp"
chmod 0644 "$tmp"
mv -f "$tmp" "$ROTATION_JSON"
}
ROTATED_AT=""
OLD_SSH_FPS=()
OLD_TLS_FP=""
NEW_SSH_FPS=()
NEW_TLS_FP=""
apply_rotation() {
trap cleanup_staging EXIT
# Step 1 — stage EVERYTHING first. If any generation fails we abort before
# touching anything live and exit non-zero. A partial rotation is the
# failure mode that loses access, so there is no path here in which one
# class is swapped and the other has not been generated yet.
if [ "$TLS_SHARED" = 1 ]; then
if ! stage_tls; then
echo "host-secrets: ABORTED — could not generate a replacement TLS keypair. Nothing was changed." >&2
cleanup_staging
return 1
fi
say "staged: replacement TLS keypair"
fi
if [ "$SSH_SHARED" = 1 ]; then
if ! stage_ssh; then
echo "host-secrets: ABORTED — could not generate a replacement SSH host-key set. Nothing was changed." >&2
cleanup_staging
return 1
fi
say "staged: replacement SSH host-key set"
fi
# Step 2 — record the OLD fingerprints BEFORE the swap. An operator who
# loses access anyway can still identify what changed; after the swap the
# old material is gone and unrecoverable.
ROTATED_AT=$(now_iso)
OLD_SSH_FPS=()
while IFS= read -r line; do [ -n "$line" ] && OLD_SSH_FPS+=("$line"); done < <(ssh_fingerprints)
OLD_TLS_FP=$(tls_fingerprint)
write_rotation_json pre
say "recorded old fingerprints to $(disp "$ROTATION_JSON") before touching anything"
# Step 3 — TLS first. The web UI going down is recoverable over SSH; SSH
# going down on a remote node is not. Do the recoverable one first.
if [ "$TLS_SHARED" = 1 ]; then
if ! swap_tls; then
echo "host-secrets: TLS swap failed. SSH host keys were NOT touched." >&2
cleanup_staging
return 1
fi
reload_nginx
say "rotated: TLS keypair, nginx reloaded"
fi
# Step 4 — SSH, then reload (never restart; see reload_sshd).
if [ "$SSH_SHARED" = 1 ]; then
if ! swap_ssh; then
echo "host-secrets: SSH swap failed partway. Check $(disp "$SSH_DIR") before disconnecting." >&2
cleanup_staging
return 1
fi
reload_sshd
say "rotated: SSH host keys, sshd reloaded (your current session is intentionally unaffected)"
fi
# Step 5 — new fingerprints on the record, on stdout and on the console,
# then re-run detect so the verdict file reflects the post-rotation state.
NEW_SSH_FPS=()
while IFS= read -r line; do [ -n "$line" ] && NEW_SSH_FPS+=("$line"); done < <(ssh_fingerprints)
NEW_TLS_FP=$(tls_fingerprint)
write_rotation_json post
shout "host-secrets: ROTATED $ROTATED_AT — new host key fingerprints for this node:"
for line in "${NEW_SSH_FPS[@]+"${NEW_SSH_FPS[@]}"}"; do shout " $line"; done
[ -n "$NEW_TLS_FP" ] && shout " TLS cert sha256: $NEW_TLS_FP"
shout "host-secrets: every known_hosts entry for this node is now stale. Update it against the fingerprints above, never by blindly accepting whatever is offered."
detect
write_audit_json
human_line
cleanup_staging
trap - EXIT
return 0
}
# ── Main ────────────────────────────────────────────────────────────────────
detect
if [ "$MODE" = "detect" ]; then
write_audit_json
human_line
[ "$EMIT_JSON" = 1 ] && cat "$AUDIT_JSON"
exit 0
fi
# --apply. Deliberately writes NOTHING — not even its own verdict file — until
# --yes is given and a rotation actually starts. "Touches nothing" is a
# property worth being able to state without a footnote, and a footnote is what
# "except for one file it rewrites" would be.
if [ "$VERDICT" != "shared" ]; then
human_line
say "host-secrets: nothing to rotate (verdict is '$VERDICT', not 'shared'). No changes made."
exit 0
fi
if [ "$CONFIRMED" != 1 ]; then
say "host-secrets: DRY RUN — this node's verdict is 'shared'. Nothing has been changed."
say ""
say "Would rotate:"
[ "$TLS_SHARED" = 1 ] && say " - TLS keypair at $(disp "$TLS_KEY") (+ cert), then reload nginx"
[ "$SSH_SHARED" = 1 ] && say " - every $(disp "$SSH_DIR")/ssh_host_*_key, then reload (not restart) sshd"
say ""
say "Old fingerprints would be written to $(disp "$ROTATION_JSON") before the swap."
say "This is ONE-WAY: every known_hosts entry for this node breaks and the old key is destroyed."
say "Re-run with --yes from a session you are willing to lose."
exit 0
fi
apply_rotation
+343
View File
@@ -0,0 +1,343 @@
#!/usr/bin/env bash
# Rotate this node's LND macaroons after the /lnd-connect-info leak.
#
# WHY THIS EXISTS
# GET /lnd-connect-info used to answer unauthenticated callers with the LND
# admin macaroon, the TLS cert, the gRPC/REST ports and the node's onion
# address. Anything that reached port 18083 — any fips0 mesh peer, LAN host
# or Tailscale peer — could take it. Every macaroon on an affected node must
# be treated as known to an attacker.
#
# WHAT ROTATION ACTUALLY DOES
# LND derives every macaroon it issues from a root key kept in macaroons.db.
# Remove that root key and the macaroon files, restart, and LND mints a fresh
# root key and a fresh set of macaroons on unlock. Every previously issued
# macaroon — including any the attacker holds — stops verifying.
#
# WHY YOUR FUNDS AND CHANNELS SURVIVE
# Macaroons are bearer tokens, not keys. Coins live in wallet.db and channel
# state in channel.db; channels are secured by the node's identity and channel
# keys, none of which are derived from the macaroon root key. This script
# never touches, moves or opens either database. The wallet is not re-created,
# the seed is not re-entered, and no channel is closed or force-closed.
#
# The only interruption is the LND restart itself, which is the same event as
# a reboot or an update — peers reconnect and channels resume. What this
# script verifies is exactly that: it records the node's identity pubkey and
# its channel counts BEFORE, and aborts loudly if either differs after.
#
# Note it does NOT assert wallet.db is byte-identical, which would be the
# wrong test: btcwallet records chain-sync progress inside wallet.db, so the
# file legitimately changes on every start. Asserting byte-identity would fire
# a frightening false alarm on a completely healthy rotation.
#
# WHAT IT DELIBERATELY NEVER DOES
# It never reads, prints, logs or copies a macaroon's CONTENT. Everything it
# reports is a SHA-256 digest or a file size, which is enough to prove the
# material changed without disclosing it to the terminal, the scrollback or
# whoever is reading over your shoulder.
#
# THE ORDERING GUARD
# Rotating before the leak is patched is worse than useless: the new macaroon
# is readable through the same open door within seconds, and you would think
# you were safe. So this script REFUSES to rotate unless the running binary
# carries the fix. Override only if you genuinely know better.
set -uo pipefail
LND_DIR="/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet"
BIN="/usr/local/bin/archipelago"
CONTAINER="lnd"
APPLY=no; ASSUME_YES=no; FORCE_UNPATCHED=no
usage() {
cat <<'USAGE'
Usage: rotate-lnd-macaroon.sh [--apply] [--yes] [--force-unpatched]
(no flags) Detect and report only. Changes nothing. THE DEFAULT.
--apply Perform the rotation. Requires --yes as well.
--yes Confirm a destructive-to-credentials action.
--force-unpatched Rotate even though the running binary lacks the
/lnd-connect-info fix. You will almost certainly be
re-leaking the new macaroon. Not recommended.
Exit: 0 ok / verdict clean, 1 error or aborted, 2 rotation needed (detect mode).
USAGE
}
while [ $# -gt 0 ]; do
case "$1" in
--apply) APPLY=yes; shift ;;
--yes) ASSUME_YES=yes; shift ;;
--force-unpatched) FORCE_UNPATCHED=yes; shift ;;
-h|--help) usage; exit 0 ;;
*) echo "unknown argument: $1" >&2; usage; exit 1 ;;
esac
done
say() { printf '%s\n' "$*"; }
die() { printf 'ERROR: %s\n' "$*" >&2; exit 1; }
# Digest helper. Uses sudo because LND's data dir is 0700 and owned by the
# container's mapped uid. Prints ONLY a digest, never a byte of content.
digest() {
sudo sha256sum "$1" 2>/dev/null | awk '{print $1}'
}
# Enumerate the macaroon material. This MUST run under sudo rather than as a
# shell glob: the LND data dir is 0700 owned by the container's mapped uid, so
# `"$LND_DIR"/*.macaroon` does not expand in this (unprivileged) shell. It
# would silently stay literal, making both the backup loop and the removal
# loop no-ops while every surrounding step still reported success.
macaroon_files() {
sudo find "$LND_DIR" -maxdepth 1 \
\( -name '*.macaroon' -o -name 'macaroons.db' \) 2>/dev/null
}
say "── LND macaroon rotation ─────────────────────────────────────────"
sudo test -d "$LND_DIR" || die "LND data dir not found at $LND_DIR — is LND installed on this node?"
# ── The ordering guard ────────────────────────────────────────────────
# The fix adds this exact string to the binary. Its presence is the only
# machine-checkable evidence that the door is shut on THIS node.
PATCH_MARKER="/auth/session-check"
if sudo grep -qa -- "$PATCH_MARKER" "$BIN" 2>/dev/null; then
say "patch status : PRESENT — the running binary carries the /lnd-connect-info fix"
PATCHED=yes
else
say "patch status : ABSENT — this binary still leaks /lnd-connect-info"
PATCHED=no
fi
# ── Live reachability check ───────────────────────────────────────────
# Proves the hole from the outside rather than trusting the marker alone.
LEAK_CODE=$(curl -s -m 5 -o /dev/null -w '%{http_code}' http://127.0.0.1:18083/lnd-connect-info 2>/dev/null || echo "000")
case "$LEAK_CODE" in
200) say "live probe : LEAKING — unauthenticated GET returned 200" ;;
401|403) say "live probe : closed — unauthenticated GET returned $LEAK_CODE" ;;
000) say "live probe : lnd-ui not reachable on :18083 (app may be stopped)" ;;
*) say "live probe : unauthenticated GET returned $LEAK_CODE" ;;
esac
OLD_ADMIN=$(digest "$LND_DIR/admin.macaroon")
OLD_ROOT=$(digest "$LND_DIR/macaroons.db")
say "admin.macaroon : ${OLD_ADMIN:-<absent>}"
say "macaroons.db : ${OLD_ROOT:-<absent>}"
# Node identity + channel census BEFORE. `lncli` runs INSIDE the container and
# reads the macaroon off its own disk, so the secret never crosses into this
# script, its output, or the operator's scrollback.
#
# It reports active AND inactive channel counts separately, because only their
# SUM is a safety property. `num_active_channels` counts channels whose peer is
# currently online, so it legitimately dips straight after any restart while
# peers reconnect — asserting on it alone would abort a perfectly healthy
# rotation. What must not change is the total number of channels the node
# holds, and its identity.
lnd_state() {
podman exec "$CONTAINER" lncli --network=mainnet getinfo 2>/dev/null \
| python3 -c 'import json,sys
try: d=json.load(sys.stdin)
except Exception: raise SystemExit(1)
print("%s %s %s %s" % (d.get("identity_pubkey",""), d.get("num_active_channels",0),
d.get("num_inactive_channels",0), d.get("num_pending_channels",0)))' 2>/dev/null
}
OLD_STATE=$(lnd_state)
if [ -n "$OLD_STATE" ]; then
set -- $OLD_STATE
OLD_PUBKEY="$1"; OLD_ACTIVE="$2"; OLD_INACTIVE="$3"; OLD_PENDING="$4"
OLD_TOTAL=$((OLD_ACTIVE + OLD_INACTIVE))
say "node identity : ${OLD_PUBKEY:0:16}"
say "channels : $OLD_TOTAL open ($OLD_ACTIVE active, $OLD_INACTIVE inactive), $OLD_PENDING pending (must survive)"
else
OLD_PUBKEY=""; OLD_ACTIVE=""; OLD_INACTIVE=""; OLD_PENDING=""; OLD_TOTAL=""
say "channels : could not read LND state (locked or down) — see below"
fi
if [ "$APPLY" != yes ]; then
say
say "Detect-only. Re-run with --apply --yes to rotate."
[ "$PATCHED" = yes ] || say "Patch this node FIRST, or the new macaroon leaks immediately."
exit 2
fi
[ "$ASSUME_YES" = yes ] || die "--apply requires --yes (this invalidates every existing macaroon)"
if [ "$PATCHED" != yes ] && [ "$FORCE_UNPATCHED" != yes ]; then
die "refusing to rotate on an unpatched node — the new macaroon would leak through the same hole. Deploy the fix first, or pass --force-unpatched if you truly intend this."
fi
# ── Back up, so a mistake is recoverable ──────────────────────────────
# Kept 0700 and OUTSIDE the dir LND rescans. Still secret material: it is the
# old root key. Delete it once you have confirmed every client re-paired.
STAMP=$(date -u +%Y%m%dT%H%M%SZ)
BACKUP="/var/lib/archipelago/lnd/macaroon-rotation-$STAMP"
sudo mkdir -p "$BACKUP" || die "could not create $BACKUP"
sudo chmod 700 "$BACKUP"
mapfile -t MAC_FILES < <(macaroon_files)
[ "${#MAC_FILES[@]}" -gt 0 ] || die "no macaroon material found in $LND_DIR — nothing to rotate, and restarting LND for no reason would be a pointless outage"
say
say "backing up old macaroon material to $BACKUP (0700, contents never printed)"
for f in "${MAC_FILES[@]}"; do
sudo cp -a "$f" "$BACKUP/" || die "backup of $(basename "$f") failed — aborting before any deletion"
say " backed up $(basename "$f")"
done
# Count what actually landed. A backup that silently copied nothing is the one
# failure mode that would make the deletion below unrecoverable.
BACKED_UP=$(sudo find "$BACKUP" -maxdepth 1 -type f 2>/dev/null | wc -l)
[ "$BACKED_UP" -eq "${#MAC_FILES[@]}" ] \
|| die "backup incomplete — $BACKED_UP of ${#MAC_FILES[@]} files in $BACKUP. Refusing to delete anything."
say "stopping $CONTAINER"
podman stop "$CONTAINER" >/dev/null 2>&1 || say " (stop reported non-zero; continuing to check state)"
# Only now, with a verified backup in hand, remove the credential material.
say "removing macaroon root key and issued macaroons"
for f in "${MAC_FILES[@]}"; do
sudo rm -f "$f" || die "could not remove $(basename "$f") — restore from $BACKUP"
done
say "starting $CONTAINER — archipelago auto-unlocks and LND re-mints on unlock"
podman start "$CONTAINER" >/dev/null 2>&1 || die "could not start $CONTAINER — restore from $BACKUP"
# ── Wait for regeneration ─────────────────────────────────────────────
printf 'waiting for a fresh admin.macaroon'
NEW_ADMIN=""
for _ in $(seq 1 60); do
sleep 5
NEW_ADMIN=$(digest "$LND_DIR/admin.macaroon")
[ -n "$NEW_ADMIN" ] && break
printf '.'
done
printf '\n'
[ -n "$NEW_ADMIN" ] || die "no new admin.macaroon after 5 minutes. LND may not have unlocked. Old material is intact in $BACKUP — restore it there and investigate before retrying."
# ── Verify: credentials changed, node and channels did not ────────────
FAIL=""
[ "$NEW_ADMIN" != "$OLD_ADMIN" ] || FAIL="$FAIL admin-macaroon-UNCHANGED"
# Deliberately NOT a wallet.db byte-identity check. btcwallet records chain
# sync progress inside wallet.db, so that file legitimately changes on every
# start; asserting equality would fire a frightening false alarm on a
# completely healthy rotation. The meaningful invariant is that this is still
# the SAME Lightning node holding the SAME channels — so assert that instead.
printf 'waiting for LND to report its state'
NEW_STATE=""
for _ in $(seq 1 60); do
NEW_STATE=$(lnd_state)
[ -n "$NEW_STATE" ] && break
printf '.'
sleep 5
done
printf '\n'
if [ -n "$OLD_PUBKEY" ]; then
if [ -n "$NEW_STATE" ]; then
set -- $NEW_STATE
NEW_PUBKEY="$1"; NEW_ACTIVE="$2"; NEW_INACTIVE="$3"; NEW_PENDING="$4"
NEW_TOTAL=$((NEW_ACTIVE + NEW_INACTIVE))
[ "$NEW_PUBKEY" = "$OLD_PUBKEY" ] || FAIL="$FAIL NODE-IDENTITY-CHANGED"
[ "$NEW_TOTAL" = "$OLD_TOTAL" ] || FAIL="$FAIL OPEN-CHANNELS-$OLD_TOTAL-to-$NEW_TOTAL"
[ "$NEW_PENDING" = "$OLD_PENDING" ] || FAIL="$FAIL PENDING-CHANNELS-$OLD_PENDING-to-$NEW_PENDING"
say
say "node identity : ${NEW_PUBKEY:0:16}… (unchanged)"
say "channels : $NEW_TOTAL open ($NEW_ACTIVE active, $NEW_INACTIVE inactive), $NEW_PENDING pending"
if [ "$NEW_ACTIVE" != "$OLD_ACTIVE" ]; then
say " active count differs from before ($OLD_ACTIVE -> $NEW_ACTIVE) — this is"
say " normal for a few minutes after any restart while peers reconnect."
fi
else
FAIL="$FAIL LND-STATE-UNREADABLE-AFTER"
fi
else
say
say "channels : NOT VERIFIED — LND state was already unreadable before the"
say " rotation, so there is no baseline to compare against."
say " Check 'lncli getinfo' yourself before trusting this run."
fi
say
say "new admin.macaroon : $NEW_ADMIN"
if [ -n "$FAIL" ]; then
say
die "rotation verification FAILED:$FAIL — old material is in $BACKUP"
fi
# ── BTCPay's inline copy ──────────────────────────────────────────────
# BTCPay reaches the internal LND node with a connection string that carries
# the macaroon INLINE as hex, not as a file path: LND's datadir is owned by its
# container's mapped uid, so btcpay cannot bind-mount the file. That copy is
# therefore now a dead credential, and nothing else will notice — the daemon
# only regenerates this secret when LND's TLS *cert* thumbprint changes, which
# macaroon rotation does not touch. The node keeps looking healthy (btcpay up,
# LND up) while every Lightning invoice BTCPay tries to create fails.
#
# Deleting the secret file gets the daemon to regenerate it from the new
# macaroon on its next reconcile tick. That is necessary but NOT sufficient, and
# the difference matters: the RUNNING container still holds the dead value, and
# the periodic reconciler only ever runs in `ExistingOnly` mode, where env drift
# on a restart-sensitive app (btcpay-server is one) is detected and then
# deliberately skipped — "leaving running restart-sensitive app untouched". So
# the container has to be recreated on purpose. The dashboard path
# (Settings → Lightning credentials) does this itself by flagging the app as
# credential-rotated; a shell script cannot reach that in-process flag, so it
# removes the container instead and lets the orchestrator's own desired-state
# recovery rebuild it around unchanged data, ports and volumes.
#
# Nothing is printed but a path — never the value.
BTCPAY_SECRET="/var/lib/archipelago/secrets/btcpay-lnd-connection"
BTCPAY_NOTE=no
if sudo test -f "$BTCPAY_SECRET"; then
if sudo rm -f "$BTCPAY_SECRET"; then
say
say "btcpay : removed its stale connection string ($BTCPAY_SECRET)."
say " The daemon regenerates it from the new macaroon within a minute."
BTCPAY_NOTE=yes
if podman container exists btcpay-server 2>/dev/null; then
say " Recreating btcpay-server so it stops using the dead one."
podman stop btcpay-server >/dev/null 2>&1 || true
if podman rm -f btcpay-server >/dev/null 2>&1; then
say " Removed; the orchestrator rebuilds it around its existing"
say " data (it was running, so desired-state recovery restores it)."
else
say " ⚠ could not remove btcpay-server. Its Lightning payments will"
say " fail until it is recreated."
BTCPAY_NOTE=warn
fi
fi
else
say
say "btcpay : ⚠ could not remove $BTCPAY_SECRET. BTCPay is still holding"
say " the OLD macaroon, so its Lightning payments will fail until"
say " that file is deleted and btcpay-server is recreated."
BTCPAY_NOTE=warn
fi
fi
say
say "✅ Rotated. Every macaroon issued before now no longer verifies."
say
say "WHAT BREAKS, AND WHAT TO DO:"
say " Anything paired with the old admin macaroon must be re-paired — most"
say " importantly Zeus or any other remote wallet. Open the LND app in the UI"
say " and scan the new pairing QR; it serves the new macaroon."
say
say " Your funds and channels are untouched: the node kept its identity and"
say " no channel was closed."
if [ "${BTCPAY_NOTE:-no}" != no ]; then
say
say " CONFIRM BTCPAY CAME BACK. A silent failure here looks identical to success:"
say " btcpay stays up and healthy while every Lightning payment it tries fails."
say " podman inspect btcpay-server --format '{{.Created}}' # should be just now"
say " sudo test -f $BTCPAY_SECRET && echo regenerated"
fi
say
say " Once every client is re-paired, delete the backup — it holds the OLD"
say " root key, which is still sensitive:"
say " sudo rm -rf $BACKUP"
exit 0
+296
View File
@@ -0,0 +1,296 @@
#!/usr/bin/env bash
# Archipelago RPC exposure probe — audit item C-6 / KEY-01 (F-01).
#
# Answers two DIFFERENT questions that the audit's original C-6 command
# conflated, and that must never be conflated again:
#
# 1. EXPOSURE — is the *unauthenticated* RPC surface reachable from
# this vantage point at all? Measured with
# `auth.isOnboardingComplete`, which really is on the
# unauthenticated allowlist
# (core/archipelago/src/api/rpc/middleware.rs:9).
# A 200 means the door F-01 depends on is open from here.
#
# 2. SESSION ENFORCEMENT — is the session check still rejecting everything
# that is NOT allowlisted? Measured with `seed.status`,
# which is deliberately absent from the allowlist, so a
# 401 is the *correct* result.
#
# Why this matters: the audit (docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md,
# C-6) probes with `seed.status` and calls a 200 a failure. But `seed.status`
# is not allowlisted, so it returns 401 by design — the audit's failure
# criterion can never fire, and the probe would report the surface as CLOSED
# while F-01's actual door stands wide open. This script fixes that.
#
# SAFETY RULES
# * Default mode is read-only BY CONSTRUCTION: the request method is taken
# from the fixed READONLY_METHODS array and never from an argument.
# * Every mutating request lives inside one explicit `--destructive` branch.
# * `--destructive` issues a real `seed.restore`. Against a node WITHOUT the
# 10-01 gate that DESTROYS the node's identity. Disposable nodes only.
# * No real key material is ever handled: the refusal check uses the
# published BIP-39 all-`abandon` + `art` test vector. This script never
# generates and never prints a mnemonic.
# * No node address, onion address, username or password is embedded here.
# Record node LABELS in evidence documents, not raw addresses.
#
# Usage:
# scripts/security/rpc-exposure-probe.sh --target <host|onion|ULA> \
# [--scheme http|https] [--port N] [--label <name>] [--insecure] \
# [--destructive]
#
# Over Tor: torsocks scripts/security/rpc-exposure-probe.sh --target <onion> ...
#
# Exit codes:
# 0 every control behaved as expected
# 1 a control failed (seed.status was not 401, or --destructive was not refused)
# 2 usage error
set -euo pipefail
# ── The ONLY methods the default path may ever call. Read-only, no side
# effects, none of them mutate identity. Never build this from an argument.
READONLY_METHODS=("health" "auth.isOnboardingComplete" "seed.status")
# Published BIP-39 test vector (32 bytes of 0x00). Public test data — NOT a
# real mnemonic, and deliberately unlike audit item C-5, which mints real ones.
TEST_MNEMONIC_WORDS='["abandon","abandon","abandon","abandon","abandon","abandon","abandon","abandon","abandon","abandon","abandon","abandon","abandon","abandon","abandon","abandon","abandon","abandon","abandon","abandon","abandon","abandon","abandon","art"]'
# The refusal prefix emitted by 10-01's gate
# (core/archipelago/src/api/rpc/onboarding_gate.rs). Load-bearing: the error
# sanitizer (middleware.rs:47-71) only lets messages with a known prefix
# through, and "Not supported" is on that list.
REFUSAL_PREFIX="Not supported:"
TARGET=""
SCHEME="http"
PORT="80"
LABEL="unlabelled"
DESTRUCTIVE=0
INSECURE=0
TIMEOUT=15
FAIL=0
if [ -t 1 ]; then
C_RED=$'\033[31m'; C_GRN=$'\033[32m'; C_YEL=$'\033[33m'; C_OFF=$'\033[0m'
else
C_RED=""; C_GRN=""; C_YEL=""; C_OFF=""
fi
usage() {
cat <<'EOF'
rpc-exposure-probe.sh — measure the unauthenticated Archipelago RPC surface (C-6 / KEY-01)
Usage:
rpc-exposure-probe.sh --target <host|onion|ULA> [options]
Options:
--target <host> Host, onion address or IPv6 ULA to probe. Required.
Bare IPv6 addresses are bracketed automatically.
--scheme http|https Default: http
--port <n> Default: 80
--label <name> Vantage-point label printed on every verdict line
(e.g. lan, tor, mesh, loopback). Default: unlabelled
--insecure Accept a self-signed TLS certificate (https only).
--destructive Enable the ONE mutating branch: the KEY-01 refusal
check. DISPOSABLE NODES ONLY.
--help This text.
Checks in default (read-only) mode — four requests total:
1. health on /rpc/v1 liveness from this vantage point
2. auth.isOnboardingComplete on /rpc/v1 EXPOSURE signal; 200 = the
unauthenticated surface is
reachable from here (C-6)
3. seed.status on /rpc/v1 SESSION-ENFORCEMENT control;
anything but 401 is CRITICAL
4. auth.isOnboardingComplete on /rpc/ same exposure signal on nginx's
second proxy path
With --destructive, one extra request:
5. seed.restore with the published BIP-39 all-abandon/art test vector.
PASS only if the response carries an error beginning "Not supported:".
Safety rules:
* Read-only mode cannot mutate identity: methods come from a fixed array,
never from an argument.
* --destructive issues a real seed.restore. On a node WITHOUT 10-01's gate
this DESTROYS that node's identity. Never run it against a node in use.
* No real mnemonic is ever generated, handled or printed by this script.
* Never paste raw node/onion/ULA addresses into committed evidence — record
the --label and the status codes.
Byte-identity check (run ON the node, not from here — this script has no
node-local file access):
before: sudo sha256sum /var/lib/archipelago/identity/node_key /var/lib/archipelago/identity/nostr_secret
after: sudo sha256sum /var/lib/archipelago/identity/node_key /var/lib/archipelago/identity/nostr_secret
The two outputs must match character for character.
Exit codes: 0 ok · 1 a control failed · 2 usage error
EOF
}
while [ $# -gt 0 ]; do
case "$1" in
--target) TARGET="${2:-}"; shift 2 ;;
--scheme) SCHEME="${2:-}"; shift 2 ;;
--port) PORT="${2:-}"; shift 2 ;;
--label) LABEL="${2:-}"; shift 2 ;;
--insecure) INSECURE=1; shift ;;
--destructive) DESTRUCTIVE=1; shift ;;
--help|-h) usage; exit 0 ;;
*) echo "unknown argument: $1" >&2; usage >&2; exit 2 ;;
esac
done
if [ -z "$TARGET" ]; then
echo "error: --target is required" >&2
usage >&2
exit 2
fi
case "$SCHEME" in
http|https) ;;
*) echo "error: --scheme must be http or https" >&2; exit 2 ;;
esac
# Bracket bare IPv6 / ULA targets so the mesh transport can be probed.
HOSTPART="$TARGET"
case "$TARGET" in
\[*\]) ;;
*:*) HOSTPART="[$TARGET]" ;;
esac
BASE="${SCHEME}://${HOSTPART}:${PORT}"
CURL_OPTS=(-sS --max-time "$TIMEOUT" -H 'Content-Type: application/json')
if [ "$SCHEME" = "https" ] && [ "$INSECURE" = "1" ]; then
CURL_OPTS+=(--insecure)
fi
BODY_FILE="$(mktemp)"
cleanup() { rm -f "$BODY_FILE"; }
trap cleanup EXIT
# rpc_call <path> <method> <params-json>
# Sets HTTP_CODE and RESP_BODY. Never fails the script on a network error;
# an unreachable vantage point is a RESULT, not a crash.
HTTP_CODE=""
RESP_BODY=""
rpc_call() {
local path="$1" method="$2" params="$3"
local payload
payload=$(printf '{"jsonrpc":"2.0","id":1,"method":"%s","params":%s}' "$method" "$params")
# NOTE: curl's %{http_code} is already "000" when no response arrived, so
# the failure fallback must REPLACE the captured value, never append to it
# (a `|| echo 000` here yields "000000" and misroutes every verdict).
if ! HTTP_CODE=$(curl "${CURL_OPTS[@]}" -o "$BODY_FILE" -w '%{http_code}' \
-X POST "${BASE}${path}" -d "$payload" 2>"${BODY_FILE}.err"); then
HTTP_CODE="000"
fi
case "$HTTP_CODE" in
[0-9][0-9][0-9]) ;;
*) HTTP_CODE="000" ;;
esac
RESP_BODY=$(cat "$BODY_FILE" 2>/dev/null || true)
if [ "$HTTP_CODE" = "000" ]; then
RESP_BODY=$(cat "${BODY_FILE}.err" 2>/dev/null || true)
fi
rm -f "${BODY_FILE}.err"
}
verdict() {
# verdict <colour> <tag> <method> <code> <note>
printf '[%s] %-30s %-4s %s%-10s%s %s\n' \
"$LABEL" "$3" "$4" "$1" "$2" "$C_OFF" "$5"
}
echo "RPC exposure probe — label=${LABEL} endpoint=${BASE}"
echo " audit item C-6 · KEY-01 (F-01) · read-only mode$([ "$DESTRUCTIVE" = "1" ] && echo " + DESTRUCTIVE")"
echo
# ── 1. Liveness ──────────────────────────────────────────────────────
rpc_call "/rpc/v1" "${READONLY_METHODS[0]}" "null"
case "$HTTP_CODE" in
200) verdict "$C_GRN" "REACHABLE" "${READONLY_METHODS[0]}" "$HTTP_CODE" "endpoint answers from this vantage point" ;;
000) verdict "$C_YEL" "UNREACHABLE" "${READONLY_METHODS[0]}" "---" "no answer: ${RESP_BODY:0:120}" ;;
429) verdict "$C_YEL" "RATELIMIT" "${READONLY_METHODS[0]}" "$HTTP_CODE" "rate limited — rerun later, this is not a refusal" ;;
*) verdict "$C_YEL" "UNEXPECTED" "${READONLY_METHODS[0]}" "$HTTP_CODE" "endpoint answered but not 200" ;;
esac
# ── 2. EXPOSURE signal (the honest C-6 measurement) ──────────────────
rpc_call "/rpc/v1" "${READONLY_METHODS[1]}" "null"
case "$HTTP_CODE" in
200) verdict "$C_YEL" "EXPOSED" "${READONLY_METHODS[1]}" "$HTTP_CODE" "unauthenticated RPC surface IS reachable from here (C-6 result)" ;;
000) verdict "$C_YEL" "UNREACHABLE" "${READONLY_METHODS[1]}" "---" "no answer: ${RESP_BODY:0:120}" ;;
429) verdict "$C_YEL" "RATELIMIT" "${READONLY_METHODS[1]}" "$HTTP_CODE" "rate limited — rerun later" ;;
*) verdict "$C_GRN" "NOT-EXPOSED" "${READONLY_METHODS[1]}" "$HTTP_CODE" "unauthenticated surface did not answer 200 from here" ;;
esac
# ── 3. SESSION-ENFORCEMENT control ───────────────────────────────────
rpc_call "/rpc/v1" "${READONLY_METHODS[2]}" "null"
case "$HTTP_CODE" in
401) verdict "$C_GRN" "PASS" "${READONLY_METHODS[2]}" "$HTTP_CODE" "session enforcement active for non-allowlisted methods" ;;
000) verdict "$C_YEL" "UNREACHABLE" "${READONLY_METHODS[2]}" "---" "no answer: ${RESP_BODY:0:120}" ;;
429) verdict "$C_YEL" "RATELIMIT" "${READONLY_METHODS[2]}" "$HTTP_CODE" "rate limited — inconclusive, rerun later" ;;
*) verdict "$C_RED" "CRITICAL" "${READONLY_METHODS[2]}" "$HTTP_CODE" "expected 401 — session enforcement is NOT working"
FAIL=1 ;;
esac
# ── 4. Same exposure signal on nginx's second proxy path ─────────────
rpc_call "/rpc/" "${READONLY_METHODS[1]}" "null"
case "$HTTP_CODE" in
200) verdict "$C_YEL" "EXPOSED" "${READONLY_METHODS[1]} (/rpc/)" "$HTTP_CODE" "alternate proxy path also reachable" ;;
000) verdict "$C_YEL" "UNREACHABLE" "${READONLY_METHODS[1]} (/rpc/)" "---" "no answer: ${RESP_BODY:0:120}" ;;
429) verdict "$C_YEL" "RATELIMIT" "${READONLY_METHODS[1]} (/rpc/)" "$HTTP_CODE" "rate limited — rerun later" ;;
*) verdict "$C_GRN" "NOT-EXPOSED" "${READONLY_METHODS[1]} (/rpc/)" "$HTTP_CODE" "alternate proxy path did not answer 200" ;;
esac
# ── 5. KEY-01 refusal check — the ONE mutating branch ────────────────
if [ "$DESTRUCTIVE" = "1" ]; then
echo
echo "${C_RED}================================================================${C_OFF}"
echo "${C_RED} DESTRUCTIVE MODE — this issues a REAL seed.restore.${C_OFF}"
echo "${C_RED} Against a node WITHOUT 10-01's gate this REPLACES that node's${C_OFF}"
echo "${C_RED} identity (node_key, nostr_secret, fips_key). DISPOSABLE NODES ONLY.${C_OFF}"
echo "${C_RED}================================================================${C_OFF}"
echo
echo "Capture the identity digest ON the node before and after this run:"
echo " sudo sha256sum /var/lib/archipelago/identity/node_key /var/lib/archipelago/identity/nostr_secret"
echo
rpc_call "/rpc/v1" "seed.restore" "{\"words\":${TEST_MNEMONIC_WORDS}}"
case "$HTTP_CODE" in
000)
verdict "$C_YEL" "UNREACHABLE" "seed.restore" "---" "no answer: ${RESP_BODY:0:120}"
;;
429)
verdict "$C_YEL" "RATELIMIT" "seed.restore" "$HTTP_CODE" "rate limited — INCONCLUSIVE, this is not a refusal"
;;
*)
if printf '%s' "$RESP_BODY" | grep -q "$REFUSAL_PREFIX"; then
verdict "$C_GRN" "REFUSED" "seed.restore" "$HTTP_CODE" "gate refused with the expected '${REFUSAL_PREFIX}' prefix"
elif printf '%s' "$RESP_BODY" | grep -q '"result"[[:space:]]*:[[:space:]]*[^n]'; then
verdict "$C_RED" "ACCEPTED" "seed.restore" "$HTTP_CODE" "IDENTITY WAS REPLACED — the gate is absent or bypassed"
FAIL=1
else
verdict "$C_RED" "UNKNOWN" "seed.restore" "$HTTP_CODE" "neither the refusal prefix nor a result — inspect manually"
FAIL=1
fi
;;
esac
echo
echo "Response body (verbatim, for the evidence record):"
printf ' %s\n' "${RESP_BODY:0:600}"
echo
echo "Now re-run the digest command ON the node. The two outputs MUST match:"
echo " sudo sha256sum /var/lib/archipelago/identity/node_key /var/lib/archipelago/identity/nostr_secret"
fi
echo
if [ "$FAIL" = "0" ]; then
echo "${C_GRN}All controls behaved as expected.${C_OFF} (An EXPOSED verdict is a recorded"
echo "finding, not a control failure — that is what C-6 exists to measure.)"
else
echo "${C_RED}A control FAILED — see the CRITICAL/ACCEPTED/UNKNOWN line above.${C_OFF}"
fi
exit "$FAIL"
+512
View File
@@ -0,0 +1,512 @@
#!/bin/bash
# Self-update: pull latest code from the OVH Gitea (source.archipelago-foundation.org) and apply
# Designed to run on installed Archipelago nodes (as archipelago user)
#
# Usage:
# ./self-update.sh # Check + apply if available
# ./self-update.sh --check # Check only, don't apply
# ./self-update.sh --force # Apply even if already up to date
#
# The script:
# 1. Pulls latest code from origin (source.archipelago-foundation.org)
# 2. Builds the Rust backend (release mode)
# 3. Builds the Vue frontend (production mode)
# 4. Installs the new binary and web UI
# 5. Restarts the archipelago service
# 6. Verifies health after restart
set -euo pipefail
REPO_DIR="$HOME/archy"
BACKEND_DIR="$REPO_DIR/core"
FRONTEND_DIR="$REPO_DIR/neode-ui"
INSTALL_BIN="/usr/local/bin/archipelago"
INSTALL_WEB="/opt/archipelago/web-ui"
STATE_FILE="/var/lib/archipelago/update_state.json"
LOG_FILE="/var/lib/archipelago/update.log"
LOCK_FILE="/tmp/archipelago-update.lock"
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'
log() { echo -e "${BLUE}[$(date '+%H:%M:%S')]${NC} $*" | tee -a "$LOG_FILE"; }
ok() { echo -e "${GREEN}[$(date '+%H:%M:%S')] OK${NC} $*" | tee -a "$LOG_FILE"; }
err() { echo -e "${RED}[$(date '+%H:%M:%S')] ERROR${NC} $*" | tee -a "$LOG_FILE"; }
warn(){ echo -e "${YELLOW}[$(date '+%H:%M:%S')] WARN${NC} $*" | tee -a "$LOG_FILE"; }
cleanup() {
rm -f "$LOCK_FILE"
}
trap cleanup EXIT
# Prevent concurrent updates
if [ -f "$LOCK_FILE" ]; then
pid=$(cat "$LOCK_FILE" 2>/dev/null)
if kill -0 "$pid" 2>/dev/null; then
err "Update already in progress (PID $pid)"
exit 1
fi
warn "Stale lock file found, removing"
rm -f "$LOCK_FILE"
fi
echo $$ > "$LOCK_FILE"
# Parse args
CHECK_ONLY=false
FORCE=false
while [[ $# -gt 0 ]]; do
case "$1" in
--check) CHECK_ONLY=true; shift ;;
--force) FORCE=true; shift ;;
*) shift ;;
esac
done
# Ensure repo exists
if [ ! -d "$REPO_DIR/.git" ]; then
err "Repo not found at $REPO_DIR"
err "Clone it first: git clone https://source.archipelago-foundation.org/lfg2025/archy ~/archy"
exit 1
fi
cd "$REPO_DIR"
if ! command -v nano >/dev/null 2>&1; then
log "Installing nano for Archipelago terminal..."
if sudo apt-get update -qq 2>>"$LOG_FILE" && sudo apt-get install -y -qq nano 2>>"$LOG_FILE"; then
ok "nano installed"
else
warn "Unable to install nano automatically; continuing update"
fi
fi
if ! command -v ping >/dev/null 2>&1; then
log "Installing iputils-ping..."
if sudo apt-get update -qq 2>>"$LOG_FILE" && sudo apt-get install -y -qq iputils-ping 2>>"$LOG_FILE"; then
ok "ping installed"
else
warn "Unable to install ping automatically; continuing update"
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
# Build-time prerequisites for reticulum-daemon/build.sh's PyInstaller step
# below (discovered the hard way: ensurepip needs python3-venv, and
# PyInstaller itself needs objdump + libpython3.13.so at build time — none
# of these are pulled in by a bare `python3` package on Debian trixie).
for pkg in python3-venv binutils libpython3.13; do
if ! dpkg -s "$pkg" >/dev/null 2>&1; then
log "Installing $pkg (reticulum-daemon build prerequisite)..."
sudo apt-get update -qq 2>>"$LOG_FILE" && sudo apt-get install -y -qq "$pkg" 2>>"$LOG_FILE" \
|| warn "Unable to install $pkg automatically; reticulum-daemon tools build may fail"
fi
done
# Fetch latest
log "Fetching from origin..."
git fetch origin main --quiet 2>>"$LOG_FILE"
# Check if there are updates
LOCAL=$(git rev-parse HEAD)
REMOTE=$(git rev-parse origin/main)
if [ "$LOCAL" = "$REMOTE" ] && [ "$FORCE" = "false" ]; then
ok "Already up to date ($LOCAL)"
if [ "$CHECK_ONLY" = "true" ]; then
echo '{"update_available": false, "current": "'"$LOCAL"'"}'
fi
exit 0
fi
# Calculate what changed
COMMITS_BEHIND=$(git rev-list HEAD..origin/main --count)
log "Update available: $COMMITS_BEHIND commits behind"
log " Local: $LOCAL"
log " Remote: $REMOTE"
if [ "$CHECK_ONLY" = "true" ]; then
CHANGELOG=$(git log HEAD..origin/main --oneline --no-merges | head -20)
echo '{"update_available": true, "current": "'"$LOCAL"'", "latest": "'"$REMOTE"'", "commits_behind": '"$COMMITS_BEHIND"'}'
echo ""
echo "Changes:"
echo "$CHANGELOG"
exit 0
fi
# Backup current binary
BACKUP_DIR="/var/lib/archipelago/update-backup"
mkdir -p "$BACKUP_DIR"
if [ -f "$INSTALL_BIN" ]; then
cp "$INSTALL_BIN" "$BACKUP_DIR/archipelago.bak"
log "Backed up current binary"
fi
# Pull latest code
log "Pulling latest code..."
git pull origin main --ff-only 2>>"$LOG_FILE" || {
err "Git pull failed — local changes? Run: git reset --hard origin/main"
exit 1
}
NEW_VERSION=$(git rev-parse --short HEAD)
log "Now at: $NEW_VERSION"
# Build backend
log "Building Rust backend (release)..."
cd "$BACKEND_DIR"
if cargo build --release --workspace 2>>"$LOG_FILE"; then
ok "Backend built successfully"
else
err "Backend build failed — rolling back"
cd "$REPO_DIR"
git reset --hard "$LOCAL" 2>>"$LOG_FILE"
exit 1
fi
# Install binary
BUILT_BIN="$BACKEND_DIR/target/release/archipelago"
if [ ! -f "$BUILT_BIN" ]; then
err "Built binary not found at $BUILT_BIN"
exit 1
fi
sudo cp "$BUILT_BIN" "$INSTALL_BIN"
sudo chmod +x "$INSTALL_BIN"
ok "Backend installed"
# Build + install reticulum-daemon tools (archy-reticulum-daemon, archy-rnodeconf).
# Non-fatal: archipelago falls back to its dev venv path if the packaged
# binaries aren't present, so a missing/failed build here degrades mesh
# Reticulum support rather than breaking the update. This mirrors
# the existing manual-deploy step, which until now was the
# only path that ever installed these — a node that only ever received OTA
# self-updates had neither binary.
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
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"
ok "$tool installed"
else
warn "$tool not built — leaving existing /usr/local/bin/$tool (if any) in place"
fi
done
else
warn "reticulum-daemon tools build failed — continuing without updating them"
fi
fi
# Build frontend
log "Building Vue frontend (production)..."
cd "$FRONTEND_DIR"
npm ci --silent 2>>"$LOG_FILE" || npm install --silent 2>>"$LOG_FILE"
if npm run build 2>>"$LOG_FILE"; then
ok "Frontend built successfully"
else
err "Frontend build failed — backend already updated, service may need manual fix"
exit 1
fi
# Install frontend (always ship fresh AIUI from demo/aiui; preserve claude-login.html)
BUILT_WEB="$REPO_DIR/web/dist/neode-ui"
if [ -d "$BUILT_WEB" ]; then
# Bake AIUI into the built tree so rsync --delete does not wipe it.
# demo/aiui is the canonical AIUI bundle checked into the repo; copying
# it here means every self-update ships a matching AIUI version instead
# of preserving whatever stale copy happened to be on disk (which is
# empty on nodes where an earlier ad-hoc deploy blew it away).
if [ -d "$REPO_DIR/demo/aiui" ] && [ -f "$REPO_DIR/demo/aiui/index.html" ]; then
log "Staging AIUI bundle from demo/aiui into frontend dist..."
rm -rf "$BUILT_WEB/aiui"
cp -r "$REPO_DIR/demo/aiui" "$BUILT_WEB/aiui"
else
warn "demo/aiui not found in repo; existing /opt/archipelago/web-ui/aiui will be wiped by rsync --delete"
fi
# Sync new files, preserving claude-login.html (per-node admin bookmark)
sudo rsync -a --delete \
--exclude 'claude-login.html' \
"$BUILT_WEB/" "$INSTALL_WEB/"
ok "Frontend installed"
else
warn "Frontend build output not found at $BUILT_WEB — skipping"
fi
# Update helper scripts in /opt/archipelago/scripts/
# These are canonical home; keep a copy at /opt/archipelago/image-versions.sh
# 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
src="$REPO_DIR/scripts/$script"
if [ -f "$src" ]; then
sudo install -m 755 "$src" "$SCRIPTS_DEST/$script"
ok "Updated $script"
else
warn "Missing $src — skipping"
fi
done
# Legacy path for image-versions.sh (older binaries looked here first)
if [ -f "$REPO_DIR/scripts/image-versions.sh" ]; then
sudo cp "$REPO_DIR/scripts/image-versions.sh" /opt/archipelago/image-versions.sh
fi
# Sync app manifests and app-local build contexts into the canonical
# production manifest root. The backend orchestrator loads install specs from
# /opt/archipelago/apps; updating only the binary/frontend can leave a node
# with new installer logic but stale or missing app manifests.
APPS_DEST="/opt/archipelago/apps"
if [ -d "$REPO_DIR/apps" ]; then
sudo mkdir -p "$APPS_DEST"
sudo rsync -a --delete "$REPO_DIR/apps/" "$APPS_DEST/"
ok "App manifests synced"
else
warn "Apps directory not found at $REPO_DIR/apps — install manifests may be stale"
fi
# Update first-boot-containers.sh too (the canonical first-boot orchestrator).
# Nodes run it once on install, but keeping a fresh copy on disk means any
# future boot or reconciler invocation uses current port specs and caps.
if [ -f "$REPO_DIR/scripts/first-boot-containers.sh" ]; then
sudo install -m 755 "$REPO_DIR/scripts/first-boot-containers.sh" \
"$SCRIPTS_DEST/first-boot-containers.sh"
fi
# Sync UI container source trees (docker/bitcoin-ui, docker/lnd-ui,
# docker/electrs-ui) into /opt/archipelago/docker/<name>/. If any file in a
# UI tree changed since last update, rebuild that image and recreate its
# container using the spec from container-specs.sh. This is what prevented
# the lnd-ui port mismatch from reaching nodes through OTA: self-update used
# to update only the backend + frontend, never the UI container images.
UI_DOCKER_DEST="/opt/archipelago/docker"
sudo mkdir -p "$UI_DOCKER_DEST"
UI_REBUILD_LIST=""
# fips-ui and fedimint-ui are synced but NOT added to UI_REBUILD_LIST below:
# container-specs.sh has no spec for either (and their container names break
# the archy-<ui> assumption — the FIPS one is plain `fips-ui`). Their rebuilds
# come from elsewhere — the daemon's companion installer for fedimint-ui, the
# orchestrator's build context for fips-ui — but BOTH read
# /opt/archipelago/docker/<ui>, and nothing was ever updating that directory.
# So source edits to those two trees reached nodes through no path at all:
# their nginx kept listening on 0.0.0.0 and served the Guardian and FIPS
# screens unauthenticated on every interface (found by scanning a test node
# from outside, 2026-08-05 — the in-node audit could not see them).
for ui in bitcoin-ui lnd-ui electrs-ui fips-ui fedimint-ui; do
src="$REPO_DIR/docker/$ui"
dst="$UI_DOCKER_DEST/$ui"
[ -d "$src" ] || continue
# Hash source tree to decide if rebuild is needed. Any content change
# (Dockerfile, nginx.conf, index.html, assets) triggers a rebuild.
# Hash file contents only (not paths or metadata) so src and dst match
# when their contents are identical regardless of directory prefix.
src_hash=$( (cd "$src" && find . -type f | LC_ALL=C sort | xargs sha256sum 2>/dev/null) | sha256sum | cut -d' ' -f1)
dst_hash=""
if [ -d "$dst" ]; then
dst_hash=$( (cd "$dst" && find . -type f | LC_ALL=C sort | xargs sha256sum 2>/dev/null) | sha256sum | cut -d' ' -f1)
fi
if [ "$src_hash" != "$dst_hash" ]; then
log "UI source changed for $ui; syncing"
sudo rsync -a --delete "$src/" "$dst/"
case "$ui" in
# Rebuilt below from container-specs.sh.
bitcoin-ui|lnd-ui|electrs-ui)
UI_REBUILD_LIST="$UI_REBUILD_LIST $ui" ;;
# Synced only — rebuilt by the daemon (companion installer /
# orchestrator build context), which watches this directory.
# Adding them to the rebuild list would fail: no spec exists and
# the container names are not archy-<ui>.
*)
log " $ui synced; rebuild is owned by the daemon" ;;
esac
else
ok "UI source unchanged for $ui"
fi
done
# Rebuild changed UI images + recreate containers as the archipelago user
# (rootless podman storage lives under ~archipelago). Port mappings and caps
# come from scripts/container-specs.sh so spec drift can't sneak in.
if [ -n "$UI_REBUILD_LIST" ]; then
log "Rebuilding UI containers:$UI_REBUILD_LIST"
# shellcheck disable=SC1091
# container-specs.sh provides load_spec_archy-<ui> and mem_limit <name>.
SPECS="$SCRIPTS_DEST/container-specs.sh"
if [ ! -f "$SPECS" ]; then
warn "container-specs.sh missing at $SPECS; skipping UI rebuild"
else
for ui in $UI_REBUILD_LIST; do
cname="archy-$ui"
log " rebuilding $cname from $UI_DOCKER_DEST/$ui"
# Build image as archipelago user so it lands in the right store.
if ! sudo -u archipelago bash -c "
export XDG_RUNTIME_DIR=/run/user/\$(id -u archipelago)
cd '$UI_DOCKER_DEST/$ui' &&
podman build --no-cache -t 'localhost/$ui:local' . >>'$LOG_FILE' 2>&1
"; then
err " build failed for $ui; keeping existing container"
continue
fi
# Recreate container using spec from container-specs.sh.
if ! sudo -u archipelago bash -c "
export XDG_RUNTIME_DIR=/run/user/\$(id -u archipelago)
source '$SPECS'
load_spec_$cname || { echo 'spec load failed for $cname'; exit 1; }
podman stop '$cname' 2>/dev/null || true
podman rm '$cname' 2>/dev/null || true
PORT_ARG=''
[ -n \"\$SPEC_PORTS\" ] && PORT_ARG=\"-p \$SPEC_PORTS\"
NET_ARG=''
[ \"\$SPEC_NETWORK\" = 'host' ] && NET_ARG='--network host'
CAP_ARGS='--cap-drop ALL'
for c in \$SPEC_CAPS; do CAP_ARGS=\"\$CAP_ARGS --cap-add \$c\"; done
podman run -d --name '$cname' \$PORT_ARG \$NET_ARG \\
--user 0:0 \$CAP_ARGS \\
--memory=\"\$SPEC_MEMORY\" \\
--restart unless-stopped \\
--security-opt \"\$SPEC_SECURITY\" \\
'localhost/$ui:local' >>'$LOG_FILE' 2>&1
"; then
err " recreate failed for $cname"
continue
fi
ok " $cname rebuilt and running"
done
fi
fi
# Update kiosk display helpers used by HDMI/TV installs.
if [ -f "$REPO_DIR/image-recipe/configs/archipelago-kiosk-launcher.sh" ]; then
sudo install -m 755 "$REPO_DIR/image-recipe/configs/archipelago-kiosk-launcher.sh" \
/usr/local/bin/archipelago-kiosk-launcher
ok "Updated archipelago-kiosk-launcher"
fi
# Update systemd services if changed
SYSTEMD_UNITS_CHANGED=false
for unit in archipelago.service archipelago-fips.service archipelago-kiosk.service archipelago-kiosk-watchdog.service; do
src="$REPO_DIR/image-recipe/configs/$unit"
dst="/etc/systemd/system/$unit"
[ -f "$src" ] || continue
if [ ! -f "$dst" ] || ! diff -q "$src" "$dst" &>/dev/null; then
sudo install -m 644 "$src" "$dst"
SYSTEMD_UNITS_CHANGED=true
ok "Updated $unit"
fi
done
if [ "$SYSTEMD_UNITS_CHANGED" = "true" ]; then
sudo systemctl daemon-reload
fi
# Keep the doctor timer/service current too. Container uptime fixes rely on
# these units as much as on the helper scripts themselves.
DOCTOR_UNITS_CHANGED=false
for unit in archipelago-doctor.service archipelago-doctor.timer; do
src="$REPO_DIR/image-recipe/configs/$unit"
dst="/etc/systemd/system/$unit"
[ -f "$src" ] || continue
if [ ! -f "$dst" ] || ! diff -q "$src" "$dst" &>/dev/null; then
sudo install -m 644 "$src" "$dst"
DOCTOR_UNITS_CHANGED=true
ok "Updated $unit"
fi
done
if [ "$DOCTOR_UNITS_CHANGED" = "true" ]; then
sudo systemctl daemon-reload
sudo systemctl enable --now archipelago-doctor.timer 2>>"$LOG_FILE" || \
warn "Failed to enable archipelago-doctor.timer"
fi
# Install/refresh tmpfiles.d rules. The logs rule creates
# /var/log/archipelago/ + container-installs.log with archipelago:archipelago
# ownership so the non-root backend can append install audit lines.
# Apply immediately so existing nodes don't need a reboot.
if [ -f "$REPO_DIR/image-recipe/configs/archipelago-tmpfiles.conf" ]; then
sudo install -m 644 "$REPO_DIR/image-recipe/configs/archipelago-tmpfiles.conf" \
/usr/lib/tmpfiles.d/archipelago-logs.conf
sudo systemd-tmpfiles --create /usr/lib/tmpfiles.d/archipelago-logs.conf 2>/dev/null || true
ok "Log tmpfiles rule installed"
fi
# Restart service
log "Restarting archipelago service..."
sudo systemctl restart archipelago
# Wait for health
log "Waiting for backend health..."
for i in $(seq 1 30); do
if curl -sf http://127.0.0.1:5678/health > /dev/null 2>&1; then
ok "Backend healthy after ${i}s"
break
fi
if [ "$i" = "30" ]; then
err "Backend failed to start within 30s"
warn "Rolling back binary..."
if [ -f "$BACKUP_DIR/archipelago.bak" ]; then
sudo cp "$BACKUP_DIR/archipelago.bak" "$INSTALL_BIN"
sudo systemctl restart archipelago
err "Rolled back to previous binary"
fi
exit 1
fi
sleep 1
done
# Update state file for the UI
python3 -c "
import json, datetime
state = {
'current_version': '$NEW_VERSION',
'last_check': datetime.datetime.utcnow().isoformat() + 'Z',
'available_update': None,
'update_in_progress': False,
'rollback_available': True,
'schedule': 'daily_check'
}
with open('$STATE_FILE', 'w') as f:
json.dump(state, f, indent=2)
" 2>/dev/null || true
echo ""
ok "Update complete: $LOCAL -> $NEW_VERSION"
log "Changelog:"
git log "$LOCAL".."$NEW_VERSION" --oneline --no-merges | head -10 | tee -a "$LOG_FILE"
+223
View File
@@ -0,0 +1,223 @@
#!/usr/bin/env bash
# Per-node certificate authority.
#
# WHY THIS EXISTS
#
# The node used to serve a bare self-signed leaf (setup-https-dev.sh). A browser
# can be told to trust that, but the exception is granted per ORIGIN — scheme +
# host + PORT. The dashboard on :443 and an app on :8334 are different origins,
# so each app port needed its own click-through, and a cert interstitial CANNOT
# be accepted inside an iframe: the embedded app just fails.
#
# A CA fixes that structurally. The user installs ONE certificate; every leaf it
# signs is then trusted, on every port, with no further prompts. Ports are not
# part of a certificate's identity — one leaf with the right SANs covers every
# port on the host — so this is what makes gated apps embeddable over HTTPS.
#
# The CA private key never leaves the node and signs nothing but this node's own
# leaf. Installing it means trusting THIS node, not a third party.
#
# Idempotent: re-running reuses an existing CA and only reissues the leaf (which
# is what you want when the node gains an address). Pass --force-ca to start over
# — that invalidates every copy users have already installed.
set -euo pipefail
SSL_DIR="${ARCHY_SSL_DIR:-/etc/archipelago/ssl}"
CA_CRT="$SSL_DIR/ca.crt"
CA_KEY="$SSL_DIR/ca.key"
CA_SRL="$SSL_DIR/ca.srl"
LEAF_CRT="$SSL_DIR/archipelago.crt"
LEAF_KEY="$SSL_DIR/archipelago.key"
CA_DAYS="${ARCHY_CA_DAYS:-3650}"
# Public CAs cap leaves at 398 days and browsers enforce it. That limit applies
# to publicly-trusted roots, not a privately-installed one, but a shorter leaf
# still bounds the damage from a key leak — and reissuing costs nothing here
# because this script is re-run on address changes anyway.
LEAF_DAYS="${ARCHY_LEAF_DAYS:-397}"
FORCE_CA=false
[ "${1:-}" = "--force-ca" ] && FORCE_CA=true
NODE_NAME="$(hostname -s 2>/dev/null || echo archipelago)"
log() { echo " $*"; }
mkdir -p "$SSL_DIR"
chmod 755 "$SSL_DIR"
# --- Subject alternative names -----------------------------------------------
# Every name/address the node can be reached by must be in the leaf, because a
# certificate is scoped to names, not ports. Missing one here means that access
# path still throws a warning even after the CA is installed.
collect_sans() {
local -a dns=() ips=()
dns+=("archipelago.local" "$NODE_NAME" "$NODE_NAME.local" "localhost")
# Tailscale gives a stable MagicDNS name; include it so tailnet access is clean.
if command -v tailscale >/dev/null 2>&1; then
local ts_name
ts_name="$(tailscale status --json 2>/dev/null \
| python3 -c 'import json,sys; d=json.load(sys.stdin); print((d.get("Self") or {}).get("DNSName","").rstrip("."))' 2>/dev/null || true)"
[ -n "$ts_name" ] && dns+=("$ts_name")
fi
# Every non-loopback address the host currently holds, plus loopback itself.
ips+=("127.0.0.1" "::1")
while read -r addr; do
[ -n "$addr" ] && ips+=("$addr")
done < <(ip -o addr show scope global 2>/dev/null | awk '{print $4}' | cut -d/ -f1 | sort -u)
local out="" i=1 j=1
for d in $(printf '%s\n' "${dns[@]}" | awk 'NF' | sort -u); do
out="${out}DNS.$i:$d,"; i=$((i+1))
done
for a in $(printf '%s\n' "${ips[@]}" | awk 'NF' | sort -u); do
out="${out}IP.$j:$a,"; j=$((j+1))
done
echo "${out%,}"
}
SAN="$(collect_sans)"
[ -z "$SAN" ] && { echo "ERROR: no SANs resolved — refusing to issue a useless cert" >&2; exit 1; }
# --- CA ----------------------------------------------------------------------
if [ "$FORCE_CA" = true ] && [ -f "$CA_CRT" ]; then
log "--force-ca: replacing the existing CA (previously installed copies stop working)"
rm -f "$CA_CRT" "$CA_KEY" "$CA_SRL"
fi
if [ -f "$CA_CRT" ] && [ -f "$CA_KEY" ]; then
log "Reusing the existing node CA (installed copies keep working)"
else
log "Creating this node's certificate authority…"
openssl req -x509 -nodes -newkey rsa:4096 -sha256 -days "$CA_DAYS" \
-keyout "$CA_KEY" -out "$CA_CRT" \
-subj "/CN=Archipelago Node CA ($NODE_NAME)/O=Archipelago/OU=Node CA" \
-addext "basicConstraints=critical,CA:TRUE,pathlen:0" \
-addext "keyUsage=critical,keyCertSign,cRLSign" 2>/dev/null
chmod 600 "$CA_KEY"
chmod 644 "$CA_CRT"
fi
# --- Leaf --------------------------------------------------------------------
log "Issuing the server certificate for: $SAN"
TMP="$(mktemp -d)"
trap 'rm -rf "$TMP"' EXIT
openssl req -nodes -newkey rsa:2048 -sha256 \
-keyout "$TMP/leaf.key" -out "$TMP/leaf.csr" \
-subj "/CN=$NODE_NAME/O=Archipelago" 2>/dev/null
cat >"$TMP/leaf.ext" <<EOF
basicConstraints=CA:FALSE
keyUsage=critical,digitalSignature,keyEncipherment
extendedKeyUsage=serverAuth
subjectAltName=$SAN
EOF
openssl x509 -req -in "$TMP/leaf.csr" -CA "$CA_CRT" -CAkey "$CA_KEY" \
-CAcreateserial -CAserial "$CA_SRL" \
-out "$TMP/leaf.crt" -days "$LEAF_DAYS" -sha256 -extfile "$TMP/leaf.ext" 2>/dev/null
# Swap in place only once both halves exist, so a failure mid-run cannot leave
# nginx pointing at a cert whose key is gone.
install -m 644 "$TMP/leaf.crt" "$LEAF_CRT"
install -m 600 "$TMP/leaf.key" "$LEAF_KEY"
# The leaf key has TWO readers with different privileges: nginx's master
# process (root) and the archipelago daemon (User=archipelago), which needs it
# to terminate TLS on gated app ports. Root-only 0600 silently costs the daemon
# its TLS — it logs "Permission denied" and every app port quietly stays plain
# HTTP, which is exactly the fail-open shape the gate is built to avoid. So the
# key is group-readable by the service user and nothing wider.
SERVICE_USER="${ARCHY_SERVICE_USER:-archipelago}"
if getent group "$SERVICE_USER" >/dev/null 2>&1; then
chgrp "$SERVICE_USER" "$LEAF_KEY" && chmod 640 "$LEAF_KEY"
log "Key readable by group $SERVICE_USER (0640) — the daemon needs it for app-port TLS"
elif getent passwd "$SERVICE_USER" >/dev/null 2>&1; then
# User exists without an eponymous group — fall back to its primary group.
PRIMARY="$(id -gn "$SERVICE_USER" 2>/dev/null || true)"
if [ -n "$PRIMARY" ]; then
chgrp "$PRIMARY" "$LEAF_KEY" && chmod 640 "$LEAF_KEY"
log "Key readable by group $PRIMARY (0640)"
fi
else
log "No '$SERVICE_USER' user on this host — key left root-only (0600)"
fi
# The dashboard serves this for download; it is a public certificate, never the key.
install -m 644 "$CA_CRT" "$SSL_DIR/ca-download.crt"
FP="$(openssl x509 -in "$CA_CRT" -noout -fingerprint -sha256 | cut -d= -f2)"
log "CA fingerprint (SHA-256): $FP"
# --- nginx HTTPS listener -----------------------------------------------------
# The CA is only useful if something actually serves TLS. Bind the dashboard's
# HTTPS on this host's LAN addresses ONLY: tailscaled already owns :443 on the
# tailnet addresses (with its own Let's Encrypt cert), so a plain
# `listen 443 default_server` binds 0.0.0.0 and fails with EADDRINUSE — nginx
# then keeps running the OLD config and the reload looks like it worked.
# Observed exactly that on archi-dev-box.
#
# Port 80 keeps serving: nodes are reached by IP on LANs where forcing a
# redirect would strand anyone who has not installed the CA yet.
ensure_nginx_https() {
local site="${ARCHY_NGINX_SITE:-/etc/nginx/sites-enabled/archipelago}"
[ -f "$site" ] || { log "No nginx site at $site — skipping HTTPS listener"; return; }
local addrs
addrs="$(ip -o -4 addr show scope global 2>/dev/null | awk '{print $4}' | cut -d/ -f1 \
| grep -vE '^100\.(6[4-9]|[7-9][0-9]|1[01][0-9]|12[0-7])\.' | sort -u)"
[ -z "$addrs" ] && { log "No LAN address — skipping HTTPS listener"; return; }
if grep -q 'listen .*:443 ssl' "$site"; then
log "nginx HTTPS listener already present"
return
fi
log "Adding nginx HTTPS listener on: $(echo "$addrs" | tr '\n' ' ')"
python3 - "$site" "$addrs" <<'PYEOF'
import sys, re, pathlib
site, addrs = pathlib.Path(sys.argv[1]), sys.argv[2].split()
s = site.read_text(); lines = s.split('\n')
start = next(i for i,l in enumerate(lines) if l.strip() == 'server {')
depth = 0; end = None
for i in range(start, len(lines)):
depth += lines[i].count('{') - lines[i].count('}')
if depth == 0 and i > start:
end = i; break
block = lines[start:end+1]
https = []
for l in block:
if re.match(r'\s*listen 80 default_server;', l):
https += [f' listen {a}:443 ssl;' for a in addrs]
https += [' ssl_certificate /etc/archipelago/ssl/archipelago.crt;',
' ssl_certificate_key /etc/archipelago/ssl/archipelago.key;',
' ssl_protocols TLSv1.2 TLSv1.3;']
continue
if re.match(r'\s*listen \[::\]:80 default_server;', l):
continue
https.append(l)
lines = lines[:end+1] + [''] + https + lines[end+1:]
site.write_text('\n'.join(lines))
PYEOF
}
ensure_nginx_https
if command -v systemctl >/dev/null 2>&1 && systemctl is-active --quiet nginx; then
if nginx -t >/dev/null 2>&1; then
systemctl reload nginx && log "nginx reloaded"
else
echo "WARNING: nginx config test failed — NOT reloading. Certs are in place; fix nginx and reload." >&2
fi
fi
cat <<EOF
Done. Install $CA_CRT on each device that should reach this node without warnings.
The dashboard serves it at /ca.crt (Settings → Node certificate).
Verify the fingerprint above matches what the dashboard shows before trusting it.
EOF
+55
View File
@@ -0,0 +1,55 @@
#!/usr/bin/env bash
# One-step release-catalog signer.
#
# Run: bash scripts/sign-catalog.sh
# Then: paste your 24-word release master mnemonic, press Enter, then Ctrl-D.
#
# It signs releases/app-catalog.json in place and checks the signature was made
# by the expected release-root key. Your mnemonic is read from the terminal only
# (never stored, never in shell history, never passed to Claude).
set -euo pipefail
REPO="/home/archipelago/Projects/archy"
CATALOG="$REPO/releases/app-catalog.json"
EXPECTED_DID="did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT"
# Use ONLY the prebuilt signer. If it isn't ready, stop cleanly — never compile
# here (compiling caused the earlier hangs). Claude builds it in the background.
BIN="/tmp/archy-sign-bin/release/archipelago"
if [[ ! -x "$BIN" ]]; then
echo "⏳ The signer isn't ready yet — Claude is still building it."
echo " Wait until Claude says 'READY', then run this again. Nothing was changed."
exit 0
fi
SIGN=("$BIN" ceremony sign "$CATALOG")
# Preflight BEFORE asking for the mnemonic. Signing is the point of no return:
# a signed catalog is authoritative for every node, and its image refs override
# the on-disk manifests. If it names a registry host the deployed fleet does not
# trust, every install fails "not from a trusted registry" — so catch that here
# rather than after publication.
if ! python3 "$REPO/scripts/check-catalog-registry-trust.py" --repo "$REPO"; then
echo
echo "✋ Refusing to sign. Nothing was changed and your mnemonic was not requested."
exit 1
fi
echo
echo "════════════════════════════════════════════════════════════════"
echo " Paste your 24-word release master mnemonic below, press Enter,"
echo " then press Ctrl-D on a new line."
echo "════════════════════════════════════════════════════════════════"
"${SIGN[@]}"
# Verify the signature is present and made by the expected key.
echo
if grep -q "\"signed_by\": \"$EXPECTED_DID\"" "$CATALOG" \
&& grep -q '"signature":' "$CATALOG"; then
echo "✅ SUCCESS — catalog signed by the correct release-root key."
echo " Tell Claude \"signed\" and it will commit + push for you."
else
echo "❌ Something is off — the catalog is NOT signed by the expected key."
echo " Expected signer: $EXPECTED_DID"
echo " Do NOT commit. Check the mnemonic and re-run, or ask Claude."
exit 1
fi
+65
View File
@@ -0,0 +1,65 @@
#!/usr/bin/env bash
# Sign an ISO's checksums with the release root (counterpart to sign-manifest.sh).
#
# Run: bash scripts/sign-iso-checksums.sh path/to/archipelago-X.Y.Z.iso
# Then: paste your 24-word release master mnemonic, press Enter, then Ctrl-D.
#
# Writes <iso>.sha256.json next to the ISO — a JSON document carrying the
# artifact name, size and sha256, signed by the pinned release-root anchor
# (same detached-Ed25519 scheme as the OTA manifest and app catalog).
# Verify anywhere with: archipelago ceremony verify <iso>.sha256.json
#
# The mnemonic is read from the terminal only (never stored, never in shell
# history). The build host never holds the release key: build emits the plain
# <iso>.sha256; the publisher signs with this script during the ceremony.
set -euo pipefail
ISO="${1:-}"
[ -n "$ISO" ] || { echo "Usage: $0 path/to/image.iso"; exit 1; }
[ -f "$ISO" ] || { echo "Error: $ISO not found"; exit 1; }
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# Use ONLY a prebuilt signer — never compile here (compiling caused hangs in
# the earlier catalog ceremony). Prefer the repo's release build.
BIN=""
for candidate in "$REPO/core/target/release/archipelago" /tmp/archy-sign-bin/release/archipelago; do
if [[ -x "$candidate" ]]; then BIN="$candidate"; break; fi
done
if [[ -z "$BIN" ]]; then
echo "⏳ No prebuilt signer found. Build one first:"
echo " (cd core && cargo build --release -p archipelago)"
echo " Nothing was changed."
exit 0
fi
ISO_NAME="$(basename "$ISO")"
ISO_DIR="$(cd "$(dirname "$ISO")" && pwd)"
OUT="$ISO_DIR/$ISO_NAME.sha256.json"
echo "Hashing $ISO_NAME (this can take a minute on a large ISO)..."
SHA256="$(sha256sum "$ISO" | awk '{print $1}')"
SIZE="$(wc -c < "$ISO" | tr -d ' ')"
cat > "$OUT" <<EOF
{
"artifact": "$ISO_NAME",
"sha256": "$SHA256",
"size_bytes": $SIZE
}
EOF
echo "════════════════════════════════════════════════════════════════"
echo " Paste your 24-word release master mnemonic below, press Enter,"
echo " then press Ctrl-D on a new line."
echo "════════════════════════════════════════════════════════════════"
"$BIN" ceremony sign "$OUT"
echo
if "$BIN" ceremony verify "$OUT"; then
echo "✅ SUCCESS — $OUT signed by the pinned release root."
echo " Publish it next to the ISO together with $ISO_NAME.sha256."
else
echo "❌ Verification failed — do not publish. Re-run the signing."
exit 1
fi
+51
View File
@@ -0,0 +1,51 @@
#!/usr/bin/env bash
# One-step OTA-manifest signer (counterpart to sign-catalog.sh).
#
# Run: bash scripts/sign-manifest.sh
# Then: paste your 24-word release master mnemonic, press Enter, then Ctrl-D.
#
# Signs releases/manifest.json in place and cryptographically verifies the
# result against the pinned release-root anchor. The mnemonic is read from the
# terminal only (never stored, never in shell history, never passed to Claude).
#
# Normally create-release.sh signs the manifest inline; this script exists for
# re-signing (e.g. a manifest edited after creation) or signing on a box where
# the release run was non-interactive.
#
# The release root was rotated 2026-08-05. From v1.7.123 this signs with the
# NEW mnemonic and the signer's own anchor already pins that key, so no
# ARCHY_RELEASE_ROOT_PUBKEY override is needed (it was, for .122 only).
set -euo pipefail
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
MANIFEST="$REPO/releases/manifest.json"
# Use ONLY a prebuilt signer — never compile here (compiling caused hangs in
# the earlier catalog ceremony). Prefer the repo's release build.
BIN=""
for candidate in "$REPO/core/target/release/archipelago" /tmp/archy-sign-bin/release/archipelago; do
if [[ -x "$candidate" ]]; then BIN="$candidate"; break; fi
done
if [[ -z "$BIN" ]]; then
echo "⏳ No prebuilt signer found. Build one first:"
echo " (cd core && cargo build --release -p archipelago)"
echo " Nothing was changed."
exit 0
fi
echo "════════════════════════════════════════════════════════════════"
echo " Paste your 24-word release master mnemonic below, press Enter,"
echo " then press Ctrl-D on a new line."
echo "════════════════════════════════════════════════════════════════"
"$BIN" ceremony sign "$MANIFEST"
echo
if "$BIN" ceremony verify "$MANIFEST"; then
echo "✅ SUCCESS — manifest signed by the pinned release root."
echo " Commit + push releases/manifest.json (and release-manifest.json if present)."
cp "$MANIFEST" "$REPO/release-manifest.json" 2>/dev/null || true
else
echo "❌ Signature did NOT verify against the pinned release-root anchor."
echo " Do NOT commit. Check the mnemonic and re-run."
exit 1
fi
+90
View File
@@ -0,0 +1,90 @@
#!/bin/bash
# Smoke test for Archipelago — verifies critical endpoints
# Usage: ./scripts/smoke-test.sh [host]
# Exit 0 if all pass, exit 1 on any failure.
set -euo pipefail
HOST="${1:-${ARCHY_HOST:-}}"
if [ -z "$HOST" ]; then
echo "usage: $0 <node-host> (or set ARCHY_HOST)" >&2
exit 2
fi
PASS=0
FAIL=0
FAILURES=""
check() {
local name="$1" cmd="$2"
if eval "$cmd" >/dev/null 2>&1; then
echo "$name"
PASS=$((PASS + 1))
else
echo "$name"
FAIL=$((FAIL + 1))
FAILURES="$FAILURES\n - $name"
fi
}
echo "=== Archipelago Smoke Test ==="
echo "Target: $HOST"
echo ""
# 1. Health endpoint
check "GET /health returns OK" \
"curl -sf http://${HOST}/health | grep -q '\"status\"'"
# 2. Login via RPC
SESSION=$(curl -sf -X POST "http://${HOST}/rpc/v1" \
-H 'Content-Type: application/json' \
-d '{"method":"auth.login","params":{"password":"'"${TEST_PASSWORD:-password123}"'"}}' \
-c - 2>/dev/null | grep session | awk '{print $NF}' || echo "")
if [ -n "$SESSION" ]; then
echo " ✓ Login via RPC"
PASS=$((PASS + 1))
else
echo " ✗ Login via RPC"
FAIL=$((FAIL + 1))
FAILURES="$FAILURES\n - Login via RPC"
fi
# 3. Authenticated RPC call
check "server.get-info returns JSON" \
"curl -sf -X POST http://${HOST}/rpc/v1 \
-H 'Content-Type: application/json' \
-b 'session=${SESSION}' \
-d '{\"method\":\"server.get-info\"}' | grep -q '\"result\"'"
# 4. Container list
check "container.list returns JSON" \
"curl -sf -X POST http://${HOST}/rpc/v1 \
-H 'Content-Type: application/json' \
-b 'session=${SESSION}' \
-d '{\"method\":\"container.list\"}' | grep -q '\"result\"'"
# 5. WebSocket upgrade
check "WebSocket upgrade (101)" \
"curl -sf -o /dev/null -w '%{http_code}' \
-H 'Upgrade: websocket' -H 'Connection: Upgrade' \
-H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==' \
-H 'Sec-WebSocket-Version: 13' \
http://${HOST}/ws/db | grep -q '101'"
# 6. Static assets served
check "Frontend index.html served" \
"curl -sf http://${HOST}/ | grep -q '<div id=\"app\"'"
# 7. Onboarding check (unauthenticated)
check "auth.isOnboardingComplete RPC" \
"curl -sf -X POST http://${HOST}/rpc/v1 \
-H 'Content-Type: application/json' \
-d '{\"method\":\"auth.isOnboardingComplete\"}' | grep -q '\"result\"'"
echo ""
echo "=== Results: $PASS passed, $FAIL failed ==="
if [ $FAIL -gt 0 ]; then
echo -e "Failures:$FAILURES"
exit 1
fi
exit 0
+128
View File
@@ -0,0 +1,128 @@
#!/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
+116
View File
@@ -0,0 +1,116 @@
#!/usr/bin/env python3
"""Sync the Settings "What's New" modal with CHANGELOG.md.
The modal (neode-ui/src/views/settings/AccountInfoSection.vue) hardcodes one
HTML block per release. It has repeatedly drifted behind CHANGELOG.md (it sat
at v1.7.84 while the fleet shipped through v1.7.92). This script is the fix:
for every version in CHANGELOG.md that has no block in the modal, it generates
a block (from the curated CHANGELOG bullets) and inserts it newest-first.
python3 scripts/sync-whats-new.py # insert any missing blocks
python3 scripts/sync-whats-new.py --check # exit 1 if anything is missing
Dev-process bullets ("Validation passed…/pending…") are dropped — the modal is
user-facing. Only CHANGELOG versions are managed; older hand-written blocks
(pre-CHANGELOG history) are never touched or removed.
"""
import re
import sys
import html
from pathlib import Path
REPO = Path(__file__).resolve().parent.parent
CHANGELOG = REPO / "CHANGELOG.md"
MODAL = REPO / "neode-ui/src/views/settings/AccountInfoSection.vue"
MONTHS = ["", "January", "February", "March", "April", "May", "June", "July",
"August", "September", "October", "November", "December"]
HEADER_RE = re.compile(r"^## (v\d+\.\d+\.\d+\S*) \((\d{4})-(\d{2})-(\d{2})\)")
def parse_changelog():
"""Return [(version, 'Month D, YYYY', [bullet, ...]), ...] newest-first."""
entries = []
cur = None
for line in CHANGELOG.read_text().splitlines():
m = HEADER_RE.match(line)
if m:
ver, y, mo, d = m.groups()
cur = {"ver": ver, "date": f"{MONTHS[int(mo)]} {int(d)}, {y}", "bullets": []}
entries.append(cur)
continue
if cur is not None and line.startswith("- "):
text = line[2:].strip()
if text.lower().startswith("validation "):
continue # dev-process note, not user-facing
cur["bullets"].append(text)
return entries
def existing_versions():
text = MODAL.read_text()
return set(re.findall(r"<!-- (v\d+\.\d+\.\d+\S*) -->", text))
def to_html(text):
text = text.replace("`", "") # drop markdown code ticks (plain prose)
return html.escape(text, quote=False) # & < > (Vue template-safe)
def render_block(entry):
paras = "\n".join(
f" <p>{to_html(b)}</p>" for b in entry["bullets"]
)
return (
f" <!-- {entry['ver']} -->\n"
f" <div>\n"
f" <div class=\"flex items-center gap-2 mb-3\">\n"
f" <span class=\"text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300\">{entry['ver']}</span>\n"
f" <span class=\"text-xs text-white/40\">{entry['date']}</span>\n"
f" </div>\n"
f" <div class=\"space-y-3 text-sm text-white/80 pl-3 border-l border-white/10\">\n"
f"{paras}\n"
f" </div>\n"
f" </div>\n"
)
def main():
check = "--check" in sys.argv
entries = parse_changelog()
have = existing_versions()
missing = [e for e in entries if e["ver"] not in have]
if not missing:
print("What's New modal is in sync with CHANGELOG.md "
f"({len(entries)} changelog versions, all present).")
return 0
names = ", ".join(e["ver"] for e in missing)
if check:
print("FAIL: these CHANGELOG versions have no block in the Settings "
f"What's New modal: {names}", file=sys.stderr)
print("Run: python3 scripts/sync-whats-new.py", file=sys.stderr)
return 1
# Insert missing blocks newest-first, immediately before the newest existing
# block marker (the first "<!-- v... -->" line in the file).
lines = MODAL.read_text().splitlines(keepends=True)
marker = re.compile(r"^\s*<!-- v\d+\.\d+\.\d+\S* -->\s*$")
idx = next((i for i, ln in enumerate(lines) if marker.match(ln)), None)
if idx is None:
print("ERROR: could not find an existing version block marker in the modal.",
file=sys.stderr)
return 2
# newest-first: sort missing by their order in `entries` (already newest-first)
block_text = "".join(render_block(e) for e in missing)
lines.insert(idx, block_text)
MODAL.write_text("".join(lines))
print(f"Inserted {len(missing)} block(s): {names}")
return 0
if __name__ == "__main__":
sys.exit(main())
+200
View File
@@ -0,0 +1,200 @@
#!/bin/bash
# tor-helper.sh — Privileged Tor operations for the Archipelago backend.
# Runs as root via systemd (archipelago-tor-helper.service), triggered by
# a path unit watching /var/lib/archipelago/tor-config/tor-action.
#
# The backend writes a JSON action file, the path unit triggers this script.
# This avoids calling sudo from within a NoNewPrivileges=yes service.
set -euo pipefail
ACTION_FILE="/var/lib/archipelago/tor-config/tor-action"
TORRC_STAGED="/var/lib/archipelago/tor-config/torrc.staged"
RESULT_FILE="/var/lib/archipelago/tor-config/tor-result"
HOSTNAMES_DIR="/var/lib/archipelago/tor-hostnames"
log() { echo "[tor-helper] $*"; }
write_result() {
echo "$1" > "$RESULT_FILE"
chown archipelago:archipelago "$RESULT_FILE" 2>/dev/null || true
}
sync_hostnames() {
mkdir -p "$HOSTNAMES_DIR"
# Clear stale copies first
rm -f "$HOSTNAMES_DIR"/* 2>/dev/null || true
# Prefer /var/lib/tor (system Tor, authoritative) over /var/lib/archipelago/tor
# Only copy from secondary if not already found in primary
for base in /var/lib/tor /var/lib/archipelago/tor; do
for dir in "$base"/hidden_service_*; do
[ -d "$dir" ] || continue
svc=$(basename "$dir" | sed 's/^hidden_service_//')
echo "$svc" | grep -q '_old_' && continue
# Skip if already synced from a higher-priority location
[ -f "${HOSTNAMES_DIR}/${svc}" ] && continue
if [ -f "$dir/hostname" ]; then
cp "$dir/hostname" "${HOSTNAMES_DIR}/${svc}"
log "Synced hostname: $svc ($base)"
fi
done
done
chown -R archipelago:archipelago "$HOSTNAMES_DIR" 2>/dev/null || true
}
# ─── Main ─────────────────────────────────────────────────────────
if [ ! -f "$ACTION_FILE" ]; then
log "No action file found"
exit 0
fi
ACTION=$(cat "$ACTION_FILE")
rm -f "$ACTION_FILE"
ACTION_TYPE=$(echo "$ACTION" | python3 -c "import sys,json; print(json.load(sys.stdin).get('action',''))" 2>/dev/null || echo "")
# Restart the daemon that actually serves, and report whether it really came up.
#
# Why the dashboard's "Restart Tor" button did nothing on 2026-08-09, in order
# of importance — note it was NOT wrong-unit targeting: `systemctl restart tor`
# does propagate to tor@default (measured on austin-sapien, MainPID changed).
#
# 1. RESET-FAILED WAS MISSING. Once tor@default has failed enough times
# systemd latches "Start request repeated too quickly" and refuses to start
# it at all; a plain restart is then a no-op no matter which unit you name.
# All three broken nodes were in exactly that state.
# 2. THE RESULT WAS ALWAYS {"ok":true}. This branch waited up to 30s for SOCKS
# and then ignored the outcome; the `restart` branch slept 3s and claimed
# success. So the UI reported "restarted" over a dead daemon.
# 3. The underlying torrc was unbindable, so every restart failed anyway —
# fixed separately in the torrc generator.
#
# tor@default is targeted explicitly because it is the unit that carries the
# state worth resetting; single-instance installs have only `tor`, hence the
# detection and fallback.
#
# Returns 0 only when SOCKS answers. Callers must not report success without it.
restart_tor_daemon() {
local unit=tor
if systemctl list-unit-files 'tor@*.service' 2>/dev/null | grep -q 'tor@'; then
unit=tor@default
fi
systemctl reset-failed "$unit" 2>/dev/null || true
if ! systemctl restart "$unit" 2>/dev/null; then
if [ "$unit" != tor ]; then
log "restart of $unit failed — falling back to tor.service"
systemctl reset-failed tor 2>/dev/null || true
systemctl restart tor 2>/dev/null || true
fi
fi
log "Restarted $unit"
local i
for i in $(seq 1 30); do
if timeout 1 bash -c 'echo > /dev/tcp/127.0.0.1/9050' 2>/dev/null; then
log "Tor SOCKS answering after ${i}s"
return 0
fi
sleep 1
done
log "ERROR: Tor SOCKS not answering 30s after restarting $unit"
return 1
}
case "$ACTION_TYPE" in
write-torrc-and-restart)
if [ ! -f "$TORRC_STAGED" ]; then
log "ERROR: No staged torrc at $TORRC_STAGED"
write_result '{"ok":false,"error":"No staged torrc"}'
exit 1
fi
cp "$TORRC_STAGED" /etc/tor/torrc
chown debian-tor:debian-tor /etc/tor/torrc 2>/dev/null || true
log "torrc updated from staged file"
if restart_tor_daemon; then
sync_hostnames
write_result '{"ok":true}'
else
# Never claim success on a dead daemon: the caller surfaces this straight
# to the operator, and a false "restarted" is how an outage stays hidden.
sync_hostnames
write_result '{"ok":false,"error":"Tor restarted but SOCKS never came up on 127.0.0.1:9050 — check journalctl -u tor@default"}'
exit 1
fi
;;
restart)
if restart_tor_daemon; then
sync_hostnames
write_result '{"ok":true}'
else
sync_hostnames
write_result '{"ok":false,"error":"Tor restarted but SOCKS never came up on 127.0.0.1:9050 — check journalctl -u tor@default"}'
exit 1
fi
;;
delete-service)
NAME=$(echo "$ACTION" | python3 -c "import sys,json; print(json.load(sys.stdin).get('name',''))" 2>/dev/null || echo "")
if [ -z "$NAME" ]; then
write_result '{"ok":false,"error":"Missing service name"}'
exit 1
fi
if ! echo "$NAME" | grep -qE '^[a-zA-Z0-9_-]+$'; then
write_result '{"ok":false,"error":"Invalid service name"}'
exit 1
fi
rm -rf "/var/lib/tor/hidden_service_${NAME}" 2>/dev/null || true
rm -rf "/var/lib/archipelago/tor/hidden_service_${NAME}" 2>/dev/null || true
rm -f "${HOSTNAMES_DIR}/${NAME}" 2>/dev/null || true
log "Deleted hidden service: $NAME"
write_result '{"ok":true}'
;;
rename-service)
NAME=$(echo "$ACTION" | python3 -c "import sys,json; print(json.load(sys.stdin).get('name',''))" 2>/dev/null || echo "")
TIMESTAMP=$(echo "$ACTION" | python3 -c "import sys,json; print(json.load(sys.stdin).get('timestamp',''))" 2>/dev/null || echo "")
if [ -z "$NAME" ] || [ -z "$TIMESTAMP" ]; then
write_result '{"ok":false,"error":"Missing service name or timestamp"}'
exit 1
fi
if ! echo "$NAME" | grep -qE '^[a-zA-Z0-9_-]+$'; then
write_result '{"ok":false,"error":"Invalid service name"}'
exit 1
fi
if ! echo "$TIMESTAMP" | grep -qE '^[0-9]+$'; then
write_result '{"ok":false,"error":"Invalid timestamp"}'
exit 1
fi
OLD_SUFFIX="${NAME}_old_${TIMESTAMP}"
for base in /var/lib/tor /var/lib/archipelago/tor; do
SRC="${base}/hidden_service_${NAME}"
DST="${base}/hidden_service_${OLD_SUFFIX}"
if [ -d "$SRC" ]; then
mv "$SRC" "$DST"
log "Renamed $SRC -> $DST"
fi
done
rm -f "${HOSTNAMES_DIR}/${NAME}" 2>/dev/null || true
write_result '{"ok":true}'
;;
sync-hostnames)
sync_hostnames
write_result '{"ok":true}'
;;
reboot)
write_result '{"ok":true}'
log "System reboot initiated"
sleep 1
systemctl reboot
;;
*)
log "Unknown action: $ACTION_TYPE"
write_result '{"ok":false,"error":"Unknown action"}'
exit 1
;;
esac
+20
View File
@@ -0,0 +1,20 @@
# Archipelago Tor Integration
Each service gets its own .onion address. Tor runs in a container with host networking so it can reach host-mapped ports.
## Service → Onion mapping
| Service | LAN Port | Tor Hidden Service Dir |
|-----------|----------|-------------------------------|
| Archipelago | 80 | hidden_service_archipelago |
| LND UI | 18083 | hidden_service_lnd |
| BTCPay | 23000 | hidden_service_btcpay |
| Mempool | 4080 | hidden_service_mempool |
| Fedimint | 8175 | hidden_service_fedimint |
## Hostname files
After Tor starts, each service's .onion address is written to:
`/var/lib/archipelago/tor/hidden_service_<name>/hostname`
The backend reads these to expose Tor addresses in the package API.
+39
View File
@@ -0,0 +1,39 @@
# Archipelago Tor Hidden Services
# Each service gets its own .onion address
# Tor runs with --network host so 127.0.0.1 refers to host ports
# DataDirectory: use /var/lib/archipelago/tor so backend can read hostnames
# SocksPort 9050: required for outbound .onion requests (peer messaging)
SocksPort 9050
ControlPort 0
DataDirectory /var/lib/archipelago/tor
# Archipelago main web UI (nginx port 80)
HiddenServiceDir /var/lib/archipelago/tor/hidden_service_archipelago/
HiddenServicePort 80 127.0.0.1:80
# Bitcoin P2P (protocol service)
HiddenServiceDir /var/lib/archipelago/tor/hidden_service_bitcoin/
HiddenServicePort 8333 127.0.0.1:8333
# ElectrumX (protocol service — wallet connections)
HiddenServiceDir /var/lib/archipelago/tor/hidden_service_electrumx/
HiddenServicePort 50001 127.0.0.1:50001
# LND (protocol service — Lightning Network)
HiddenServiceDir /var/lib/archipelago/tor/hidden_service_lnd/
HiddenServicePort 80 127.0.0.1:8081
HiddenServicePort 9735 127.0.0.1:9735
HiddenServicePort 10009 127.0.0.1:10009
# BTCPay Server
HiddenServiceDir /var/lib/archipelago/tor/hidden_service_btcpay/
HiddenServicePort 80 127.0.0.1:23000
# Mempool (frontend)
HiddenServiceDir /var/lib/archipelago/tor/hidden_service_mempool/
HiddenServicePort 80 127.0.0.1:4080
# Fedimint Guardian UI
HiddenServiceDir /var/lib/archipelago/tor/hidden_service_fedimint/
HiddenServicePort 80 127.0.0.1:8175
+77
View File
@@ -0,0 +1,77 @@
#!/bin/bash
#
# Trust the Archipelago server's self-signed certificate on macOS.
# Run this to eliminate "Not secure" when accessing https://<node-host>
#
# Usage: ./scripts/trust-archipelago-cert.sh [host]
# Host is required: pass it as $1 or set ARCHY_HOST
#
# Requires: SSH access to archipelago@host (uses deploy-config.sh password)
#
set -e
HOST="${1:-${ARCHY_HOST:-}}"
if [ -z "$HOST" ]; then
echo "usage: $0 <node-host> (or set ARCHY_HOST)" >&2
exit 2
fi
CERT_FILE="/tmp/archipelago-${HOST}.crt"
KEYCHAIN="${HOME}/Library/Keychains/login.keychain-db"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
# Try to fetch cert from server via SSH (most reliable)
SSH_KEY="${ARCHIPELAGO_SSH_KEY:-$HOME/.ssh/archipelago-deploy}"
echo "Fetching certificate from server..."
if [ -f "$SSH_KEY" ]; then
ssh -o StrictHostKeyChecking=no -i "$SSH_KEY" archipelago@${HOST} \
'sudo -n cat /etc/archipelago/ssl/archipelago.crt' > "$CERT_FILE" 2>/dev/null || true
elif [ -f "$SCRIPT_DIR/deploy-config.sh" ]; then
# Last-resort fallback: password auth (leaks credentials to process list)
. "$SCRIPT_DIR/deploy-config.sh"
echo "WARNING: SSH key not found at $SSH_KEY — falling back to password auth"
if command -v sshpass >/dev/null 2>&1; then
sshpass -p "$ARCHIPELAGO_PASSWORD" ssh -o StrictHostKeyChecking=no archipelago@${HOST} \
'sudo -n cat /etc/archipelago/ssl/archipelago.crt' > "$CERT_FILE" 2>/dev/null || true
else
echo "WARNING: No SSH key and sshpass not installed — skipping SSH fetch"
fi
fi
# Fallback: fetch via openssl (can hang on some systems)
if [ ! -s "$CERT_FILE" ]; then
echo "Fetching certificate via TLS..."
(echo "Q"; sleep 1) | openssl s_client -connect "${HOST}:443" -servername "${HOST}" 2>/dev/null | \
openssl x509 -outform PEM > "$CERT_FILE"
fi
if [ ! -s "$CERT_FILE" ]; then
echo "Failed to fetch certificate. Ensure deploy-config.sh exists and SSH works, or the server is reachable."
exit 1
fi
echo "Adding to your login keychain..."
# Remove old cert if present (by common name)
security delete-certificate -c "archipelago.local" "$KEYCHAIN" 2>/dev/null || true
# Add to user keychain with trust (no sudo needed)
if security add-trusted-cert -d -r trustRoot -k "$KEYCHAIN" "$CERT_FILE" 2>/dev/null; then
echo " Certificate trusted successfully."
elif security add-trusted-cert -d -r trustAsRoot -k "$KEYCHAIN" "$CERT_FILE" 2>/dev/null; then
echo " Certificate trusted successfully."
else
# Fallback: add cert and open Keychain Access for manual trust
cp "$CERT_FILE" "$HOME/Desktop/archipelago-${HOST}.crt"
echo ""
echo " Could not auto-trust. Certificate saved to Desktop."
echo " Double-click archipelago-${HOST}.crt to add it, then in Keychain Access"
echo " find it, double-click, expand Trust → set to 'Always Trust'."
CERT_FILE="" # Don't delete, we copied to Desktop
fi
rm -f "$CERT_FILE"
echo ""
echo "✅ Done. Restart your browser fully (quit Chrome/Safari) and visit https://${HOST}"
+97
View File
@@ -0,0 +1,97 @@
#!/usr/bin/env bash
# Uptime Monitor for REL-05
# Runs every 5 minutes via cron, records metrics to a CSV file.
# Install: */5 * * * * /opt/archipelago/scripts/uptime-monitor.sh
#
# Tracks: timestamp, http_status, response_time_ms, cpu_percent,
# mem_used_mb, mem_total_mb, disk_used_gb, disk_total_gb,
# container_count, uptime_secs, restart_count
set -euo pipefail
LOG_DIR="/var/lib/archipelago/uptime-monitor"
LOG_FILE="$LOG_DIR/metrics.csv"
RESTART_FILE="$LOG_DIR/restart-count"
BACKEND_URL="http://localhost:5678/health"
RPC_URL="http://localhost:5678/rpc/v1"
mkdir -p "$LOG_DIR"
# Write CSV header if file doesn't exist
if [ ! -f "$LOG_FILE" ]; then
echo "timestamp,http_status,response_ms,cpu_percent,mem_used_mb,mem_total_mb,disk_used_gb,disk_total_gb,containers,uptime_secs,restart_count" > "$LOG_FILE"
fi
# Track restart count
if [ ! -f "$RESTART_FILE" ]; then
echo "0" > "$RESTART_FILE"
fi
RESTART_COUNT=$(cat "$RESTART_FILE" 2>/dev/null || echo "0")
TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
# Check HTTP health
HTTP_START=$(date +%s%N)
HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 "$BACKEND_URL" 2>/dev/null || echo "000")
HTTP_END=$(date +%s%N)
RESPONSE_MS=$(( (HTTP_END - HTTP_START) / 1000000 ))
# Authenticate for RPC access
curl -s -c /tmp/uptime-cookies --max-time 5 -X POST "$RPC_URL" \
-H "Content-Type: application/json" \
-d '{"method":"auth.login","params":{"password":"password123"}}' >/dev/null 2>&1
CSRF=$(grep csrf_token /tmp/uptime-cookies 2>/dev/null | awk '{print $NF}')
# Get system stats from RPC
STATS=$(curl -s --max-time 10 -b /tmp/uptime-cookies \
-H "Content-Type: application/json" \
-H "X-CSRF-Token: $CSRF" \
-X POST "$RPC_URL" \
-d '{"method":"system.stats"}' 2>/dev/null || echo '{"result":{}}')
CPU=$(echo "$STATS" | python3 -c "import sys,json; d=json.load(sys.stdin).get('result',{}); print(d.get('cpu_usage_percent',0))" 2>/dev/null || echo "0")
MEM_USED=$(echo "$STATS" | python3 -c "import sys,json; d=json.load(sys.stdin).get('result',{}); print(round(d.get('mem_used_bytes',0)/1048576))" 2>/dev/null || echo "0")
MEM_TOTAL=$(echo "$STATS" | python3 -c "import sys,json; d=json.load(sys.stdin).get('result',{}); print(round(d.get('mem_total_bytes',0)/1048576))" 2>/dev/null || echo "0")
DISK_USED=$(echo "$STATS" | python3 -c "import sys,json; d=json.load(sys.stdin).get('result',{}); print(round(d.get('disk_used_bytes',0)/1073741824,1))" 2>/dev/null || echo "0")
DISK_TOTAL=$(echo "$STATS" | python3 -c "import sys,json; d=json.load(sys.stdin).get('result',{}); print(round(d.get('disk_total_bytes',0)/1073741824,1))" 2>/dev/null || echo "0")
UPTIME=$(echo "$STATS" | python3 -c "import sys,json; d=json.load(sys.stdin).get('result',{}); print(d.get('uptime_secs',0))" 2>/dev/null || echo "0")
# Count running containers
CONTAINERS=$(podman ps --format "{{.Names}}" 2>/dev/null | wc -l || echo "0")
# Detect restart (uptime < 300s = likely just restarted)
if [ "$UPTIME" -lt 300 ] 2>/dev/null; then
# Check if we already counted this restart
LAST_UPTIME_FILE="$LOG_DIR/last-uptime"
LAST_UPTIME=$(cat "$LAST_UPTIME_FILE" 2>/dev/null || echo "99999")
if [ "$LAST_UPTIME" -gt 300 ] 2>/dev/null; then
RESTART_COUNT=$((RESTART_COUNT + 1))
echo "$RESTART_COUNT" > "$RESTART_FILE"
fi
echo "$UPTIME" > "$LAST_UPTIME_FILE"
else
echo "$UPTIME" > "$LOG_DIR/last-uptime"
fi
# Append metrics
echo "$TIMESTAMP,$HTTP_STATUS,$RESPONSE_MS,$CPU,$MEM_USED,$MEM_TOTAL,$DISK_USED,$DISK_TOTAL,$CONTAINERS,$UPTIME,$RESTART_COUNT" >> "$LOG_FILE"
# Generate summary report
TOTAL_CHECKS=$(wc -l < "$LOG_FILE")
TOTAL_CHECKS=$((TOTAL_CHECKS - 1)) # exclude header
if [ "$TOTAL_CHECKS" -gt 0 ]; then
OK_CHECKS=$(grep -c ",200," "$LOG_FILE" || echo "0")
UPTIME_PCT=$(python3 -c "print(round($OK_CHECKS / $TOTAL_CHECKS * 100, 3))" 2>/dev/null || echo "0")
cat > "$LOG_DIR/summary.json" << EOF
{
"start": "$(head -2 "$LOG_FILE" | tail -1 | cut -d',' -f1)",
"last_check": "$TIMESTAMP",
"total_checks": $TOTAL_CHECKS,
"ok_checks": $OK_CHECKS,
"uptime_percent": $UPTIME_PCT,
"restart_count": $RESTART_COUNT,
"current_status": "$HTTP_STATUS"
}
EOF
fi
+328
View File
@@ -0,0 +1,328 @@
#!/usr/bin/env bash
#
# validate-app-manifest.sh - validate an Archipelago app manifest.
#
# Usage:
# ./scripts/validate-app-manifest.sh [--repo-audit] apps/my-app/manifest.yml
#
# This intentionally mirrors the public app contract documented in
# docs/app-manifest-spec.md: manifests have a top-level `app:` block and are
# ultimately validated by the Rust parser in core/container/src/manifest.rs.
# This script is the contributor-friendly preflight; the Rust parser remains
# canonical.
set -euo pipefail
REPO_AUDIT=0
if [[ "${1:-}" == "--repo-audit" ]]; then
REPO_AUDIT=1
shift
fi
if [[ $# -ne 1 ]]; then
echo "Usage: $0 [--repo-audit] <manifest.yml>"
exit 1
fi
MANIFEST="$1"
PASS=0
FAIL=0
WARN=0
check() {
local desc="$1" result="$2"
case "$result" in
pass)
PASS=$((PASS + 1))
echo " PASS: $desc"
;;
warn)
WARN=$((WARN + 1))
echo " WARN: $desc"
;;
*)
FAIL=$((FAIL + 1))
echo " FAIL: $desc"
;;
esac
}
# Preflight the YAML parser BEFORE any check runs. This used to shell out to
# ruby with stderr discarded, so a machine without ruby reported "invalid YAML"
# and rejected every manifest that was in fact perfectly valid — the first tool
# an app developer runs, failing with a message that sent them to fix the wrong
# thing. Fail loudly about the real cause instead.
if ! command -v python3 >/dev/null 2>&1; then
echo "ERROR: python3 is required to validate manifests, but was not found." >&2
exit 3
fi
if ! python3 -c 'import yaml' >/dev/null 2>&1; then
echo "ERROR: the PyYAML module is required to validate manifests." >&2
echo " Install it with: python3 -m pip install pyyaml" >&2
echo " (Debian/Ubuntu: apt-get install python3-yaml)" >&2
exit 3
fi
# Evaluate a path expression against the manifest's top-level `app` block.
# Missing keys yield an empty string rather than an error, so callers can write
# a plain chain like app["container"]["build"]["tag"] without guarding each hop.
yaml_eval() {
python3 -c '
import sys, yaml
class Nil:
"""Absent value: indexes to itself, is falsy, prints as empty."""
def __getitem__(self, key): return self
def __bool__(self): return False
def __str__(self): return ""
def __iter__(self): return iter(())
NIL = Nil()
class SafeDict(dict):
def __missing__(self, key): return NIL
def wrap(value):
if isinstance(value, dict):
return SafeDict({k: wrap(v) for k, v in value.items()})
if isinstance(value, list):
return [wrap(v) for v in value]
return value
path, expr = sys.argv[1], sys.argv[2]
with open(path) as fh:
data = yaml.safe_load(fh)
app = data.get("app") if isinstance(data, dict) else None
if not isinstance(app, dict):
sys.exit("missing top-level app block")
app = wrap(app)
value = eval(expr, {"__builtins__": {}}, {"app": app})
if isinstance(value, list):
print("\n".join(str(v) for v in value))
elif isinstance(value, dict):
print("\n".join(f"{k}={v}" for k, v in value.items()))
elif value is None or isinstance(value, Nil):
print("")
elif isinstance(value, bool):
print("true" if value else "false")
else:
print(value)
' "$MANIFEST" "$1"
}
echo "Validating: $MANIFEST"
echo ""
if [[ ! -f "$MANIFEST" ]]; then
echo " FAIL: File not found: $MANIFEST"
exit 1
fi
check "File exists" "pass"
if ! python3 -c '
import sys, yaml
with open(sys.argv[1]) as fh:
data = yaml.safe_load(fh)
sys.exit(0 if isinstance(data, dict) and isinstance(data.get("app"), dict) else 1)
' "$MANIFEST" 2>/dev/null; then
check "Valid YAML with top-level app block" "fail"
echo ""
echo "Results: $PASS passed, $FAIL failed, $WARN warnings"
echo "STATUS: REJECTED - fix failures before resubmitting"
exit 1
fi
check "Valid YAML with top-level app block" "pass"
APP_ID="$(yaml_eval 'app["id"]')"
APP_NAME="$(yaml_eval 'app["name"]')"
APP_VERSION="$(yaml_eval 'app["version"]')"
APP_DESCRIPTION="$(yaml_eval 'app["description"]')"
APP_INTERNAL="$(yaml_eval 'app["internal"]')"
IMAGE="$(yaml_eval 'app["container"]["image"]')"
BUILD_CONTEXT="$(yaml_eval 'app["container"]["build"]["context"]')"
BUILD_TAG="$(yaml_eval 'app["container"]["build"]["tag"]')"
if [[ "$APP_ID" =~ ^[a-z0-9]+(-[a-z0-9]+)*$ ]]; then
check "app.id is lowercase kebab-case ($APP_ID)" "pass"
else
check "app.id is lowercase kebab-case" "fail"
fi
if [[ -n "$APP_NAME" ]]; then
check "app.name present" "pass"
else
check "app.name present" "fail"
fi
if [[ "$APP_VERSION" =~ [0-9] ]]; then
check "app.version present and contains a digit" "pass"
else
check "app.version present and contains a digit" "fail"
fi
if [[ -n "$APP_DESCRIPTION" ]]; then
check "app.description present" "pass"
else
check "app.description present" "warn"
fi
HAS_IMAGE=0
HAS_BUILD=0
[[ -n "$IMAGE" ]] && HAS_IMAGE=1
[[ -n "$BUILD_CONTEXT" || -n "$BUILD_TAG" ]] && HAS_BUILD=1
if [[ "$HAS_IMAGE" -eq 1 && "$HAS_BUILD" -eq 0 ]]; then
check "container.image specified" "pass"
elif [[ "$HAS_IMAGE" -eq 0 && "$HAS_BUILD" -eq 1 ]]; then
if [[ -n "$BUILD_CONTEXT" && -n "$BUILD_TAG" ]]; then
check "container.build specified with context and tag" "pass"
else
check "container.build requires context and tag" "fail"
fi
else
check "exactly one of container.image or container.build specified" "fail"
fi
if [[ -n "$IMAGE" ]]; then
TRUSTED=false
for reg in "docker.io" "ghcr.io" "quay.io" "registry.hub.docker.com" "source.archipelago-foundation.org" "localhost/"; do
if [[ "$IMAGE" == *"$reg"* ]]; then
TRUSTED=true
break
fi
done
if [[ "$TRUSTED" == "true" || "$IMAGE" != */* ]]; then
check "image registry is recognized" "pass"
else
check "image registry is not in the reviewed list ($IMAGE)" "warn"
fi
if [[ "$IMAGE" == *":latest" ]]; then
if [[ "$APP_INTERNAL" == "true" || "$IMAGE" == localhost/* ]]; then
check "internal/local build uses :latest ($IMAGE)" "warn"
elif [[ "$REPO_AUDIT" -eq 1 ]]; then
check "existing manifest uses :latest and must be pinned before public app submission ($IMAGE)" "warn"
else
check "image tag is pinned and not :latest ($IMAGE)" "fail"
fi
elif [[ "$IMAGE" != *:* ]]; then
check "image tag is explicit ($IMAGE)" "warn"
else
check "image tag is pinned" "pass"
fi
fi
MEMORY_LIMIT="$(yaml_eval 'app["resources"]["memory_limit"] or app["resources"]["memory"]')"
CPU_LIMIT="$(yaml_eval 'app["resources"]["cpu_limit"] or app["resources"]["cpu"]')"
[[ -n "$MEMORY_LIMIT" ]] && check "resources.memory_limit specified ($MEMORY_LIMIT)" "pass" || check "resources.memory_limit specified" "warn"
[[ -n "$CPU_LIMIT" ]] && check "resources.cpu_limit specified ($CPU_LIMIT)" "pass" || check "resources.cpu_limit specified" "warn"
READONLY_ROOT="$(yaml_eval 'app["security"]["readonly_root"]')"
NO_NEW_PRIVS="$(yaml_eval 'app["security"]["no_new_privileges"]')"
NETWORK_POLICY="$(yaml_eval 'app["security"]["network_policy"]')"
CONTAINER_NETWORK="$(yaml_eval 'app["container"]["network"]')"
if [[ "$READONLY_ROOT" == "true" || -z "$READONLY_ROOT" ]]; then
check "security.readonly_root true (explicit or Rust default)" "pass"
else
check "security.readonly_root true or explicitly justified" "warn"
fi
if [[ "$NO_NEW_PRIVS" == "true" || -z "$NO_NEW_PRIVS" ]]; then
check "security.no_new_privileges true (explicit or Rust default)" "pass"
elif [[ "$REPO_AUDIT" -eq 1 ]]; then
check "existing manifest disables security.no_new_privileges and needs review" "warn"
else
check "security.no_new_privileges true" "fail"
fi
if [[ "$NETWORK_POLICY" == "isolated" || "$NETWORK_POLICY" == "bridge" || "$NETWORK_POLICY" == "host" || -z "$NETWORK_POLICY" ]]; then
check "security.network_policy valid" "pass"
else
check "security.network_policy valid" "fail"
fi
if [[ "$CONTAINER_NETWORK" == container:* || "$CONTAINER_NETWORK" == ns:* ]]; then
check "container.network does not share another namespace" "fail"
else
check "container.network does not share another namespace" "pass"
fi
SECRET_ENV="$(yaml_eval 'app["environment"]')"
if echo "$SECRET_ENV" | grep -iqE '^[A-Z0-9_]*(PASSWORD|PASS|SECRET|TOKEN|API_KEY|PRIVATE_KEY)[A-Z0-9_]*=.+$'; then
check "no hardcoded secret-like values in app.environment" "warn"
else
check "no hardcoded secret-like values in app.environment" "pass"
fi
if [[ -n "$APP_ID" && -n "$MANIFEST" ]]; then
EXPECTED_DIR="$(basename "$(dirname "$MANIFEST")")"
if [[ "$EXPECTED_DIR" == "$APP_ID" ]]; then
check "app.id matches directory name" "pass"
elif [[ "$REPO_AUDIT" -eq 1 ]]; then
check "existing manifest app.id differs from directory name ($EXPECTED_DIR)" "warn"
else
check "app.id matches directory name ($EXPECTED_DIR)" "fail"
fi
fi
PORT_CHECK="$(python3 -c '
import glob, os, sys, yaml
def load_app(path):
try:
with open(path) as fh:
data = yaml.safe_load(fh)
except Exception:
return None
return data.get("app") if isinstance(data, dict) else None
current = sys.argv[1]
current_id = os.path.basename(os.path.dirname(current))
def port_keys(app):
for entry in (app.get("ports") or []):
if not isinstance(entry, dict):
continue
host = entry.get("host")
if not host:
continue
yield (host, entry.get("protocol") or "tcp", entry.get("bind") or "")
claimed = {}
for path in sorted(glob.glob("apps/*/manifest.yml")):
app = load_app(path)
if not isinstance(app, dict):
continue
app_id = app.get("id") or os.path.basename(os.path.dirname(path))
if app_id == current_id:
continue
for key in port_keys(app):
claimed[key] = app_id
app = load_app(current)
if isinstance(app, dict):
for key in port_keys(app):
if key in claimed:
host, proto, bind = key
shown = bind if bind else "*"
print(f"{shown}:{host}/{proto} already used by {claimed[key]}")
' "$MANIFEST")"
if [[ -n "$PORT_CHECK" ]]; then
while IFS= read -r conflict; do
check "port conflict: $conflict" "warn"
done <<< "$PORT_CHECK"
else
check "no duplicate host port bindings" "pass"
fi
echo ""
echo "Results: $PASS passed, $FAIL failed, $WARN warnings"
if [[ "$FAIL" -gt 0 ]]; then
echo "STATUS: REJECTED - fix failures before resubmitting"
exit 1
fi
echo "STATUS: APPROVED (with $WARN warnings)"
+91
View File
@@ -0,0 +1,91 @@
#!/usr/bin/env bash
# verify-aiui-deploy.sh — post-deploy check that a marker string is actually
# reachable by a browser loading AIUI, not merely present somewhere on disk.
#
# The node's /opt/archipelago/web-ui/aiui/assets/ directory is a
# never-pruned graveyard: nginx never deletes an old build's chunk files
# when a new build lands (only the tar+rsync step below AIUI_DIST replaces
# what's there, and even that has left orphaned files in past incidents —
# see feedback_node_side_frontend_verify_stale_chunks). A disk grep over
# assets/ can therefore report "deployed" before the deploy actually
# happened, because a DEAD chunk from an old build still contains the old
# string. The only honest check fetches what a browser would actually load:
# resolve the LIVE chunk set via the service worker's precache manifest
# (sw.js), fetch each live chunk over HTTP, and grep the fetched bytes.
#
# This script never opens a remote shell session onto the node and never
# greps the node's filesystem directly — every check here is an HTTP
# fetch, exactly what a browser session would do.
#
# Usage:
# ./verify-aiui-deploy.sh <node-host> "<marker string>"
#
# Exit 0 = the marker was found in at least one live chunk fetched over HTTP.
# Exit 1 = the marker was not found in any live chunk (negative control
# should also produce this — a check that always passes is not a
# check).
set -uo pipefail
HOST="${1:?usage: verify-aiui-deploy.sh <node-host> \"<marker string>\"}"
MARKER="${2:?usage: verify-aiui-deploy.sh <node-host> \"<marker string>\"}"
# Accept a bare host or a host:port; default to plain HTTP on :80, matching
# how neode-ui/AIUI are actually served on a node (nginx terminates TLS
# elsewhere; the lifecycle gate and other production-quality scripts in
# this directory talk to nodes over plain HTTP the same way).
BASE="http://${HOST}"
SW_URL="${BASE}/aiui/sw.js"
timestamp() { echo "[$(date +%H:%M:%S)]"; }
echo "$(timestamp) Fetching service worker manifest: $SW_URL"
SW_BODY="$(curl -sf -m 15 "$SW_URL" 2>/dev/null || true)"
if [ -z "$SW_BODY" ]; then
echo "FATAL: could not fetch $SW_URL — is AIUI deployed and nginx up on $HOST?" >&2
exit 1
fi
# vite-plugin-pwa's generateSW mode emits sw.js with a
# workbox.precacheAndRoute([{url:"...",revision:"..."|null}, ...]) call —
# a JS array literal (unquoted keys), not JSON. Extract every url:"..."
# value without a full JS parser.
mapfile -t LIVE_PATHS < <(
grep -oE 'url:"[^"]*"' <<<"$SW_BODY" | sed -E 's/^url:"//; s/"$//'
)
if [ "${#LIVE_PATHS[@]}" -eq 0 ]; then
echo "FATAL: $SW_URL fetched but no precache entries found — cannot resolve live chunks." >&2
exit 1
fi
echo "$(timestamp) Resolved ${#LIVE_PATHS[@]} live chunk(s) from the precache manifest."
FOUND=0
CHECKED=0
# Fetch to a temp file and grep that, NOT `curl | grep -q`: under this
# script's pipefail, grep -q's early exit EPIPEs curl (exit 23) whenever
# the marker sits before the tail of a >64KB chunk, turning a genuine
# match into a nondeterministic FAIL.
BODY_TMP="$(mktemp)"
trap 'rm -f "$BODY_TMP"' EXIT
for path in "${LIVE_PATHS[@]}"; do
[ -z "$path" ] && continue
CHECKED=$((CHECKED + 1))
url="${BASE}/aiui/${path}"
if curl -sf -m 15 -o "$BODY_TMP" "$url" 2>/dev/null && grep -q -- "$MARKER" "$BODY_TMP"; then
echo "$(timestamp) MATCH: $path"
FOUND=1
break
fi
done
echo "$(timestamp) Checked $CHECKED live chunk(s) fetched over HTTP for marker: $MARKER"
if [ "$FOUND" -eq 1 ]; then
echo "$(timestamp) PASS — marker found in a live, browser-fetchable chunk."
exit 0
else
echo "$(timestamp) FAIL — marker not found in any live chunk (fetched via sw.js manifest, not a disk grep)." >&2
exit 1
fi
+129
View File
@@ -0,0 +1,129 @@
#!/usr/bin/env python3
"""
Cryptographically verify that a node's on-disk keys are deterministically
derived from its onboarding seed, exactly as documented in core/archipelago/
src/seed.rs:
BIP-39 mnemonic (24 words)
-> PBKDF2-HMAC-SHA512(2048, salt="mnemonic") = 64-byte seed
-> HKDF-SHA256(salt=None, IKM=seed, info=<domain>) = each 32-byte key
"archipelago/node/ed25519/v1" -> node_key (=> Node DID)
"archipelago/nostr-node/secp256k1/v1" -> nostr_secret (=> npub)
"archipelago/fips/secp256k1/v1" -> fips_key (FIPS transport)
It compares each freshly-derived key against the bytes actually on disk under
/var/lib/archipelago/identity/. A MATCH proves the on-disk key was derived from
the seed (and nothing else). Also prints the resulting did:key for cross-check
against Settings -> Node DID.
Usage (run on the node):
sudo python3 verify-seed-derivation.py
# paste the 24-word mnemonic when prompted (input is hidden, never logged)
Pure standard library — no third-party crypto packages required.
"""
import sys, os, hmac, hashlib, getpass, unicodedata
IDENT = "/var/lib/archipelago/identity"
DOMAINS = {
"node_key (=> Node DID)": (b"archipelago/node/ed25519/v1", f"{IDENT}/node_key", "raw"),
"nostr_secret (=> node npub)": (b"archipelago/nostr-node/secp256k1/v1", f"{IDENT}/nostr_secret", "nsec"),
"fips_key (FIPS transport)": (b"archipelago/fips/secp256k1/v1", f"{IDENT}/fips_key", "nsec"),
}
def hkdf_sha256(ikm: bytes, info: bytes, length: int = 32) -> bytes:
"""RFC 5869 HKDF-SHA256 with salt=None (== HashLen zero bytes)."""
salt = b"\x00" * hashlib.sha256().digest_size
prk = hmac.new(salt, ikm, hashlib.sha256).digest()
okm, t, i = b"", b"", 1
while len(okm) < length:
t = hmac.new(prk, t + info + bytes([i]), hashlib.sha256).digest()
okm += t
i += 1
return okm[:length]
# --- minimal bech32 decode (BIP-173) to recover the 32-byte secret from nsec ---
_B32 = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
def _bech32_decode_data(s: str) -> bytes:
s = s.strip().lower()
pos = s.rfind("1")
data = [_B32.index(c) for c in s[pos + 1:]]
data = data[:-6] # drop 6-char checksum
# convert 5-bit groups -> 8-bit bytes
acc = bits = 0
out = bytearray()
for v in data:
acc = (acc << 5) | v
bits += 5
if bits >= 8:
bits -= 8
out.append((acc >> bits) & 0xFF)
return bytes(out)
# --- minimal base58btc + multicodec to render did:key from the ed25519 pubkey ---
_B58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"
def _b58(b: bytes) -> str:
n = int.from_bytes(b, "big")
s = ""
while n:
n, r = divmod(n, 58)
s = _B58[r] + s
return "1" * (len(b) - len(b.lstrip(b"\x00"))) + s
def did_key_from_ed25519_pub(pub: bytes) -> str:
return "did:key:z" + _b58(b"\xed\x01" + pub) # 0xed01 = ed25519-pub multicodec
def main() -> int:
if not os.path.isdir(IDENT):
print(f"!! {IDENT} not found — run this on a node.")
return 2
mnemonic = getpass.getpass("Paste the node's 24-word mnemonic (hidden): ").strip()
words = mnemonic.split()
if len(words) != 24:
print(f"!! expected 24 words, got {len(words)}")
return 2
# BIP-39: seed = PBKDF2-HMAC-SHA512(NFKD(mnemonic), "mnemonic"+passphrase, 2048, 64)
norm = unicodedata.normalize("NFKD", " ".join(words)).encode("utf-8")
seed = hashlib.pbkdf2_hmac("sha512", norm, b"mnemonic", 2048, 64)
all_ok = True
for name, (info, path, fmt) in DOMAINS.items():
derived = hkdf_sha256(seed, info, 32)
try:
raw = open(path, "rb").read()
disk = raw if fmt == "raw" else _bech32_decode_data(raw.decode().strip())
disk = disk[:32]
except Exception as e:
print(f"[{name}] could not read {path}: {e}")
all_ok = False
continue
ok = disk == derived
all_ok &= ok
print(f"[{'MATCH ✅' if ok else 'MISMATCH ❌'}] {name}")
print(f" derived(seed): {derived.hex()}")
print(f" on-disk : {disk.hex()}")
# Render the Node DID from node_key.pub for a visual cross-check vs the UI.
try:
pub = open(f"{IDENT}/node_key.pub", "rb").read()[:32]
print(f"\nNode DID (from node_key.pub): {did_key_from_ed25519_pub(pub)}")
print(" ^ should equal Settings -> Node DID")
except Exception:
pass
print("\n==> ALL KEYS SEED-DERIVED ✅" if all_ok else "\n==> SOME KEYS DID NOT MATCH ❌")
return 0 if all_ok else 1
if __name__ == "__main__":
sys.exit(main())