Archipelago — open-source initial import

This commit is contained in:
Archipelago
2026-08-12 10:55:49 +00:00
commit 82ca09f8b9
1664 changed files with 359095 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.168.1.198 \
--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 = ("146.59.87.168:3000", "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.168.1.228 --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
+118
View File
@@ -0,0 +1,118 @@
#!/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-"
"AKIA[A-Z0-9]{16}"
"ghp_[a-zA-Z0-9]{36}"
"glpat-[a-zA-Z0-9_-]{20}"
)
# Allowed files (config templates, docs, test fixtures)
ALLOW_PATTERNS="test|e2e|mock|demo|example|Example|template|CLAUDE.md|deploy-config|\.md$|node_modules|dist|target|default\)|grep.*rpc|audit-secrets|startsWith|should start with"
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
for pattern in "${PATTERNS[@]}"; do
local matches
matches=$(cd "$REPO_ROOT" && grep -rniE "$pattern" \
--include='*.rs' --include='*.ts' --include='*.vue' --include='*.js' \
--include='*.json' --include='*.sh' --include='*.py' \
2>/dev/null | grep -vE "$ALLOW_PATTERNS" || echo "")
if [ -n "$matches" ]; then
# Filter out false positives (empty strings, variable declarations, etc.)
local real_matches
real_matches=$(echo "$matches" | grep -vE '""|\x27\x27|None|null|undefined|TODO|placeholder|example|Option<|\$\{[A-Z0-9_]+:-\}|\$[A-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
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)$' || 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.168.1.228
# scripts/bitcoin-stack-lifecycle-test.sh --target archipelago@192.168.1.116 --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.168.1.228" >&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:-146.59.87.168:3000/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:-146.59.87.168:3000/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 ==="
+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
+185
View File
@@ -0,0 +1,185 @@
#!/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]]:
with path.open("r", encoding="utf-8") as fh:
data = json.load(fh)
apps = data.get("apps", [])
if not isinstance(apps, list):
raise ValueError(f"{path}: expected .apps to be a list")
return {str(app.get("id", "")): app for app in apps if isinstance(app, dict) and app.get("id")}
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())
+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/"
+675
View File
@@ -0,0 +1,675 @@
#!/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 (must be 700)
# 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 ────────────────────
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)
if [ "$perms" != "700" ]; then
chmod 700 "$dir"
log "Fixed permissions on $dir ($perms -> 700)"
fixed=true
fi
done < <(find "$base" -maxdepth 1 -name "hidden_service_*" -type d 2>/dev/null)
done
# If we fixed permissions, restart system Tor to pick up the changes
if $fixed; then
systemctl restart tor@default 2>/dev/null || true
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 shorty-s
# 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 shorty-s 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 shorty-s 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
+640
View File
@@ -0,0 +1,640 @@
#!/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"
# 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"
# 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"
SPEC_PORTS="18083:80"
SPEC_MEMORY="$(mem_limit archy-lnd-ui)"
SPEC_TIER="4"
SPEC_LOCAL_IMAGE="true"
SPEC_CAPS="CHOWN SETUID SETGID NET_BIND_SERVICE"
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[@]}"
}
+284
View File
@@ -0,0 +1,284 @@
#!/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="http://146.59.87.168:3000/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
if [ -f "$PROJECT_ROOT/image-recipe/configs/archipelago-doctor.service" ] || \
[ -f "$PROJECT_ROOT/image-recipe/configs/archipelago-doctor.timer" ]; then
mkdir -p "$RUNTIME_DIR/image-recipe/configs"
for unit in archipelago-doctor.service archipelago-doctor.timer; do
if [ -f "$PROJECT_ROOT/image-recipe/configs/$unit" ]; then
echo " Including runtime unit $unit"
cp "$PROJECT_ROOT/image-recipe/configs/$unit" "$RUNTIME_DIR/image-recipe/configs/$unit"
fi
done
fi
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
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"
+250
View File
@@ -0,0 +1,250 @@
#!/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.168.1.228"
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 "[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 " Sign it before publishing: bash scripts/sign-manifest.sh"
echo " (publish-release-assets.sh refuses to ship an unsigned manifest)"
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"
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 http://146.59.87.168:3000/lfg2025/archy/raw/branch/main/releases/manifest.json"
+31
View File
@@ -0,0 +1,31 @@
#!/bin/bash
set -euo pipefail
# Check what's actually in the deployed frontend
TARGET_HOST="${ARCHIPELAGO_TARGET:-archipelago@192.168.1.228}"
echo "Checking deployed frontend content..."
echo ""
echo "1. Search for 'bundledApps' variable in JS:"
ssh "$TARGET_HOST" "grep -o 'bundledApps' /opt/archipelago/web-ui/assets/*.js | wc -l"
echo ""
echo "2. Search for 'Bitcoin Knots' string:"
ssh "$TARGET_HOST" "grep -o 'Bitcoin Knots' /opt/archipelago/web-ui/assets/*.js | head -1"
echo ""
echo "3. Search for the v-for loop pattern:"
ssh "$TARGET_HOST" "grep -o 'v-for.*bundled' /opt/archipelago/web-ui/assets/*.js | head -1"
echo ""
echo "4. List all JS assets (to see if they updated):"
ssh "$TARGET_HOST" "ls -lh /opt/archipelago/web-ui/assets/*.js | head -10"
echo ""
echo "5. Check index.html timestamp:"
ssh "$TARGET_HOST" "stat /opt/archipelago/web-ui/index.html | grep Modify"
echo ""
echo "6. Try accessing the API from target:"
ssh "$TARGET_HOST" 'curl -s http://localhost:80/ | head -20'
+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:-146.59.87.168:3000/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 ""
+7
View File
@@ -0,0 +1,7 @@
#!/bin/bash
# Default deployment targets — override in deploy-config.sh (gitignored)
DEFAULT_PRIMARY="192.168.1.228"
DEFAULT_SECONDARY="192.168.1.198"
TAILSCALE_ARCH1="100.82.97.63"
TAILSCALE_ARCH2="100.122.84.60"
TAILSCALE_ARCH3="100.124.105.113"
+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'
+1246
View File
File diff suppressed because it is too large Load Diff
+2066
View File
File diff suppressed because it is too large Load Diff
+259
View File
@@ -0,0 +1,259 @@
#!/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:-archipelago@192.168.1.228}"
SSH_OPTS="-o StrictHostKeyChecking=no -o ServerAliveInterval=15 -i $SSH_KEY"
REMOTE_DIR="/home/archipelago/archy"
RPC_URL="http://192.168.1.228/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://192.168.1.228/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="146.59.87.168:3000/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.168.1.228: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
+1477
View File
File diff suppressed because it is too large Load Diff
+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 ==="
+176
View File
@@ -0,0 +1,176 @@
#!/bin/bash
# LAN fast-path pairing for our 4 dev fleet nodes.
#
# ── Is this needed for every archipelago install? No. ────────────────
# For nodes deployed anywhere in the world, FIPS-to-FIPS routing by
# npub works via the anchor peer network (fips.v0l.io ships by default
# in /etc/fips/fips.yaml on every install — that anchor bootstraps DHT
# routing for any npub the node has ever heard about). The peer's
# fips_npub is advertised in our federation invite codes (since v1.4),
# so accepting an invite is enough for `dial::peer_base_url(npub)` to
# reach the peer through the anchor mesh.
#
# ── Why this script exists ───────────────────────────────────────────
# Our 4 fleet nodes are all on 192.168.1.0/24. Hopping through the
# fips.v0l.io anchor for intra-LAN traffic is wasteful when the peers
# are on the same wire. This script writes per-node fips.yaml with:
# 1. The public anchor (fips.v0l.io) so internet peers still route.
# 2. The other 3 fleet nodes as static LAN peers (UDP 2121 / TCP
# 8443) so LAN traffic stays on LAN.
# 3. `persistent: true` so the npub is stable across restarts —
# without this the daemon rolls a new keypair on every restart
# and any federation invite we advertised goes stale.
#
# Idempotent: re-running picks up any newly-added or removed nodes.
#
# For a production install on an unknown LAN, this script isn't the
# mechanism — the ISO install writes the anchor-only fips.yaml and
# identity comes from the archipelago seed; peer discovery is purely
# through the DHT + federation invites.
#
# Usage:
# scripts/fleet-fips-pair.sh # apply to all nodes
# scripts/fleet-fips-pair.sh --verify # just print the peer state
set -eo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
. "$SCRIPT_DIR/lib/common.sh"
# Fleet roster: "<ip-last-octet> <nic-name> <fips-npub>"
NODES=(
"116 enp0s25 npub1mxavs6scfgl056k6lm4mk73ddnrhjewg78zlyzfn2lmr0rfyrs5qhcr03g"
"198 enp2s0 npub13cy4lml94cj4rdu8runrr945z2muszuvr5tql8mr9m063d7xzpqqu3k8se"
"228 enp2s0 npub1a0xxcqce2tsv8ulwastep23jtf3h4wvvry8r8nklnl36jtrdnefqh5qn6h"
"253 enx9cbf0d0129f9 npub1dl0m0yfzfw6467c3z6q63s7ggzd77yg97j90ptfrheprxeypt3msj0mq4g"
)
LAN_PREFIX="192.168.1"
UDP_PORT=2121
TCP_PORT=8443
if [ "${1:-}" = "--verify" ]; then
for row in "${NODES[@]}"; do
read -r node _nic _npub <<< "$row"
echo "=== .$node ==="
ssh_cmd "$LAN_PREFIX.$node" "sudo fipsctl show peers 2>/dev/null | python3 -c 'import sys,json; d=json.load(sys.stdin); print(f\"{len(d[\"peers\"])} authenticated peers\"); [print(\" npub=\", p.get(\"npub\",\"?\"), \"alias=\", p.get(\"alias\",\"?\")) for p in d[\"peers\"]]' || echo ' fipsctl show peers failed'"
done
exit 0
fi
TMP_ROOT=$(mktemp -d)
trap 'rm -rf "$TMP_ROOT"' EXIT
generate_yaml() {
# $1 = self node octet, $2 = self nic
local self_node="$1"
local self_nic="$2"
local out="$TMP_ROOT/fips.yaml.$self_node"
cat > "$out" <<YAML
# FIPS Node Configuration — managed by scripts/fleet-fips-pair.sh
# DO NOT hand-edit: re-run the script to regenerate.
node:
identity:
# Persistent identity so the npub stays stable across restarts.
# Without this, every restart rolls a new keypair and federation
# peer lists go stale.
persistent: true
tun:
enabled: true
name: fips0
mtu: 1280
dns:
enabled: true
bind_addr: "127.0.0.1"
port: 5354
transports:
udp:
bind_addr: "0.0.0.0:$UDP_PORT"
tcp:
bind_addr: "0.0.0.0:$TCP_PORT"
ethernet:
interface: "$self_nic"
discovery: true
announce: true
auto_connect: true
accept_connections: true
peers:
# Public anchor — bootstraps DHT routing for any npub heard via
# federation invites. Every archipelago install ships this peer.
- npub: "npub1zv58cn7v83mxvttl70w5fwjwuclfmntv9cnmv5wmz2nzz88u5urqvdx96n"
alias: "fips.v0l.io"
addresses:
- transport: tcp
addr: "fips.v0l.io:8443"
- transport: udp
addr: "fips.v0l.io:2121"
connect_policy: auto_connect
# Fleet LAN fast-path — other archipelago nodes on this subnet.
YAML
for other_row in "${NODES[@]}"; do
read -r o_node _o_nic o_npub <<< "$other_row"
[ "$o_node" = "$self_node" ] && continue
cat >> "$out" <<YAML
- npub: "$o_npub"
alias: "archi-$o_node"
addresses:
- transport: udp
addr: "$LAN_PREFIX.$o_node:$UDP_PORT"
- transport: tcp
addr: "$LAN_PREFIX.$o_node:$TCP_PORT"
connect_policy: auto_connect
YAML
done
echo "$out"
}
deploy_to() {
local node="$1"
local nic="$2"
local ip="$LAN_PREFIX.$node"
local yaml
yaml=$(generate_yaml "$node" "$nic")
log_info "[.${node}] uploading fips.yaml"
scp_cmd "$yaml" "archipelago@${ip}:/tmp/fips.yaml.new"
log_info "[.${node}] installing + restarting fips.service"
ssh_cmd "$ip" '
set -e
sudo install -o root -g root -m 0600 /tmp/fips.yaml.new /etc/fips/fips.yaml
rm -f /tmp/fips.yaml.new
sudo systemctl restart fips.service
# Give the daemon a beat to come up before we ask about peers
for i in $(seq 1 10); do
if sudo systemctl is-active fips.service >/dev/null 2>&1; then break; fi
sleep 0.5
done
sudo systemctl is-active fips.service
'
}
for row in "${NODES[@]}"; do
read -r node nic _npub <<< "$row"
deploy_to "$node" "$nic"
done
echo
log_info "Waiting 10s for peer handshakes to settle…"
sleep 10
echo
log_info "Post-pair peer state:"
for row in "${NODES[@]}"; do
read -r node _nic _npub <<< "$row"
count=$(ssh_cmd "$LAN_PREFIX.$node" "sudo fipsctl show peers 2>/dev/null | grep -c '\"npub\"' || echo 0")
log_info " .$node: $count authenticated peers"
done
+135
View File
@@ -0,0 +1,135 @@
#!/bin/bash
# Strip the LAN fast-path peers from all 4 fleet nodes' fips.yaml,
# leaving only the public anchor (fips.v0l.io). Restart fips.service
# on each node.
#
# Purpose: verify that the general-case deployment (nodes anywhere in
# the world, no LAN between them) actually works — i.e. that two
# paired archipelago peers can reach each other purely through the
# FIPS DHT bootstrapped from the anchor.
#
# After running this, test with:
# scripts/fleet-fips-pair.sh --verify (peer state per node)
# for ip in 116 198 228 253; do
# ssh archipelago@192.168.1.$ip "dig @127.0.0.1 -p 5354 +short \
# <other-node-npub>.fips AAAA"
# done
#
# To restore the LAN fast-path: re-run scripts/fleet-fips-pair.sh.
#
# Usage: scripts/fleet-fips-unpair.sh
set -eo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
. "$SCRIPT_DIR/lib/common.sh"
# Roster — only need NIC names to preserve them in the yaml.
NODES=(
"116 enp0s25"
"198 enp2s0"
"228 enp2s0"
"253 enx9cbf0d0129f9"
)
TMP_ROOT=$(mktemp -d)
trap 'rm -rf "$TMP_ROOT"' EXIT
for row in "${NODES[@]}"; do
read -r node nic <<< "$row"
out="$TMP_ROOT/fips.yaml.$node"
cat > "$out" <<YAML
# FIPS Node Configuration — anchor-only (managed by fleet-fips-unpair.sh)
# This is the shape a general archipelago install ships with: fleet
# nodes are NOT pre-paired; discovery happens via the anchor DHT.
node:
identity:
persistent: true
tun:
enabled: true
name: fips0
mtu: 1280
dns:
enabled: true
bind_addr: "127.0.0.1"
port: 5354
transports:
udp:
bind_addr: "0.0.0.0:2121"
tcp:
bind_addr: "0.0.0.0:8443"
ethernet:
interface: "$nic"
discovery: true
announce: true
auto_connect: true
accept_connections: true
peers:
- npub: "npub1zv58cn7v83mxvttl70w5fwjwuclfmntv9cnmv5wmz2nzz88u5urqvdx96n"
alias: "fips.v0l.io"
addresses:
- transport: tcp
addr: "fips.v0l.io:8443"
- transport: udp
addr: "fips.v0l.io:2121"
connect_policy: auto_connect
YAML
ip="192.168.1.$node"
log_info "[.${node}] uploading anchor-only fips.yaml"
scp_cmd "$out" "archipelago@${ip}:/tmp/fips.yaml.new"
log_info "[.${node}] installing + restarting fips.service"
ssh_cmd "$ip" '
set -e
sudo install -o root -g root -m 0600 /tmp/fips.yaml.new /etc/fips/fips.yaml
rm -f /tmp/fips.yaml.new
sudo systemctl restart fips.service
for i in $(seq 1 10); do
if sudo systemctl is-active fips.service >/dev/null 2>&1; then break; fi
sleep 0.5
done
sudo systemctl is-active fips.service
'
done
echo
log_info "Waiting 20s for anchor handshake + DHT propagation…"
sleep 20
echo
log_info "Post-unpair state (should show only fips.v0l.io as an authenticated peer):"
for row in "${NODES[@]}"; do
read -r node _nic <<< "$row"
ip="192.168.1.$node"
count=$(ssh_cmd "$ip" "sudo fipsctl show peers 2>/dev/null | grep -c '\"npub\"' || echo 0")
log_info " .$node: $count authenticated peers"
done
echo
log_info "DHT resolution test — each node resolves the other 3 by npub:"
declare -A NPUBS=(
[116]="npub1mxavs6scfgl056k6lm4mk73ddnrhjewg78zlyzfn2lmr0rfyrs5qhcr03g"
[198]="npub13cy4lml94cj4rdu8runrr945z2muszuvr5tql8mr9m063d7xzpqqu3k8se"
[228]="npub1a0xxcqce2tsv8ulwastep23jtf3h4wvvry8r8nklnl36jtrdnefqh5qn6h"
[253]="npub1dl0m0yfzfw6467c3z6q63s7ggzd77yg97j90ptfrheprxeypt3msj0mq4g"
)
for row in "${NODES[@]}"; do
read -r self_node _ <<< "$row"
ip="192.168.1.$self_node"
echo ".${self_node}:"
for other in 116 198 228 253; do
[ "$other" = "$self_node" ] && continue
r=$(ssh_cmd "$ip" "dig @127.0.0.1 -p 5354 +short +time=3 +tries=1 ${NPUBS[$other]}.fips AAAA" 2>&1)
if [ -z "$r" ]; then
echo " .${other} → unresolved (DHT route not found)"
else
echo " .${other}$r"
fi
done
done
+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())
+242
View File
@@ -0,0 +1,242 @@
#!/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 = 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"] = 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"},
REGISTRY = os.environ.get("ARCHY_REGISTRY", "146.59.87.168:3000/lfg2025")
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
# docs/bitcoin-version-bulletproof-rollout.md).
"bitcoin-knots": [
{"version": "latest",
"image": f"{REGISTRY}/bitcoin-knots:29.3.knots20260508", "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.3.knots20260210",
"image": f"{REGISTRY}/bitcoin-knots:29.3.knots20260210"},
{"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
+125
View File
@@ -0,0 +1,125 @@
#!/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 146.59.87.168:3000/lfg2025/
# Run: podman images --format '{{.Repository}}:{{.Tag}}' | grep '146.59.87.168:3000' | sort
# to verify against the registry.
# Archipelago app registries (primary + fallback)
ARCHY_REGISTRY="146.59.87.168:3000/lfg2025"
# No fallback registry: the old tx1138 registry host was retired (2026-06-13); empty disables the fallback path.
ARCHY_REGISTRY_FALLBACK=""
# Bitcoin stack
BITCOIN_KNOTS_IMAGE="$ARCHY_REGISTRY/bitcoin-knots:latest"
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.3.9"
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"
PORTAINER_IMAGE="$ARCHY_REGISTRY/portainer:2.19.4"
# 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:latest"
# AI / Routing
ROUTSTR_IMAGE="$ARCHY_REGISTRY/routstr:v0.4.3"
# Community / Gaming
BOTFIGHTS_IMAGE="$ARCHY_REGISTRY/botfights:1.1.0"
# 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.84-alpha"
LND_UI_IMAGE="$ARCHY_REGISTRY/lnd-ui:latest"
ELECTRS_UI_IMAGE="$ARCHY_REGISTRY/electrs-ui:latest"
# 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.168.1.198${NC}"
row=$((row + 2))
# Credentials — white, NOT orange (user request)
goto $row 1
p " ${WHITE}SSH ssh archipelago@192.168.1.198${NC}"
row=$((row + 1))
goto $row 1
p " ${WHITE}Password archipelago${NC}"
row=$((row + 1))
goto $row 1
p " ${WHITE}Web Login password123${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"
+183
View File
@@ -0,0 +1,183 @@
#!/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
}
# ── 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;
}
+205
View File
@@ -0,0 +1,205 @@
# 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;
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";
}
+252
View File
@@ -0,0 +1,252 @@
#!/bin/bash
# node-profile.sh — CPU/memory/container profiling across all Archipelago nodes
#
# Usage:
# ./scripts/node-profile.sh # All reachable nodes
# ./scripts/node-profile.sh 192.168.1.228 # Single node
# ./scripts/node-profile.sh --watch # Repeat every 30s
#
# Requires: SSH key at ~/.ssh/archipelago-deploy (or ARCHIPELAGO_SSH_KEY)
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
source "$SCRIPT_DIR/lib/common.sh"
source "$SCRIPT_DIR/deploy-config-defaults.sh"
[ -f "$SCRIPT_DIR/deploy-config.sh" ] && source "$SCRIPT_DIR/deploy-config.sh"
ALL_NODES=(
"$DEFAULT_PRIMARY"
"$DEFAULT_SECONDARY"
"$TAILSCALE_ARCH1"
"$TAILSCALE_ARCH2"
"$TAILSCALE_ARCH3"
)
NODE_LABELS=(
"primary (.228)"
"secondary (.198)"
"tailscale-1"
"tailscale-2"
"tailscale-3"
)
WATCH_MODE=false
WATCH_INTERVAL=30
TARGET_NODES=()
# ── Parse args ─────────────────────────────────────────────────────────
while [[ $# -gt 0 ]]; do
case "$1" in
--watch)
WATCH_MODE=true
shift
;;
--interval)
WATCH_INTERVAL="$2"
shift 2
;;
*)
TARGET_NODES+=("$1")
shift
;;
esac
done
# If specific nodes given, use those; otherwise use all
if [ ${#TARGET_NODES[@]} -eq 0 ]; then
TARGET_NODES=("${ALL_NODES[@]}")
fi
# ── Remote profiling command ───────────────────────────────────────────
PROFILE_CMD='
hostname_val=$(hostname 2>/dev/null || echo "unknown")
uptime_val=$(uptime -p 2>/dev/null || uptime | sed "s/.*up/up/;s/,.*//")
# CPU info
cpu_cores=$(nproc 2>/dev/null || echo "?")
load_avg=$(cat /proc/loadavg 2>/dev/null | awk "{print \$1, \$2, \$3}")
# Memory
mem_info=$(free -h 2>/dev/null | awk "/^Mem:/{printf \"%s / %s (%s free)\", \$3, \$2, \$4}")
swap_info=$(free -h 2>/dev/null | awk "/^Swap:/{if(\$2 != \"0B\" && \$2 != \"0\") printf \"%s / %s\", \$3, \$2; else print \"none\"}")
# Disk
disk_info=$(df -h / 2>/dev/null | awk "NR==2{printf \"%s / %s (%s)\", \$3, \$2, \$5}")
# CPU temperature (if available)
temp="n/a"
if [ -f /sys/class/thermal/thermal_zone0/temp ]; then
raw=$(cat /sys/class/thermal/thermal_zone0/temp)
temp="$((raw / 1000))°C"
fi
echo "HEADER|${hostname_val}|${uptime_val}|${cpu_cores} cores|load ${load_avg}|${temp}"
echo "MEM|${mem_info}"
echo "SWAP|${swap_info}"
echo "DISK|${disk_info}"
# Top 10 processes by CPU
echo "PROCS_START"
ps aux --sort=-%cpu 2>/dev/null | head -11 | awk "NR>1{printf \"%-6s %-5s %-5s %s\n\", \$2, \$3, \$4, \$11}" 2>/dev/null
echo "PROCS_END"
# Container status
echo "CONTAINERS_START"
if command -v podman >/dev/null 2>&1; then
podman ps -a --format "{{.Names}}|{{.Status}}|{{.Size}}" 2>/dev/null || \
podman ps -a --format "{{.Names}}|{{.Status}}" 2>/dev/null || \
echo "podman error"
elif command -v docker >/dev/null 2>&1; then
docker ps -a --format "{{.Names}}|{{.Status}}" 2>/dev/null || echo "docker error"
else
echo "no container runtime"
fi
echo "CONTAINERS_END"
'
# ── Formatting ─────────────────────────────────────────────────────────
BOLD="\033[1m"
DIM="\033[2m"
GREEN="\033[0;32m"
YELLOW="\033[0;33m"
RED="\033[0;31m"
CYAN="\033[0;36m"
RESET="\033[0m"
SEP="━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
print_node_report() {
local ip="$1"
local label="$2"
local output="$3"
echo -e "\n${BOLD}${CYAN}${SEP}${RESET}"
echo -e "${BOLD}${CYAN} ${label} ${DIM}(${ip})${RESET}"
echo -e "${BOLD}${CYAN}${SEP}${RESET}"
# Parse HEADER line
local header
header=$(echo "$output" | grep "^HEADER|" | head -1)
if [ -n "$header" ]; then
IFS='|' read -r _ hostname uptime cores load temp <<< "$header"
echo -e " ${BOLD}Host:${RESET} ${hostname} ${DIM}${uptime}${RESET}"
echo -e " ${BOLD}CPU:${RESET} ${cores} ${load} ${temp}"
fi
# Memory
local mem
mem=$(echo "$output" | grep "^MEM|" | cut -d'|' -f2)
[ -n "$mem" ] && echo -e " ${BOLD}Mem:${RESET} ${mem}"
local swap
swap=$(echo "$output" | grep "^SWAP|" | cut -d'|' -f2)
[ -n "$swap" ] && echo -e " ${BOLD}Swap:${RESET} ${swap}"
local disk
disk=$(echo "$output" | grep "^DISK|" | cut -d'|' -f2)
[ -n "$disk" ] && echo -e " ${BOLD}Disk:${RESET} ${disk}"
# Top processes
echo ""
echo -e " ${BOLD}Top processes by CPU:${RESET}"
echo -e " ${DIM}PID CPU% MEM% Command${RESET}"
local procs
procs=$(echo "$output" | sed -n '/^PROCS_START$/,/^PROCS_END$/p' | grep -v "^PROCS_")
if [ -n "$procs" ]; then
while IFS= read -r line; do
local cpu_pct
cpu_pct=$(echo "$line" | awk '{print $2}' | tr -d '.')
if [ "${cpu_pct:-0}" -gt 500 ] 2>/dev/null; then
echo -e " ${RED}${line}${RESET}"
elif [ "${cpu_pct:-0}" -gt 100 ] 2>/dev/null; then
echo -e " ${YELLOW}${line}${RESET}"
else
echo -e " ${line}"
fi
done <<< "$procs"
else
echo -e " ${DIM}(no process data)${RESET}"
fi
# Containers
echo ""
echo -e " ${BOLD}Containers:${RESET}"
local containers
containers=$(echo "$output" | sed -n '/^CONTAINERS_START$/,/^CONTAINERS_END$/p' | grep -v "^CONTAINERS_")
if [ -n "$containers" ] && [ "$containers" != "no container runtime" ] && [ "$containers" != "podman error" ]; then
while IFS='|' read -r name status size; do
local icon
if echo "$status" | grep -qi "up"; then
icon="${GREEN}${RESET}"
else
icon="${RED}${RESET}"
fi
echo -e " ${icon} ${BOLD}${name}${RESET} ${DIM}${status}${RESET}"
done <<< "$containers"
else
echo -e " ${DIM}${containers:-none}${RESET}"
fi
}
# ── Main profiling loop ───────────────────────────────────────────────
profile_all() {
echo -e "\n${BOLD}Archipelago Node Profile${RESET} ${DIM}$(date '+%Y-%m-%d %H:%M:%S')${RESET}"
local tmpdir
tmpdir=$(mktemp -d)
# Probe all nodes in parallel
local pids=()
for i in "${!TARGET_NODES[@]}"; do
local ip="${TARGET_NODES[$i]}"
local label="${NODE_LABELS[$i]:-$ip}"
(
result=$(ssh_cmd "$ip" "$PROFILE_CMD" 2>/dev/null) && \
echo "$result" > "$tmpdir/$i.out" || \
echo "UNREACHABLE" > "$tmpdir/$i.out"
) &
pids+=($!)
done
# Wait for all probes
for pid in "${pids[@]}"; do
wait "$pid" 2>/dev/null || true
done
# Print reports
local reachable=0 unreachable=0
for i in "${!TARGET_NODES[@]}"; do
local ip="${TARGET_NODES[$i]}"
local label="${NODE_LABELS[$i]:-$ip}"
local outfile="$tmpdir/$i.out"
if [ -f "$outfile" ] && [ "$(cat "$outfile")" != "UNREACHABLE" ]; then
print_node_report "$ip" "$label" "$(cat "$outfile")"
reachable=$((reachable + 1))
else
echo -e "\n${DIM}${SEP}${RESET}"
echo -e "${RED} ${label} (${ip}) — unreachable${RESET}"
echo -e "${DIM}${SEP}${RESET}"
unreachable=$((unreachable + 1))
fi
done
echo -e "\n${DIM}${reachable} reachable, ${unreachable} unreachable${RESET}\n"
rm -rf "$tmpdir"
}
if $WATCH_MODE; then
while true; do
clear
profile_all
echo -e "${DIM}Refreshing every ${WATCH_INTERVAL}s — Ctrl+C to stop${RESET}"
sleep "$WATCH_INTERVAL"
done
else
profile_all
fi
+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
+121
View File
@@ -0,0 +1,121 @@
#!/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.
EXPECTED_DID="did:key:z6MkkidEnEpo6qHMCNSZoNKWtvQvxq3whnaME9wGgEFhq7ur"
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")
case "$remote_url" in
http://*@*) ;;
*) fail "$REMOTE must be an authenticated http:// Gitea remote URL for API uploads" ;;
esac
auth=${remote_url#http://}
auth=${auth%@*}
host_path=${remote_url#http://$auth@}
host=${host_path%%/*}
repo_path=${host_path#*/}
repo_path=${repo_path%.git}
api="http://$host/api/v1/repos/$repo_path"
release_url="$api/releases/tags/v${VERSION}"
echo "Pushing main and v${VERSION} to $REMOTE..."
git -C "$PROJECT_ROOT" push "$REMOTE" main "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 from manifest (size + sha256)..."
python3 - "$PROJECT_ROOT/releases/manifest.json" <<'PY' | while read -r url size sha; do
import json
import sys
manifest = json.load(open(sys.argv[1]))
for component in manifest["components"]:
print(component["download_url"], component["size_bytes"], component["sha256"])
PY
# Full GET, not HEAD: a size-correct/content-wrong mirror asset must fail
# the publish gate, so compare the actual bytes against the manifest sha256.
tmp=$(mktemp)
curl -fsSL --max-time 900 -o "$tmp" "$url" || { rm -f "$tmp"; fail "download URL failed: $url"; }
actual_size=$(stat -c %s "$tmp")
actual_sha=$(sha256sum "$tmp" | awk '{print $1}')
rm -f "$tmp"
[ "$actual_size" = "$size" ] || fail "download size mismatch for $url (expected $size, got $actual_size)"
[ "$actual_sha" = "$sha" ] || fail "download sha256 mismatch for $url (expected $sha, got $actual_sha)"
done
echo "Release v${VERSION} published and verified on $REMOTE."
+900
View File
@@ -0,0 +1,900 @@
#!/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
[ "$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
if [ ! -f "$SECRETS_DIR/fedimint-gateway-password" ]; then
if ! $CHECK_ONLY; then
local fpass
fpass=$(openssl rand -base64 16)
echo "$fpass" | sudo tee "$SECRETS_DIR/fedimint-gateway-password" >/dev/null
sudo chmod 600 "$SECRETS_DIR/fedimint-gateway-password"
if command -v htpasswd >/dev/null 2>&1; then
htpasswd -bnBC 10 "" "$fpass" | tr -d ':\n' | sudo tee "$SECRETS_DIR/fedimint-gateway-hash" >/dev/null
sudo chmod 600 "$SECRETS_DIR/fedimint-gateway-hash"
fi
info "Generated fedimint gateway secret"
fi
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 {
listen 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.168.1.228
Or non-interactive (CI):
RESILIENCE_SSH_PASS=… RESILIENCE_UI_PASS=… \
scripts/resilience/resilience.sh archipelago@192.168.1.228
Filters:
# Smoke test (3 apps, no reboot, ~15min)
scripts/resilience/resilience.sh archipelago@192.168.1.228 smoke
# Single app
scripts/resilience/resilience.sh archipelago@192.168.1.228 bitcoin-knots
# Subset
scripts/resilience/resilience.sh archipelago@192.168.1.228 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.168.1.228
# 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.168.1.228 [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"
+513
View File
@@ -0,0 +1,513 @@
#!/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]
# 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="testpass123!"
for arg in "$@"; do
case "$arg" in
--phase1-only) PHASE1_ONLY=true ;;
*) PASSWORD="$arg" ;;
esac
done
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
+410
View File
@@ -0,0 +1,410 @@
#!/bin/bash
# Self-update: pull latest code from the OVH Gitea (146.59.87.168:3000) 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 (146.59.87.168:3000)
# 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 http://146.59.87.168:3000/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
# 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 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=""
for ui in bitcoin-ui lnd-ui electrs-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 and marking for rebuild"
sudo rsync -a --delete "$src/" "$dst/"
UI_REBUILD_LIST="$UI_REBUILD_LIST $ui"
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"
+176
View File
@@ -0,0 +1,176 @@
#!/bin/bash
#
# Setup AIUI + Claude API proxy + FileBrowser on any Archipelago server
#
# Usage:
# ./scripts/setup-aiui-server.sh <host>
# ./scripts/setup-aiui-server.sh archipelago@192.168.1.198
# ./scripts/setup-aiui-server.sh archipelago@192.168.1.228
#
# What it does:
# 1. Deploys AIUI files (from local build)
# 2. Configures nginx Claude API proxy (direct to Anthropic with API key)
# 3. Fixes FileBrowser container (removes read-only root if needed)
# 4. Reloads nginx
#
# Prerequisites:
# - AIUI must be built locally first: cd AIUI/packages/app && VITE_BASE_PATH=/aiui/ npx vite build
# - SSH key access to target server
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
SSH_KEY="${ARCHIPELAGO_SSH_KEY:-$HOME/.ssh/archipelago-deploy}"
SSH_OPTS="-o StrictHostKeyChecking=no -i $SSH_KEY"
# Anthropic API key used by the AIUI Claude chat proxy. Keep this in the
# caller's environment or scripts/deploy-config.sh; never commit live keys.
ANTHROPIC_API_KEY="${ANTHROPIC_API_KEY:-}"
TARGET_HOST="$1"
if [ -z "$TARGET_HOST" ]; then
echo "Usage: $0 <user@host>"
echo " e.g. $0 archipelago@192.168.1.198"
exit 1
fi
if [ -z "$ANTHROPIC_API_KEY" ]; then
echo "ERROR: ANTHROPIC_API_KEY must be set in the environment."
echo "Example: ANTHROPIC_API_KEY=<key> $0 $TARGET_HOST"
exit 1
fi
AIUI_DIST="$PROJECT_DIR/../AIUI/packages/app/dist"
if [ ! -f "$AIUI_DIST/index.html" ]; then
echo "ERROR: AIUI build not found at $AIUI_DIST"
echo "Build it first: cd ../AIUI/packages/app && VITE_BASE_PATH=/aiui/ npx vite build"
exit 1
fi
timestamp() { echo "[$(date +%H:%M:%S)]"; }
echo "╔════════════════════════════════════════════════════════════╗"
echo "║ Archipelago AIUI + Claude API Setup ║"
echo "║ Target: $TARGET_HOST"
echo "╚════════════════════════════════════════════════════════════╝"
# --- Step 1: Deploy AIUI files ---
echo ""
echo "$(timestamp) 📦 Deploying AIUI files..."
# Check if rsync is available on remote
if ssh $SSH_OPTS "$TARGET_HOST" "which rsync" &>/dev/null; then
rsync -avz --delete -e "ssh $SSH_OPTS" "$AIUI_DIST/" "$TARGET_HOST:/opt/archipelago/web-ui/aiui/" 2>&1 | tail -3
else
echo " rsync not available, using tar+scp..."
TMPTAR=$(mktemp /tmp/aiui-dist-XXXXX.tar.gz)
(cd "$AIUI_DIST" && tar czf "$TMPTAR" .)
scp $SSH_OPTS "$TMPTAR" "$TARGET_HOST:/tmp/aiui-dist.tar.gz"
ssh $SSH_OPTS "$TARGET_HOST" "sudo mkdir -p /opt/archipelago/web-ui/aiui && cd /opt/archipelago/web-ui/aiui && sudo tar xzf /tmp/aiui-dist.tar.gz --overwrite"
rm -f "$TMPTAR"
fi
echo " AIUI deployed."
# --- Step 2: Configure nginx Claude API proxy ---
echo ""
echo "$(timestamp) 🔧 Configuring nginx Claude API proxy..."
# Create a Python script to patch nginx config
cat << 'PYSCRIPT' > /tmp/patch-nginx-claude.py
import sys
import re
API_KEY = sys.argv[1]
with open("/etc/nginx/sites-available/archipelago") as f:
content = f.read()
# The new Claude API proxy block
new_block = '''location /aiui/api/claude/ {
if ($cookie_session = "") {
return 401 '{"error":"Unauthorized"}';
}
proxy_pass https://api.anthropic.com/;
proxy_http_version 1.1;
proxy_set_header Host api.anthropic.com;
proxy_set_header x-api-key "''' + API_KEY + '''";
proxy_set_header anthropic-version "2023-06-01";
proxy_set_header anthropic-dangerous-direct-browser-access "true";
proxy_ssl_server_name on;
proxy_set_header X-Real-IP $remote_addr;
proxy_buffering off;
proxy_cache off;
proxy_connect_timeout 120s;
proxy_read_timeout 300s;
proxy_send_timeout 120s;
}'''
# Replace existing Claude API proxy blocks (handles both old proxy and direct patterns)
pattern = r'location /aiui/api/claude/ \{[^}]*(?:\{[^}]*\}[^}]*)*\}'
content = re.sub(pattern, new_block, content)
with open("/etc/nginx/sites-available/archipelago", "w") as f:
f.write(content)
# Verify
count = content.count("api.anthropic.com")
print(f" Patched {count // 2} Claude API proxy blocks (HTTP + HTTPS)")
PYSCRIPT
scp $SSH_OPTS /tmp/patch-nginx-claude.py "$TARGET_HOST:/tmp/patch-nginx-claude.py"
ssh $SSH_OPTS "$TARGET_HOST" "sudo python3 /tmp/patch-nginx-claude.py '$ANTHROPIC_API_KEY'"
# Test and reload nginx
echo " Testing nginx config..."
ssh $SSH_OPTS "$TARGET_HOST" "sudo nginx -t 2>&1 && sudo systemctl reload nginx && echo ' Nginx reloaded OK'" || {
echo " ERROR: nginx config test failed!"
exit 1
}
# --- Step 3: Fix FileBrowser container ---
echo ""
echo "$(timestamp) 📁 Checking FileBrowser..."
FB_STATUS=$(ssh $SSH_OPTS "$TARGET_HOST" "podman inspect filebrowser 2>/dev/null | grep -oP '\"ReadonlyRootfs\":\s*\K\w+'" 2>/dev/null || echo "not_found")
if [ "$FB_STATUS" = "true" ]; then
echo " FileBrowser has read-only root — recreating..."
ssh $SSH_OPTS "$TARGET_HOST" "
podman stop filebrowser 2>/dev/null
podman rm filebrowser 2>/dev/null
sudo mkdir -p /var/lib/archipelago/filebrowser
podman run -d --name filebrowser --restart=always \
-p 8083:80 \
-v /var/lib/archipelago/filebrowser:/srv \
filebrowser/filebrowser:v2.27.0
" 2>&1 | tail -2
echo " FileBrowser recreated."
elif [ "$FB_STATUS" = "not_found" ]; then
echo " FileBrowser not found — creating..."
ssh $SSH_OPTS "$TARGET_HOST" "
sudo mkdir -p /var/lib/archipelago/filebrowser
podman run -d --name filebrowser --restart=always \
-p 8083:80 \
-v /var/lib/archipelago/filebrowser:/srv \
filebrowser/filebrowser:v2.27.0
" 2>&1 | tail -2
echo " FileBrowser created."
else
echo " FileBrowser OK (ReadonlyRootfs: $FB_STATUS)"
fi
# --- Step 4: Verify ---
echo ""
echo "$(timestamp) ✅ Verification..."
ssh $SSH_OPTS "$TARGET_HOST" "
echo \" AIUI index: \$(ls -la /opt/archipelago/web-ui/aiui/index.html 2>/dev/null | awk '{print \$6,\$7,\$8}')\"
echo \" FileBrowser: \$(podman ps --format '{{.Names}} {{.Status}}' | grep filebrowser)\"
echo \" Nginx: \$(systemctl is-active nginx)\"
echo \" Backend: \$(systemctl is-active archipelago)\"
echo \" Claude API test: \$(curl -s -o /dev/null -w '%{http_code}' -X POST http://localhost/aiui/api/claude/v1/messages -H 'Content-Type: application/json' -H 'Cookie: session=test' -d '{\"model\":\"claude-sonnet-4-20250514\",\"max_tokens\":5,\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}')\"
"
echo ""
echo "$(timestamp) Done! Server configured."
echo " Access: http://$(echo $TARGET_HOST | cut -d@ -f2)"
+280
View File
@@ -0,0 +1,280 @@
#!/bin/bash
#
# Set up HTTPS on Archipelago dev server for PWA installability.
# Browsers require HTTPS (or localhost) to install PWAs.
# Generates a self-signed certificate and configures nginx.
#
# Run on the target server: sudo ./setup-https-dev.sh
# Or via deploy: the deploy script runs this automatically.
#
set -e
SSL_DIR="/etc/archipelago/ssl"
NGINX_CFG="/etc/nginx/sites-available/archipelago"
CERT="$SSL_DIR/archipelago.crt"
KEY="$SSL_DIR/archipelago.key"
# Create SSL directory
mkdir -p "$SSL_DIR"
chmod 755 "$SSL_DIR"
# Generate self-signed cert if missing (valid 365 days)
# SAN includes common dev IPs so cert works when accessing via IP
# Build dynamic SAN with all node IPs (LAN + Tailscale + loopback)
SAN_IPS="DNS:archipelago.local,DNS:localhost,IP:127.0.0.1"
# Add all IPv4 addresses on this machine (LAN, Tailscale, etc.)
for ip in $(hostname -I 2>/dev/null | tr ' ' '\n' | grep -E '^[0-9]+\.' | grep -v '^127\.'); do
SAN_IPS="$SAN_IPS,IP:$ip"
done
# Always include common LAN IPs as fallback
for ip in 192.168.1.228 192.168.1.198 10.0.0.1; do
echo "$SAN_IPS" | grep -q "$ip" || SAN_IPS="$SAN_IPS,IP:$ip"
done
# Regenerate cert if missing OR if current cert doesn't include this node's primary IP
REGEN=false
if [ ! -f "$CERT" ] || [ ! -f "$KEY" ]; then
REGEN=true
else
# Check if cert has this node's primary IP
MY_IP=$(hostname -I 2>/dev/null | awk '{print $1}')
if [ -n "$MY_IP" ] && ! openssl x509 -in "$CERT" -noout -text 2>/dev/null | grep -q "$MY_IP"; then
echo " Certificate missing this node's IP ($MY_IP) — regenerating..."
REGEN=true
fi
fi
if [ "$REGEN" = true ]; then
echo "Generating self-signed certificate for PWA (HTTPS)..."
echo " SAN: $SAN_IPS"
openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
-keyout "$KEY" \
-out "$CERT" \
-subj "/CN=archipelago.local/O=Archipelago/C=US" \
-addext "subjectAltName=$SAN_IPS"
chmod 644 "$CERT"
chmod 600 "$KEY"
echo " Certificate created at $CERT"
fi
# PWA snippet for manifest + service worker headers (required for Android install)
NGINX_SNIPPETS="/etc/nginx/snippets"
PWA_SNIPPET="$NGINX_SNIPPETS/archipelago-pwa.conf"
mkdir -p "$NGINX_SNIPPETS"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
if [ -f "$SCRIPT_DIR/nginx-pwa-snippet.conf" ]; then
cp "$SCRIPT_DIR/nginx-pwa-snippet.conf" "$PWA_SNIPPET"
echo " PWA nginx snippet installed at $PWA_SNIPPET"
fi
# Add PWA snippet include to existing HTTPS block if missing
if grep -q "listen 443 ssl" "$NGINX_CFG" 2>/dev/null && [ -f "$PWA_SNIPPET" ]; then
if ! grep -q "archipelago-pwa" "$NGINX_CFG" 2>/dev/null; then
echo " Adding PWA snippet include to HTTPS block..."
# Insert include after "index index.html;" within the HTTPS server block (listen 443 to next })
sed -i '/listen 443 ssl/,/^}$/{
/index index.html;/a\
include snippets/archipelago-pwa.conf;
}' "$NGINX_CFG" 2>/dev/null || true
fi
fi
# Install app proxies snippet (mempool, fedimint, lnd, etc.) - fixes apps not opening over HTTPS (mixed content)
APPS_SNIPPET="$NGINX_SNIPPETS/archipelago-https-app-proxies.conf"
if [ -f "$SCRIPT_DIR/nginx-https-app-proxies.conf" ]; then
cp "$SCRIPT_DIR/nginx-https-app-proxies.conf" "$APPS_SNIPPET"
echo " HTTPS app proxies snippet installed at $APPS_SNIPPET"
# Add include to HTTPS block if missing
if grep -q "listen 443 ssl" "$NGINX_CFG" 2>/dev/null && ! grep -q "archipelago-https-app-proxies" "$NGINX_CFG" 2>/dev/null; then
echo " Adding app proxies include to HTTPS block..."
sed -i '/listen 443 ssl/,/^}$/{
/location \/ws {/i\
include snippets/archipelago-https-app-proxies.conf;
}' "$NGINX_CFG" 2>/dev/null || true
fi
fi
# Check if HTTPS is already configured
if grep -q "listen 443 ssl" "$NGINX_CFG" 2>/dev/null; then
echo "HTTPS already configured in nginx."
nginx -t 2>/dev/null && systemctl reload nginx
MY_IP=$(hostname -I 2>/dev/null | awk '{print $1}')
echo ""
echo "PWA: Use https://${MY_IP:-192.168.1.228} (not http) - accept cert once, then Install app."
exit 0
fi
# Add HTTPS server block (duplicate of HTTP block with SSL)
# PWA requires HTTPS for install on Android
HTTPS_BLOCK='
# HTTPS - required for PWA install (Add to Home Screen) from dev servers
server {
listen 443 ssl;
server_name _;
ssl_certificate '"$CERT"';
ssl_certificate_key '"$KEY"';
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
root /opt/archipelago/web-ui;
index index.html;
include snippets/archipelago-pwa.conf;
location / {
try_files $uri $uri/ /index.html;
}
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;
}
location /rpc/ {
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;
proxy_connect_timeout 600s;
proxy_send_timeout 600s;
proxy_read_timeout 600s;
}
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;
}
location /app/btcpay/ {
proxy_pass http://127.0.0.1:23000/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_hide_header X-Frame-Options;
proxy_hide_header Content-Security-Policy;
}
location /app/homeassistant/ {
proxy_pass http://127.0.0.1:8123/;
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 86400s;
proxy_send_timeout 86400s;
}
location /app/mempool/ {
proxy_pass http://127.0.0.1:4080/;
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/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/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/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 /ws {
proxy_pass http://127.0.0.1:5678;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_read_timeout 86400s;
}
}
'
# Append HTTPS block to nginx config
echo "$HTTPS_BLOCK" >> "$NGINX_CFG"
echo "Added HTTPS (port 443) to nginx config."
# Test and reload
nginx -t && systemctl reload nginx
echo ""
MY_IP=$(hostname -I 2>/dev/null | awk '{print $1}')
echo "HTTPS enabled. PWA install: https://${MY_IP:-192.168.1.228} (accept the certificate warning once, then Install app)."
+93
View File
@@ -0,0 +1,93 @@
#!/bin/bash
#
# Setup development environment on Archipelago target machine
#
# Run this ON the HP ProDesk via SSH:
# curl -sSL https://raw.githubusercontent.com/.../setup-target-dev.sh | bash
# Or copy and run locally:
# scp scripts/setup-target-dev.sh archipelago@192.168.1.228:~/
# ssh archipelago@192.168.1.228 'bash ~/setup-target-dev.sh'
#
set -e
echo "╔════════════════════════════════════════════════════════════════╗"
echo "║ Setting up Archipelago Development Environment ║"
echo "╚════════════════════════════════════════════════════════════════╝"
echo ""
# Update packages
echo "📦 Updating packages..."
sudo apt update
# Install Node.js (for Vue.js frontend)
echo ""
echo "📦 Installing Node.js..."
if ! command -v node &> /dev/null; then
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
sudo apt install -y nodejs
else
echo " Node.js already installed: $(node --version)"
fi
# Install Rust (for backend)
echo ""
echo "📦 Installing Rust..."
if ! command -v cargo &> /dev/null; then
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
source ~/.cargo/env
else
echo " Rust already installed: $(rustc --version)"
fi
# Install build tools
echo ""
echo "📦 Installing build tools..."
sudo apt install -y \
build-essential \
pkg-config \
libssl-dev \
git
# Create development directory
echo ""
echo "📁 Creating development directory..."
mkdir -p ~/archy
# Fix XDG_RUNTIME_DIR for rootless Podman (add to bashrc)
if ! grep -q "XDG_RUNTIME_DIR" ~/.bashrc; then
echo ""
echo "🔧 Fixing Podman rootless setup..."
cat >> ~/.bashrc << 'EOF'
# Fix for rootless Podman
if [ -z "$XDG_RUNTIME_DIR" ]; then
export XDG_RUNTIME_DIR=/run/user/$(id -u)
if [ ! -d "$XDG_RUNTIME_DIR" ]; then
sudo mkdir -p "$XDG_RUNTIME_DIR"
sudo chown $(whoami):$(whoami) "$XDG_RUNTIME_DIR"
sudo chmod 700 "$XDG_RUNTIME_DIR"
fi
fi
EOF
fi
# Enable user lingering for Podman
sudo loginctl enable-linger archipelago 2>/dev/null || true
echo ""
echo "╔════════════════════════════════════════════════════════════════╗"
echo "║ ✅ Development environment ready! ║"
echo "╚════════════════════════════════════════════════════════════════╝"
echo ""
echo "Installed:"
echo " • Node.js: $(node --version 2>/dev/null || echo 'not found')"
echo " • npm: $(npm --version 2>/dev/null || echo 'not found')"
echo " • Rust: $(rustc --version 2>/dev/null || echo 'not found')"
echo " • Cargo: $(cargo --version 2>/dev/null || echo 'not found')"
echo ""
echo "Next steps:"
echo " 1. Log out and back in (or run: source ~/.bashrc)"
echo " 2. From your Mac, run: ./scripts/deploy-to-target.sh"
echo " 3. To start Vue.js dev server: cd ~/archy/neode-ui && npm run dev -- --host 0.0.0.0"
echo ""
+43
View File
@@ -0,0 +1,43 @@
#!/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:z6MkkidEnEpo6qHMCNSZoNKWtvQvxq3whnaME9wGgEFhq7ur"
# 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")
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
+47
View File
@@ -0,0 +1,47 @@
#!/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.
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
+86
View File
@@ -0,0 +1,86 @@
#!/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:-192.168.1.198}"
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())
+152
View File
@@ -0,0 +1,152 @@
#!/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 "")
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"
systemctl restart tor
log "Tor restarted"
# Wait for SOCKS port
for i in $(seq 1 30); do
if timeout 1 bash -c 'echo > /dev/tcp/127.0.0.1/9050' 2>/dev/null; then
break
fi
sleep 1
done
sync_hostnames
write_result '{"ok":true}'
;;
restart)
systemctl restart tor
log "Tor restarted"
sleep 3
sync_hostnames
write_result '{"ok":true}'
;;
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
+73
View File
@@ -0,0 +1,73 @@
#!/bin/bash
#
# Trust the Archipelago server's self-signed certificate on macOS.
# Run this to eliminate "Not secure" when accessing https://192.168.1.228
#
# Usage: ./scripts/trust-archipelago-cert.sh [host]
# Default host: 192.168.1.228
#
# Requires: SSH access to archipelago@host (uses deploy-config.sh password)
#
set -e
HOST="${1:-192.168.1.228}"
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
+265
View File
@@ -0,0 +1,265 @@
#!/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
}
yaml_eval() {
ruby -ryaml -e '
path, expr = ARGV
data = YAML.load_file(path)
app = data.is_a?(Hash) ? data["app"] : nil
abort "missing top-level app block" unless app.is_a?(Hash)
value = eval(expr)
case value
when Array
puts value.join("\n")
when Hash
puts value.to_a.map { |k, v| "#{k}=#{v}" }.join("\n")
when NilClass
puts ""
else
puts value
end
' "$MANIFEST" "$1"
}
echo "Validating: $MANIFEST"
echo ""
if [[ ! -f "$MANIFEST" ]]; then
echo " FAIL: File not found: $MANIFEST"
exit 1
fi
check "File exists" "pass"
if ! ruby -ryaml -e 'data = YAML.load_file(ARGV[0]); exit(data.is_a?(Hash) && data["app"].is_a?(Hash) ? 0 : 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" "146.59.87.168:3000" "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"] || (app["resources"] || {})["memory"])')"
CPU_LIMIT="$(yaml_eval '((app["resources"] || {})["cpu_limit"] || (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="$(ruby -ryaml -e '
current = ARGV[0]
current_id = File.basename(File.dirname(current))
ports = {}
Dir.glob("apps/*/manifest.yml").sort.each do |path|
data = YAML.load_file(path)
app = data.is_a?(Hash) ? data["app"] : nil
next unless app.is_a?(Hash)
id = app["id"] || File.basename(File.dirname(path))
next if id == current_id
Array(app["ports"]).each do |p|
next unless p.is_a?(Hash)
proto = p["protocol"] || "tcp"
bind = p["bind"] || ""
host = p["host"]
ports[[host, proto, bind]] = id if host
end
end
data = YAML.load_file(current)
app = data["app"]
conflicts = []
Array(app["ports"]).each do |p|
next unless p.is_a?(Hash)
key = [p["host"], p["protocol"] || "tcp", p["bind"] || ""]
conflicts << "#{key[2].empty? ? "*" : key[2]}:#{key[0]}/#{key[1]} already used by #{ports[key]}" if ports.key?(key)
end
puts conflicts.join("\n")
' "$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)"
+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())