Archipelago — open-source initial import
This commit is contained in:
Executable
+214
@@ -0,0 +1,214 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Production app catalog image smoke test.
|
||||
|
||||
Parses local app manifests, then probes images on a target production node via
|
||||
SSH. This catches catalog/image mismatches before a user clicks Install.
|
||||
|
||||
Checks:
|
||||
- manifest YAML loads and required app/container fields exist
|
||||
- production node health endpoint responds
|
||||
- each non-local image can be pulled on the node
|
||||
- shell-entrypoint apps reference commands that exist inside the image
|
||||
|
||||
Usage:
|
||||
scripts/app-catalog-image-smoke-test.py \
|
||||
--target archipelago@192.0.2.11 \
|
||||
--ssh-key /home/archipelago/.ssh/id_ed25519
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
INSECURE_REGISTRIES = ("source.archipelago-foundation.org", "23.182.128.160:3000")
|
||||
|
||||
|
||||
def run(cmd: list[str], timeout: int = 120) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(
|
||||
cmd,
|
||||
text=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
|
||||
class Remote:
|
||||
def __init__(self, target: str, ssh_key: str | None, extra: list[str]) -> None:
|
||||
self.base = [
|
||||
"ssh",
|
||||
"-F",
|
||||
"/dev/null",
|
||||
"-o",
|
||||
"ConnectTimeout=8",
|
||||
"-o",
|
||||
"BatchMode=yes",
|
||||
"-o",
|
||||
"PreferredAuthentications=publickey",
|
||||
"-o",
|
||||
"PasswordAuthentication=no",
|
||||
"-o",
|
||||
"StrictHostKeyChecking=no",
|
||||
]
|
||||
if ssh_key:
|
||||
self.base.extend(["-i", ssh_key])
|
||||
self.base.extend(extra)
|
||||
self.target = target
|
||||
|
||||
def sh(self, script: str, timeout: int = 120) -> subprocess.CompletedProcess[str]:
|
||||
return run(self.base + [self.target, script], timeout=timeout)
|
||||
|
||||
|
||||
def load_manifests(apps_dir: Path) -> list[dict]:
|
||||
manifests = []
|
||||
for path in sorted(apps_dir.glob("*/manifest.yml")):
|
||||
with path.open("r", encoding="utf-8") as fh:
|
||||
data = yaml.safe_load(fh)
|
||||
if not isinstance(data, dict):
|
||||
app = None
|
||||
container = None
|
||||
elif isinstance(data.get("app"), dict):
|
||||
app = data["app"]
|
||||
container = app.get("container")
|
||||
else:
|
||||
app = data
|
||||
container = data.get("container") if isinstance(data.get("container"), dict) else data
|
||||
manifests.append({"path": path, "app": app, "container": container})
|
||||
return manifests
|
||||
|
||||
|
||||
def insecure(image: str) -> bool:
|
||||
return image.startswith(INSECURE_REGISTRIES)
|
||||
|
||||
|
||||
def shell_probe_for(app_id: str, command: str) -> str | None:
|
||||
if app_id in {"bitcoin-core", "bitcoin-knots"}:
|
||||
return "command -v bitcoind || find /opt -path '*/bin/bitcoind' -type f 2>/dev/null | sort | tail -n 1"
|
||||
|
||||
match = re.search(r"\bexec\s+([\"']?)([A-Za-z0-9_./-]+)\1", command)
|
||||
if not match:
|
||||
return None
|
||||
|
||||
binary = match.group(2)
|
||||
if binary.startswith("$"):
|
||||
return None
|
||||
if "/" in binary:
|
||||
return f"test -x {shlex.quote(binary)} && echo {shlex.quote(binary)}"
|
||||
return f"command -v {shlex.quote(binary)}"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--target", required=True)
|
||||
parser.add_argument("--ssh-key", default=os.environ.get("ARCHIPELAGO_SSH_KEY"))
|
||||
parser.add_argument("--apps-dir", default="apps")
|
||||
parser.add_argument("--pull", action="store_true", help="pull missing images before probing")
|
||||
parser.add_argument("--ssh-option", action="append", default=[])
|
||||
args = parser.parse_args()
|
||||
|
||||
apps_dir = Path(args.apps_dir)
|
||||
remote = Remote(args.target, args.ssh_key, sum((["-o", x] for x in args.ssh_option), []))
|
||||
|
||||
failures: list[str] = []
|
||||
warnings: list[str] = []
|
||||
passes = 0
|
||||
|
||||
health = remote.sh("curl -fsS --max-time 5 http://127.0.0.1:5678/health", timeout=15)
|
||||
if health.returncode != 0:
|
||||
failures.append(f"target health failed: {health.stderr.strip() or health.stdout.strip()}")
|
||||
print(json.dumps({"passes": passes, "warnings": 0, "failures": len(failures)}, sort_keys=True))
|
||||
for failure in failures:
|
||||
print(f"FAIL {failure}")
|
||||
return 1
|
||||
else:
|
||||
passes += 1
|
||||
print(f"PASS target health {health.stdout.strip()}")
|
||||
|
||||
manifests = load_manifests(apps_dir)
|
||||
print(f"INFO loaded {len(manifests)} manifests from {apps_dir}")
|
||||
|
||||
for item in manifests:
|
||||
path = item["path"]
|
||||
app = item["app"]
|
||||
container = item["container"]
|
||||
if not isinstance(app, dict) or not isinstance(container, dict):
|
||||
failures.append(f"{path}: missing app.container")
|
||||
continue
|
||||
|
||||
app_id = str(app.get("id") or "")
|
||||
image = str(container.get("image") or app.get("image") or "")
|
||||
if not app_id:
|
||||
failures.append(f"{path}: missing app id")
|
||||
continue
|
||||
if not image and container.get("build"):
|
||||
warnings.append(f"{app_id}: skipped locally built image")
|
||||
continue
|
||||
if not image:
|
||||
failures.append(f"{path}: missing container image")
|
||||
continue
|
||||
passes += 1
|
||||
|
||||
if image.startswith("localhost/") or image.startswith("archipelago/"):
|
||||
warnings.append(f"{app_id}: skipped local/unpublished image {image}")
|
||||
continue
|
||||
|
||||
pull_args = ["pull"]
|
||||
if insecure(image):
|
||||
pull_args.append("--tls-verify=false")
|
||||
pull_args.append(image)
|
||||
|
||||
if args.pull:
|
||||
pull_cmd = "timeout 300s podman " + " ".join(shlex.quote(x) for x in pull_args)
|
||||
pulled = remote.sh(pull_cmd, timeout=330)
|
||||
if pulled.returncode != 0:
|
||||
failures.append(f"{app_id}: pull failed for {image}: {(pulled.stderr or pulled.stdout).strip()[-500:]}")
|
||||
continue
|
||||
print(f"PASS {app_id}: pulled {image}")
|
||||
passes += 1
|
||||
else:
|
||||
exists = remote.sh(f"podman image exists {shlex.quote(image)}", timeout=30)
|
||||
if exists.returncode != 0:
|
||||
warnings.append(f"{app_id}: image not present on target, rerun with --pull: {image}")
|
||||
continue
|
||||
|
||||
custom_args = container.get("custom_args") or []
|
||||
entrypoint = container.get("entrypoint") or []
|
||||
if entrypoint == ["sh", "-lc"] and custom_args:
|
||||
command = str(custom_args[0])
|
||||
probe = shell_probe_for(app_id, command)
|
||||
if probe:
|
||||
remote_script = (
|
||||
"timeout 45s podman run --rm "
|
||||
f"--entrypoint sh {shlex.quote(image)} -c {shlex.quote(probe)}"
|
||||
)
|
||||
checked = remote.sh(remote_script, timeout=60)
|
||||
found = checked.stdout.strip().splitlines()[-1:] or [""]
|
||||
if checked.returncode == 0 and found[0]:
|
||||
print(f"PASS {app_id}: command probe found {found[0]}")
|
||||
passes += 1
|
||||
else:
|
||||
failures.append(
|
||||
f"{app_id}: command probe failed in {image}: {(checked.stderr or checked.stdout).strip()[-500:]}"
|
||||
)
|
||||
|
||||
print(json.dumps({"passes": passes, "warnings": len(warnings), "failures": len(failures)}, sort_keys=True))
|
||||
for warning in warnings:
|
||||
print(f"WARN {warning}")
|
||||
for failure in failures:
|
||||
print(f"FAIL {failure}")
|
||||
return 1 if failures else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Executable
+161
@@ -0,0 +1,161 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# App surface smoke test.
|
||||
#
|
||||
# Verifies that installed containers have their published host ports listening
|
||||
# and that known nginx app proxy paths return a non-5xx response. This catches
|
||||
# the common "container is running but UI disappeared" failure mode.
|
||||
#
|
||||
# Usage:
|
||||
# scripts/app-surface-smoke-test.sh --target archipelago@192.0.2.10 --ssh-key /path/key
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
TARGET=""
|
||||
SSH_KEY="${ARCHIPELAGO_SSH_KEY:-}"
|
||||
SSH_EXTRA=()
|
||||
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
--target) TARGET="${2:-}"; shift 2 ;;
|
||||
--ssh-key) SSH_KEY="${2:-}"; shift 2 ;;
|
||||
--ssh-option) SSH_EXTRA+=("-o" "${2:-}"); shift 2 ;;
|
||||
-h|--help) sed -n '1,12p' "$0"; exit 0 ;;
|
||||
*) echo "unknown argument: $1" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
[ -n "$TARGET" ] || { echo "--target is required" >&2; exit 2; }
|
||||
|
||||
SSH_OPTS=(-F /dev/null -o BatchMode=yes -o PreferredAuthentications=publickey -o PasswordAuthentication=no)
|
||||
[ -n "$SSH_KEY" ] && SSH_OPTS+=(-i "$SSH_KEY")
|
||||
SSH_OPTS+=("${SSH_EXTRA[@]}")
|
||||
|
||||
ssh_run() {
|
||||
ssh "${SSH_OPTS[@]}" "$TARGET" "$@"
|
||||
}
|
||||
|
||||
ssh_run 'bash -s' <<'REMOTE'
|
||||
set -u
|
||||
|
||||
pass=0
|
||||
fail=0
|
||||
|
||||
ok() { echo " PASS $*"; pass=$((pass + 1)); }
|
||||
bad() { echo " FAIL $*"; fail=$((fail + 1)); }
|
||||
|
||||
container_exists() {
|
||||
podman ps -a --format '{{.Names}}' 2>/dev/null | grep -qx "$1"
|
||||
}
|
||||
|
||||
port_listening() {
|
||||
ss -ltn 2>/dev/null | awk '{print $4}' | grep -Eq "(^|:)$1$"
|
||||
}
|
||||
|
||||
http_code() {
|
||||
local url="$1" code
|
||||
for _ in 1 2 3; do
|
||||
code=$(curl -ksS -o /dev/null -w '%{http_code}' --max-time 12 "$url" 2>/dev/null || true)
|
||||
[ -n "$code" ] || code=000
|
||||
[ "$code" != "000" ] && { echo "$code"; return; }
|
||||
sleep 2
|
||||
done
|
||||
echo "$code"
|
||||
}
|
||||
|
||||
http_post_code() {
|
||||
local url="$1" code
|
||||
for _ in 1 2 3; do
|
||||
code=$(curl -ksS -o /dev/null -w '%{http_code}' --max-time 25 \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"jsonrpc":"2.0","id":1,"method":"getblockchaininfo","params":[]}' \
|
||||
"$url" 2>/dev/null || true)
|
||||
[ -n "$code" ] || code=000
|
||||
[ "$code" != "000" ] && { echo "$code"; return; }
|
||||
sleep 2
|
||||
done
|
||||
echo "$code"
|
||||
}
|
||||
|
||||
assert_http() {
|
||||
local label="$1" url="$2" code
|
||||
code=$(http_code "$url")
|
||||
case "$code" in
|
||||
200|204|301|302|307|308|401|403) ok "$label HTTP $code" ;;
|
||||
*) bad "$label HTTP $code ($url)" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
assert_http_post() {
|
||||
local label="$1" url="$2" code
|
||||
code=$(http_post_code "$url")
|
||||
case "$code" in
|
||||
200|204|401|403) ok "$label HTTP POST $code" ;;
|
||||
*) bad "$label HTTP POST $code ($url)" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
assert_container_ports() {
|
||||
local name="$1" ports port missing=0
|
||||
container_exists "$name" || return 0
|
||||
ports=$(podman inspect "$name" --format '{{range $p,$bindings := .NetworkSettings.Ports}}{{if $bindings}}{{range $bindings}}{{.HostPort}}{{"\n"}}{{end}}{{end}}{{end}}' 2>/dev/null | sort -u)
|
||||
[ -n "$ports" ] || return 0
|
||||
while IFS= read -r port; do
|
||||
[ -n "$port" ] || continue
|
||||
if port_listening "$port"; then
|
||||
ok "$name port $port listening"
|
||||
else
|
||||
bad "$name port $port missing listener"
|
||||
missing=1
|
||||
fi
|
||||
done <<< "$ports"
|
||||
return "$missing"
|
||||
}
|
||||
|
||||
assert_env_contains() {
|
||||
local name="$1" key="$2" needle="$3" val
|
||||
container_exists "$name" || return 0
|
||||
val=$(podman inspect "$name" --format '{{range .Config.Env}}{{println .}}{{end}}' 2>/dev/null | sed -n "s/^${key}=//p" | head -n 1)
|
||||
if [ -n "$val" ] && printf '%s' "$val" | grep -qF "$needle"; then
|
||||
ok "$name env $key"
|
||||
else
|
||||
bad "$name env $key missing $needle"
|
||||
fi
|
||||
}
|
||||
|
||||
echo "[surface] host=$(hostname) ip=$(hostname -I 2>/dev/null | awk '{print $1}')"
|
||||
|
||||
for c in $(podman ps -a --format '{{.Names}}' 2>/dev/null | sort); do
|
||||
assert_container_ports "$c" || true
|
||||
done
|
||||
|
||||
container_exists archy-bitcoin-ui && {
|
||||
assert_http "bitcoin-ui" "http://127.0.0.1/app/bitcoin-ui/"
|
||||
assert_http "bitcoin status" "http://127.0.0.1/app/bitcoin-ui/bitcoin-status"
|
||||
assert_http_post "bitcoin rpc proxy" "http://127.0.0.1/app/bitcoin-ui/bitcoin-rpc/"
|
||||
}
|
||||
|
||||
container_exists archy-electrs-ui && {
|
||||
assert_http "electrumx ui" "http://127.0.0.1/app/electrumx/"
|
||||
assert_http "electrumx status" "http://127.0.0.1/app/electrumx/electrs-status"
|
||||
assert_http "electrs legacy status" "http://127.0.0.1/app/electrs/electrs-status"
|
||||
}
|
||||
|
||||
container_exists mempool && assert_http "mempool ui" "http://127.0.0.1/app/mempool/"
|
||||
container_exists indeedhub && assert_http "indeedhub ui" "http://127.0.0.1:7778/"
|
||||
container_exists uptime-kuma && assert_http "uptime-kuma" "http://127.0.0.1/app/uptime-kuma/"
|
||||
container_exists filebrowser && assert_http "filebrowser" "http://127.0.0.1/app/filebrowser/"
|
||||
container_exists searxng && assert_http "searxng" "http://127.0.0.1/app/searxng/"
|
||||
container_exists grafana && assert_http "grafana" "http://127.0.0.1/app/grafana/"
|
||||
container_exists portainer && assert_http "portainer" "http://127.0.0.1/app/portainer/"
|
||||
container_exists vaultwarden && assert_http "vaultwarden" "http://127.0.0.1/app/vaultwarden/"
|
||||
container_exists nextcloud && assert_http "nextcloud" "http://127.0.0.1/app/nextcloud/"
|
||||
container_exists archy-nbxplorer && assert_env_contains "archy-nbxplorer" "NBXPLORER_POSTGRES" "Database=nbxplorer"
|
||||
container_exists btcpay-server && {
|
||||
assert_env_contains "btcpay-server" "BTCPAY_POSTGRES" "Database=btcpay"
|
||||
assert_http "btcpay" "http://127.0.0.1/app/btcpay/"
|
||||
}
|
||||
|
||||
echo "[surface] summary: pass=$pass fail=$fail"
|
||||
[ "$fail" -eq 0 ]
|
||||
REMOTE
|
||||
@@ -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
|
||||
Executable
+58
@@ -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
|
||||
Executable
+65
@@ -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
|
||||
Executable
+142
@@ -0,0 +1,142 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
# SEC-202: Secrets audit — checks for hardcoded credentials in the codebase.
|
||||
# Scans source files for common secret patterns.
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
PASS=0
|
||||
FAIL=0
|
||||
RESULTS=()
|
||||
|
||||
log() { echo -e "\033[1;34m[AUDIT]\033[0m $*"; }
|
||||
pass() { echo -e "\033[1;32m[PASS]\033[0m $*"; PASS=$((PASS + 1)); RESULTS+=("PASS: $*"); }
|
||||
fail() { echo -e "\033[1;31m[FAIL]\033[0m $*"; FAIL=$((FAIL + 1)); RESULTS+=("FAIL: $*"); }
|
||||
|
||||
# Patterns to search for (case insensitive)
|
||||
PATTERNS=(
|
||||
"password\s*=\s*['\"][^'\"]*['\"]"
|
||||
"api_key\s*=\s*['\"][^'\"]*['\"]"
|
||||
"secret\s*=\s*['\"][^'\"]*['\"]"
|
||||
"private_key\s*=\s*['\"][^'\"]*['\"]"
|
||||
"sk-ant-[A-Za-z0-9_-]{20,}"
|
||||
"AKIA[A-Z0-9]{16}"
|
||||
"ghp_[a-zA-Z0-9]{36}"
|
||||
"glpat-[a-zA-Z0-9_-]{20}"
|
||||
# Credentialed URLs: scheme://user:pass@host
|
||||
"://[A-Za-z0-9_.-]+:[A-Za-z0-9_.@!%-]{8,}@"
|
||||
# sshpass with an inline literal
|
||||
"sshpass\s+-p\s*['\"][^'\"]+['\"]"
|
||||
)
|
||||
|
||||
# Path allowlist — anchored to the PATH only, never to line content.
|
||||
# The old version allow-matched the whole "file:line:content" string against
|
||||
# bare words like "test" and "\.md$", so any hit whose path or text contained
|
||||
# "test"/"demo"/"example" was silently dropped, and *.md was never scanned at
|
||||
# all. That is why live API keys and node passwords survived this audit.
|
||||
ALLOW_PATHS="(^|/)node_modules/|(^|/)(dist|target|\.git)/|\.example($|\.)|(^|/)package-lock\.json$|(^|/)Cargo\.lock$|(^|/)scripts/audit-secrets\.sh$"
|
||||
|
||||
# File types to scan. Markdown and YAML are in scope: docs and CI workflows are
|
||||
# where the real leaks have historically lived.
|
||||
SCAN_EXTS='\.(rs|ts|vue|js|mjs|cjs|json|sh|py|md|ya?ml|toml|kt|java|gradle|env)$'
|
||||
|
||||
main() {
|
||||
log "=== Secrets Audit ==="
|
||||
echo ""
|
||||
|
||||
# 1. Check for .env files in version control
|
||||
log "1. Checking for .env files in git..."
|
||||
local env_files
|
||||
env_files=$(cd "$REPO_ROOT" && git ls-files | grep -E '(^|/)\.env($|[.])|(^|/)[^/]*\.env($|[.])' | grep -vE '(^|/)\.env\.example$|(^|/)[^/]*\.env\.example$' || echo "")
|
||||
if [ -z "$env_files" ]; then
|
||||
pass "No .env files tracked in git"
|
||||
else
|
||||
fail "Found .env files in git: $env_files"
|
||||
fi
|
||||
|
||||
# 2. Check .gitignore includes sensitive patterns
|
||||
log "2. Checking .gitignore coverage..."
|
||||
local gitignore="$REPO_ROOT/.gitignore"
|
||||
if [ -f "$gitignore" ]; then
|
||||
local has_env has_key
|
||||
has_env=$(grep -c '\.env' "$gitignore" || echo 0)
|
||||
has_key=$(grep -c 'credentials\|\.key\|\.pem' "$gitignore" || echo 0)
|
||||
if [ "$has_env" -gt 0 ]; then
|
||||
pass ".gitignore covers .env files"
|
||||
else
|
||||
fail ".gitignore missing .env pattern"
|
||||
fi
|
||||
else
|
||||
fail "No .gitignore found"
|
||||
fi
|
||||
|
||||
# 3. Scan source for hardcoded credentials
|
||||
log "3. Scanning source for hardcoded secrets..."
|
||||
local found_secrets=0
|
||||
# Scan TRACKED files only — that is exactly the set that would be published.
|
||||
local scan_files
|
||||
scan_files=$(cd "$REPO_ROOT" && git ls-files | grep -E "$SCAN_EXTS" | grep -vE "$ALLOW_PATHS" || echo "")
|
||||
if [ -z "$scan_files" ]; then
|
||||
fail "No tracked files matched the scan set (is this a git repo?)"
|
||||
return 1
|
||||
fi
|
||||
for pattern in "${PATTERNS[@]}"; do
|
||||
local matches
|
||||
matches=$(cd "$REPO_ROOT" && echo "$scan_files" | tr '\n' '\0' \
|
||||
| xargs -0 grep -niE "$pattern" 2>/dev/null || echo "")
|
||||
if [ -n "$matches" ]; then
|
||||
# Filter out false positives: empty strings, variable indirection, and
|
||||
# scrubbed <PLACEHOLDER> tokens. NOTE: the previous version wrote the
|
||||
# single-quote class as \x27\x27, which GNU grep does not expand in an
|
||||
# ERE — so the empty-string rule silently never matched. Use a literal
|
||||
# quote via a shell variable instead.
|
||||
local q="'"
|
||||
local real_matches
|
||||
real_matches=$(echo "$matches" | grep -vE "\"\"|${q}${q}|<[A-Z_]+>|None|null|undefined|TODO|placeholder|Option<|\\\$\{[A-Za-z0-9_]+(:-[^}]*)?\}|\\\$[A-Za-z0-9_]+|TestPassword|password123|entertoexit|…|\\.\\.\\." || echo "")
|
||||
if [ -n "$real_matches" ]; then
|
||||
echo " WARNING: Pattern '$pattern' found:"
|
||||
echo "$real_matches" | head -5 | sed 's/^/ /'
|
||||
found_secrets=$((found_secrets + 1))
|
||||
fi
|
||||
fi
|
||||
done
|
||||
if [ "$found_secrets" -eq 0 ]; then
|
||||
pass "No hardcoded secrets found in source"
|
||||
else
|
||||
fail "Found $found_secrets secret pattern matches (review above)"
|
||||
fi
|
||||
|
||||
# 4. Check deploy-config is gitignored
|
||||
log "4. Checking deploy-config.sh is gitignored..."
|
||||
if cd "$REPO_ROOT" && git check-ignore scripts/deploy-config.sh > /dev/null 2>&1; then
|
||||
pass "scripts/deploy-config.sh is gitignored"
|
||||
elif [ -f "$REPO_ROOT/scripts/deploy-config.sh" ]; then
|
||||
fail "scripts/deploy-config.sh exists but is NOT gitignored"
|
||||
else
|
||||
pass "scripts/deploy-config.sh does not exist (using env vars)"
|
||||
fi
|
||||
|
||||
# 5. Check for credential files in repo
|
||||
log "5. Checking for credential files..."
|
||||
local cred_files
|
||||
# `testdata/` holds throwaway keypairs generated for unit tests (appgate TLS);
|
||||
# they are not credentials for anything real. Narrow, path-anchored exemption.
|
||||
cred_files=$(cd "$REPO_ROOT" && git ls-files | grep -Ei '(\.pem$|\.key$|\.p12$|\.pfx$|\.jks$|\.keystore$|id_rsa|id_ed25519|macaroon)' | grep -vE '\.(rs|ts|sh)$|(^|/)testdata/' || echo "")
|
||||
if [ -z "$cred_files" ]; then
|
||||
pass "No credential files tracked in git"
|
||||
else
|
||||
fail "Credential files in git: $cred_files"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
log "=== RESULTS ==="
|
||||
for r in "${RESULTS[@]}"; do
|
||||
echo " $r"
|
||||
done
|
||||
echo ""
|
||||
log "Pass: $PASS | Fail: $FAIL"
|
||||
|
||||
[ $FAIL -gt 0 ] && exit 1
|
||||
exit 0
|
||||
}
|
||||
|
||||
main "$@"
|
||||
Executable
+249
@@ -0,0 +1,249 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Bitcoin stack lifecycle test.
|
||||
#
|
||||
# Exercises the production Bitcoin stack under repeated stop/start and
|
||||
# remove/recreate cycles while asserting the actual user-facing surfaces:
|
||||
# Bitcoin RPC, bitcoin-ui /bitcoin-rpc, ElectrumX status, and electrs-ui.
|
||||
#
|
||||
# This intentionally removes containers but not data volumes. It is safe for
|
||||
# installed nodes, but it will briefly interrupt Bitcoin/ElectrumX service.
|
||||
#
|
||||
# Usage:
|
||||
# scripts/bitcoin-stack-lifecycle-test.sh --target archipelago@192.0.2.10
|
||||
# scripts/bitcoin-stack-lifecycle-test.sh --target archipelago@192.0.2.12 --cycles 5
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
TARGET=""
|
||||
SSH_KEY="${ARCHIPELAGO_SSH_KEY:-}"
|
||||
CYCLES=3
|
||||
SSH_EXTRA=()
|
||||
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
--target)
|
||||
TARGET="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--ssh-key)
|
||||
SSH_KEY="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--cycles)
|
||||
CYCLES="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--ssh-option)
|
||||
SSH_EXTRA+=("-o" "${2:-}")
|
||||
shift 2
|
||||
;;
|
||||
-h|--help)
|
||||
sed -n '1,22p' "$0"
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "unknown argument: $1" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ -z "$TARGET" ]; then
|
||||
echo "--target is required, for example archipelago@192.0.2.10" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
SSH=(ssh -F /dev/null -o BatchMode=yes -o PreferredAuthentications=publickey -o PasswordAuthentication=no -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null)
|
||||
if [ -n "$SSH_KEY" ]; then
|
||||
SSH+=("-i" "$SSH_KEY")
|
||||
fi
|
||||
SSH+=("${SSH_EXTRA[@]}")
|
||||
|
||||
"${SSH[@]}" "$TARGET" "CYCLES='$CYCLES' bash -s" <<'REMOTE'
|
||||
set -euo pipefail
|
||||
|
||||
PODMAN="${PODMAN:-podman}"
|
||||
SCRIPTS_DIR="/opt/archipelago/scripts"
|
||||
if [ ! -x "$SCRIPTS_DIR/reconcile-containers.sh" ]; then
|
||||
SCRIPTS_DIR="$HOME/archy/scripts"
|
||||
fi
|
||||
RECONCILE="$SCRIPTS_DIR/reconcile-containers.sh"
|
||||
|
||||
pass_count=0
|
||||
fail_count=0
|
||||
|
||||
log() { printf '[%s] %s\n' "$(date +%H:%M:%S)" "$*"; }
|
||||
pass() { pass_count=$((pass_count + 1)); printf ' PASS %s\n' "$*"; }
|
||||
fail() { fail_count=$((fail_count + 1)); printf ' FAIL %s\n' "$*" >&2; }
|
||||
|
||||
retry() {
|
||||
local timeout="$1" label="$2"
|
||||
shift 2
|
||||
local end=$((SECONDS + timeout))
|
||||
local out rc
|
||||
while [ "$SECONDS" -lt "$end" ]; do
|
||||
set +e
|
||||
out=$("$@" 2>&1)
|
||||
rc=$?
|
||||
set -e
|
||||
if [ "$rc" -eq 0 ]; then
|
||||
pass "$label"
|
||||
return 0
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
fail "$label: $out"
|
||||
return 1
|
||||
}
|
||||
|
||||
rpc_pass() {
|
||||
cat /var/lib/archipelago/secrets/bitcoin-rpc-password
|
||||
}
|
||||
|
||||
json_rpc_reachable_or_warming() {
|
||||
local url="$1" auth_arg=() body rc
|
||||
if [ "${2:-}" = "auth" ]; then
|
||||
auth_arg=(--user "archipelago:$(rpc_pass)")
|
||||
fi
|
||||
set +e
|
||||
body=$(curl --connect-timeout 3 --max-time 20 -sS "${auth_arg[@]}" \
|
||||
-H "Content-Type: application/json" \
|
||||
--data-binary '{"jsonrpc":"1.0","id":"lifecycle-test","method":"getblockchaininfo","params":[]}' \
|
||||
"$url" 2>&1)
|
||||
rc=$?
|
||||
set -e
|
||||
[ "$rc" -eq 0 ] || {
|
||||
echo "$body"
|
||||
return 1
|
||||
}
|
||||
echo "$body" | grep -q '"result"' && return 0
|
||||
echo "$body" | grep -q '"code":-28' && return 0
|
||||
echo "$body"
|
||||
return 1
|
||||
}
|
||||
|
||||
bitcoin_status_usable() {
|
||||
local url="$1"
|
||||
local body
|
||||
body=$(curl --connect-timeout 3 --max-time 20 -fsS "$url")
|
||||
echo "$body" | grep -q '"ok":\(true\|false\)' || {
|
||||
echo "$body"
|
||||
return 1
|
||||
}
|
||||
echo "$body" | grep -q '"blockchain_info"' || echo "$body" | grep -q '"error"'
|
||||
}
|
||||
|
||||
http_ok() {
|
||||
local url="$1"
|
||||
curl --connect-timeout 3 --max-time 20 -fsS -o /dev/null "$url"
|
||||
}
|
||||
|
||||
electrs_status_ok() {
|
||||
local url="${1:-http://127.0.0.1:50002/electrs-status}"
|
||||
local body
|
||||
body=$(curl --connect-timeout 3 --max-time 20 -fsS "$url")
|
||||
echo "$body" | grep -q '"network_height":[1-9]' || {
|
||||
echo "$body"
|
||||
return 1
|
||||
}
|
||||
echo "$body" | grep -q '"status":"\(indexing\|syncing\|synced\|waiting\)"'
|
||||
}
|
||||
|
||||
container_running() {
|
||||
local name="$1"
|
||||
[ "$($PODMAN inspect "$name" --format '{{.State.Status}}' 2>/dev/null || true)" = "running" ]
|
||||
}
|
||||
|
||||
container_healthy_or_starting() {
|
||||
local name="$1"
|
||||
local health
|
||||
health=$($PODMAN inspect "$name" --format '{{if .State.Health}}{{.State.Health.Status}}{{end}}' 2>/dev/null || true)
|
||||
[ "$health" = "healthy" ] || [ "$health" = "starting" ] || [ -z "$health" ]
|
||||
}
|
||||
|
||||
assert_bitcoin_stack() {
|
||||
retry 90 "bitcoin-knots running" container_running bitcoin-knots
|
||||
retry 90 "bitcoin-knots healthy/starting" container_healthy_or_starting bitcoin-knots
|
||||
retry 90 "host Bitcoin RPC reachable/ready" json_rpc_reachable_or_warming http://127.0.0.1:8332/ auth
|
||||
retry 90 "backend Bitcoin status bridge usable" bitcoin_status_usable http://127.0.0.1:5678/bitcoin-status
|
||||
retry 90 "bitcoin-ui page" http_ok http://127.0.0.1:8334/
|
||||
retry 90 "bitcoin-ui status bridge usable" bitcoin_status_usable http://127.0.0.1:8334/bitcoin-status
|
||||
retry 90 "bitcoin-ui app-session status bridge usable" bitcoin_status_usable http://127.0.0.1/app/bitcoin-ui/bitcoin-status
|
||||
retry 90 "bitcoin-ui RPC proxy reachable/ready" json_rpc_reachable_or_warming http://127.0.0.1:8334/bitcoin-rpc/
|
||||
retry 90 "bitcoin-ui app-session RPC proxy reachable/ready" json_rpc_reachable_or_warming http://127.0.0.1/app/bitcoin-ui/bitcoin-rpc/
|
||||
}
|
||||
|
||||
assert_electrum_stack() {
|
||||
retry 120 "electrumx running" container_running electrumx
|
||||
retry 120 "electrumx healthy/starting" container_healthy_or_starting electrumx
|
||||
retry 90 "electrs-ui page" http_ok http://127.0.0.1:50002/
|
||||
retry 120 "electrs status has network height" electrs_status_ok
|
||||
retry 120 "electrs app-session status has network height" electrs_status_ok http://127.0.0.1/app/electrumx/electrs-status
|
||||
retry 120 "electrs legacy app-session status has network height" electrs_status_ok http://127.0.0.1/app/electrs/electrs-status
|
||||
}
|
||||
|
||||
reconcile_one() {
|
||||
local name="$1"
|
||||
"$RECONCILE" --container="$name" --force --force-recreate --create-missing
|
||||
}
|
||||
|
||||
restart_container() {
|
||||
local name="$1"
|
||||
log "restart $name"
|
||||
$PODMAN restart "$name" >/dev/null || {
|
||||
log "podman restart failed for $name; using stop/start"
|
||||
$PODMAN stop "$name" >/dev/null 2>&1 || true
|
||||
sleep 3
|
||||
$PODMAN start "$name" >/dev/null
|
||||
}
|
||||
}
|
||||
|
||||
remove_and_reconcile() {
|
||||
local name="$1"
|
||||
log "remove/recreate $name"
|
||||
$PODMAN rm -f "$name" >/dev/null 2>&1 || true
|
||||
reconcile_one "$name"
|
||||
}
|
||||
|
||||
log "target $(hostname) cycles=$CYCLES"
|
||||
log "using reconciler: $RECONCILE"
|
||||
|
||||
assert_bitcoin_stack
|
||||
assert_electrum_stack
|
||||
|
||||
for i in $(seq 1 "$CYCLES"); do
|
||||
log "cycle $i/$CYCLES: bitcoin restart"
|
||||
restart_container bitcoin-knots
|
||||
assert_bitcoin_stack
|
||||
assert_electrum_stack
|
||||
|
||||
log "cycle $i/$CYCLES: bitcoin remove/reconcile"
|
||||
remove_and_reconcile bitcoin-knots
|
||||
assert_bitcoin_stack
|
||||
assert_electrum_stack
|
||||
|
||||
log "cycle $i/$CYCLES: bitcoin UI remove/reconcile"
|
||||
remove_and_reconcile archy-bitcoin-ui
|
||||
assert_bitcoin_stack
|
||||
|
||||
log "cycle $i/$CYCLES: electrumx restart"
|
||||
restart_container electrumx
|
||||
assert_electrum_stack
|
||||
|
||||
log "cycle $i/$CYCLES: electrumx remove/reconcile"
|
||||
remove_and_reconcile electrumx
|
||||
assert_electrum_stack
|
||||
|
||||
log "cycle $i/$CYCLES: electrs UI remove/reconcile"
|
||||
remove_and_reconcile archy-electrs-ui
|
||||
assert_electrum_stack
|
||||
done
|
||||
|
||||
log "final container state"
|
||||
$PODMAN ps -a --format 'table {{.Names}}\t{{.State}}\t{{.Status}}' \
|
||||
| grep -E 'bitcoin-knots|electrumx|archy-bitcoin-ui|archy-electrs-ui' || true
|
||||
|
||||
log "summary: pass=$pass_count fail=$fail_count"
|
||||
[ "$fail_count" -eq 0 ]
|
||||
REMOTE
|
||||
Executable
+113
@@ -0,0 +1,113 @@
|
||||
#!/bin/bash
|
||||
# bootstrap-switchover.sh — Switches Bitcoin-dependent services from bootstrap node to local
|
||||
# Runs periodically via systemd timer. Once local Bitcoin finishes IBD, recreates
|
||||
# ElectrumX/Mempool/LND/BTCPay/Fedimint containers pointing at the local node.
|
||||
set -euo pipefail
|
||||
|
||||
BOOTSTRAP_FLAG="/var/lib/archipelago/.bootstrap-active"
|
||||
LOG="/var/log/archipelago-bootstrap-switchover.log"
|
||||
SECRETS_DIR="/var/lib/archipelago/secrets"
|
||||
DOCKER=podman
|
||||
command -v podman >/dev/null 2>&1 || DOCKER=docker
|
||||
|
||||
log() { echo "$(date '+%Y-%m-%d %H:%M:%S') $*" | tee -a "$LOG"; }
|
||||
|
||||
# Only run if bootstrap mode is active
|
||||
if [ ! -f "$BOOTSTRAP_FLAG" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Check if local Bitcoin is past IBD
|
||||
RPC_PASS=$(cat "$SECRETS_DIR/bitcoin-rpc-password" 2>/dev/null)
|
||||
if [ -z "$RPC_PASS" ]; then
|
||||
log "No local Bitcoin RPC password — skipping"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
IBD_STATUS=$($DOCKER exec bitcoin-knots bitcoin-cli -datadir=/home/bitcoin/.bitcoin getblockchaininfo 2>/dev/null | python3 -c "
|
||||
import sys, json
|
||||
try:
|
||||
d = json.load(sys.stdin)
|
||||
print(f\"{d.get('initialblockdownload', True)}|{d.get('blocks', 0)}|{d.get('headers', 0)}\")
|
||||
except:
|
||||
print('True|0|0')
|
||||
" 2>/dev/null) || IBD_STATUS="True|0|0"
|
||||
|
||||
IBD=$(echo "$IBD_STATUS" | cut -d'|' -f1)
|
||||
BLOCKS=$(echo "$IBD_STATUS" | cut -d'|' -f2)
|
||||
HEADERS=$(echo "$IBD_STATUS" | cut -d'|' -f3)
|
||||
|
||||
if [ "$IBD" != "False" ]; then
|
||||
log "Local Bitcoin still in IBD (blocks=$BLOCKS headers=$HEADERS) — keeping bootstrap"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
log "=== Local Bitcoin synced (blocks=$BLOCKS) — switching from bootstrap to local node ==="
|
||||
|
||||
# Source image versions
|
||||
for img_src in /opt/archipelago/scripts/image-versions.sh /home/archipelago/archy/scripts/image-versions.sh; do
|
||||
[ -f "$img_src" ] && . "$img_src" && break
|
||||
done
|
||||
|
||||
RPC_USER="archipelago"
|
||||
|
||||
# Helper: recreate a container with local Bitcoin config
|
||||
recreate_container() {
|
||||
local name="$1"
|
||||
shift
|
||||
log "Recreating $name..."
|
||||
$DOCKER stop "$name" 2>/dev/null || true
|
||||
$DOCKER rm -f "$name" 2>/dev/null || true
|
||||
if $DOCKER run -d "$@" 2>>"$LOG"; then
|
||||
log " $name switched to local Bitcoin"
|
||||
else
|
||||
log " WARNING: Failed to recreate $name"
|
||||
fi
|
||||
}
|
||||
|
||||
# ElectrumX — key service for wallet connections
|
||||
if $DOCKER ps -a --format '{{.Names}}' 2>/dev/null | grep -q '^electrumx$'; then
|
||||
recreate_container electrumx \
|
||||
--name electrumx --restart unless-stopped \
|
||||
--health-cmd="python3 -c 'import socket; socket.create_connection((\"localhost\",8000),2).close()' || exit 1" \
|
||||
--health-interval=120s --health-timeout=5s --health-retries=3 \
|
||||
--memory=1g --network archy-net --network-alias electrumx \
|
||||
--cap-drop ALL --cap-add CHOWN --cap-add SETUID --cap-add SETGID \
|
||||
--security-opt no-new-privileges:true \
|
||||
-p 50001:50001 -v /var/lib/archipelago/electrumx:/data \
|
||||
-e "DAEMON_URL=http://${RPC_USER}:${RPC_PASS}@bitcoin-knots:8332/" \
|
||||
-e COIN=Bitcoin -e DB_DIRECTORY=/data \
|
||||
-e "SERVICES=tcp://:50001,rpc://0.0.0.0:8000" \
|
||||
"${ELECTRUMX_IMAGE:-source.archipelago-foundation.org/lfg2025/electrumx:v1.18.0}"
|
||||
fi
|
||||
|
||||
# Mempool API
|
||||
if $DOCKER ps -a --format '{{.Names}}' 2>/dev/null | grep -q '^mempool-api$'; then
|
||||
recreate_container mempool-api \
|
||||
--name mempool-api --restart unless-stopped \
|
||||
--health-cmd="curl -sf http://localhost:8999/api/v1/backend-info || exit 1" \
|
||||
--health-interval=120s --health-timeout=5s --health-retries=3 \
|
||||
--memory=512m --network archy-net --network-alias mempool-api \
|
||||
--cap-drop ALL --cap-add CHOWN --cap-add SETUID --cap-add SETGID \
|
||||
--security-opt no-new-privileges:true \
|
||||
-v /var/lib/archipelago/mempool-data:/backend/cache \
|
||||
-e "MEMPOOL_BACKEND=electrum" \
|
||||
-e "CORE_RPC_HOST=bitcoin-knots" -e "CORE_RPC_PORT=8332" \
|
||||
-e "CORE_RPC_USERNAME=${RPC_USER}" -e "CORE_RPC_PASSWORD=${RPC_PASS}" \
|
||||
-e "ELECTRUM_HOST=electrumx" -e "ELECTRUM_PORT=50001" -e "ELECTRUM_TLS_ENABLED=false" \
|
||||
-e "DATABASE_ENABLED=true" -e "DATABASE_HOST=archy-mempool-db" \
|
||||
-e "DATABASE_DATABASE=mempool" -e "DATABASE_USERNAME=mempool" \
|
||||
-e "DATABASE_PASSWORD=$(cat "$SECRETS_DIR/mempool-db-password" 2>/dev/null || echo mempoolpass)" \
|
||||
"${MEMPOOL_API_IMAGE:-source.archipelago-foundation.org/lfg2025/mempool-api:v3.2.0}"
|
||||
fi
|
||||
|
||||
# Stop Tor tunnel if it was active
|
||||
if systemctl is-active archipelago-bootstrap-tunnel.service >/dev/null 2>&1; then
|
||||
log "Stopping bootstrap Tor tunnel..."
|
||||
systemctl stop archipelago-bootstrap-tunnel.service 2>/dev/null || true
|
||||
systemctl disable archipelago-bootstrap-tunnel.service 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Done — remove bootstrap flag
|
||||
rm -f "$BOOTSTRAP_FLAG"
|
||||
log "=== Bootstrap switchover complete — all services now using local Bitcoin node ==="
|
||||
Executable
+181
@@ -0,0 +1,181 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# build-aiui.sh — the ONE supported way to build AIUI for a node.
|
||||
#
|
||||
# D-19 (2026-08-03): AIUI is no longer a second repository at
|
||||
# git.tx1138.com/lfg2025/AIUI. It was migrated in-repo to aiui/ via
|
||||
# `git subtree`, full history intact. There is no second checkout to clone,
|
||||
# no separate per-repo pin file to read or write, and no dirty-second-tree
|
||||
# refusal to perform — this repo's own ordinary commit discipline is what
|
||||
# keeps its history honest now, not a second-repo-specific check. D-15's
|
||||
# *delivery* half still stands and is what this script enforces:
|
||||
#
|
||||
# - VITE_BASE_PATH must be exactly /aiui/. A wrong value produces a BLACK
|
||||
# PAGE when embedded — the router base breaks, not the assets (this has
|
||||
# burned this project before). Enforced here, not remembered by whoever
|
||||
# runs the build.
|
||||
# - A fresh checkout of this repo has no aiui/node_modules (unlike the old
|
||||
# world, where a developer's separate AIUI clone was assumed already
|
||||
# `pnpm install`ed) — this script installs from aiui/pnpm-lock.yaml
|
||||
# with --frozen-lockfile before building, and treats a lockfile/
|
||||
# package.json mismatch as a hard failure, not something to silently
|
||||
# resolve.
|
||||
# - The build runs AIUI's own real command (vue-tsc --noEmit && vite
|
||||
# build via `pnpm build`), so a type error fails the build loudly
|
||||
# instead of shipping a stale dist.
|
||||
# - Before anything is copied anywhere, the emitted dist is verified:
|
||||
# every local asset href carries the AIUI mount path, and this repo's
|
||||
# own current commit (there is no second repo's SHA to pin — D-19 made
|
||||
# them the same thing) is discoverable in the output, so a deployed
|
||||
# node is attributable to a commit of THIS repo.
|
||||
#
|
||||
# Usage:
|
||||
# bash scripts/build-aiui.sh
|
||||
#
|
||||
# On success, aiui/packages/app/dist/ is a fresh, verified AIUI build ready
|
||||
# to be rsynced/tar'd to a node by scripts/deploy-to-target.sh or
|
||||
# scripts/setup-aiui-server.sh.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
|
||||
AIUI_ROOT="$PROJECT_DIR/aiui"
|
||||
AIUI_APP_DIR="$AIUI_ROOT/packages/app"
|
||||
AIUI_DIST="$AIUI_APP_DIR/dist"
|
||||
AIUI_MOUNT_PATH="/aiui/"
|
||||
|
||||
timestamp() { echo "[$(date +%H:%M:%S)]"; }
|
||||
|
||||
# ── require_base_path ──────────────────────────────────────────────────
|
||||
# Fails loudly, not silently, when VITE_BASE_PATH is unset or wrong. The
|
||||
# normal case never reaches the "unset" branch below: the caller sets a
|
||||
# default via `: "${VITE_BASE_PATH:=$AIUI_MOUNT_PATH}"` before this runs.
|
||||
# This function's job is to catch an operator override with the WRONG
|
||||
# value (T-13-57: a wrong base path ships a black page to every node).
|
||||
require_base_path() {
|
||||
if [ -z "${VITE_BASE_PATH:-}" ]; then
|
||||
echo "FATAL: VITE_BASE_PATH is unset." >&2
|
||||
echo " AIUI must be built with VITE_BASE_PATH=${AIUI_MOUNT_PATH}" >&2
|
||||
echo " or every asset href in the built index.html will be" >&2
|
||||
echo " wrong and AIUI will render as a BLACK PAGE when" >&2
|
||||
echo " embedded at ${AIUI_MOUNT_PATH} on a node." >&2
|
||||
return 1
|
||||
fi
|
||||
if [ "$VITE_BASE_PATH" != "$AIUI_MOUNT_PATH" ]; then
|
||||
echo "FATAL: VITE_BASE_PATH='$VITE_BASE_PATH' is wrong." >&2
|
||||
echo " AIUI must be built with VITE_BASE_PATH=${AIUI_MOUNT_PATH}" >&2
|
||||
echo " exactly. A wrong base path breaks the router base (not" >&2
|
||||
echo " the assets) and ships a BLACK PAGE to every node this" >&2
|
||||
echo " dist reaches." >&2
|
||||
return 1
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
# ── verify_dist ─────────────────────────────────────────────────────────
|
||||
# Asserts the build is safe to ship, BEFORE anything is copied anywhere.
|
||||
verify_dist() {
|
||||
local index="$AIUI_DIST/index.html"
|
||||
if [ ! -f "$index" ]; then
|
||||
echo "FATAL: $index does not exist — the build did not produce a dist." >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Every local (leading-"/") src=/href= must carry the AIUI mount path.
|
||||
# A hand-built bundle with the wrong base path gives a black page, and
|
||||
# the router base is what actually breaks, not the assets.
|
||||
local bad_refs
|
||||
bad_refs=$(grep -oE '(src|href)="/[^"]*"' "$index" \
|
||||
| grep -v -F "=\"${AIUI_MOUNT_PATH}" || true)
|
||||
if [ -n "$bad_refs" ]; then
|
||||
echo "FATAL: $index references local assets outside ${AIUI_MOUNT_PATH}:" >&2
|
||||
echo "$bad_refs" | sed 's/^/ /' >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Mock quarantine (operator decision 2026-08-07, enforced 8329b826):
|
||||
# production bundles must carry NO mock content hosts. The demo site's
|
||||
# content pack builds with VITE_DEMO_CONTENT=true and legitimately
|
||||
# contains them — skip this check for that build.
|
||||
if [ "${VITE_DEMO_CONTENT:-false}" != "true" ]; then
|
||||
local mock_hits
|
||||
mock_hits=$(grep -rl -e 'spotify\.com/track/example' -e 'cloud\.example\.com' \
|
||||
-e 'plex://play' -e 'image\.tmdb\.org' "$AIUI_DIST" 2>/dev/null || true)
|
||||
if [ -n "$mock_hits" ]; then
|
||||
echo "FATAL: production bundle contains mock content hosts" >&2
|
||||
echo " (mocks are demo-site-only per the 2026-08-07 operator decision):" >&2
|
||||
echo "$mock_hits" | sed 's/^/ /' >&2
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Attribute this build to THIS repo's own current commit (D-19: no
|
||||
# second-repo pin file — this repo's own commit IS the answer now).
|
||||
local commit_sha
|
||||
commit_sha="$(git -C "$PROJECT_DIR" rev-parse HEAD)"
|
||||
{
|
||||
echo "commit=$commit_sha"
|
||||
echo "built_at=$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
echo "base_path=$VITE_BASE_PATH"
|
||||
} > "$AIUI_DIST/BUILD-INFO"
|
||||
if ! grep -rq "$commit_sha" "$AIUI_DIST/"; then
|
||||
echo "FATAL: this repo's current commit ($commit_sha) is not" >&2
|
||||
echo " discoverable anywhere under $AIUI_DIST — a deployed" >&2
|
||||
echo " node would be unattributable to a source commit." >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Best-effort staleness check: if the source tree changed since the
|
||||
# last recorded build but the emitted asset filenames are IDENTICAL,
|
||||
# something didn't actually rebuild — Vite's content hashes should
|
||||
# differ whenever the content they hash differs. State lives in a
|
||||
# gitignored marker beside dist/ (dist/ itself gets wiped by every
|
||||
# `vite build`, so it can't hold its own prior-run history).
|
||||
local src_hash_file="$AIUI_APP_DIR/.build-aiui-last-src-hash"
|
||||
local prev_asset_list="$AIUI_APP_DIR/.build-aiui-last-assets"
|
||||
local cur_src_hash cur_assets
|
||||
cur_src_hash=$(find "$AIUI_APP_DIR/src" -type f -print0 2>/dev/null \
|
||||
| sort -z | xargs -0 sha256sum 2>/dev/null | sha256sum | awk '{print $1}')
|
||||
cur_assets=$(ls "$AIUI_DIST/assets" 2>/dev/null | sort)
|
||||
if [ -f "$src_hash_file" ] && [ -f "$prev_asset_list" ]; then
|
||||
local prev_src_hash prev_assets
|
||||
prev_src_hash=$(cat "$src_hash_file")
|
||||
prev_assets=$(cat "$prev_asset_list")
|
||||
if [ "$prev_src_hash" != "$cur_src_hash" ] && [ "$prev_assets" = "$cur_assets" ] && [ -n "$cur_assets" ]; then
|
||||
echo "FATAL: source changed since the last build but the" >&2
|
||||
echo " emitted asset filenames are IDENTICAL to the" >&2
|
||||
echo " previous build — this looks like a stale/cached" >&2
|
||||
echo " dist, not a fresh build." >&2
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
echo "$cur_src_hash" > "$src_hash_file"
|
||||
echo "$cur_assets" > "$prev_asset_list"
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
echo "$(timestamp) build-aiui.sh: building AIUI from $AIUI_ROOT (in-repo, D-19)"
|
||||
|
||||
if [ ! -d "$AIUI_ROOT" ]; then
|
||||
echo "FATAL: $AIUI_ROOT does not exist." >&2
|
||||
echo " AIUI is expected in-repo at aiui/ — it is no longer a" >&2
|
||||
echo " sibling checkout at ../AIUI (D-19)." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
: "${VITE_BASE_PATH:=$AIUI_MOUNT_PATH}"
|
||||
export VITE_BASE_PATH
|
||||
require_base_path
|
||||
|
||||
echo "$(timestamp) Installing aiui/ workspace from its committed lockfile..."
|
||||
(cd "$AIUI_ROOT" && pnpm install --frozen-lockfile)
|
||||
|
||||
echo "$(timestamp) Building AIUI (vue-tsc --noEmit && vite build)..."
|
||||
(cd "$AIUI_APP_DIR" && VITE_BASE_PATH="$VITE_BASE_PATH" pnpm build)
|
||||
|
||||
echo "$(timestamp) Verifying dist..."
|
||||
verify_dist
|
||||
|
||||
echo "$(timestamp) AIUI build OK — $AIUI_DIST attributable to $(git -C "$PROJECT_DIR" rev-parse --short HEAD)"
|
||||
Executable
+171
@@ -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.x–31.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
|
||||
Executable
+199
@@ -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
|
||||
@@ -0,0 +1,222 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Report drift between app-catalog/catalog.json and apps/*/manifest.yml."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
INTERNAL_MANIFEST_IDS = {
|
||||
"aiui",
|
||||
"archy-btcpay-db",
|
||||
"archy-mempool-db",
|
||||
"archy-mempool-web",
|
||||
"archy-nbxplorer",
|
||||
"bitcoin-ui",
|
||||
"core-lightning",
|
||||
"did-wallet",
|
||||
"electrs-ui",
|
||||
"fips-ui",
|
||||
"lightning-stack",
|
||||
"lnd-ui",
|
||||
"mempool-api",
|
||||
"morphos-server",
|
||||
"router",
|
||||
"strfry",
|
||||
"web5-dwn",
|
||||
"immich-postgres",
|
||||
"immich-redis",
|
||||
"indeedhub-api",
|
||||
"indeedhub-ffmpeg",
|
||||
"indeedhub-minio",
|
||||
"indeedhub-postgres",
|
||||
"indeedhub-redis",
|
||||
"indeedhub-relay",
|
||||
"netbird-dashboard",
|
||||
"netbird-server",
|
||||
"pine-whisper",
|
||||
"pine-piper",
|
||||
"pine-openwakeword",
|
||||
}
|
||||
|
||||
LEGACY_STACK_CATALOG_IDS = {
|
||||
"immich",
|
||||
"netbird",
|
||||
"tailscale",
|
||||
}
|
||||
|
||||
|
||||
def load_catalog(path: Path) -> dict[str, dict[str, Any]]:
|
||||
"""Load either catalog shape into {app_id: app-fields}.
|
||||
|
||||
Two formats exist and only one used to be understood here:
|
||||
|
||||
* app-catalog/catalog.json — `apps` is a LIST of entries carrying `id`.
|
||||
* releases/app-catalog.json — `apps` is a DICT keyed by app id, and each
|
||||
entry wraps the app's full manifest under `manifest.app` (the signed
|
||||
release catalog; EMBED_MANIFESTS has been on since 2026-06-23).
|
||||
|
||||
The signed release catalog is the one nodes actually resolve apps through,
|
||||
so a drift checker that only parsed the list form was checking the file
|
||||
that governs nothing and crashing on the file that governs everything.
|
||||
"""
|
||||
with path.open("r", encoding="utf-8") as fh:
|
||||
data = json.load(fh)
|
||||
apps = data.get("apps", [])
|
||||
|
||||
if isinstance(apps, list):
|
||||
return {
|
||||
str(app.get("id", "")): app
|
||||
for app in apps
|
||||
if isinstance(app, dict) and app.get("id")
|
||||
}
|
||||
|
||||
if isinstance(apps, dict):
|
||||
out: dict[str, dict[str, Any]] = {}
|
||||
for app_id, entry in apps.items():
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
manifest = entry.get("manifest")
|
||||
if isinstance(manifest, dict) and isinstance(manifest.get("app"), dict):
|
||||
# Embedded manifest: compare against the same fields the disk
|
||||
# manifests expose, plus the entry's own version.
|
||||
app = dict(manifest["app"])
|
||||
else:
|
||||
app = {}
|
||||
app.setdefault("id", app_id)
|
||||
if entry.get("version") is not None:
|
||||
app["version"] = entry["version"]
|
||||
out[str(app_id)] = app
|
||||
return out
|
||||
|
||||
raise ValueError(f"{path}: expected .apps to be a list or an object")
|
||||
|
||||
|
||||
def load_manifests(apps_dir: Path) -> dict[str, dict[str, Any]]:
|
||||
manifests: dict[str, dict[str, Any]] = {}
|
||||
for path in sorted(apps_dir.glob("*/manifest.yml")):
|
||||
with path.open("r", encoding="utf-8") as fh:
|
||||
data = yaml.safe_load(fh)
|
||||
if not isinstance(data, dict) or not isinstance(data.get("app"), dict):
|
||||
continue
|
||||
app = data["app"]
|
||||
app_id = app.get("id")
|
||||
if app_id:
|
||||
manifests[str(app_id)] = {"path": str(path), "app": app}
|
||||
return manifests
|
||||
|
||||
|
||||
def metadata(app: dict[str, Any]) -> dict[str, Any]:
|
||||
value = app.get("metadata")
|
||||
return value if isinstance(value, dict) else {}
|
||||
|
||||
|
||||
def manifest_value(app: dict[str, Any], field: str) -> Any:
|
||||
meta = metadata(app)
|
||||
container = app.get("container") if isinstance(app.get("container"), dict) else {}
|
||||
match field:
|
||||
case "title":
|
||||
return app.get("name")
|
||||
case "version":
|
||||
return str(app.get("version", ""))
|
||||
case "description":
|
||||
return app.get("description")
|
||||
case "dockerImage":
|
||||
return container.get("image")
|
||||
case "category":
|
||||
return app.get("category") or meta.get("category")
|
||||
case "tier":
|
||||
return meta.get("tier")
|
||||
case "icon":
|
||||
return meta.get("icon")
|
||||
case "repoUrl":
|
||||
return meta.get("repo") or meta.get("repoUrl")
|
||||
case _:
|
||||
return None
|
||||
|
||||
|
||||
def normalize(value: Any) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
return str(value).strip()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--catalog", default="app-catalog/catalog.json")
|
||||
parser.add_argument("--apps-dir", default="apps")
|
||||
parser.add_argument(
|
||||
"--strict",
|
||||
action="store_true",
|
||||
help="exit non-zero when missing entries or metadata drift are found",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--release",
|
||||
action="store_true",
|
||||
help="suppress known internal/legacy-stack entries so output is release-actionable",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
catalog = load_catalog(Path(args.catalog))
|
||||
manifests = load_manifests(Path(args.apps_dir))
|
||||
|
||||
catalog_ids = set(catalog)
|
||||
manifest_ids = set(manifests)
|
||||
missing_manifests = sorted(catalog_ids - manifest_ids)
|
||||
missing_catalog = sorted(manifest_ids - catalog_ids)
|
||||
if args.release:
|
||||
missing_manifests = [app_id for app_id in missing_manifests if app_id not in LEGACY_STACK_CATALOG_IDS]
|
||||
missing_catalog = [app_id for app_id in missing_catalog if app_id not in INTERNAL_MANIFEST_IDS]
|
||||
|
||||
compared_fields = [
|
||||
"title",
|
||||
"version",
|
||||
"description",
|
||||
"dockerImage",
|
||||
"category",
|
||||
"tier",
|
||||
"icon",
|
||||
"repoUrl",
|
||||
]
|
||||
drift: list[str] = []
|
||||
for app_id in sorted(catalog_ids & manifest_ids):
|
||||
catalog_app = catalog[app_id]
|
||||
manifest_app = manifests[app_id]["app"]
|
||||
for field in compared_fields:
|
||||
catalog_val = normalize(catalog_app.get(field))
|
||||
manifest_val = normalize(manifest_value(manifest_app, field))
|
||||
if catalog_val and manifest_val and catalog_val != manifest_val:
|
||||
drift.append(f"{app_id}: {field}: catalog={catalog_val!r} manifest={manifest_val!r}")
|
||||
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"catalog_apps": len(catalog),
|
||||
"manifest_apps": len(manifests),
|
||||
"missing_manifests": len(missing_manifests),
|
||||
"missing_catalog": len(missing_catalog),
|
||||
"metadata_drift": len(drift),
|
||||
},
|
||||
sort_keys=True,
|
||||
)
|
||||
)
|
||||
|
||||
for app_id in missing_manifests:
|
||||
print(f"MISSING_MANIFEST {app_id}")
|
||||
for app_id in missing_catalog:
|
||||
print(f"MISSING_CATALOG {app_id}")
|
||||
for item in drift:
|
||||
print(f"DRIFT {item}")
|
||||
|
||||
if args.strict and (missing_manifests or missing_catalog or drift):
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Executable
+163
@@ -0,0 +1,163 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Refuse to publish a catalog naming registry hosts the fleet cannot pull from.
|
||||
|
||||
The signed app catalog is authoritative for deployed nodes: `catalog_image_override`
|
||||
makes its image reference win over the on-disk manifest. So a catalog that names a
|
||||
registry host the *deployed* binaries do not trust turns every install into
|
||||
"not from a trusted registry" — fleet-wide, at publish time, with no local signal.
|
||||
|
||||
The subtlety this guard exists for: TRUSTED_REGISTRIES in the working tree
|
||||
describes a binary being built today. Nodes run what was shipped to them. Those
|
||||
two lists diverge for exactly as long as it takes an OTA to reach the fleet, and
|
||||
that window is when a catalog regeneration silently breaks everything.
|
||||
|
||||
So the floor is tracked explicitly in releases/registry-trust-floor.json and the
|
||||
catalog is checked against that, never against the source tree.
|
||||
|
||||
Usage:
|
||||
scripts/check-catalog-registry-trust.py # check the release catalog
|
||||
scripts/check-catalog-registry-trust.py --catalog path.json
|
||||
scripts/check-catalog-registry-trust.py --show # print current state
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterator
|
||||
|
||||
DEFAULT_CATALOG = "releases/app-catalog.json"
|
||||
DEFAULT_FLOOR = "releases/registry-trust-floor.json"
|
||||
IMAGE_POLICY = "core/archipelago/src/container/image_policy.rs"
|
||||
|
||||
|
||||
def iter_images(node: Any) -> Iterator[str]:
|
||||
"""Yield every value stored under an `image` key, at any depth."""
|
||||
if isinstance(node, dict):
|
||||
for key, value in node.items():
|
||||
if key == "image" and isinstance(value, str) and value:
|
||||
yield value
|
||||
else:
|
||||
yield from iter_images(value)
|
||||
elif isinstance(node, list):
|
||||
for item in node:
|
||||
yield from iter_images(item)
|
||||
|
||||
|
||||
def registry_host(image: str) -> str | None:
|
||||
"""Registry host of an image ref, or None for Docker Hub shorthand.
|
||||
|
||||
A ref's first segment is a registry only if it contains a '.' or ':'
|
||||
(docker.io, host:3000). Otherwise it is a Docker Hub namespace — `nginx`,
|
||||
`btcpayserver/btcpayserver` — which resolves via registries.conf, not an
|
||||
attacker-controlled host. This mirrors is_valid_docker_image() in
|
||||
image_policy.rs; keep the two in step.
|
||||
"""
|
||||
head = image.split("/", 1)[0]
|
||||
if "/" not in image:
|
||||
return None
|
||||
if "." in head or ":" in head:
|
||||
return head
|
||||
return None
|
||||
|
||||
|
||||
def source_trusted_registries(repo: Path) -> list[str]:
|
||||
"""TRUSTED_REGISTRIES as the working tree currently defines it (advisory)."""
|
||||
path = repo / IMAGE_POLICY
|
||||
try:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
except OSError:
|
||||
return []
|
||||
match = re.search(r"TRUSTED_REGISTRIES:\s*&\[&str\]\s*=\s*&\[(.*?)\];", text, re.S)
|
||||
if not match:
|
||||
return []
|
||||
body = match.group(1)
|
||||
hosts = re.findall(r'"([^"]+)"', body)
|
||||
# Entries may be consts (LEGACY_REGISTRY_HOST); resolve those too.
|
||||
for const in re.findall(r"\b([A-Z][A-Z0-9_]+)\b", body):
|
||||
const_match = re.search(rf'{const}:\s*&str\s*=\s*"([^"]+)"', text)
|
||||
if const_match:
|
||||
hosts.append(const_match.group(1))
|
||||
return sorted(set(hosts))
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
parser.add_argument("--catalog", default=DEFAULT_CATALOG)
|
||||
parser.add_argument("--floor", default=DEFAULT_FLOOR)
|
||||
parser.add_argument("--repo", default=".")
|
||||
parser.add_argument("--show", action="store_true",
|
||||
help="print the floor, the source list and the catalog's hosts")
|
||||
args = parser.parse_args()
|
||||
|
||||
repo = Path(args.repo)
|
||||
catalog_path = repo / args.catalog
|
||||
floor_path = repo / args.floor
|
||||
|
||||
try:
|
||||
catalog = json.loads(catalog_path.read_text(encoding="utf-8"))
|
||||
except OSError as exc:
|
||||
print(f"ERROR: cannot read catalog: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
try:
|
||||
floor_doc = json.loads(floor_path.read_text(encoding="utf-8"))
|
||||
except OSError as exc:
|
||||
print(f"ERROR: cannot read trust floor: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
floor = set(floor_doc.get("hosts") or [])
|
||||
if not floor:
|
||||
print(f"ERROR: {args.floor} lists no hosts; refusing to pass vacuously.",
|
||||
file=sys.stderr)
|
||||
return 2
|
||||
|
||||
hosts: dict[str, list[str]] = {}
|
||||
for image in iter_images(catalog):
|
||||
host = registry_host(image)
|
||||
if host:
|
||||
hosts.setdefault(host, []).append(image)
|
||||
|
||||
if args.show:
|
||||
print("trust floor (deployed binaries):")
|
||||
for h in sorted(floor):
|
||||
print(f" {h}")
|
||||
pending = floor_doc.get("pending") or {}
|
||||
if pending:
|
||||
print("pending (not yet in the fleet):")
|
||||
for h, meta in pending.items():
|
||||
print(f" {h} — trusted_from_binary={meta.get('trusted_from_binary')}")
|
||||
print("working-tree TRUSTED_REGISTRIES (advisory):")
|
||||
for h in source_trusted_registries(repo) or ["(could not parse)"]:
|
||||
print(f" {h}")
|
||||
print(f"catalog hosts ({catalog_path}):")
|
||||
for h in sorted(hosts):
|
||||
print(f" {h} ({len(hosts[h])} image refs)")
|
||||
|
||||
violations = sorted(set(hosts) - floor)
|
||||
if violations:
|
||||
print("")
|
||||
print("REFUSING: the catalog names registry hosts the deployed fleet does not trust.")
|
||||
for host in violations:
|
||||
examples = hosts[host][:3]
|
||||
print(f"\n {host} — {len(hosts[host])} image refs, e.g.")
|
||||
for ref in examples:
|
||||
print(f" {ref}")
|
||||
print("")
|
||||
print("Publishing this would make every install fail with")
|
||||
print('"not from a trusted registry" on every node in the field.')
|
||||
print("")
|
||||
print(f"Fix by ordering the migration — see the _comment in {args.floor}:")
|
||||
print(" ship a binary that trusts the host, confirm the fleet is on it,")
|
||||
print(" promote the host in the floor file, and only then regenerate.")
|
||||
return 1
|
||||
|
||||
print(f"OK: all {len(hosts)} registry host(s) in {args.catalog} are trusted by the deployed fleet.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Executable
+134
@@ -0,0 +1,134 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Fail when a hardcoded installer image tag disagrees with the app manifest.
|
||||
|
||||
The legacy stack installers in core/archipelago/src/api/rpc/package/stacks.rs
|
||||
carry image references as string literals. Those literals are a second source of
|
||||
truth for a version, sitting behind the manifest and the signed catalog, and
|
||||
nothing keeps them in step.
|
||||
|
||||
That is not cosmetic. BTCPay shipped 2.4.2 for an actively exploited 2FA bypass
|
||||
on 2026-08-07 while the legacy installer still named 2.3.9, so the fallback
|
||||
install path would have deployed the withdrawn release. The same shape applies
|
||||
to any app whose installer literal is left behind.
|
||||
|
||||
The rule enforced here: if an installer literal names the same image repository
|
||||
as an app manifest, the tags must match. Repositories with no manifest are
|
||||
ignored, and so are floating tags, which carry no version claim.
|
||||
|
||||
Usage:
|
||||
scripts/check-installer-image-pins.py
|
||||
scripts/check-installer-image-pins.py --show
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
# Files that pin images as literals on an install path. Test modules inside them
|
||||
# are stripped before scanning: fixtures deliberately name old versions.
|
||||
INSTALLER_SOURCES = [
|
||||
"core/archipelago/src/api/rpc/package/stacks.rs",
|
||||
]
|
||||
|
||||
FLOATING_TAGS = {"latest", "stable", "release", "main", "edge"}
|
||||
|
||||
IMAGE_RE = re.compile(r'"([a-z0-9][a-z0-9._-]*(?:\.[a-z]+|:[0-9]+)?/[a-z0-9._/-]+:[A-Za-z0-9._-]+)"')
|
||||
|
||||
|
||||
def strip_test_modules(text: str) -> str:
|
||||
"""Remove #[cfg(test)] modules so fixture literals are not treated as pins."""
|
||||
marker = "#[cfg(test)]"
|
||||
idx = text.find(marker)
|
||||
return text if idx == -1 else text[:idx]
|
||||
|
||||
|
||||
def repo_of(image: str) -> str:
|
||||
"""Image repository without registry host or tag."""
|
||||
without_tag = image.rsplit(":", 1)[0] if ":" in image.rsplit("/", 1)[-1] else image
|
||||
head, _, rest = without_tag.partition("/")
|
||||
if "." in head or ":" in head or head == "localhost":
|
||||
return rest
|
||||
return without_tag
|
||||
|
||||
|
||||
def tag_of(image: str) -> str:
|
||||
last = image.rsplit("/", 1)[-1]
|
||||
return last.rsplit(":", 1)[1] if ":" in last else "latest"
|
||||
|
||||
|
||||
def manifest_images(repo_root: Path) -> dict[str, tuple[str, str]]:
|
||||
"""{image repo: (tag, manifest path)} across apps/*/manifest.yml."""
|
||||
out: dict[str, tuple[str, str]] = {}
|
||||
for path in sorted((repo_root / "apps").glob("*/manifest.yml")):
|
||||
try:
|
||||
data = yaml.safe_load(path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
continue
|
||||
app = (data or {}).get("app")
|
||||
if not isinstance(app, dict):
|
||||
continue
|
||||
image = (app.get("container") or {}).get("image")
|
||||
if isinstance(image, str) and image:
|
||||
out[repo_of(image)] = (tag_of(image), str(path.relative_to(repo_root)))
|
||||
return out
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
parser.add_argument("--repo", default=".")
|
||||
parser.add_argument("--show", action="store_true")
|
||||
args = parser.parse_args()
|
||||
|
||||
repo_root = Path(args.repo)
|
||||
manifests = manifest_images(repo_root)
|
||||
problems: list[str] = []
|
||||
checked = 0
|
||||
|
||||
for rel in INSTALLER_SOURCES:
|
||||
path = repo_root / rel
|
||||
if not path.exists():
|
||||
continue
|
||||
text = strip_test_modules(path.read_text(encoding="utf-8"))
|
||||
for line_no, line in enumerate(text.splitlines(), start=1):
|
||||
for image in IMAGE_RE.findall(line):
|
||||
repo = repo_of(image)
|
||||
if repo not in manifests:
|
||||
continue
|
||||
tag = tag_of(image)
|
||||
manifest_tag, manifest_path = manifests[repo]
|
||||
checked += 1
|
||||
if args.show:
|
||||
print(f" {rel}:{line_no} {repo}:{tag} (manifest {manifest_tag})")
|
||||
if tag in FLOATING_TAGS or manifest_tag in FLOATING_TAGS:
|
||||
continue
|
||||
if tag != manifest_tag:
|
||||
problems.append(
|
||||
f"{rel}:{line_no}\n"
|
||||
f" installer pins {repo}:{tag}\n"
|
||||
f" manifest wants {repo}:{manifest_tag} ({manifest_path})"
|
||||
)
|
||||
|
||||
if problems:
|
||||
print("")
|
||||
print("Installer image pins disagree with their app manifests:")
|
||||
for problem in problems:
|
||||
print(f"\n {problem}")
|
||||
print("")
|
||||
print("An installer literal left behind deploys the older image on the")
|
||||
print("fallback install path — which is how a withdrawn, vulnerable")
|
||||
print("release gets installed after it has supposedly been replaced.")
|
||||
print("Update the literal to match the manifest.")
|
||||
return 1
|
||||
|
||||
print(f"OK: {checked} installer image pin(s) agree with their app manifests.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Executable
+86
@@ -0,0 +1,86 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Syntax-check the shell embedded in apps/*/manifest.yml.
|
||||
|
||||
A manifest can carry a whole startup script in `container.custom_args` /
|
||||
`entrypoint`. Nothing validated it, so a broken one shipped through the
|
||||
signed catalog and only failed on the node — as a container that exits
|
||||
instantly and an app that vanishes from the UI.
|
||||
|
||||
Two checks, both learned from v1.7.124 (bitcoin-knots / bitcoin-core):
|
||||
|
||||
1. `sh -n` the snippet. The break was `sh: Syntax error: "fi" unexpected`,
|
||||
which no YAML parse and no Rust test could have caught.
|
||||
|
||||
2. Reject `#` inside the snippet. These are YAML **folded** scalars (`>-`),
|
||||
where `#` is NOT a comment — it is literal text that reaches the shell,
|
||||
and because folding joins lines with spaces it comments out the rest of
|
||||
the folded line. That is exactly how an `if ... then` was swallowed while
|
||||
its more-indented body survived, leaving an orphan `fi`. Put explanations
|
||||
above the `- >-` line, where YAML really does treat them as comments.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import glob
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
import yaml
|
||||
|
||||
# Long enough to be a script rather than a flag.
|
||||
MIN_SCRIPT_LEN = 60
|
||||
|
||||
|
||||
def snippets(path: str):
|
||||
with open(path, encoding="utf-8") as fh:
|
||||
data = yaml.safe_load(fh)
|
||||
container = ((data or {}).get("app") or {}).get("container") or {}
|
||||
for key in ("custom_args", "entrypoint"):
|
||||
value = container.get(key)
|
||||
if not isinstance(value, list):
|
||||
continue
|
||||
for i, part in enumerate(value):
|
||||
if isinstance(part, str) and len(part) >= MIN_SCRIPT_LEN:
|
||||
yield f"{key}[{i}]", part
|
||||
|
||||
|
||||
def main() -> int:
|
||||
failures = []
|
||||
checked = 0
|
||||
for path in sorted(glob.glob("apps/*/manifest.yml")):
|
||||
app = os.path.basename(os.path.dirname(path))
|
||||
try:
|
||||
found = list(snippets(path))
|
||||
except Exception as exc: # noqa: BLE001 — report, don't crash the gate
|
||||
failures.append(f"{app}: manifest does not parse: {exc}")
|
||||
continue
|
||||
for where, script in found:
|
||||
checked += 1
|
||||
if "#" in script:
|
||||
failures.append(
|
||||
f"{app} {where}: contains '#'. In a folded YAML scalar that is not a "
|
||||
f"comment — it reaches the shell and comments out the rest of the "
|
||||
f"folded line. Move the explanation above the '- >-' line."
|
||||
)
|
||||
with tempfile.NamedTemporaryFile("w", suffix=".sh", delete=False) as tmp:
|
||||
tmp.write(script)
|
||||
tmp_path = tmp.name
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
["sh", "-n", tmp_path], capture_output=True, text=True, check=False
|
||||
)
|
||||
finally:
|
||||
os.unlink(tmp_path)
|
||||
if proc.returncode != 0:
|
||||
failures.append(f"{app} {where}: {proc.stderr.strip()}")
|
||||
|
||||
for f in failures:
|
||||
print(f"MANIFEST-SHELL {f}", file=sys.stderr)
|
||||
print(f'{{"snippets_checked": {checked}, "failures": {len(failures)}}}')
|
||||
return 1 if failures else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Executable
+105
@@ -0,0 +1,105 @@
|
||||
#!/usr/bin/env bash
|
||||
# check-release-assets.sh — prove a release's artifacts are actually fetchable
|
||||
# BEFORE its manifest goes live on main.
|
||||
#
|
||||
# The manifest is the trigger: nodes read releases/manifest.json from branch
|
||||
# main, and the moment it names a new version they try to download it. So the
|
||||
# assets must resolve before the manifest lands, not after. On 2026-08-07 the
|
||||
# order was reversed — the v1.7.126-alpha manifest went live while its binary
|
||||
# 500'd and its frontend tarball had never uploaded — and every polling node
|
||||
# would have advertised an update it could not fetch.
|
||||
#
|
||||
# For each component in the manifest this checks:
|
||||
# 1. the download URL returns HTTP 200
|
||||
# 2. the downloaded bytes match the manifest's sha256 and size
|
||||
#
|
||||
# It downloads each asset in full, because a HEAD 200 is not proof the body is
|
||||
# intact — the corrupt binary that day passed HEAD-shaped checks and still
|
||||
# served a broken stream. Slower, but this is the last gate before publish.
|
||||
#
|
||||
# Usage:
|
||||
# scripts/check-release-assets.sh # check releases/manifest.json
|
||||
# scripts/check-release-assets.sh path/to/manifest.json
|
||||
#
|
||||
# Exit 0 = every asset is downloadable and matches. Non-zero = do NOT publish.
|
||||
set -euo pipefail
|
||||
|
||||
MANIFEST="${1:-releases/manifest.json}"
|
||||
if [[ ! -f "$MANIFEST" ]]; then
|
||||
echo "ERROR: manifest not found: $MANIFEST" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
command -v python3 >/dev/null 2>&1 || { echo "ERROR: python3 required" >&2; exit 2; }
|
||||
command -v sha256sum >/dev/null 2>&1 || { echo "ERROR: sha256sum required" >&2; exit 2; }
|
||||
|
||||
TMP="$(mktemp -d)"
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
|
||||
# Emit "url<TAB>sha256<TAB>size<TAB>name" per component, tolerating the field
|
||||
# name variations the manifest has used (download_url/url, size_bytes/size).
|
||||
rows="$(python3 - "$MANIFEST" <<'PY'
|
||||
import json, sys
|
||||
d = json.load(open(sys.argv[1]))
|
||||
comps = d.get("components") or []
|
||||
if not comps:
|
||||
sys.exit("manifest has no components")
|
||||
for c in comps:
|
||||
url = c.get("download_url") or c.get("url") or ""
|
||||
sha = c.get("sha256") or ""
|
||||
size = c.get("size_bytes") or c.get("size") or ""
|
||||
name = c.get("name") or "(unnamed)"
|
||||
if not url or not sha:
|
||||
sys.exit(f"component {name!r} missing url or sha256")
|
||||
print(f"{url}\t{sha}\t{size}\t{name}")
|
||||
PY
|
||||
)"
|
||||
|
||||
version="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get("version","?"))' "$MANIFEST")"
|
||||
echo "Checking release assets for v${version} ($MANIFEST)"
|
||||
echo ""
|
||||
|
||||
fail=0
|
||||
n=0
|
||||
while IFS=$'\t' read -r url sha size name; do
|
||||
[ -z "$url" ] && continue
|
||||
n=$((n + 1))
|
||||
out="$TMP/asset.$n"
|
||||
echo " [$name]"
|
||||
echo " $url"
|
||||
|
||||
code="$(curl -sL -o "$out" -w '%{http_code}' "$url" || echo "000")"
|
||||
if [ "$code" != "200" ]; then
|
||||
echo " FAIL: HTTP $code (asset not served)"
|
||||
fail=1
|
||||
continue
|
||||
fi
|
||||
|
||||
got_size="$(stat -c%s "$out")"
|
||||
if [ -n "$size" ] && [ "$size" != "$got_size" ]; then
|
||||
echo " FAIL: size $got_size, manifest says $size"
|
||||
fail=1
|
||||
continue
|
||||
fi
|
||||
|
||||
got_sha="$(sha256sum "$out" | awk '{print $1}')"
|
||||
if [ "$got_sha" != "$sha" ]; then
|
||||
echo " FAIL: sha256 mismatch"
|
||||
echo " served: $got_sha"
|
||||
echo " manifest: $sha"
|
||||
fail=1
|
||||
continue
|
||||
fi
|
||||
|
||||
echo " OK: HTTP 200, ${got_size} bytes, sha256 matches"
|
||||
done <<< "$rows"
|
||||
|
||||
echo ""
|
||||
if [ "$fail" -ne 0 ]; then
|
||||
echo "REFUSING: one or more assets are not fetchable or do not match the manifest."
|
||||
echo "Do NOT publish the manifest — nodes would advertise an update they cannot"
|
||||
echo "apply. Upload/repair the assets, re-run this, and only then flip the"
|
||||
echo "manifest live on main."
|
||||
exit 1
|
||||
fi
|
||||
echo "OK: all $n asset(s) for v${version} download and match the manifest."
|
||||
Executable
+108
@@ -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}"
|
||||
Executable
+43
@@ -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/"
|
||||
Executable
+712
@@ -0,0 +1,712 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Container Doctor — diagnose and fix common container health issues
|
||||
#
|
||||
# Usage:
|
||||
# sudo ./scripts/container-doctor.sh # Run locally on node
|
||||
# ./scripts/container-doctor.sh user@host # Run remotely via SSH
|
||||
#
|
||||
# Fixes:
|
||||
# 1. Stale podman ps/stats processes (>10 = pileup)
|
||||
# 2. Orphaned conmon/crun processes holding ports
|
||||
# 3. System tor conflicting with container tor
|
||||
# 4. Tor hidden service directory permissions (group/other must have no
|
||||
# access; Tor's own setgid 2700 is fine, restart is backed off)
|
||||
# 5. SearXNG read-only root / cap-drop ALL
|
||||
# 6. Bitcoin Knots prune+txindex conflict
|
||||
# 7. Containers stuck with exit code 127 (binary not found)
|
||||
# 8. Stopped core containers (rootless restart policy workaround)
|
||||
# 9. Missing rootless port listeners while Podman still shows published ports
|
||||
# 10. Nginx Proxy Manager public hosts not mirrored into host nginx
|
||||
# 11. BTCPay stores producing unpayable Lightning invoices (route hints off)
|
||||
# 12. Missing catatonit (Podman init binary) — init-enabled deploys fail
|
||||
#
|
||||
# Safe to run multiple times (idempotent). Never blocks deploy (exit 0 always).
|
||||
#
|
||||
|
||||
set -o pipefail
|
||||
|
||||
# Source pinned image versions (single source of truth)
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
[ -f "$SCRIPT_DIR/image-versions.sh" ] && . "$SCRIPT_DIR/image-versions.sh"
|
||||
|
||||
FIXES_APPLIED=0
|
||||
CHECKS_PASSED=0
|
||||
FIX_NAMES=()
|
||||
|
||||
log() { echo "[$(date +%H:%M:%S)] DOCTOR: $*"; }
|
||||
|
||||
podman_rootless() {
|
||||
if [ "$(id -u)" = "0" ] && id archipelago >/dev/null 2>&1; then
|
||||
local archi_uid
|
||||
archi_uid=$(id -u archipelago)
|
||||
sudo -u archipelago env XDG_RUNTIME_DIR="/run/user/$archi_uid" podman "$@"
|
||||
else
|
||||
podman "$@"
|
||||
fi
|
||||
}
|
||||
|
||||
port_is_listening() {
|
||||
local port="$1"
|
||||
ss -ltn 2>/dev/null | awk '{print $4}' | grep -Eq "(^|:)$port$"
|
||||
}
|
||||
|
||||
run_fix() {
|
||||
local name="$1"
|
||||
shift
|
||||
if "$@"; then
|
||||
FIXES_APPLIED=$((FIXES_APPLIED + 1))
|
||||
FIX_NAMES+=("$name")
|
||||
else
|
||||
CHECKS_PASSED=$((CHECKS_PASSED + 1))
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Fix 1: Stale podman processes ────────────────────────────
|
||||
fix_stale_podman() {
|
||||
local count
|
||||
count=$(pgrep -f "podman (ps|stats)" 2>/dev/null | wc -l)
|
||||
count=${count:-0}
|
||||
if [ "$count" -gt 10 ]; then
|
||||
log "Killing $count stale podman ps/stats processes"
|
||||
pkill -f "podman (ps|stats)" 2>/dev/null || true
|
||||
sleep 2
|
||||
local after
|
||||
after=$(pgrep -f "podman (ps|stats)" 2>/dev/null | wc -l)
|
||||
after=${after:-0}
|
||||
log "Reduced from $count to $after"
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
# ── Fix 2: Orphaned conmon holding ports ─────────────────────
|
||||
fix_orphaned_conmon() {
|
||||
local fixed=false
|
||||
# Find conmon processes whose containers no longer exist
|
||||
local pids
|
||||
pids=$(pgrep -f "conmon.*--exit-command" 2>/dev/null || true)
|
||||
if [ -z "$pids" ]; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Doctor runs as root but containers are rootless under archipelago user.
|
||||
# Must check container existence using the rootless podman database.
|
||||
local PODMANCMD="sudo -u archipelago XDG_RUNTIME_DIR=/run/user/1000 podman"
|
||||
|
||||
for pid in $pids; do
|
||||
# Extract container ID from conmon args
|
||||
local cid
|
||||
cid=$(tr '\0' ' ' < /proc/"$pid"/cmdline 2>/dev/null | grep -oP '(?<=-c )[a-f0-9]{64}' || true)
|
||||
if [ -z "$cid" ]; then
|
||||
continue
|
||||
fi
|
||||
# Check if container still exists in rootless podman
|
||||
if ! $PODMANCMD inspect "$cid" &>/dev/null; then
|
||||
local port_info
|
||||
port_info=$(ss -tlnp 2>/dev/null | grep "pid=$pid" | grep -oP ':\K\d+' | head -3 | tr '\n' ',' | sed 's/,$//')
|
||||
log "Killing orphaned conmon pid=$pid (ports: ${port_info:-none})"
|
||||
kill "$pid" 2>/dev/null || kill -9 "$pid" 2>/dev/null || true
|
||||
fixed=true
|
||||
fi
|
||||
done
|
||||
|
||||
$fixed && return 0 || return 1
|
||||
}
|
||||
|
||||
# ── Fix 3: Ensure system Tor is running (preferred over container) ──
|
||||
fix_system_tor_conflict() {
|
||||
# System Tor is preferred over container Tor.
|
||||
# If archy-tor container exists, remove it and use system Tor instead.
|
||||
if podman ps -a --format '{{.Names}}' 2>/dev/null | grep -qE '^archy-tor$'; then
|
||||
podman stop archy-tor 2>/dev/null || true
|
||||
podman rm -f archy-tor 2>/dev/null || true
|
||||
log "Removed archy-tor container (system Tor is preferred)"
|
||||
fi
|
||||
|
||||
# Ensure system Tor is enabled and running
|
||||
if command -v tor >/dev/null 2>&1; then
|
||||
if ! systemctl is-active tor@default >/dev/null 2>&1; then
|
||||
systemctl enable tor tor@default 2>/dev/null || true
|
||||
systemctl start tor tor@default 2>/dev/null || true
|
||||
log "Started system Tor"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
# ── Fix 4: Tor hidden service permissions ────────────────────
|
||||
# `stat -c '%a'` omits leading zeros, so a directory Tor manages itself
|
||||
# reads "2700" (setgid bit set) rather than "700" -- but the setgid bit
|
||||
# doesn't grant group/other any access. Comparing the full string against
|
||||
# the literal "700" therefore flagged Tor's own correct setting as broken
|
||||
# on every run, chmod'd it right back to what it already effectively was,
|
||||
# and restarted Tor -- forever, every ~5 minutes (the doctor timer
|
||||
# interval), so Tor never kept its consensus/HSDir cache long enough to
|
||||
# resolve any .onion peer. Only the last 3 digits of the mode are the
|
||||
# owner/group/other permission bits; anything before that is
|
||||
# setuid/setgid/sticky, which is irrelevant to "is group/other access
|
||||
# denied". Compare only those 3 digits so 700 and 2700 both pass, while a
|
||||
# genuinely permissive mode (750, 707, 2755, ...) is still corrected.
|
||||
#
|
||||
# Restart backoff: even a real, legitimate fix must not restart Tor more
|
||||
# than once per TOR_RESTART_BACKOFF_SECONDS, so no future defect in this
|
||||
# (or any other) predicate can reproduce a restart storm. The timestamp
|
||||
# file persists across doctor runs (and doctor is re-invoked from a fresh
|
||||
# shell every run, so this can't just be a shell variable).
|
||||
TOR_RESTART_BACKOFF_SECONDS=1800 # 30 minutes
|
||||
TOR_RESTART_STATE_FILE="/var/lib/archipelago/doctor-tor-last-restart"
|
||||
fix_tor_permissions() {
|
||||
local fixed=false
|
||||
local tor_dirs=("/var/lib/archipelago/tor" "/var/lib/tor")
|
||||
|
||||
for base in "${tor_dirs[@]}"; do
|
||||
if [ ! -d "$base" ]; then
|
||||
continue
|
||||
fi
|
||||
while IFS= read -r dir; do
|
||||
local perms
|
||||
perms=$(stat -c '%a' "$dir" 2>/dev/null)
|
||||
local mode_bits="${perms: -3}"
|
||||
if [ "$mode_bits" != "700" ]; then
|
||||
chmod 700 "$dir"
|
||||
log "Fixed permissions on $dir ($perms -> 700; group/other access was NOT fully denied)"
|
||||
fixed=true
|
||||
elif [ -n "$DOCTOR_DEBUG" ]; then
|
||||
log "DEBUG: $dir already denies group/other access (mode $perms) — no change"
|
||||
fi
|
||||
done < <(find "$base" -maxdepth 1 -name "hidden_service_*" -type d 2>/dev/null)
|
||||
done
|
||||
|
||||
# If we fixed a real permission defect, restart system Tor to pick up
|
||||
# the change -- but never more than once per backoff window.
|
||||
if $fixed; then
|
||||
local now last_restart elapsed
|
||||
now=$(date +%s)
|
||||
last_restart=$(cat "$TOR_RESTART_STATE_FILE" 2>/dev/null || echo 0)
|
||||
case "$last_restart" in *[!0-9]*|"") last_restart=0 ;; esac
|
||||
elapsed=$((now - last_restart))
|
||||
if [ "$elapsed" -ge "$TOR_RESTART_BACKOFF_SECONDS" ]; then
|
||||
systemctl restart tor@default 2>/dev/null || true
|
||||
mkdir -p "$(dirname "$TOR_RESTART_STATE_FILE")" 2>/dev/null
|
||||
echo "$now" > "$TOR_RESTART_STATE_FILE" 2>/dev/null
|
||||
log "Restarted Tor to apply hidden-service permission fix"
|
||||
else
|
||||
log "Skipped Tor restart: last restart was ${elapsed}s ago (backoff window ${TOR_RESTART_BACKOFF_SECONDS}s) — permissions were still corrected"
|
||||
fi
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
# ── Fix 5: SearXNG read-only / cap-drop ─────────────────────
|
||||
fix_searxng() {
|
||||
if ! podman ps -a --format '{{.Names}}' 2>/dev/null | grep -q '^searxng$'; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
local state
|
||||
state=$(podman inspect searxng --format '{{.State.Status}}' 2>/dev/null || true)
|
||||
local readonly_root
|
||||
readonly_root=$(podman inspect searxng --format '{{.HostConfig.ReadonlyRootfs}}' 2>/dev/null || true)
|
||||
local cap_drop
|
||||
cap_drop=$(podman inspect searxng --format '{{.HostConfig.CapDrop}}' 2>/dev/null || true)
|
||||
|
||||
# Fix if: exited, or has read-only root, or has cap-drop ALL
|
||||
local needs_fix=false
|
||||
if [ "$state" = "exited" ]; then
|
||||
needs_fix=true
|
||||
fi
|
||||
if [ "$readonly_root" = "true" ]; then
|
||||
needs_fix=true
|
||||
fi
|
||||
if [[ "$cap_drop" == *"ALL"* ]] || [[ "$cap_drop" == *"all"* ]]; then
|
||||
needs_fix=true
|
||||
fi
|
||||
|
||||
if ! $needs_fix; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
log "Recreating SearXNG (readonly=$readonly_root, cap_drop=$cap_drop, state=$state)"
|
||||
|
||||
# Get current port mapping
|
||||
local port
|
||||
port=$(podman inspect searxng --format '{{range $k,$v := .HostConfig.PortBindings}}{{$k}}={{range $v}}{{.HostPort}}{{end}}{{println}}{{end}}' 2>/dev/null | head -1)
|
||||
local host_port="${port##*=}"
|
||||
host_port="${host_port:-8888}"
|
||||
|
||||
# Kill any stale conmon holding the port
|
||||
local conmon_pid
|
||||
conmon_pid=$(ss -tlnp 2>/dev/null | grep ":${host_port} " | grep -oP 'pid=\K\d+' | head -1)
|
||||
|
||||
podman stop searxng 2>/dev/null || true
|
||||
podman rm -f searxng 2>/dev/null || true
|
||||
|
||||
if [ -n "$conmon_pid" ]; then
|
||||
kill -9 "$conmon_pid" 2>/dev/null || true
|
||||
sleep 2
|
||||
fi
|
||||
|
||||
podman run -d \
|
||||
--name searxng \
|
||||
--restart=unless-stopped \
|
||||
--security-opt=no-new-privileges:true \
|
||||
--tmpfs /tmp:rw,noexec,nosuid,size=256m \
|
||||
-v searxng-config:/etc/searxng:rw \
|
||||
-v searxng-cache:/var/cache/searxng:rw \
|
||||
-p "${host_port}:8080" \
|
||||
--memory=512m \
|
||||
"${SEARXNG_IMAGE}" 2>&1 || true
|
||||
|
||||
log "SearXNG recreated (no readonly, no cap-drop ALL)"
|
||||
return 0
|
||||
}
|
||||
|
||||
# ── Fix 6: Bitcoin Knots prune+txindex conflict ──────────────
|
||||
fix_bitcoin_txindex() {
|
||||
if ! podman ps -a --format '{{.Names}}' 2>/dev/null | grep -q '^bitcoin-knots$'; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Check if bitcoin.conf has prune enabled
|
||||
local conf="/var/lib/archipelago/bitcoin/bitcoin.conf"
|
||||
if [ ! -f "$conf" ] || ! grep -q '^prune=' "$conf"; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Check if container args include txindex
|
||||
local cmd
|
||||
cmd=$(podman inspect bitcoin-knots --format '{{json .Config.Cmd}}' 2>/dev/null || true)
|
||||
if ! echo "$cmd" | grep -q "txindex"; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
log "Bitcoin Knots: prune+txindex conflict detected"
|
||||
|
||||
# Get current config
|
||||
local image
|
||||
image=$(podman inspect bitcoin-knots --format '{{.ImageName}}' 2>/dev/null)
|
||||
local network
|
||||
network=$(podman inspect bitcoin-knots --format '{{.HostConfig.NetworkMode}}' 2>/dev/null)
|
||||
|
||||
# Read per-installation RPC password
|
||||
local SECRETS_DIR="/var/lib/archipelago/secrets"
|
||||
local BTC_RPC_PASS="archipelago"
|
||||
if [ -f "$SECRETS_DIR/bitcoin-rpc-password" ]; then
|
||||
BTC_RPC_PASS=$(cat "$SECRETS_DIR/bitcoin-rpc-password")
|
||||
fi
|
||||
|
||||
# Ensure bitcoin.conf has all RPC settings
|
||||
if ! grep -q 'rpcuser=' "$conf"; then
|
||||
cat > "$conf" <<BCONF
|
||||
server=1
|
||||
prune=550
|
||||
rpcuser=archipelago
|
||||
rpcpassword=$BTC_RPC_PASS
|
||||
rpcallowip=127.0.0.1/32
|
||||
rpcallowip=10.88.0.0/16
|
||||
listen=1
|
||||
printtoconsole=0
|
||||
BCONF
|
||||
log "Updated bitcoin.conf with full RPC settings"
|
||||
fi
|
||||
|
||||
# Remove stale txindex if present
|
||||
if [ -d "/var/lib/archipelago/bitcoin/indexes/txindex" ]; then
|
||||
find /var/lib/archipelago/bitcoin/indexes/txindex -type f -delete 2>/dev/null
|
||||
rmdir /var/lib/archipelago/bitcoin/indexes/txindex 2>/dev/null || true
|
||||
log "Removed stale txindex directory"
|
||||
fi
|
||||
|
||||
# Recreate without txindex
|
||||
podman stop bitcoin-knots 2>/dev/null || true
|
||||
podman rm -f bitcoin-knots 2>/dev/null || true
|
||||
sleep 2
|
||||
|
||||
# Kill stale conmon on port 8332/8333
|
||||
for p in 8332 8333; do
|
||||
local cpid
|
||||
cpid=$(ss -tlnp 2>/dev/null | grep ":${p} " | grep -oP 'pid=\K\d+' | head -1)
|
||||
if [ -n "$cpid" ]; then
|
||||
kill -9 "$cpid" 2>/dev/null || true
|
||||
fi
|
||||
done
|
||||
sleep 1
|
||||
|
||||
local net_arg=""
|
||||
if [ -n "$network" ] && [ "$network" != "bridge" ] && [ "$network" != "host" ]; then
|
||||
net_arg="--network=$network"
|
||||
elif [ "$network" = "host" ]; then
|
||||
net_arg="--network=host"
|
||||
else
|
||||
net_arg="--network=archy-net"
|
||||
fi
|
||||
|
||||
podman run -d \
|
||||
--name bitcoin-knots \
|
||||
--restart=always \
|
||||
$net_arg \
|
||||
-p 8332:8332 \
|
||||
-p 8333:8333 \
|
||||
-v /var/lib/archipelago/bitcoin:/home/bitcoin/.bitcoin \
|
||||
--memory=2g \
|
||||
--cap-drop=ALL \
|
||||
--cap-add=CHOWN \
|
||||
--cap-add=FOWNER \
|
||||
--cap-add=SETUID \
|
||||
--cap-add=SETGID \
|
||||
--cap-add=DAC_OVERRIDE \
|
||||
--security-opt=no-new-privileges:true \
|
||||
--health-cmd="bitcoin-cli -rpcuser=archipelago -rpcpassword=$BTC_RPC_PASS getblockchaininfo || exit 1" \
|
||||
--health-interval=30s \
|
||||
--health-retries=3 \
|
||||
"$image" 2>&1 || true
|
||||
|
||||
log "Bitcoin Knots recreated without txindex (prune mode)"
|
||||
return 0
|
||||
}
|
||||
|
||||
# ── Fix 7: Exit code 127 containers ─────────────────────────
|
||||
fix_exit_127() {
|
||||
local containers
|
||||
containers=$(podman ps -a --format '{{.Names}} {{.Status}}' 2>/dev/null | grep 'Exited (127)' | awk '{print $1}' || true)
|
||||
|
||||
if [ -z "$containers" ]; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
local fixed_names=()
|
||||
for name in $containers; do
|
||||
# Skip containers handled by other fixes
|
||||
if [ "$name" = "searxng" ]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
log "Container $name has exit code 127 — recreating"
|
||||
# Get image and create command for recreation
|
||||
local image
|
||||
image=$(podman inspect "$name" --format '{{.ImageName}}' 2>/dev/null || true)
|
||||
local create_cmd
|
||||
create_cmd=$(podman inspect "$name" --format '{{json .Config.CreateCommand}}' 2>/dev/null || true)
|
||||
|
||||
podman rm -f "$name" 2>/dev/null || true
|
||||
|
||||
if [ -n "$create_cmd" ] && [ "$create_cmd" != "null" ]; then
|
||||
# Re-run the original create command (strip the leading "podman" and "run")
|
||||
local recreate_args
|
||||
recreate_args=$(echo "$create_cmd" | python3 -c "
|
||||
import json, sys
|
||||
args = json.load(sys.stdin)
|
||||
# Skip 'podman' and 'run', output the rest
|
||||
print(' '.join(['\"' + a + '\"' if ' ' in a else a for a in args[2:]]))
|
||||
" 2>/dev/null || true)
|
||||
|
||||
if [ -n "$recreate_args" ]; then
|
||||
eval "podman run $recreate_args" 2>&1 || true
|
||||
fixed_names+=("$name")
|
||||
log "Recreated $name from original args"
|
||||
else
|
||||
fixed_names+=("$name(removed)")
|
||||
log "Removed $name — will be recreated on next deploy"
|
||||
fi
|
||||
else
|
||||
fixed_names+=("$name(removed)")
|
||||
log "Removed $name — will be recreated on next deploy"
|
||||
fi
|
||||
done
|
||||
|
||||
[ ${#fixed_names[@]} -gt 0 ] && return 0 || return 1
|
||||
}
|
||||
|
||||
# ── Fix 8: Rootless netns egress lost ────────────────────────
|
||||
# Rootless podman uses pasta to give containers internet egress. If pasta's
|
||||
# tap vanishes (host link flap, mount churn, pasta dying during a boot-time
|
||||
# restart storm), the rootless-netns keeps inter-container traffic working
|
||||
# but silently loses outbound. Bitcoin IBD stalls at 0 peers; package pulls
|
||||
# fail. The repair must rebuild the netns from scratch: merely cycling the
|
||||
# containers reuses the existing (broken) netns because its holders
|
||||
# (aardvark-dns, podman's pause process) survive — observed on a test node
|
||||
# 2026-07-10, where the old stop/start-only cycle bounced all 35 containers
|
||||
# every timer run for ~an hour without ever restoring egress. So: stop the
|
||||
# containers, kill the netns holders, `podman system migrate`, clear the
|
||||
# stale netns state, then start everything back up.
|
||||
#
|
||||
# Destructive-action latch: cycling the whole fleet is a last resort. After
|
||||
# NETNS_CYCLE_MAX consecutive failed repairs we stop cycling (and log loudly)
|
||||
# until a run observes egress healthy again, which resets the counter.
|
||||
NETNS_CYCLE_STATE="/var/lib/archipelago/doctor-netns-cycle-failures"
|
||||
NETNS_CYCLE_MAX=3
|
||||
fix_rootless_netns_egress() {
|
||||
# Needs root for nsenter. When doctor runs as the rootless container owner,
|
||||
# a failed nsenter probe is a permissions artifact, not evidence of broken
|
||||
# egress; do not cycle the fleet from that context.
|
||||
[ "$(id -u)" = "0" ] || return 1
|
||||
|
||||
local archi_uid
|
||||
archi_uid=$(id -u archipelago 2>/dev/null) || return 1
|
||||
|
||||
# Locate the rootless-netns via aardvark-dns (it lives inside it).
|
||||
local aardvark_pid
|
||||
aardvark_pid=$(pgrep -U "$archi_uid" -f '^/usr/lib/podman/aardvark-dns' 2>/dev/null | head -1)
|
||||
[ -z "$aardvark_pid" ] && return 1 # no rootless network active
|
||||
|
||||
# Host precheck: if the host itself can't reach the internet, no point
|
||||
# cycling containers — this is an upstream problem.
|
||||
if ! timeout 3 bash -c '</dev/tcp/1.1.1.1/443' 2>/dev/null; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Probe egress from inside the rootless-netns. One probe is noisy;
|
||||
# require two consecutive failures 10s apart to rule out transients.
|
||||
if timeout 3 nsenter -t "$aardvark_pid" -n bash -c '</dev/tcp/1.1.1.1/443' 2>/dev/null; then
|
||||
rm -f "$NETNS_CYCLE_STATE" # healthy again — re-arm the latch
|
||||
return 1 # first probe succeeded
|
||||
fi
|
||||
sleep 10
|
||||
aardvark_pid=$(pgrep -U "$archi_uid" -f '^/usr/lib/podman/aardvark-dns' 2>/dev/null | head -1)
|
||||
[ -z "$aardvark_pid" ] && return 1
|
||||
if timeout 3 nsenter -t "$aardvark_pid" -n bash -c '</dev/tcp/1.1.1.1/443' 2>/dev/null; then
|
||||
rm -f "$NETNS_CYCLE_STATE"
|
||||
return 1 # recovered on its own
|
||||
fi
|
||||
|
||||
# Latch: don't keep bouncing the fleet when the rebuild demonstrably
|
||||
# isn't fixing it.
|
||||
local failures
|
||||
failures=$(cat "$NETNS_CYCLE_STATE" 2>/dev/null || echo 0)
|
||||
case "$failures" in *[!0-9]*|"") failures=0;; esac
|
||||
if [ "$failures" -ge "$NETNS_CYCLE_MAX" ]; then
|
||||
log "Rootless-netns egress still broken but $failures rebuilds already failed — NOT cycling again (manual intervention needed; rm $NETNS_CYCLE_STATE to re-arm)"
|
||||
return 1
|
||||
fi
|
||||
|
||||
log "Rootless-netns egress is broken (host online, container netns unreachable) — rebuilding netns"
|
||||
|
||||
local PODMANCMD="sudo -u archipelago XDG_RUNTIME_DIR=/run/user/$archi_uid podman"
|
||||
local running
|
||||
running=$($PODMANCMD ps --format '{{.Names}}' 2>/dev/null)
|
||||
if [ -z "$running" ]; then
|
||||
log " No running containers to cycle — skipping"
|
||||
return 1
|
||||
fi
|
||||
|
||||
local count
|
||||
count=$(echo "$running" | wc -l)
|
||||
log " Stopping $count running containers (graceful, 30s)..."
|
||||
$PODMANCMD stop --all --time 30 >/dev/null 2>&1
|
||||
sleep 5
|
||||
|
||||
# Tear the broken netns down for real: kill its holders and drop the
|
||||
# stale state so the first container start rebuilds pasta + aardvark-dns
|
||||
# from scratch. Without this, podman re-enters the old netns and the
|
||||
# missing pasta tap never comes back.
|
||||
log " Rebuilding rootless netns (killing holders, clearing state)..."
|
||||
pkill -U "$archi_uid" -x aardvark-dns 2>/dev/null
|
||||
pkill -U "$archi_uid" -x pasta 2>/dev/null
|
||||
pkill -U "$archi_uid" -x pasta.avx2 2>/dev/null
|
||||
pkill -U "$archi_uid" -x slirp4netns 2>/dev/null
|
||||
sleep 2
|
||||
$PODMANCMD system migrate >/dev/null 2>&1
|
||||
rm -rf "/run/user/$archi_uid/containers/networks"
|
||||
|
||||
log " Starting containers back up..."
|
||||
for c in $running; do
|
||||
$PODMANCMD start "$c" >/dev/null 2>&1 &
|
||||
done
|
||||
wait
|
||||
sleep 5
|
||||
|
||||
aardvark_pid=$(pgrep -U "$archi_uid" -f '^/usr/lib/podman/aardvark-dns' 2>/dev/null | head -1)
|
||||
if [ -n "$aardvark_pid" ] && timeout 3 nsenter -t "$aardvark_pid" -n bash -c '</dev/tcp/1.1.1.1/443' 2>/dev/null; then
|
||||
log " Rootless-netns egress restored ($count containers cycled)"
|
||||
rm -f "$NETNS_CYCLE_STATE"
|
||||
else
|
||||
failures=$((failures + 1))
|
||||
echo "$failures" > "$NETNS_CYCLE_STATE"
|
||||
log " WARN: egress still broken after rebuild (failure $failures/$NETNS_CYCLE_MAX) — may need manual intervention"
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
# ── Fix 9: Restart stopped core containers ──────────────────
|
||||
# Rootless Podman 4.x restart policies don't auto-restart on crash.
|
||||
# This check restarts any exited core containers (tiers 0-2).
|
||||
fix_stopped_core_containers() {
|
||||
local core_containers="bitcoin-knots lnd electrumx mempool-api archy-mempool-web archy-mempool-db archy-btcpay-db archy-nbxplorer btcpay-server"
|
||||
local restarted=()
|
||||
# Doctor runs as root but containers are rootless under archipelago user
|
||||
local PODMANCMD="sudo -u archipelago XDG_RUNTIME_DIR=/run/user/1000 podman"
|
||||
|
||||
for name in $core_containers; do
|
||||
local state
|
||||
state=$($PODMANCMD inspect "$name" --format '{{.State.Status}}' 2>/dev/null || echo "missing")
|
||||
if [ "$state" = "exited" ] || [ "$state" = "stopped" ]; then
|
||||
log "Restarting stopped container: $name"
|
||||
$PODMANCMD start "$name" 2>/dev/null && restarted+=("$name") || true
|
||||
fi
|
||||
done
|
||||
|
||||
[ ${#restarted[@]} -gt 0 ] && return 0 || return 1
|
||||
}
|
||||
|
||||
# ── Fix 10: Missing rootless port listeners ─────────────────
|
||||
# Rootless Podman can leave a container running with PortBindings still present
|
||||
# while the host-side rootlessport process has disappeared. Nginx then returns
|
||||
# 502 and direct app ports refuse connections even though `podman ps` looks OK.
|
||||
fix_missing_rootless_ports() {
|
||||
local containers
|
||||
containers=$(podman_rootless ps --format '{{.Names}}' 2>/dev/null || true)
|
||||
[ -n "$containers" ] || return 1
|
||||
|
||||
local fixed=false
|
||||
local name
|
||||
for name in $containers; do
|
||||
local ports
|
||||
ports=$(podman_rootless inspect "$name" --format '{{range $p,$bindings := .NetworkSettings.Ports}}{{if $bindings}}{{range $bindings}}{{.HostPort}}{{"\n"}}{{end}}{{end}}{{end}}' 2>/dev/null | sort -u)
|
||||
[ -n "$ports" ] || continue
|
||||
|
||||
local missing=()
|
||||
local port
|
||||
for port in $ports; do
|
||||
[ -n "$port" ] || continue
|
||||
if ! port_is_listening "$port"; then
|
||||
missing+=("$port")
|
||||
fi
|
||||
done
|
||||
|
||||
if [ ${#missing[@]} -gt 0 ]; then
|
||||
log "Restarting $name: missing rootlessport listener(s): ${missing[*]}"
|
||||
if podman_rootless restart "$name" >/dev/null 2>&1; then
|
||||
fixed=true
|
||||
else
|
||||
log "WARN: failed to restart $name for missing rootlessport listener(s)"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
$fixed && return 0 || return 1
|
||||
}
|
||||
|
||||
# ── Fix 11: Nginx Proxy Manager public host bridge ───────────
|
||||
# Host nginx owns public 80/443 on Archipelago. Mirror NPM proxy hosts into
|
||||
# host nginx so issued certs and public traffic reach the intended upstreams.
|
||||
fix_npm_public_hosts() {
|
||||
local script="/opt/archipelago/scripts/sync-npm-public-hosts.sh"
|
||||
[ -x "$script" ] || script="$SCRIPT_DIR/sync-npm-public-hosts.sh"
|
||||
[ -x "$script" ] || return 1
|
||||
[ -f /var/lib/archipelago/nginx-proxy-manager/data/database.sqlite ] || return 1
|
||||
|
||||
if "$script" >/dev/null 2>&1; then
|
||||
log "Synced Nginx Proxy Manager public hosts into host nginx"
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
# ── Fix 12: BTCPay Lightning route hints ─────────────────────
|
||||
# A BTCPay store whose LND node has only private (unannounced) channels
|
||||
# produces BOLT11 invoices that external wallets cannot route to unless the
|
||||
# store's lightningPrivateRouteHints flag is on — payers see "no way to pay
|
||||
# this invoice" (observed on a test node 2026-07-10 with a Blink payer). Route
|
||||
# hints are a no-op with public channels and essential with private ones, so
|
||||
# the doctor enforces the flag on every store. BTCPay reads store blobs from
|
||||
# Postgres per request; no restart needed.
|
||||
fix_btcpay_route_hints() {
|
||||
local state
|
||||
state=$(podman_rootless inspect archy-btcpay-db --format '{{.State.Status}}' 2>/dev/null || echo "missing")
|
||||
[ "$state" = "running" ] || return 1
|
||||
|
||||
local count
|
||||
count=$(podman_rootless exec archy-btcpay-db psql -U btcpay -d btcpay -t -A -c \
|
||||
"SELECT count(*) FROM \"Stores\" WHERE (\"StoreBlob\"->>'lightningPrivateRouteHints') = 'false';" 2>/dev/null)
|
||||
[ -n "$count" ] && [ "$count" -gt 0 ] 2>/dev/null || return 1
|
||||
|
||||
if podman_rootless exec archy-btcpay-db psql -U btcpay -d btcpay -q -c \
|
||||
"UPDATE \"Stores\" SET \"StoreBlob\" = jsonb_set(\"StoreBlob\", '{lightningPrivateRouteHints}', 'true'::jsonb) WHERE (\"StoreBlob\"->>'lightningPrivateRouteHints') = 'false';" >/dev/null 2>&1; then
|
||||
log "Enabled Lightning route hints on $count BTCPay store(s) (private-channel invoices were unpayable)"
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
# ── Fix 13: Missing catatonit (container init binary) ────────
|
||||
# Podman resolves `--init` (and any Portainer/compose deploy with
|
||||
# "init: true") through catatonit; Debian's podman package only
|
||||
# Recommends it, so a node installed or upgraded without it fails those
|
||||
# deploys with a missing-init error (observed on a test node 2026-07-10
|
||||
# deploying sites via Portainer). install-podman.sh covers fresh ISO
|
||||
# installs; this heals nodes that predate it.
|
||||
fix_missing_catatonit() {
|
||||
command -v catatonit >/dev/null 2>&1 && return 1
|
||||
command -v apt-get >/dev/null 2>&1 || return 1
|
||||
|
||||
if DEBIAN_FRONTEND=noninteractive apt-get install -y catatonit >/dev/null 2>&1; then
|
||||
log "Installed catatonit (init-enabled container deploys were failing)"
|
||||
return 0
|
||||
fi
|
||||
log "WARNING: catatonit missing and apt-get install failed — init-enabled deploys will fail"
|
||||
return 1
|
||||
}
|
||||
|
||||
# ── Fix 14: archipelago user missing dialout (mesh radios) ───
|
||||
# The image used to create the archipelago user with only `sudo`, so the
|
||||
# backend couldn't open /dev/ttyUSB*/ttyACM* serial LoRa radios — Mesh
|
||||
# never detected a plugged-in device (observed on the 1.7.99 RC install
|
||||
# 2026-07-13). Group change takes effect on the next service restart; we
|
||||
# restart archipelago only if a serial device is actually present.
|
||||
fix_archipelago_dialout() {
|
||||
id -nG archipelago 2>/dev/null | grep -qw dialout && return 1
|
||||
usermod -aG dialout archipelago 2>/dev/null || return 1
|
||||
log "Added archipelago to dialout (serial mesh radios were unreadable)"
|
||||
if ls /dev/ttyUSB* /dev/ttyACM* >/dev/null 2>&1; then
|
||||
systemctl try-restart archipelago 2>/dev/null || true
|
||||
log "Restarted archipelago to pick up dialout (radio present)"
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
# ── Main ─────────────────────────────────────────────────────
|
||||
|
||||
# If remote host provided, run via SSH
|
||||
if [ -n "$1" ] && [ "$1" != "--local" ]; then
|
||||
REMOTE_HOST="$1"
|
||||
SSH_KEY="${ARCHIPELAGO_SSH_KEY:-$HOME/.ssh/archipelago-deploy}"
|
||||
SSH_OPTS="-o StrictHostKeyChecking=no -o ServerAliveInterval=15 -o ServerAliveCountMax=4 -i $SSH_KEY"
|
||||
|
||||
log "Running container doctor on $REMOTE_HOST"
|
||||
|
||||
# Copy script to remote and execute
|
||||
scp $SSH_OPTS "$0" "$REMOTE_HOST:/tmp/container-doctor.sh" 2>/dev/null
|
||||
ssh $SSH_OPTS "$REMOTE_HOST" "sudo bash /tmp/container-doctor.sh --local" 2>&1
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Running locally (on the node itself)
|
||||
log "Starting container health check"
|
||||
|
||||
run_fix "stale-podman" fix_stale_podman
|
||||
run_fix "orphaned-conmon" fix_orphaned_conmon
|
||||
run_fix "system-tor" fix_system_tor_conflict
|
||||
run_fix "tor-permissions" fix_tor_permissions
|
||||
run_fix "searxng" fix_searxng
|
||||
run_fix "bitcoin-txindex" fix_bitcoin_txindex
|
||||
run_fix "exit-127" fix_exit_127
|
||||
run_fix "netns-egress" fix_rootless_netns_egress
|
||||
run_fix "stopped-core" fix_stopped_core_containers
|
||||
run_fix "rootless-ports" fix_missing_rootless_ports
|
||||
run_fix "npm-public-hosts" fix_npm_public_hosts
|
||||
run_fix "btcpay-route-hints" fix_btcpay_route_hints
|
||||
run_fix "catatonit" fix_missing_catatonit
|
||||
run_fix "dialout" fix_archipelago_dialout
|
||||
|
||||
echo ""
|
||||
if [ $FIXES_APPLIED -gt 0 ]; then
|
||||
log "Done: $FIXES_APPLIED fixes applied (${FIX_NAMES[*]}), $CHECKS_PASSED checks passed"
|
||||
else
|
||||
log "Done: all $CHECKS_PASSED checks passed — no fixes needed"
|
||||
fi
|
||||
|
||||
exit 0
|
||||
Executable
+664
@@ -0,0 +1,664 @@
|
||||
#!/bin/bash
|
||||
# Container specification registry — SINGLE SOURCE OF TRUTH
|
||||
# Every container's exact creation spec lives here.
|
||||
# Sourced by reconcile-containers.sh, first-boot-containers.sh, deploy scripts.
|
||||
#
|
||||
# Usage:
|
||||
# source container-specs.sh
|
||||
# load_spec "bitcoin-knots" # Sets SPEC_* variables
|
||||
# all_specs # Returns ordered list of all containers
|
||||
|
||||
[ -n "${_CONTAINER_SPECS_LOADED:-}" ] && return 0
|
||||
_CONTAINER_SPECS_LOADED=1
|
||||
|
||||
# Source image versions
|
||||
for f in /opt/archipelago/image-versions.sh \
|
||||
"$(dirname "${BASH_SOURCE[0]}")/image-versions.sh" \
|
||||
"$(dirname "${BASH_SOURCE[0]}")/../image-versions.sh"; do
|
||||
[ -f "$f" ] && { source "$f"; break; }
|
||||
done
|
||||
|
||||
# Source common utilities (mem_limit)
|
||||
for f in "$(dirname "${BASH_SOURCE[0]}")/lib/common.sh" \
|
||||
/opt/archipelago/scripts/lib/common.sh; do
|
||||
[ -f "$f" ] && { source "$f"; break; }
|
||||
done
|
||||
|
||||
# ── Environment detection ─────────────────────────────────────────────
|
||||
detect_environment() {
|
||||
# Measure disk where container data actually lives, not the OS partition.
|
||||
# Archipelago installs mount a separate (usually-encrypted) data volume at
|
||||
# /var/lib/archipelago on any host with meaningful storage, so checking /
|
||||
# would always report the ~30 GB OS partition and wrongly trip prune mode
|
||||
# on 2 TB boxes. Fall back to / only for first-boot before the data
|
||||
# partition is mounted.
|
||||
local disk_target="/var/lib/archipelago"
|
||||
[ -d "$disk_target" ] || disk_target="/"
|
||||
DISK_GB=$(df --output=size -BG "$disk_target" 2>/dev/null | tail -1 | tr -dc '0-9')
|
||||
DISK_GB=${DISK_GB:-500}
|
||||
TOTAL_MEM_MB=$(($(awk '/MemTotal/{print $2}' /proc/meminfo 2>/dev/null || echo 16000000) / 1024))
|
||||
LOW_MEM=false
|
||||
[ "$TOTAL_MEM_MB" -lt 12000 ] && LOW_MEM=true
|
||||
# Bitcoin UTXO cache (dbcache) sized to host RAM, NOT a fixed value.
|
||||
# A large dbcache on a small box pushes total memory (bitcoind + the ~20 app
|
||||
# containers) past physical RAM and forces system-wide swap thrash: the disk
|
||||
# saturates, bitcoind can't answer its own RPC, and the dashboard backend's
|
||||
# sqlite reads stall — surfacing as fleet-wide /rpc/v1 502s and a blank
|
||||
# Bitcoin UI. The old binary LOW_MEM->2048 toggle still over-committed 8 GB
|
||||
# nodes. Budget ~1/16 of RAM for the cache, leaving the bulk for the OS +
|
||||
# containers; floor 300 MB (bitcoind default is 450), cap 4096 MB.
|
||||
BTC_DBCACHE=$(( TOTAL_MEM_MB / 16 ))
|
||||
[ "$BTC_DBCACHE" -lt 300 ] && BTC_DBCACHE=300
|
||||
[ "$BTC_DBCACHE" -gt 4096 ] && BTC_DBCACHE=4096
|
||||
HOST_IP=$(hostname -I 2>/dev/null | awk '{print $1}')
|
||||
HOST_IP=${HOST_IP:-127.0.0.1}
|
||||
# Stable mDNS hostname for URLs that get baked into federation/consensus data.
|
||||
# Survives DHCP churn and reinstalls-on-different-IP (which $HOST_IP does not).
|
||||
# Requires avahi-daemon (shipped on all Archipelago nodes).
|
||||
HOST_MDNS="$(hostname 2>/dev/null).local"
|
||||
HOST_MDNS="${HOST_MDNS:-archipelago.local}"
|
||||
|
||||
# Secrets
|
||||
SECRETS_DIR="/var/lib/archipelago/secrets"
|
||||
BITCOIN_RPC_USER="archipelago"
|
||||
BITCOIN_RPC_PASS=$(cat "$SECRETS_DIR/bitcoin-rpc-password" 2>/dev/null || echo "")
|
||||
MEMPOOL_DB_PASS=$(cat "$SECRETS_DIR/mempool-db-password" 2>/dev/null || echo "")
|
||||
BTCPAY_DB_PASS=$(cat "$SECRETS_DIR/btcpay-db-password" 2>/dev/null || echo "")
|
||||
MYSQL_ROOT_PASS=$(cat "$SECRETS_DIR/mysql-root-db-password" 2>/dev/null || echo "")
|
||||
FEDI_HASH=$(cat "$SECRETS_DIR/fedimint-gateway-hash" 2>/dev/null || echo "")
|
||||
# Escape $ so SPEC_ENTRYPOINT survives eval in reconcile-containers.sh:build_run_cmd.
|
||||
# bcrypt hashes have the form $2y$10$... and get mangled if $2 and $10 are
|
||||
# interpolated as positional args at eval time.
|
||||
FEDI_HASH="${FEDI_HASH//\$/\\\$}"
|
||||
}
|
||||
|
||||
# ── Spec variables ────────────────────────────────────────────────────
|
||||
# Each load_spec_* function sets these variables:
|
||||
# SPEC_NAME Container name
|
||||
# SPEC_IMAGE Full image reference (pinned)
|
||||
# SPEC_NETWORK Network mode (archy-net, bridge, host)
|
||||
# SPEC_PORTS Space-separated host:container port pairs
|
||||
# SPEC_VOLUMES Space-separated host:container volume mappings
|
||||
# SPEC_MEMORY Memory limit (e.g. 2g, 512m)
|
||||
# SPEC_CAPS Space-separated capabilities to add
|
||||
# SPEC_SECURITY Security options
|
||||
# SPEC_RESTART Restart policy
|
||||
# SPEC_HEALTH_CMD Health check command
|
||||
# SPEC_ENV Space-separated KEY=VALUE environment variables
|
||||
# SPEC_CUSTOM_ARGS Extra args appended to podman run
|
||||
# SPEC_READONLY true/false for --read-only
|
||||
# SPEC_TMPFS Space-separated tmpfs mounts
|
||||
# SPEC_TIER 0=DB, 1=Core, 2=Service, 3=App, 4=UI
|
||||
# SPEC_DATA_DIR Host data directory (for ownership fix)
|
||||
# SPEC_DATA_UID Host UID:GID for data dir (rootless mapped)
|
||||
# SPEC_DEPENDS Space-separated container dependencies
|
||||
# SPEC_LOCAL_IMAGE true if image is built locally (don't pull)
|
||||
# SPEC_OPTIONAL true if container should be skipped when image missing
|
||||
|
||||
reset_spec() {
|
||||
SPEC_NAME="" SPEC_IMAGE="" SPEC_NETWORK="bridge" SPEC_PORTS=""
|
||||
SPEC_VOLUMES="" SPEC_MEMORY="512m" SPEC_CAPS="CHOWN FOWNER SETUID SETGID DAC_OVERRIDE"
|
||||
SPEC_SECURITY="no-new-privileges:true" SPEC_RESTART="unless-stopped"
|
||||
SPEC_HEALTH_CMD="" SPEC_ENV="" SPEC_CUSTOM_ARGS="" SPEC_READONLY="false"
|
||||
SPEC_TMPFS="" SPEC_TIER="3" SPEC_DATA_DIR="" SPEC_DATA_UID="100000:100000"
|
||||
# Set by a loader (e.g. fedimint-gateway) to signal "spec is valid but this
|
||||
# container must not be created/recreated right now" — e.g. a required
|
||||
# per-install secret hasn't been generated yet. Empty means no skip.
|
||||
SPEC_SKIP_REASON=""
|
||||
# SPEC_OPTIONAL defaults true: reconcile-containers.sh only REPAIRS existing
|
||||
# containers — it never creates missing ones. Baseline (filebrowser) is
|
||||
# bootstrapped by first-boot-containers.sh; all other apps come from the
|
||||
# install RPC. Per-spec `SPEC_OPTIONAL="true"` lines below are now redundant
|
||||
# but kept for readability.
|
||||
SPEC_DEPENDS="" SPEC_LOCAL_IMAGE="false" SPEC_OPTIONAL="true"
|
||||
SPEC_ENTRYPOINT=""
|
||||
}
|
||||
|
||||
if ! declare -F alloc_port >/dev/null 2>&1; then
|
||||
alloc_port() { printf '%s' "$2"; }
|
||||
fi
|
||||
|
||||
# ── Tier 0: Databases ────────────────────────────────────────────────
|
||||
|
||||
load_spec_archy-mempool-db() {
|
||||
reset_spec
|
||||
SPEC_NAME="archy-mempool-db"
|
||||
SPEC_IMAGE="${MARIADB_IMAGE}"
|
||||
SPEC_NETWORK="archy-net"
|
||||
SPEC_MEMORY="$(mem_limit archy-mempool-db)"
|
||||
SPEC_VOLUMES="/var/lib/archipelago/mysql-mempool:/var/lib/mysql"
|
||||
SPEC_HEALTH_CMD="mariadb -uroot -e 'SELECT 1' || exit 1"
|
||||
SPEC_ENV="MYSQL_DATABASE=mempool MYSQL_USER=mempool MYSQL_PASSWORD=$MEMPOOL_DB_PASS MYSQL_ROOT_PASSWORD=$MYSQL_ROOT_PASS"
|
||||
SPEC_TIER="0"
|
||||
SPEC_DATA_DIR="/var/lib/archipelago/mysql-mempool"
|
||||
SPEC_DATA_UID="100999:100999"
|
||||
SPEC_CAPS="CHOWN FOWNER SETUID SETGID DAC_OVERRIDE"
|
||||
}
|
||||
|
||||
load_spec_archy-btcpay-db() {
|
||||
reset_spec
|
||||
SPEC_NAME="archy-btcpay-db"
|
||||
SPEC_IMAGE="${BTCPAY_POSTGRES_IMAGE}"
|
||||
SPEC_NETWORK="archy-net"
|
||||
SPEC_MEMORY="$(mem_limit archy-btcpay-db)"
|
||||
SPEC_VOLUMES="/var/lib/archipelago/postgres-btcpay:/var/lib/postgresql/data"
|
||||
SPEC_HEALTH_CMD="pg_isready -U postgres || exit 1"
|
||||
SPEC_ENV="POSTGRES_DB=btcpay POSTGRES_USER=btcpay POSTGRES_PASSWORD=$BTCPAY_DB_PASS"
|
||||
SPEC_TIER="0"
|
||||
SPEC_DATA_DIR="/var/lib/archipelago/postgres-btcpay"
|
||||
SPEC_DATA_UID="100070:100070"
|
||||
SPEC_CAPS="CHOWN FOWNER SETUID SETGID DAC_OVERRIDE"
|
||||
}
|
||||
|
||||
load_spec_immich_postgres() {
|
||||
reset_spec
|
||||
SPEC_NAME="immich_postgres"
|
||||
SPEC_IMAGE="${IMMICH_POSTGRES_IMAGE}"
|
||||
SPEC_NETWORK="bridge"
|
||||
SPEC_MEMORY="$(mem_limit immich_postgres)"
|
||||
SPEC_VOLUMES="/var/lib/archipelago/immich-db:/var/lib/postgresql/data"
|
||||
SPEC_ENV="POSTGRES_USER=postgres POSTGRES_DB=immich POSTGRES_PASSWORD=$BTCPAY_DB_PASS"
|
||||
SPEC_TIER="0"
|
||||
SPEC_DATA_DIR="/var/lib/archipelago/immich-db"
|
||||
SPEC_DATA_UID="100070:100070"
|
||||
SPEC_CAPS="CHOWN FOWNER SETUID SETGID DAC_OVERRIDE"
|
||||
SPEC_OPTIONAL="true"
|
||||
}
|
||||
|
||||
load_spec_immich_redis() {
|
||||
reset_spec
|
||||
SPEC_NAME="immich_redis"
|
||||
SPEC_IMAGE="${VALKEY_IMAGE}"
|
||||
SPEC_NETWORK="bridge"
|
||||
SPEC_MEMORY="$(mem_limit immich_redis)"
|
||||
SPEC_TIER="0"
|
||||
SPEC_CAPS="CHOWN SETUID SETGID"
|
||||
SPEC_OPTIONAL="true"
|
||||
}
|
||||
|
||||
# ── Tier 1: Core Infrastructure ──────────────────────────────────────
|
||||
|
||||
load_spec_bitcoin-knots() {
|
||||
reset_spec
|
||||
SPEC_NAME="bitcoin-knots"
|
||||
SPEC_IMAGE="${BITCOIN_KNOTS_IMAGE}"
|
||||
SPEC_NETWORK="archy-net"
|
||||
SPEC_PORTS="8332:8332 8333:8333"
|
||||
SPEC_VOLUMES="/var/lib/archipelago/bitcoin:/home/bitcoin/.bitcoin"
|
||||
SPEC_MEMORY="$(mem_limit bitcoin-knots)"
|
||||
SPEC_HEALTH_CMD="bitcoin-cli -rpcuser=\$BITCOIN_RPC_USER -rpcpassword=\$BITCOIN_RPC_PASS getblockchaininfo || exit 1"
|
||||
SPEC_TIER="1"
|
||||
SPEC_DATA_DIR="/var/lib/archipelago/bitcoin"
|
||||
SPEC_DATA_UID="100101:100101"
|
||||
local btc_rpc_headroom="-rpcthreads=16 -rpcworkqueue=256"
|
||||
local btc_txrelay_flags="-rpcwhitelistdefault=0"
|
||||
if [ -f "$SECRETS_DIR/bitcoin-rpc-txrelay-rpcauth" ]; then
|
||||
btc_txrelay_flags="$btc_txrelay_flags -rpcauth=$(cat "$SECRETS_DIR/bitcoin-rpc-txrelay-rpcauth") -rpcwhitelist=txrelay:sendrawtransaction,submitpackage,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblock,getblockheader,getrawtransaction,gettxout,gettxspendingprevout,decoderawtransaction,decodescript,estimatesmartfee,uptime,ping,getconnectioncount,getpeerinfo,getindexinfo,getdeploymentinfo,getchaintips"
|
||||
fi
|
||||
# Dynamic: prune on small disk
|
||||
if [ "${DISK_GB:-0}" -lt 1000 ]; then
|
||||
SPEC_CUSTOM_ARGS="-server=1 -prune=550 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=${BTC_DBCACHE} -par=0 -maxconnections=125 ${btc_rpc_headroom} ${btc_txrelay_flags}"
|
||||
else
|
||||
SPEC_CUSTOM_ARGS="-server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=${BTC_DBCACHE} -par=0 -maxconnections=125 ${btc_rpc_headroom} ${btc_txrelay_flags}"
|
||||
fi
|
||||
}
|
||||
|
||||
load_spec_electrumx() {
|
||||
reset_spec
|
||||
SPEC_NAME="electrumx"
|
||||
SPEC_IMAGE="${ELECTRUMX_IMAGE}"
|
||||
SPEC_NETWORK="archy-net"
|
||||
SPEC_PORTS="50001:50001"
|
||||
SPEC_VOLUMES="/var/lib/archipelago/electrumx:/data"
|
||||
SPEC_MEMORY="$(mem_limit electrumx)"
|
||||
SPEC_HEALTH_CMD="python3 -c 'import socket; socket.create_connection((\\\"localhost\\\",8000),2).close()' || exit 1"
|
||||
SPEC_ENV="DAEMON_URL=http://$BITCOIN_RPC_USER:$BITCOIN_RPC_PASS@bitcoin-knots:8332/ COIN=Bitcoin DB_DIRECTORY=/data SERVICES=tcp://:50001,rpc://0.0.0.0:8000"
|
||||
SPEC_TIER="1"
|
||||
SPEC_DATA_DIR="/var/lib/archipelago/electrumx"
|
||||
SPEC_DEPENDS="bitcoin-knots"
|
||||
SPEC_CAPS="DAC_OVERRIDE"
|
||||
}
|
||||
|
||||
# ── Tier 2: Services ─────────────────────────────────────────────────
|
||||
|
||||
load_spec_lnd() {
|
||||
reset_spec
|
||||
SPEC_NAME="lnd"
|
||||
SPEC_IMAGE="${LND_IMAGE}"
|
||||
SPEC_NETWORK="archy-net"
|
||||
SPEC_PORTS="9735:9735 10009:10009 18080:8080"
|
||||
SPEC_VOLUMES="/var/lib/archipelago/lnd:/root/.lnd"
|
||||
SPEC_MEMORY="$(mem_limit lnd)"
|
||||
SPEC_CAPS="CHOWN FOWNER SETUID SETGID DAC_OVERRIDE NET_RAW"
|
||||
SPEC_HEALTH_CMD="lncli --tlscertpath /root/.lnd/tls.cert --macaroonpath /root/.lnd/data/chain/bitcoin/mainnet/readonly.macaroon --rpcserver localhost:10009 getinfo > /dev/null 2>&1 || exit 1"
|
||||
SPEC_TIER="2"
|
||||
SPEC_DATA_DIR="/var/lib/archipelago/lnd"
|
||||
SPEC_DEPENDS="bitcoin-knots"
|
||||
}
|
||||
|
||||
load_spec_mempool-api() {
|
||||
reset_spec
|
||||
SPEC_NAME="mempool-api"
|
||||
SPEC_IMAGE="${MEMPOOL_BACKEND_IMAGE}"
|
||||
SPEC_NETWORK="archy-net"
|
||||
SPEC_PORTS="8999:8999"
|
||||
SPEC_VOLUMES="/var/lib/archipelago/mempool:/data"
|
||||
SPEC_MEMORY="$(mem_limit mempool-api)"
|
||||
SPEC_HEALTH_CMD="curl -sf http://localhost:8999/ || exit 1"
|
||||
local MYSQL_CNT="archy-mempool-db"
|
||||
SPEC_ENV="MEMPOOL_BACKEND=electrum ELECTRUM_HOST=electrumx ELECTRUM_PORT=50001 ELECTRUM_TLS_ENABLED=false CORE_RPC_HOST=bitcoin-knots CORE_RPC_PORT=8332 CORE_RPC_USERNAME=$BITCOIN_RPC_USER CORE_RPC_PASSWORD=$BITCOIN_RPC_PASS DATABASE_ENABLED=true DATABASE_HOST=$MYSQL_CNT DATABASE_DATABASE=mempool DATABASE_USERNAME=mempool DATABASE_PASSWORD=$MEMPOOL_DB_PASS"
|
||||
SPEC_TIER="2"
|
||||
SPEC_DATA_DIR="/var/lib/archipelago/mempool"
|
||||
SPEC_DEPENDS="bitcoin-knots electrumx archy-mempool-db"
|
||||
SPEC_CAPS=""
|
||||
}
|
||||
|
||||
load_spec_archy-mempool-web() {
|
||||
reset_spec
|
||||
SPEC_NAME="archy-mempool-web"
|
||||
SPEC_IMAGE="${MEMPOOL_WEB_IMAGE}"
|
||||
SPEC_NETWORK="archy-net"
|
||||
SPEC_PORTS="4080:8080"
|
||||
SPEC_MEMORY="$(mem_limit archy-mempool-web)"
|
||||
SPEC_HEALTH_CMD="curl -sf http://localhost:8080/ || exit 1"
|
||||
SPEC_ENV="FRONTEND_HTTP_PORT=8080 BACKEND_MAINNET_HTTP_HOST=mempool-api"
|
||||
SPEC_TIER="2"
|
||||
SPEC_DEPENDS="mempool-api"
|
||||
SPEC_CAPS=""
|
||||
}
|
||||
|
||||
load_spec_archy-nbxplorer() {
|
||||
reset_spec
|
||||
SPEC_NAME="archy-nbxplorer"
|
||||
SPEC_IMAGE="${NBXPLORER_IMAGE}"
|
||||
SPEC_NETWORK="archy-net"
|
||||
SPEC_PORTS="32838:32838"
|
||||
SPEC_VOLUMES="/var/lib/archipelago/nbxplorer:/data"
|
||||
SPEC_MEMORY="$(mem_limit archy-nbxplorer)"
|
||||
SPEC_HEALTH_CMD="curl -sf http://localhost:32838/ || exit 1"
|
||||
SPEC_ENV="NBXPLORER_DATADIR=/data NBXPLORER_NETWORK=mainnet NBXPLORER_CHAINS=btc NBXPLORER_BIND=0.0.0.0:32838 NBXPLORER_BTCRPCURL=http://bitcoin-knots:8332 NBXPLORER_BTCRPCUSER=$BITCOIN_RPC_USER NBXPLORER_BTCRPCPASSWORD=$BITCOIN_RPC_PASS NBXPLORER_POSTGRES=Username=btcpay;Password=$BTCPAY_DB_PASS;Host=archy-btcpay-db;Port=5432;Database=nbxplorer"
|
||||
SPEC_TIER="2"
|
||||
SPEC_DATA_DIR="/var/lib/archipelago/nbxplorer"
|
||||
SPEC_DEPENDS="bitcoin-knots archy-btcpay-db"
|
||||
SPEC_CAPS=""
|
||||
}
|
||||
|
||||
load_spec_btcpay-server() {
|
||||
reset_spec
|
||||
SPEC_NAME="btcpay-server"
|
||||
SPEC_IMAGE="${BTCPAY_IMAGE}"
|
||||
SPEC_NETWORK="archy-net"
|
||||
SPEC_PORTS="23000:49392"
|
||||
SPEC_VOLUMES="/var/lib/archipelago/btcpay:/datadir"
|
||||
SPEC_MEMORY="$(mem_limit btcpay-server)"
|
||||
SPEC_HEALTH_CMD="bash -ec '</dev/tcp/127.0.0.1/49392'"
|
||||
SPEC_ENV="ASPNETCORE_URLS=http://0.0.0.0:49392 BTCPAY_PROTOCOL=http BTCPAY_HOST=$HOST_IP:23000 BTCPAY_CHAINS=btc BTCPAY_BTCEXPLORERURL=http://archy-nbxplorer:32838 BTCPAY_BTCRPCURL=http://bitcoin-knots:8332 BTCPAY_BTCRPCUSER=$BITCOIN_RPC_USER BTCPAY_BTCRPCPASSWORD=$BITCOIN_RPC_PASS BTCPAY_POSTGRES=Username=btcpay;Password=$BTCPAY_DB_PASS;Host=archy-btcpay-db;Port=5432;Database=btcpay"
|
||||
SPEC_TIER="2"
|
||||
SPEC_DATA_DIR="/var/lib/archipelago/btcpay"
|
||||
SPEC_DEPENDS="archy-nbxplorer archy-btcpay-db"
|
||||
}
|
||||
|
||||
load_spec_fedimint() {
|
||||
reset_spec
|
||||
SPEC_NAME="fedimint"
|
||||
SPEC_IMAGE="${FEDIMINT_IMAGE}"
|
||||
SPEC_NETWORK="archy-net"
|
||||
SPEC_PORTS="8173:8173 8174:8174 8175:8175"
|
||||
SPEC_VOLUMES="/var/lib/archipelago/fedimint:/data"
|
||||
SPEC_MEMORY="$(mem_limit fedimint)"
|
||||
SPEC_HEALTH_CMD="curl -sf http://localhost:8175/ || exit 1"
|
||||
SPEC_ENV="FM_DATA_DIR=/data FM_BITCOIND_USERNAME=$BITCOIN_RPC_USER FM_BITCOIND_PASSWORD=$BITCOIN_RPC_PASS FM_BITCOIN_NETWORK=bitcoin FM_BIND_P2P=0.0.0.0:8173 FM_BIND_API=0.0.0.0:8174 FM_BIND_UI=0.0.0.0:8175 FM_P2P_URL=fedimint://$HOST_MDNS:8173 FM_API_URL=ws://$HOST_MDNS:8174 FM_BITCOIND_URL=http://bitcoin-knots:8332"
|
||||
SPEC_TIER="2"
|
||||
SPEC_DATA_DIR="/var/lib/archipelago/fedimint"
|
||||
SPEC_DEPENDS="bitcoin-knots"
|
||||
SPEC_OPTIONAL="true"
|
||||
}
|
||||
|
||||
load_spec_fedimint-gateway() {
|
||||
reset_spec
|
||||
SPEC_NAME="fedimint-gateway"
|
||||
SPEC_IMAGE="${FEDIMINT_GATEWAY_IMAGE}"
|
||||
SPEC_NETWORK="archy-net"
|
||||
SPEC_PORTS="8176:8176"
|
||||
SPEC_VOLUMES="/var/lib/archipelago/fedimint-gateway:/data"
|
||||
SPEC_MEMORY="$(mem_limit fedimint-gateway)"
|
||||
SPEC_HEALTH_CMD="curl -sf http://localhost:8176/ || exit 1"
|
||||
SPEC_TIER="2"
|
||||
SPEC_DATA_DIR="/var/lib/archipelago/fedimint-gateway"
|
||||
SPEC_DEPENDS="bitcoin-knots fedimint"
|
||||
SPEC_OPTIONAL="true"
|
||||
# FED-07: no shipped fallback credential. If the per-install bcrypt hash
|
||||
# (fedimint-gateway-hash, generated by container::secrets::ensure_gateway_credential
|
||||
# via the daemon, first-boot, or reconcile secret-generation step) doesn't
|
||||
# exist yet, do not build an entrypoint with an empty --bcrypt-password-hash
|
||||
# — skip creating/recreating this container and let the caller retry once
|
||||
# the credential exists.
|
||||
if [ -z "$FEDI_HASH" ]; then
|
||||
SPEC_SKIP_REASON="fedimint-gateway credential not generated yet (no shipped default; will retry once a per-install credential exists)"
|
||||
return
|
||||
fi
|
||||
# Custom entrypoint depends on whether LND is available
|
||||
local LND_CERT=/var/lib/archipelago/lnd/tls.cert
|
||||
local LND_MAC=/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/admin.macaroon
|
||||
if [ -f "$LND_CERT" ] && [ -f "$LND_MAC" ]; then
|
||||
SPEC_VOLUMES="$SPEC_VOLUMES $LND_CERT:/lnd/tls.cert:ro $LND_MAC:/lnd/admin.macaroon:ro"
|
||||
SPEC_ENTRYPOINT="gatewayd --data-dir /data --listen 0.0.0.0:8176 --bcrypt-password-hash $FEDI_HASH --network bitcoin --bitcoind-url http://bitcoin-knots:8332 --bitcoind-username $BITCOIN_RPC_USER --bitcoind-password $BITCOIN_RPC_PASS lnd --lnd-rpc-host lnd:10009 --lnd-tls-cert /lnd/tls.cert --lnd-macaroon /lnd/admin.macaroon"
|
||||
else
|
||||
SPEC_PORTS="8176:8176 9737:9737"
|
||||
SPEC_ENTRYPOINT="gatewayd --data-dir /data --listen 0.0.0.0:8176 --bcrypt-password-hash $FEDI_HASH --network bitcoin --bitcoind-url http://bitcoin-knots:8332 --bitcoind-username $BITCOIN_RPC_USER --bitcoind-password $BITCOIN_RPC_PASS ldk --ldk-lightning-port 9737 --ldk-alias archipelago-gateway"
|
||||
fi
|
||||
}
|
||||
|
||||
load_spec_immich_server() {
|
||||
reset_spec
|
||||
SPEC_NAME="immich_server"
|
||||
SPEC_IMAGE="${IMMICH_SERVER_IMAGE}"
|
||||
SPEC_NETWORK="bridge"
|
||||
SPEC_PORTS="2283:2283"
|
||||
SPEC_VOLUMES="/var/lib/archipelago/immich:/usr/src/app/upload"
|
||||
SPEC_MEMORY="$(mem_limit immich_server)"
|
||||
SPEC_ENV="DB_HOSTNAME=immich_postgres DB_DATABASE_NAME=immich DB_USERNAME=postgres DB_PASSWORD=$BTCPAY_DB_PASS REDIS_HOSTNAME=immich_redis UPLOAD_LOCATION=/usr/src/app/upload"
|
||||
SPEC_TIER="2"
|
||||
SPEC_DATA_DIR="/var/lib/archipelago/immich"
|
||||
SPEC_DEPENDS="immich_postgres immich_redis"
|
||||
SPEC_CAPS=""
|
||||
SPEC_OPTIONAL="true"
|
||||
}
|
||||
|
||||
# ── Tier 3: Applications ─────────────────────────────────────────────
|
||||
|
||||
load_spec_homeassistant() {
|
||||
reset_spec
|
||||
SPEC_NAME="homeassistant"
|
||||
SPEC_IMAGE="${HOMEASSISTANT_IMAGE}"
|
||||
SPEC_PORTS="8123:8123"
|
||||
SPEC_VOLUMES="/var/lib/archipelago/home-assistant:/config"
|
||||
SPEC_MEMORY="$(mem_limit homeassistant)"
|
||||
SPEC_HEALTH_CMD="curl -sf http://localhost:8123/ || exit 1"
|
||||
SPEC_ENV="TZ=UTC"
|
||||
SPEC_TIER="3"
|
||||
SPEC_DATA_DIR="/var/lib/archipelago/home-assistant"
|
||||
SPEC_CAPS="CHOWN SETUID SETGID DAC_OVERRIDE NET_BIND_SERVICE"
|
||||
SPEC_OPTIONAL="true"
|
||||
}
|
||||
|
||||
load_spec_grafana() {
|
||||
reset_spec
|
||||
SPEC_NAME="grafana"
|
||||
SPEC_IMAGE="${GRAFANA_IMAGE}"
|
||||
SPEC_PORTS="3000:3000"
|
||||
SPEC_VOLUMES="/var/lib/archipelago/grafana:/var/lib/grafana"
|
||||
SPEC_MEMORY="$(mem_limit grafana)"
|
||||
SPEC_HEALTH_CMD="curl -sf http://localhost:3000/api/health || exit 1"
|
||||
SPEC_ENV="GF_PATHS_DATA=/var/lib/grafana GF_USERS_ALLOW_SIGN_UP=false"
|
||||
SPEC_READONLY="true"
|
||||
SPEC_TMPFS="/tmp:rw,noexec,nosuid,size=256m /run:rw,noexec,nosuid,size=64m"
|
||||
SPEC_TIER="3"
|
||||
SPEC_DATA_DIR="/var/lib/archipelago/grafana"
|
||||
SPEC_DATA_UID="100472:100472"
|
||||
SPEC_CAPS="CHOWN SETUID SETGID DAC_OVERRIDE NET_BIND_SERVICE"
|
||||
SPEC_OPTIONAL="true"
|
||||
}
|
||||
|
||||
load_spec_uptime-kuma() {
|
||||
reset_spec
|
||||
SPEC_NAME="uptime-kuma"
|
||||
SPEC_IMAGE="${UPTIME_KUMA_IMAGE}"
|
||||
SPEC_PORTS="3002:3001"
|
||||
SPEC_VOLUMES="/var/lib/archipelago/uptime-kuma:/app/data"
|
||||
SPEC_MEMORY="$(mem_limit uptime-kuma)"
|
||||
SPEC_HEALTH_CMD="curl -sf http://localhost:3001/ || exit 1"
|
||||
SPEC_ENV="TZ=UTC"
|
||||
SPEC_TIER="3"
|
||||
SPEC_DATA_DIR="/var/lib/archipelago/uptime-kuma"
|
||||
SPEC_CAPS="CHOWN FOWNER SETUID SETGID"
|
||||
SPEC_OPTIONAL="true"
|
||||
}
|
||||
|
||||
load_spec_jellyfin() {
|
||||
reset_spec
|
||||
SPEC_NAME="jellyfin"
|
||||
SPEC_IMAGE="${JELLYFIN_IMAGE}"
|
||||
SPEC_PORTS="8096:8096"
|
||||
SPEC_VOLUMES="/var/lib/archipelago/jellyfin/config:/config /var/lib/archipelago/jellyfin/cache:/cache"
|
||||
SPEC_MEMORY="$(mem_limit jellyfin)"
|
||||
SPEC_HEALTH_CMD="curl -sf http://localhost:8096/ || exit 1"
|
||||
SPEC_TIER="3"
|
||||
SPEC_DATA_DIR="/var/lib/archipelago/jellyfin"
|
||||
SPEC_CAPS=""
|
||||
SPEC_OPTIONAL="true"
|
||||
}
|
||||
|
||||
load_spec_photoprism() {
|
||||
reset_spec
|
||||
SPEC_NAME="photoprism"
|
||||
SPEC_IMAGE="${PHOTOPRISM_IMAGE}"
|
||||
SPEC_PORTS="2342:2342"
|
||||
SPEC_VOLUMES="/var/lib/archipelago/photoprism:/photoprism/storage"
|
||||
SPEC_MEMORY="$(mem_limit photoprism)"
|
||||
SPEC_HEALTH_CMD="curl -sf http://localhost:2342/ || exit 1"
|
||||
SPEC_ENV="PHOTOPRISM_ADMIN_PASSWORD=archipelago PHOTOPRISM_DEFAULT_LOCALE=en"
|
||||
SPEC_TIER="3"
|
||||
SPEC_DATA_DIR="/var/lib/archipelago/photoprism"
|
||||
SPEC_CAPS="CHOWN SETUID SETGID"
|
||||
SPEC_OPTIONAL="true"
|
||||
}
|
||||
|
||||
load_spec_vaultwarden() {
|
||||
reset_spec
|
||||
SPEC_NAME="vaultwarden"
|
||||
SPEC_IMAGE="${VAULTWARDEN_IMAGE}"
|
||||
SPEC_PORTS="8082:80"
|
||||
SPEC_VOLUMES="/var/lib/archipelago/vaultwarden:/data"
|
||||
SPEC_MEMORY="$(mem_limit vaultwarden)"
|
||||
SPEC_HEALTH_CMD="curl -sf http://localhost:80/ || exit 1"
|
||||
SPEC_TIER="3"
|
||||
SPEC_DATA_DIR="/var/lib/archipelago/vaultwarden"
|
||||
SPEC_CAPS="CHOWN SETUID SETGID NET_BIND_SERVICE"
|
||||
SPEC_OPTIONAL="true"
|
||||
}
|
||||
|
||||
load_spec_nextcloud() {
|
||||
reset_spec
|
||||
SPEC_NAME="nextcloud"
|
||||
SPEC_IMAGE="${NEXTCLOUD_IMAGE}"
|
||||
SPEC_PORTS="8085:80"
|
||||
SPEC_VOLUMES="/var/lib/archipelago/nextcloud:/var/www/html"
|
||||
SPEC_MEMORY="$(mem_limit nextcloud)"
|
||||
SPEC_HEALTH_CMD="curl -sf http://localhost:80/ || exit 1"
|
||||
SPEC_TIER="3"
|
||||
SPEC_DATA_DIR="/var/lib/archipelago/nextcloud"
|
||||
SPEC_CAPS="CHOWN SETUID SETGID DAC_OVERRIDE NET_BIND_SERVICE"
|
||||
SPEC_OPTIONAL="true"
|
||||
}
|
||||
|
||||
load_spec_searxng() {
|
||||
reset_spec
|
||||
SPEC_NAME="searxng"
|
||||
SPEC_IMAGE="${SEARXNG_IMAGE}"
|
||||
SPEC_PORTS="8888:8080"
|
||||
SPEC_MEMORY="$(mem_limit searxng)"
|
||||
SPEC_VOLUMES="/var/lib/archipelago/searxng:/etc/searxng"
|
||||
SPEC_HEALTH_CMD="curl -sf http://localhost:8080/ || exit 1"
|
||||
SPEC_READONLY="true"
|
||||
SPEC_TMPFS="/tmp:rw,noexec,nosuid,size=256m /run:rw,noexec,nosuid,size=64m"
|
||||
SPEC_TIER="3"
|
||||
SPEC_CAPS=""
|
||||
SPEC_DATA_DIR="/var/lib/archipelago/searxng"
|
||||
SPEC_OPTIONAL="true"
|
||||
}
|
||||
|
||||
load_spec_filebrowser() {
|
||||
reset_spec
|
||||
SPEC_NAME="filebrowser"
|
||||
SPEC_IMAGE="${FILEBROWSER_IMAGE}"
|
||||
SPEC_NETWORK="archy-net"
|
||||
SPEC_PORTS="8083:80"
|
||||
SPEC_VOLUMES="/var/lib/archipelago/filebrowser:/srv /var/lib/archipelago/filebrowser-data:/data"
|
||||
SPEC_MEMORY="$(mem_limit filebrowser)"
|
||||
SPEC_HEALTH_CMD="wget -q --spider http://localhost:80/health || exit 1"
|
||||
SPEC_TIER="3"
|
||||
SPEC_DATA_DIR="/var/lib/archipelago/filebrowser"
|
||||
SPEC_DATA_UID="100000:100000"
|
||||
# first-boot-containers.sh writes /data/.filebrowser.json (see filebrowser
|
||||
# creation block at ~line 1128). Config path is required or filebrowser
|
||||
# opens /database.db in CWD and fails with permission denied.
|
||||
SPEC_CUSTOM_ARGS="--config /data/.filebrowser.json"
|
||||
# Needs default caps (CHOWN FOWNER SETUID SETGID DAC_OVERRIDE) from reset_spec
|
||||
# for rootless userns-root to write /data/filebrowser.db, plus NET_BIND_SERVICE
|
||||
# to listen on port 80.
|
||||
SPEC_CAPS="CHOWN FOWNER SETUID SETGID DAC_OVERRIDE NET_BIND_SERVICE"
|
||||
SPEC_OPTIONAL="true"
|
||||
}
|
||||
|
||||
load_spec_nginx-proxy-manager() {
|
||||
reset_spec
|
||||
SPEC_NAME="nginx-proxy-manager"
|
||||
SPEC_IMAGE="${NPM_IMAGE}"
|
||||
local admin_port http_port https_port
|
||||
admin_port=$(alloc_port nginx-proxy-manager 8081 81)
|
||||
http_port=$(alloc_port nginx-proxy-manager-http 8084 80)
|
||||
https_port=$(alloc_port nginx-proxy-manager-https 8444 443)
|
||||
SPEC_PORTS="$admin_port:81 $http_port:80 $https_port:443"
|
||||
SPEC_VOLUMES="/var/lib/archipelago/nginx-proxy-manager/data:/data /var/lib/archipelago/nginx-proxy-manager/letsencrypt:/etc/letsencrypt"
|
||||
SPEC_MEMORY="$(mem_limit nginx-proxy-manager)"
|
||||
SPEC_HEALTH_CMD="curl -sf http://localhost:81/ || exit 1"
|
||||
SPEC_TIER="3"
|
||||
SPEC_DATA_DIR="/var/lib/archipelago/nginx-proxy-manager"
|
||||
SPEC_CAPS="CHOWN FOWNER SETUID SETGID DAC_OVERRIDE NET_BIND_SERVICE"
|
||||
SPEC_OPTIONAL="true"
|
||||
}
|
||||
|
||||
load_spec_portainer() {
|
||||
reset_spec
|
||||
SPEC_NAME="portainer"
|
||||
SPEC_IMAGE="${PORTAINER_IMAGE}"
|
||||
SPEC_PORTS="9000:9000"
|
||||
SPEC_VOLUMES="/var/lib/archipelago/portainer:/data /run/user/1000/podman/podman.sock:/var/run/docker.sock /var/lib/archipelago/portainer/compose:/data/compose"
|
||||
SPEC_MEMORY="$(mem_limit portainer)"
|
||||
SPEC_HEALTH_CMD="curl -sf http://localhost:9000/ || exit 1"
|
||||
SPEC_TIER="3"
|
||||
SPEC_DATA_DIR="/var/lib/archipelago/portainer"
|
||||
SPEC_DATA_UID="1000:1000"
|
||||
SPEC_OPTIONAL="true"
|
||||
}
|
||||
|
||||
load_spec_ollama() {
|
||||
reset_spec
|
||||
SPEC_NAME="ollama"
|
||||
SPEC_IMAGE="${OLLAMA_IMAGE}"
|
||||
SPEC_PORTS="11434:11434"
|
||||
SPEC_VOLUMES="/var/lib/archipelago/ollama:/root/.ollama"
|
||||
SPEC_MEMORY="$(mem_limit ollama)"
|
||||
SPEC_HEALTH_CMD="curl -sf http://localhost:11434/ || exit 1"
|
||||
SPEC_READONLY="true"
|
||||
SPEC_TMPFS="/tmp:rw,noexec,nosuid,size=256m /run:rw,noexec,nosuid,size=64m"
|
||||
SPEC_TIER="3"
|
||||
SPEC_DATA_DIR="/var/lib/archipelago/ollama"
|
||||
SPEC_CAPS=""
|
||||
SPEC_OPTIONAL="true"
|
||||
}
|
||||
|
||||
# ── Tier 4: Frontend UIs ─────────────────────────────────────────────
|
||||
|
||||
load_spec_archy-bitcoin-ui() {
|
||||
reset_spec
|
||||
SPEC_NAME="archy-bitcoin-ui"
|
||||
SPEC_IMAGE="localhost/bitcoin-ui:local"
|
||||
SPEC_NETWORK="host"
|
||||
SPEC_VOLUMES="/var/lib/archipelago/bitcoin-ui/nginx.conf:/etc/nginx/conf.d/default.conf:ro"
|
||||
SPEC_MEMORY="$(mem_limit archy-bitcoin-ui)"
|
||||
SPEC_TIER="4"
|
||||
SPEC_LOCAL_IMAGE="true"
|
||||
SPEC_CAPS="CHOWN SETUID SETGID"
|
||||
SPEC_SECURITY="no-new-privileges:true"
|
||||
}
|
||||
|
||||
load_spec_archy-lnd-ui() {
|
||||
reset_spec
|
||||
SPEC_NAME="archy-lnd-ui"
|
||||
SPEC_IMAGE="localhost/lnd-ui:local"
|
||||
# Host-networked, NOT bridge with 18083:80. docker/lnd-ui/nginx.conf listens
|
||||
# on 18083 directly (it must, so it can proxy to the backend on
|
||||
# 127.0.0.1:5678 without a cross-origin hop). This spec used to say
|
||||
# SPEC_PORTS="18083:80", which published host 18083 to container port 80 —
|
||||
# where nothing listens. Nobody noticed because the running containers were
|
||||
# created by first-boot-containers.sh, which is host-networked and never
|
||||
# consults this file; the spec is only read when self-update.sh rebuilds a
|
||||
# UI image, and that only fires when a file under docker/lnd-ui/ changes.
|
||||
# Verified on a test node: recreating from the old spec left :18083
|
||||
# refusing connections.
|
||||
SPEC_NETWORK="host"
|
||||
SPEC_MEMORY="$(mem_limit archy-lnd-ui)"
|
||||
SPEC_TIER="4"
|
||||
SPEC_LOCAL_IMAGE="true"
|
||||
SPEC_CAPS="CHOWN SETUID SETGID"
|
||||
SPEC_SECURITY="no-new-privileges:true"
|
||||
}
|
||||
|
||||
load_spec_archy-electrs-ui() {
|
||||
reset_spec
|
||||
SPEC_NAME="archy-electrs-ui"
|
||||
SPEC_IMAGE="localhost/electrs-ui:local"
|
||||
SPEC_NETWORK="host"
|
||||
SPEC_MEMORY="$(mem_limit archy-electrs-ui)"
|
||||
SPEC_TIER="4"
|
||||
SPEC_LOCAL_IMAGE="true"
|
||||
SPEC_CAPS="CHOWN SETUID SETGID"
|
||||
SPEC_SECURITY="no-new-privileges:true"
|
||||
}
|
||||
|
||||
# ── Registry ─────────────────────────────────────────────────────────
|
||||
# Ordered by tier, then dependency order within tier
|
||||
|
||||
ALL_CONTAINER_SPECS=(
|
||||
# Tier 0: Databases
|
||||
archy-mempool-db
|
||||
archy-btcpay-db
|
||||
immich_postgres
|
||||
immich_redis
|
||||
# Tier 1: Core
|
||||
bitcoin-knots
|
||||
electrumx
|
||||
# Tier 2: Services
|
||||
lnd
|
||||
mempool-api
|
||||
archy-mempool-web
|
||||
archy-nbxplorer
|
||||
btcpay-server
|
||||
fedimint
|
||||
fedimint-gateway
|
||||
immich_server
|
||||
# Tier 3: Apps
|
||||
homeassistant
|
||||
grafana
|
||||
uptime-kuma
|
||||
jellyfin
|
||||
photoprism
|
||||
vaultwarden
|
||||
nextcloud
|
||||
searxng
|
||||
filebrowser
|
||||
nginx-proxy-manager
|
||||
portainer
|
||||
ollama
|
||||
# Tier 4: UIs
|
||||
archy-bitcoin-ui
|
||||
archy-lnd-ui
|
||||
archy-electrs-ui
|
||||
)
|
||||
|
||||
# Load a spec by name. Usage: load_spec "bitcoin-knots"
|
||||
load_spec() {
|
||||
local fn="load_spec_${1}"
|
||||
if declare -f "$fn" >/dev/null 2>&1; then
|
||||
"$fn"
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
# Return all spec names
|
||||
all_specs() {
|
||||
echo "${ALL_CONTAINER_SPECS[@]}"
|
||||
}
|
||||
Executable
+304
@@ -0,0 +1,304 @@
|
||||
#!/usr/bin/env bash
|
||||
# create-release-manifest.sh — Build a release manifest for the Archipelago update system.
|
||||
#
|
||||
# Generates a JSON manifest with version info, changelog, and SHA256 hashes for
|
||||
# each component, matching the format expected by core/archipelago/src/update.rs.
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/create-release-manifest.sh --version 0.2.0 --date 2026-04-01
|
||||
#
|
||||
# The script reads built artifacts from the build output directories and produces
|
||||
# a manifest.json file suitable for hosting at the UPDATE_MANIFEST_URL.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Defaults
|
||||
VERSION=""
|
||||
RELEASE_DATE=""
|
||||
OUTPUT_FILE="manifest.json"
|
||||
BACKEND_BINARY=""
|
||||
FRONTEND_ARCHIVE=""
|
||||
BASE_URL="https://source.archipelago-foundation.org/lfg2025/archy/releases/download"
|
||||
|
||||
usage() {
|
||||
echo "Usage: $0 --version VERSION [--date DATE] [--output FILE]"
|
||||
echo ""
|
||||
echo "Options:"
|
||||
echo " --version VERSION Release version (e.g., 0.2.0) [required]"
|
||||
echo " --date DATE Release date (YYYY-MM-DD) [default: today]"
|
||||
echo " --output FILE Output manifest path [default: manifest.json]"
|
||||
echo " --backend PATH Path to backend binary [default: auto-detect]"
|
||||
echo " --frontend PATH Path to frontend archive [default: auto-detect]"
|
||||
echo " --base-url URL Base download URL [default: Gitea release attachments]"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Parse arguments
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--version) VERSION="$2"; shift 2 ;;
|
||||
--date) RELEASE_DATE="$2"; shift 2 ;;
|
||||
--output) OUTPUT_FILE="$2"; shift 2 ;;
|
||||
--backend) BACKEND_BINARY="$2"; shift 2 ;;
|
||||
--frontend) FRONTEND_ARCHIVE="$2"; shift 2 ;;
|
||||
--base-url) BASE_URL="$2"; shift 2 ;;
|
||||
-h|--help) usage ;;
|
||||
*) echo "Unknown option: $1"; usage ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ -z "$VERSION" ]; then
|
||||
echo "Error: --version is required"
|
||||
usage
|
||||
fi
|
||||
|
||||
if [ -z "$RELEASE_DATE" ]; then
|
||||
RELEASE_DATE=$(date +%Y-%m-%d)
|
||||
fi
|
||||
|
||||
# Find project root
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
|
||||
# Auto-detect backend binary
|
||||
if [ -z "$BACKEND_BINARY" ]; then
|
||||
BACKEND_BINARY="$PROJECT_ROOT/core/target/release/archipelago"
|
||||
fi
|
||||
|
||||
# Auto-detect frontend archive.
|
||||
# Layout: flat tarball (`./index.html`, `./assets/…`, `./aiui/…`) so the
|
||||
# Rust updater can unpack it directly into /opt/archipelago/web-ui/.
|
||||
# Using `-C web/dist neode-ui` would produce a `neode-ui/` prefix which
|
||||
# breaks the installer and returns 403 on every fleet UI — see
|
||||
# feedback_release_tarball_layout.md.
|
||||
if [ -z "$FRONTEND_ARCHIVE" ]; then
|
||||
FRONTEND_DIST="$PROJECT_ROOT/web/dist/neode-ui"
|
||||
if [ -d "$FRONTEND_DIST" ]; then
|
||||
FRONTEND_ARCHIVE="/tmp/archipelago-frontend-${VERSION}.tar.gz"
|
||||
STAGING_DIR=$(mktemp -d -t archipelago-frontend.XXXXXX)
|
||||
echo "Staging frontend archive in $STAGING_DIR..."
|
||||
cp -r "$FRONTEND_DIST/." "$STAGING_DIR/"
|
||||
# Bake AIUI in so fresh installs pick it up. OTA already
|
||||
# carries-forward the existing aiui/ if the tarball lacks one
|
||||
# (update.rs:922), but including it here makes the tarball
|
||||
# the single source of truth instead of relying on a side-
|
||||
# effect of the in-place swap.
|
||||
if [ -d "$PROJECT_ROOT/demo/aiui" ] && [ -f "$PROJECT_ROOT/demo/aiui/index.html" ]; then
|
||||
echo " Including AIUI from demo/aiui/"
|
||||
cp -r "$PROJECT_ROOT/demo/aiui" "$STAGING_DIR/aiui"
|
||||
fi
|
||||
# OTA bridge for nodes running older updaters: they only know how to
|
||||
# apply the backend binary and frontend archive. Carry host runtime
|
||||
# assets inside the frontend tarball; the new backend promotes them
|
||||
# from /opt/archipelago/web-ui/archipelago-runtime on first startup.
|
||||
RUNTIME_DIR="$STAGING_DIR/archipelago-runtime"
|
||||
mkdir -p "$RUNTIME_DIR"
|
||||
for runtime_path in apps scripts docker; do
|
||||
if [ -d "$PROJECT_ROOT/$runtime_path" ]; then
|
||||
echo " Including runtime $runtime_path/"
|
||||
cp -r "$PROJECT_ROOT/$runtime_path" "$RUNTIME_DIR/$runtime_path"
|
||||
fi
|
||||
done
|
||||
# KEEP IN SYNC with the `for unit in [...]` array in
|
||||
# core/archipelago/src/bootstrap.rs (run_runtime_assets). A unit that
|
||||
# bootstrap installs but this list does not ship simply never reaches a
|
||||
# node: bootstrap looks for it in the runtime payload, does not find
|
||||
# it, and silently installs nothing. There is no error to notice.
|
||||
mkdir -p "$RUNTIME_DIR/image-recipe/configs"
|
||||
for unit in archipelago-doctor.service archipelago-doctor.timer \
|
||||
archipelago-host-secrets-audit.service; do
|
||||
if [ -f "$PROJECT_ROOT/image-recipe/configs/$unit" ]; then
|
||||
echo " Including runtime unit $unit"
|
||||
cp "$PROJECT_ROOT/image-recipe/configs/$unit" "$RUNTIME_DIR/image-recipe/configs/$unit"
|
||||
fi
|
||||
done
|
||||
if [ -f "$PROJECT_ROOT/image-recipe/configs/nginx-archipelago.conf" ]; then
|
||||
mkdir -p "$RUNTIME_DIR/image-recipe/configs"
|
||||
echo " Including runtime nginx-archipelago.conf"
|
||||
cp "$PROJECT_ROOT/image-recipe/configs/nginx-archipelago.conf" \
|
||||
"$RUNTIME_DIR/image-recipe/configs/nginx-archipelago.conf"
|
||||
fi
|
||||
# Packaged radio tools ride the runtime payload: OTA-only nodes never
|
||||
# get them any other way — the v1.7.117 rollout left fleet nodes with
|
||||
# a stale archy-reticulum-daemon (exits on the new --enable-transport
|
||||
# flag → mesh dead) and no archy-rnodeconf at all (Flash LoRa fails
|
||||
# with "No such file or directory"). bootstrap.rs promotes these to
|
||||
# /usr/local/bin on first startup after the update.
|
||||
for tool in archy-reticulum-daemon archy-rnodeconf; do
|
||||
if [ -f "$PROJECT_ROOT/reticulum-daemon/dist/$tool" ]; then
|
||||
mkdir -p "$RUNTIME_DIR/radio-tools"
|
||||
echo " Including radio tool $tool"
|
||||
cp "$PROJECT_ROOT/reticulum-daemon/dist/$tool" "$RUNTIME_DIR/radio-tools/$tool"
|
||||
else
|
||||
echo " ERROR: reticulum-daemon/dist/$tool missing — run reticulum-daemon/build.sh first" >&2
|
||||
rm -rf "$STAGING_DIR"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
rm -rf "$RUNTIME_DIR/scripts/resilience/reports"
|
||||
find "$RUNTIME_DIR" -type d -name '__pycache__' -prune -exec rm -rf {} +
|
||||
find "$RUNTIME_DIR" -type f \( -name '*.bak' -o -name '*.bak-*' -o -name '._*' -o -name '*.log' -o -name '*.pyc' \) -delete
|
||||
# Force world-readable perms on every entry BEFORE tar, so the
|
||||
# archive's internal mode bits are 755/644 regardless of what
|
||||
# the staging dir's umask gave us. Without this, mktemp -d
|
||||
# creates the staging dir at 700, that 700 gets baked into the
|
||||
# tarball's root `./` entry, and every node that extracts the
|
||||
# archive ends up with /opt/archipelago/web-ui at 700 — which
|
||||
# causes nginx (www-data) to return 500 "permission denied" on
|
||||
# every page. Bit us on the v1.7.38 + v1.7.39 rollouts.
|
||||
chmod 755 "$STAGING_DIR"
|
||||
find "$STAGING_DIR" -type d -exec chmod 755 {} +
|
||||
find "$STAGING_DIR" -type f -exec chmod 644 {} +
|
||||
echo "Creating frontend archive $FRONTEND_ARCHIVE..."
|
||||
# --mode is a belt-and-braces in case a file's on-disk perms
|
||||
# drift again; forces 755 dir / 644 file in the archive too.
|
||||
tar --owner=0 --group=0 \
|
||||
--mode='u=rwX,go=rX' \
|
||||
-czf "$FRONTEND_ARCHIVE" \
|
||||
-C "$STAGING_DIR" .
|
||||
# Verify the archive root entry is world-readable before we
|
||||
# declare success — catches regressions in tar-flag handling
|
||||
# (BSD tar, busybox tar) that might silently drop --mode.
|
||||
# SIGPIPE-safe: use awk to read only the first line and exit,
|
||||
# then terminate the tar pipeline explicitly so `pipefail`+SIGPIPE
|
||||
# don't kill the whole `set -euo pipefail` script.
|
||||
root_mode=$({ tar tvzf "$FRONTEND_ARCHIVE" 2>/dev/null || true; } | awk 'NR==1{print $1; exit}')
|
||||
case "$root_mode" in
|
||||
drwxr-xr-x|drwxr-x*x*)
|
||||
echo " Tarball root perms OK: $root_mode"
|
||||
;;
|
||||
*)
|
||||
echo " ERROR: tarball root perms are $root_mode (want drwxr-xr-x) — aborting release"
|
||||
rm -f "$FRONTEND_ARCHIVE"
|
||||
rm -rf "$STAGING_DIR"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
rm -rf "$STAGING_DIR"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Compute SHA256 hash
|
||||
sha256_of() {
|
||||
if command -v sha256sum &>/dev/null; then
|
||||
sha256sum "$1" | awk '{print $1}'
|
||||
else
|
||||
shasum -a 256 "$1" | awk '{print $1}'
|
||||
fi
|
||||
}
|
||||
|
||||
# File size in bytes
|
||||
size_of() {
|
||||
if [[ "$(uname)" == "Darwin" ]]; then
|
||||
stat -f%z "$1"
|
||||
else
|
||||
stat -c%s "$1"
|
||||
fi
|
||||
}
|
||||
|
||||
# Get current version from Cargo.toml
|
||||
CURRENT_VERSION=$(grep '^version' "$PROJECT_ROOT/core/archipelago/Cargo.toml" | head -1 | sed 's/.*"\(.*\)".*/\1/')
|
||||
|
||||
echo "Building release manifest v${VERSION}"
|
||||
echo " Current version: ${CURRENT_VERSION}"
|
||||
echo " Release date: ${RELEASE_DATE}"
|
||||
echo " Output: ${OUTPUT_FILE}"
|
||||
|
||||
# Build components array
|
||||
COMPONENTS="[]"
|
||||
|
||||
if [ -f "$BACKEND_BINARY" ]; then
|
||||
HASH=$(sha256_of "$BACKEND_BINARY")
|
||||
SIZE=$(size_of "$BACKEND_BINARY")
|
||||
echo " Backend binary: ${BACKEND_BINARY} (${SIZE} bytes, sha256: ${HASH})"
|
||||
COMPONENTS=$(echo "$COMPONENTS" | python3 -c "
|
||||
import sys, json
|
||||
c = json.load(sys.stdin)
|
||||
c.append({
|
||||
'name': 'archipelago',
|
||||
'current_version': '$CURRENT_VERSION',
|
||||
'new_version': '$VERSION',
|
||||
'download_url': '$BASE_URL/v$VERSION/archipelago',
|
||||
'sha256': '$HASH',
|
||||
'size_bytes': $SIZE
|
||||
})
|
||||
print(json.dumps(c))
|
||||
")
|
||||
else
|
||||
echo " Warning: Backend binary not found at $BACKEND_BINARY"
|
||||
fi
|
||||
|
||||
if [ -n "$FRONTEND_ARCHIVE" ] && [ -f "$FRONTEND_ARCHIVE" ]; then
|
||||
HASH=$(sha256_of "$FRONTEND_ARCHIVE")
|
||||
SIZE=$(size_of "$FRONTEND_ARCHIVE")
|
||||
ARCHIVE_NAME=$(basename "$FRONTEND_ARCHIVE")
|
||||
echo " Frontend archive: ${FRONTEND_ARCHIVE} (${SIZE} bytes, sha256: ${HASH})"
|
||||
COMPONENTS=$(echo "$COMPONENTS" | python3 -c "
|
||||
import sys, json
|
||||
c = json.load(sys.stdin)
|
||||
c.append({
|
||||
'name': '$ARCHIVE_NAME',
|
||||
'current_version': '$CURRENT_VERSION',
|
||||
'new_version': '$VERSION',
|
||||
'download_url': '$BASE_URL/v$VERSION/$ARCHIVE_NAME',
|
||||
'sha256': '$HASH',
|
||||
'size_bytes': $SIZE
|
||||
})
|
||||
print(json.dumps(c))
|
||||
")
|
||||
else
|
||||
echo " Warning: Frontend archive not found"
|
||||
fi
|
||||
|
||||
# Read changelog from CHANGELOG.md if available
|
||||
CHANGELOG="[]"
|
||||
CHANGELOG_FILE="$PROJECT_ROOT/CHANGELOG.md"
|
||||
if [ -f "$CHANGELOG_FILE" ]; then
|
||||
# Extract entries for this version (lines between ## vVERSION and next ##)
|
||||
ENTRIES=$(python3 -c "
|
||||
import re, sys
|
||||
content = open('$CHANGELOG_FILE').read()
|
||||
pattern = r'## .*?${VERSION}.*?\n(.*?)(?=\n## |\Z)'
|
||||
m = re.search(pattern, content, re.DOTALL)
|
||||
if m:
|
||||
for line in m.group(1).strip().split('\n')[:10]:
|
||||
line = line.strip()
|
||||
if line:
|
||||
print(line)
|
||||
" 2>/dev/null || echo "")
|
||||
if [ -n "$ENTRIES" ]; then
|
||||
CHANGELOG=$(echo "$ENTRIES" | python3 -c "
|
||||
import sys, json
|
||||
lines = [l.strip().lstrip('- ') for l in sys.stdin if l.strip()]
|
||||
print(json.dumps(lines))
|
||||
")
|
||||
fi
|
||||
fi
|
||||
|
||||
# If no changelog entries found, add a default
|
||||
if [ "$CHANGELOG" = "[]" ]; then
|
||||
CHANGELOG="[\"Update to version ${VERSION}\"]"
|
||||
fi
|
||||
|
||||
# Generate manifest
|
||||
python3 -c "
|
||||
import json
|
||||
manifest = {
|
||||
'version': '$VERSION',
|
||||
'release_date': '$RELEASE_DATE',
|
||||
'changelog': $CHANGELOG,
|
||||
'components': $COMPONENTS
|
||||
}
|
||||
print(json.dumps(manifest, indent=2))
|
||||
" > "$OUTPUT_FILE"
|
||||
|
||||
echo ""
|
||||
echo "Manifest written to: $OUTPUT_FILE"
|
||||
echo ""
|
||||
cat "$OUTPUT_FILE"
|
||||
echo ""
|
||||
echo "Next steps:"
|
||||
echo " 1. Review the manifest above"
|
||||
echo " 2. Upload artifacts to Gitea release v$VERSION"
|
||||
echo " 3. Commit manifest.json to releases/manifest.json on main"
|
||||
echo " 4. Tag the release: git tag v$VERSION && git push --tags"
|
||||
Executable
+312
@@ -0,0 +1,312 @@
|
||||
#!/usr/bin/env bash
|
||||
# create-release.sh — Full release automation for Archipelago
|
||||
#
|
||||
# Bumps version in Cargo.toml and package.json, generates changelog from git log,
|
||||
# creates release manifest, and creates git tag.
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/create-release.sh 1.0.0 # Release v1.0.0
|
||||
# ./scripts/create-release.sh 1.0.0 --dry-run # Preview without changes
|
||||
#
|
||||
# Releases are tarball-only. ISO builds are archived under
|
||||
# image-recipe/_archived/. Nodes OTA-update from releases/manifest.json.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
DRY_RUN=false
|
||||
VERSION=""
|
||||
|
||||
# Parse args
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--dry-run) DRY_RUN=true ;;
|
||||
--help|-h)
|
||||
echo "Usage: $0 VERSION [--dry-run]"
|
||||
echo ""
|
||||
echo "Steps performed:"
|
||||
echo " 1. Validate version format (SemVer)"
|
||||
echo " 2. Bump version in Cargo.toml and package.json"
|
||||
echo " 3. Build backend"
|
||||
echo " 4. Build frontend"
|
||||
echo " 5. Generate changelog from git log"
|
||||
echo " 6. Create release manifest"
|
||||
echo " 7. Commit version bump"
|
||||
echo " 8. Create git tag v{VERSION}"
|
||||
echo ""
|
||||
echo "Options:"
|
||||
echo " --dry-run Show what would be done without making changes"
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
if [ -z "$VERSION" ]; then
|
||||
VERSION="$arg"
|
||||
else
|
||||
echo "Error: Unknown argument: $arg"
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ -z "$VERSION" ]; then
|
||||
echo "Error: VERSION argument required"
|
||||
echo "Usage: $0 VERSION [--dry-run]"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Validate SemVer format
|
||||
if ! echo "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?$'; then
|
||||
echo "Error: Version '$VERSION' is not valid SemVer (expected: X.Y.Z or X.Y.Z-suffix)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check we're on main branch
|
||||
BRANCH=$(git -C "$PROJECT_ROOT" branch --show-current)
|
||||
if [ "$BRANCH" != "main" ]; then
|
||||
echo "Error: Must be on 'main' branch (currently on '$BRANCH')"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check for uncommitted changes
|
||||
if ! git -C "$PROJECT_ROOT" diff --quiet HEAD; then
|
||||
echo "Error: Uncommitted changes detected. Commit or stash first."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ── Pre-flight test gate ──────────────────────────────────────────────
|
||||
# A release must not ship if the static/frontend/backend checks fail. This
|
||||
# runs the release gate harness (cargo fmt/check, catalog drift, vitest, and
|
||||
# the focused cargo suites — incl. the receive/port-drift/secret regressions).
|
||||
# Skipped on --dry-run, or set SKIP_RELEASE_TESTS=1 to bypass in an emergency.
|
||||
# The lifecycle bats harness (tests/lifecycle/run-gate.sh) still runs separately
|
||||
# against live nodes — see tests/lifecycle/TESTING.md.
|
||||
if ! $DRY_RUN; then
|
||||
if [ "${SKIP_RELEASE_TESTS:-0}" = "1" ]; then
|
||||
echo "WARNING: SKIP_RELEASE_TESTS=1 — bypassing the pre-flight test gate"
|
||||
elif [ -x "$PROJECT_ROOT/tests/release/run.sh" ]; then
|
||||
echo "[0/7] Running release gate (tests/release/run.sh)..."
|
||||
if ! "$PROJECT_ROOT/tests/release/run.sh"; then
|
||||
echo "Error: release gate failed — aborting release. Fix the failing"
|
||||
echo " stage, or re-run with SKIP_RELEASE_TESTS=1 to override."
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "WARNING: tests/release/run.sh not found/executable — skipping test gate"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Check tag doesn't already exist
|
||||
if git -C "$PROJECT_ROOT" tag -l "v$VERSION" | grep -q "v$VERSION"; then
|
||||
echo "Error: Tag v$VERSION already exists"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Get current version
|
||||
CURRENT_CARGO_VERSION=$(grep '^version' "$PROJECT_ROOT/core/archipelago/Cargo.toml" | head -1 | sed 's/.*"\(.*\)".*/\1/')
|
||||
CURRENT_NPM_VERSION=$(node -p "require('$PROJECT_ROOT/neode-ui/package.json').version")
|
||||
|
||||
echo "=== Archipelago Release v${VERSION} ==="
|
||||
echo " Current Cargo version: ${CURRENT_CARGO_VERSION}"
|
||||
echo " Current npm version: ${CURRENT_NPM_VERSION}"
|
||||
echo " Target version: ${VERSION}"
|
||||
echo " Dry run: ${DRY_RUN}"
|
||||
echo ""
|
||||
|
||||
if $DRY_RUN; then
|
||||
echo "[DRY RUN] Would perform the following:"
|
||||
echo " 0. Run pre-flight test gate (tests/release/run.sh) — aborts on failure"
|
||||
echo " 1. Update core/archipelago/Cargo.toml version to $VERSION"
|
||||
echo " 2. Update neode-ui/package.json version to $VERSION"
|
||||
echo " 3. Build backend (cargo build --release -p archipelago)"
|
||||
echo " 4. Build frontend (npm run build)"
|
||||
echo " 5. Generate changelog from git log since v${CURRENT_CARGO_VERSION}"
|
||||
echo " 6. Create release manifest"
|
||||
echo " 7. Commit: 'chore: release v${VERSION}'"
|
||||
echo " 8. Tag: v${VERSION}"
|
||||
echo ""
|
||||
echo "After this script, you would:"
|
||||
echo " - Push: git push && git push --tags"
|
||||
echo " - Build ISOs on server: ssh archipelago@192.0.2.10"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "[1/7] Bumping version in Cargo.toml..."
|
||||
# Update archipelago Cargo.toml
|
||||
sed -i.bak "s/^version = \".*\"/version = \"$VERSION\"/" "$PROJECT_ROOT/core/archipelago/Cargo.toml"
|
||||
rm -f "$PROJECT_ROOT/core/archipelago/Cargo.toml.bak"
|
||||
|
||||
# Also update workspace Cargo.lock if it exists
|
||||
if [ -f "$PROJECT_ROOT/core/Cargo.lock" ]; then
|
||||
# Cargo will update the lock file on next build; touch the toml to trigger
|
||||
true
|
||||
fi
|
||||
|
||||
echo "[2/7] Bumping version in package.json..."
|
||||
cd "$PROJECT_ROOT/neode-ui"
|
||||
npm version "$VERSION" --no-git-tag-version --allow-same-version 2>/dev/null || true
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
echo "[3/8] Building backend..."
|
||||
cd "$PROJECT_ROOT/core"
|
||||
cargo build --release -p archipelago
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
echo "[4/8] Building frontend..."
|
||||
cd "$PROJECT_ROOT/neode-ui"
|
||||
npm run build 2>&1 | tail -3
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
# npm run build wipes web/dist — fold AIUI straight back in. The OTA tarball
|
||||
# bakes it from demo/aiui independently, but build-iso-release.sh's
|
||||
# verify-artifacts guard checks web/dist/neode-ui/aiui and failed on two
|
||||
# consecutive releases (.127, .129) because this fold-in was manual.
|
||||
if [ -d "$PROJECT_ROOT/demo/aiui" ] && [ -f "$PROJECT_ROOT/demo/aiui/index.html" ]; then
|
||||
rm -rf "$PROJECT_ROOT/web/dist/neode-ui/aiui"
|
||||
cp -r "$PROJECT_ROOT/demo/aiui" "$PROJECT_ROOT/web/dist/neode-ui/aiui"
|
||||
echo " AIUI folded into web/dist from demo/aiui"
|
||||
fi
|
||||
|
||||
# npm run build can silently no-op (vue-tsc EACCES burned us before) — a stale
|
||||
# dist would ship with a perfectly valid sha256. Require the freshly built
|
||||
# bundle to embed the version we just bumped to before it gets packaged.
|
||||
if ! grep -rqo "${VERSION}" "$PROJECT_ROOT"/web/dist/neode-ui/assets/*.js; then
|
||||
echo "Error: web/dist/neode-ui does not contain v${VERSION} — the frontend" >&2
|
||||
echo " build no-opped or its output is stale. Aborting release." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[4b/8] Building packaged radio tools (archy-reticulum-daemon, archy-rnodeconf)..."
|
||||
# These ride the frontend tarball's runtime payload (radio-tools/) and are
|
||||
# promoted to /usr/local/bin by bootstrap.rs — the ONLY path that updates them
|
||||
# on OTA-only nodes. Stale-dist releases re-broke fleet mesh once (v1.7.117),
|
||||
# so always rebuild here; the manifest script hard-fails if they're missing.
|
||||
(cd "$PROJECT_ROOT/reticulum-daemon" && ./build.sh) || {
|
||||
echo "Error: reticulum-daemon/build.sh failed — radio tools are release-critical" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
echo "[5/8] Validating curated changelog..."
|
||||
|
||||
CHANGELOG_FILE="$PROJECT_ROOT/CHANGELOG.md"
|
||||
RELEASE_DATE=$(date +%Y-%m-%d)
|
||||
if [ ! -f "$CHANGELOG_FILE" ] || ! grep -q "^## v${VERSION} (" "$CHANGELOG_FILE"; then
|
||||
echo "Error: CHANGELOG.md must already contain curated notes for v${VERSION}."
|
||||
echo "Add a section like:"
|
||||
echo ""
|
||||
echo "## v${VERSION} (${RELEASE_DATE})"
|
||||
echo ""
|
||||
echo "- User/operator-facing change ..."
|
||||
echo "- Another concrete change ..."
|
||||
echo "- Validation or operational note ..."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[6/8] Creating release manifest..."
|
||||
mkdir -p "$PROJECT_ROOT/releases"
|
||||
"$SCRIPT_DIR/create-release-manifest.sh" --version "$VERSION" --date "$RELEASE_DATE" --output "$PROJECT_ROOT/releases/manifest.json" 2>&1 | grep -v "^$"
|
||||
|
||||
# §A supply-chain: the OTA manifest must carry the release-root signature.
|
||||
# Nodes refuse to AUTO-apply unsigned manifests, and publish-release-assets.sh
|
||||
# hard-refuses to ship one. The mnemonic is read interactively (or from
|
||||
# RELEASE_MASTER_MNEMONIC) — it must never land in files or shell history.
|
||||
SIGNER="$PROJECT_ROOT/core/target/release/archipelago"
|
||||
if [ ! -x "$SIGNER" ]; then
|
||||
echo "Error: release binary not found at $SIGNER — cannot sign manifest" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -n "${RELEASE_MASTER_MNEMONIC:-}" ] || [ -t 0 ]; then
|
||||
echo "[6b/8] Signing release manifest (paste the release master mnemonic when prompted)..."
|
||||
"$SIGNER" ceremony sign "$PROJECT_ROOT/releases/manifest.json"
|
||||
"$SIGNER" ceremony verify "$PROJECT_ROOT/releases/manifest.json"
|
||||
else
|
||||
echo "⚠ WARNING: no TTY and RELEASE_MASTER_MNEMONIC unset — manifest left UNSIGNED."
|
||||
echo " This run will ABORT before committing (step 7 refuses an unsigned"
|
||||
echo " manifest), because nodes read releases/manifest.json from branch main"
|
||||
echo " and would refuse to auto-apply it."
|
||||
echo " Sign it, then re-run: bash scripts/sign-manifest.sh"
|
||||
fi
|
||||
cp "$PROJECT_ROOT/releases/manifest.json" "$PROJECT_ROOT/release-manifest.json"
|
||||
|
||||
echo "[6c/8] Staging release artifacts for validation..."
|
||||
VERSION_DIR="$PROJECT_ROOT/releases/v${VERSION}"
|
||||
FRONTEND_ARCHIVE="/tmp/archipelago-frontend-${VERSION}.tar.gz"
|
||||
mkdir -p "$VERSION_DIR"
|
||||
install -m 0755 "$PROJECT_ROOT/core/target/release/archipelago" "$VERSION_DIR/archipelago"
|
||||
install -m 0644 "$FRONTEND_ARCHIVE" "$VERSION_DIR/archipelago-frontend-${VERSION}.tar.gz"
|
||||
"$SCRIPT_DIR/check-release-manifest.sh"
|
||||
|
||||
# §A supply-chain gate, mirroring publish-release-assets.sh — but EARLIER,
|
||||
# because publishing is not the first way an unsigned manifest reaches the
|
||||
# fleet. Nodes fetch releases/manifest.json straight from branch `main`
|
||||
# (see the verification URLs printed below), so the COMMIT is what exposes
|
||||
# it, not the publish. publish-release-assets.sh refusing to ship is a
|
||||
# backstop that arrives one step too late: by then the unsigned manifest is
|
||||
# already on main and the fleet is already refusing to auto-apply.
|
||||
#
|
||||
# This is why every cycle needed a manual catch. The signing block above is
|
||||
# conditional — no TTY and no RELEASE_MASTER_MNEMONIC means it prints a
|
||||
# warning and falls through — and the commit then happened anyway. A release
|
||||
# commit carrying a manifest no node will accept has no valid use, so refuse
|
||||
# to create one rather than leave a tag that has to be re-cut.
|
||||
# Release root ROTATED 2026-08-05. v1.7.122-alpha was the last release signed
|
||||
# with the old root (z6Mkkid…q7ur) — it is the release that installed this
|
||||
# pin on every node. From v1.7.123 onward the new root signs, and nodes
|
||||
# running .122+ reject anything signed with the old key.
|
||||
EXPECTED_DID="did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT"
|
||||
if ! grep -q '"signature":' "$PROJECT_ROOT/releases/manifest.json" \
|
||||
|| ! grep -q "\"signed_by\": \"$EXPECTED_DID\"" "$PROJECT_ROOT/releases/manifest.json"; then
|
||||
echo "" >&2
|
||||
echo "Error: releases/manifest.json is NOT signed by the release root." >&2
|
||||
echo " Refusing to commit — nodes read this file from branch main and will" >&2
|
||||
echo " refuse to auto-apply it, so the release would be dead on arrival." >&2
|
||||
echo "" >&2
|
||||
echo " Sign it, then re-run this script:" >&2
|
||||
echo " bash scripts/sign-manifest.sh" >&2
|
||||
echo "" >&2
|
||||
echo " (Signing needs a TTY for the mnemonic prompt, or RELEASE_MASTER_MNEMONIC set.)" >&2
|
||||
exit 1
|
||||
fi
|
||||
"$SIGNER" ceremony verify "$PROJECT_ROOT/releases/manifest.json" \
|
||||
|| { echo "Error: manifest signature failed cryptographic verification — refusing to commit" >&2; exit 1; }
|
||||
|
||||
echo "[7/8] Committing version bump..."
|
||||
git -C "$PROJECT_ROOT" add \
|
||||
core/archipelago/Cargo.toml \
|
||||
core/Cargo.lock \
|
||||
neode-ui/package.json \
|
||||
neode-ui/package-lock.json \
|
||||
neode-ui/public/catalog.json \
|
||||
CHANGELOG.md \
|
||||
releases/manifest.json \
|
||||
release-manifest.json \
|
||||
2>/dev/null || true
|
||||
# Cargo.lock (rewritten by the release build after the version bump) and
|
||||
# neode-ui/public/catalog.json (regenerated by the frontend build) belong in
|
||||
# THIS commit: leaving them dirty failed build-iso-release.sh's clean-tree
|
||||
# preflight on three consecutive releases (.127-.129, 2026-08-09/10).
|
||||
|
||||
git -C "$PROJECT_ROOT" commit -m "chore: release v${VERSION}"
|
||||
|
||||
echo "[8/8] Creating git tag..."
|
||||
git -C "$PROJECT_ROOT" tag -a "v${VERSION}" -m "Release v${VERSION}"
|
||||
|
||||
echo ""
|
||||
echo "=== Release v${VERSION} Ready ==="
|
||||
echo ""
|
||||
echo "Artifacts:"
|
||||
echo " - Version bumped in Cargo.toml and package.json"
|
||||
echo " - Changelog updated in CHANGELOG.md"
|
||||
echo " - Release manifest: releases/manifest.json"
|
||||
echo " - Release manifest copy: release-manifest.json"
|
||||
echo " - Staged artifacts: releases/v${VERSION}/"
|
||||
echo " - Git tag: v${VERSION}"
|
||||
echo ""
|
||||
echo "Next steps:"
|
||||
echo " 1. Review: git log --oneline -5"
|
||||
echo " 2. Publish commits, tag, artifacts, and verify download URLs:"
|
||||
echo " scripts/publish-release-assets.sh ${VERSION} gitea-vps2"
|
||||
echo " 3. Verify manifest is live on both mirrors:"
|
||||
echo " curl -fsS http://localhost:3000/lfg2025/archy/raw/branch/main/releases/manifest.json"
|
||||
echo " curl -fsS https://source.archipelago-foundation.org/lfg2025/archy/raw/branch/main/releases/manifest.json"
|
||||
@@ -0,0 +1,136 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Complete Bitcoin Knots Deployment for Archipelago
|
||||
# This script deploys Bitcoin Knots with a working web UI
|
||||
#
|
||||
# For production/beta releases, this needs to be captured in the auto-installer
|
||||
# or provided as a one-click install in the App Store
|
||||
#
|
||||
|
||||
set -e
|
||||
|
||||
# Source pinned image versions (single source of truth)
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
[ -f "$SCRIPT_DIR/image-versions.sh" ] && . "$SCRIPT_DIR/image-versions.sh"
|
||||
|
||||
# Read per-installation Bitcoin RPC credentials
|
||||
SECRETS_DIR="/var/lib/archipelago/secrets"
|
||||
sudo mkdir -p "$SECRETS_DIR" && sudo chmod 700 "$SECRETS_DIR"
|
||||
if [ ! -f "$SECRETS_DIR/bitcoin-rpc-password" ]; then
|
||||
openssl rand -base64 24 | sudo tee "$SECRETS_DIR/bitcoin-rpc-password" > /dev/null
|
||||
sudo chmod 600 "$SECRETS_DIR/bitcoin-rpc-password"
|
||||
fi
|
||||
BITCOIN_RPC_USER="archipelago"
|
||||
BITCOIN_RPC_PASS=$(sudo cat "$SECRETS_DIR/bitcoin-rpc-password")
|
||||
|
||||
echo "╔════════════════════════════════════════════════════════════════╗"
|
||||
echo "║ Deploying Bitcoin Knots with Web UI ║"
|
||||
echo "╚════════════════════════════════════════════════════════════════╝"
|
||||
echo ""
|
||||
|
||||
# Step 1: Create data directory
|
||||
echo "📁 Creating Bitcoin data directory..."
|
||||
sudo mkdir -p /var/lib/archipelago/bitcoin
|
||||
echo " ✅ Directory created"
|
||||
|
||||
# Step 2: Deploy Bitcoin Knots node
|
||||
echo ""
|
||||
echo "₿ Deploying Bitcoin Knots node..."
|
||||
podman run -d \
|
||||
--name bitcoin-knots \
|
||||
--restart unless-stopped \
|
||||
-p 8332:8332 \
|
||||
-p 8333:8333 \
|
||||
-v /var/lib/archipelago/bitcoin:/home/bitcoin/.bitcoin \
|
||||
--label "com.archipelago.app=bitcoin-knots" \
|
||||
--label "com.archipelago.title=Bitcoin Knots" \
|
||||
--label "com.archipelago.version=28.1" \
|
||||
--label "com.archipelago.category=bitcoin" \
|
||||
--label "com.archipelago.description.short=Full Bitcoin node implementation" \
|
||||
--label "com.archipelago.description.long=Bitcoin Knots is a derivative of Bitcoin Core with additional features and bug fixes. Maintain the full blockchain and validate all transactions." \
|
||||
--label "com.archipelago.license=MIT" \
|
||||
--label "com.archipelago.icon=/assets/img/app-icons/bitcoin-knots.webp" \
|
||||
--label "com.archipelago.port=8332" \
|
||||
--label "com.archipelago.repo=https://github.com/bitcoinknots/bitcoin" \
|
||||
"${BITCOIN_KNOTS_IMAGE}" \
|
||||
-server=1 \
|
||||
-txindex=1 \
|
||||
-rpcallowip=127.0.0.1/32 -rpcallowip=10.88.0.0/16 \
|
||||
-rpcbind=0.0.0.0:8332 \
|
||||
-rpcuser=archipelago \
|
||||
-rpcpassword=$BITCOIN_RPC_PASS \
|
||||
-dbcache=4096
|
||||
|
||||
echo " ✅ Bitcoin Knots node starting"
|
||||
|
||||
# Step 3: Build and deploy web UI
|
||||
echo ""
|
||||
echo "🌐 Building Bitcoin Knots web UI..."
|
||||
|
||||
# Create temporary build directory
|
||||
BUILD_DIR="/tmp/bitcoin-ui-build"
|
||||
rm -rf "$BUILD_DIR"
|
||||
mkdir -p "$BUILD_DIR"
|
||||
|
||||
# Create Dockerfile
|
||||
cat > "$BUILD_DIR/Dockerfile" << 'EOF'
|
||||
FROM ${NGINX_ALPINE_IMAGE:-source.archipelago-foundation.org/lfg2025/nginx:1.29.6-alpine}
|
||||
|
||||
# Copy the static UI
|
||||
COPY index.html /usr/share/nginx/html/
|
||||
|
||||
# Create assets directories
|
||||
RUN mkdir -p /usr/share/nginx/html/assets/img/app-icons && \
|
||||
mkdir -p /usr/share/nginx/html/assets/img
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
EOF
|
||||
|
||||
# Copy UI file from the project
|
||||
# For beta: this needs to be included in the ISO or downloadable
|
||||
cp /home/archipelago/archy/docker/bitcoin-ui/index.html "$BUILD_DIR/"
|
||||
|
||||
# Build the image
|
||||
podman build -t localhost/bitcoin-ui:local "$BUILD_DIR"
|
||||
|
||||
# Deploy UI container
|
||||
podman run -d \
|
||||
--name bitcoin-ui \
|
||||
--restart unless-stopped \
|
||||
-p 8334:80 \
|
||||
--label "com.archipelago.app=bitcoin-ui" \
|
||||
--label "com.archipelago.parent=bitcoin-knots" \
|
||||
localhost/bitcoin-ui:local
|
||||
|
||||
echo " ✅ Bitcoin UI deployed on port 8334"
|
||||
|
||||
# Cleanup
|
||||
rm -rf "$BUILD_DIR"
|
||||
|
||||
# Step 4: Wait for backend to detect
|
||||
echo ""
|
||||
echo "⏳ Waiting for backend to detect containers..."
|
||||
sleep 5
|
||||
|
||||
echo ""
|
||||
echo "╔════════════════════════════════════════════════════════════════╗"
|
||||
echo "║ ✅ BITCOIN KNOTS DEPLOYED! ║"
|
||||
echo "╚════════════════════════════════════════════════════════════════╝"
|
||||
echo ""
|
||||
echo "📊 Status:"
|
||||
podman ps | grep bitcoin
|
||||
echo ""
|
||||
echo "🌐 Access:"
|
||||
echo " • Web UI: http://YOUR-SERVER-IP:8334"
|
||||
echo " • RPC: http://localhost:8332"
|
||||
echo " • Network: Port 8333 (Bitcoin P2P)"
|
||||
echo ""
|
||||
echo "📝 RPC Credentials:"
|
||||
echo " • User: archipelago"
|
||||
echo " • Pass: (stored in /var/lib/archipelago/secrets/bitcoin-rpc-password)"
|
||||
echo ""
|
||||
echo "⏰ Blockchain sync will take several hours to days."
|
||||
echo " Check progress: podman logs -f bitcoin-knots"
|
||||
echo ""
|
||||
@@ -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'
|
||||
Executable
+264
@@ -0,0 +1,264 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Container Orchestration Dev Loop
|
||||
# Fast edit-build-test cycle against real containers on .228
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/dev-container-test.sh # Interactive loop
|
||||
# ./scripts/dev-container-test.sh --once # Single run (for CI)
|
||||
#
|
||||
# Workflow: edit locally → rsync → build on server → restart → smoke test
|
||||
#
|
||||
|
||||
set -o pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
|
||||
SSH_KEY="${ARCHIPELAGO_SSH_KEY:-$HOME/.ssh/archipelago-deploy}"
|
||||
SSH_HOST="${ARCHIPELAGO_SSH_HOST:-}"
|
||||
if [ -z "$SSH_HOST" ]; then
|
||||
echo "ARCHIPELAGO_SSH_HOST must be set, e.g. archipelago@<node-host>" >&2
|
||||
exit 2
|
||||
fi
|
||||
HOST_ONLY="${SSH_HOST#*@}"
|
||||
SSH_OPTS="-o StrictHostKeyChecking=no -o ServerAliveInterval=15 -i $SSH_KEY"
|
||||
REMOTE_DIR="/home/archipelago/archy"
|
||||
RPC_URL="http://${HOST_ONLY}/rpc/v1"
|
||||
COOKIE=""
|
||||
ONCE=false
|
||||
[ "$1" = "--once" ] && ONCE=true
|
||||
|
||||
# ── Colors ──────────────────────────────────────────────────────────────
|
||||
RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[0;33m'
|
||||
CYAN='\033[0;36m' BOLD='\033[1m' NC='\033[0m'
|
||||
|
||||
pass() { echo -e " ${GREEN}✓${NC} $*"; }
|
||||
fail() { echo -e " ${RED}✗${NC} $*"; FAILURES=$((FAILURES + 1)); }
|
||||
info() { echo -e " ${CYAN}→${NC} $*"; }
|
||||
header() { echo -e "\n${BOLD}$*${NC}"; }
|
||||
|
||||
TESTS=0
|
||||
FAILURES=0
|
||||
|
||||
# ── Helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
rpc() {
|
||||
local method="$1"
|
||||
local params="${2:-{}}"
|
||||
local result
|
||||
result=$(curl -sf -b "$COOKIE" -X POST "$RPC_URL" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"jsonrpc\":\"2.0\",\"method\":\"$method\",\"params\":$params,\"id\":1}" \
|
||||
--connect-timeout 10 --max-time 30 2>/dev/null)
|
||||
echo "$result"
|
||||
}
|
||||
|
||||
login() {
|
||||
# Get session cookie
|
||||
COOKIE=$(mktemp)
|
||||
local resp
|
||||
resp=$(curl -sf -c "$COOKIE" -X POST "$RPC_URL" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"jsonrpc":"2.0","method":"auth.login","params":{"password":"password123"},"id":1}' \
|
||||
--connect-timeout 10 2>/dev/null)
|
||||
if echo "$resp" | grep -q '"result"'; then
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
wait_for_health() {
|
||||
local timeout=${1:-30}
|
||||
for i in $(seq 1 "$timeout"); do
|
||||
if curl -sf "http://${HOST_ONLY}/health" >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# ── Sync & Build ────────────────────────────────────────────────────────
|
||||
|
||||
sync_and_build() {
|
||||
header "Step 1: Sync code to .228"
|
||||
rsync -az --delete \
|
||||
--exclude='.git' --exclude='target' --exclude='node_modules' \
|
||||
--exclude='dist' --exclude='*.iso' --exclude='.claude' \
|
||||
-e "ssh $SSH_OPTS" \
|
||||
"$PROJECT_ROOT/" "$SSH_HOST:$REMOTE_DIR/" 2>&1
|
||||
pass "Code synced"
|
||||
|
||||
header "Step 2: Build backend (incremental)"
|
||||
local build_start=$(date +%s)
|
||||
if ssh $SSH_OPTS "$SSH_HOST" "cd $REMOTE_DIR/core && cargo build --release -p archipelago 2>&1 | tail -3"; then
|
||||
local elapsed=$(( $(date +%s) - build_start ))
|
||||
pass "Built in ${elapsed}s"
|
||||
else
|
||||
fail "Build failed"
|
||||
return 1
|
||||
fi
|
||||
|
||||
header "Step 3: Restart service"
|
||||
ssh $SSH_OPTS "$SSH_HOST" "sudo systemctl restart archipelago"
|
||||
info "Waiting for health..."
|
||||
if wait_for_health 30; then
|
||||
pass "Backend healthy"
|
||||
else
|
||||
fail "Backend failed to start (30s timeout)"
|
||||
ssh $SSH_OPTS "$SSH_HOST" "journalctl -u archipelago --since '30 sec ago' --no-pager | tail -20"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Smoke Tests ─────────────────────────────────────────────────────────
|
||||
|
||||
run_smoke_tests() {
|
||||
header "Step 4: Container Orchestration Smoke Tests"
|
||||
TESTS=0
|
||||
FAILURES=0
|
||||
|
||||
# Login
|
||||
if login; then
|
||||
pass "Authenticated"
|
||||
else
|
||||
fail "Login failed"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Test 1: Container list
|
||||
TESTS=$((TESTS + 1))
|
||||
local list
|
||||
list=$(rpc "container.list")
|
||||
if echo "$list" | grep -q '"result"'; then
|
||||
local count
|
||||
count=$(echo "$list" | python3 -c "import sys,json; print(len(json.load(sys.stdin).get('result',{}).get('containers',[])))" 2>/dev/null || echo "?")
|
||||
pass "container.list: $count containers"
|
||||
else
|
||||
fail "container.list failed"
|
||||
fi
|
||||
|
||||
# Test 2: Health status
|
||||
TESTS=$((TESTS + 1))
|
||||
local health
|
||||
health=$(rpc "container.health")
|
||||
if echo "$health" | grep -q '"result"'; then
|
||||
pass "container.health: OK"
|
||||
else
|
||||
fail "container.health failed"
|
||||
fi
|
||||
|
||||
# Test 3: Install a lightweight container (filebrowser — small, fast, no deps)
|
||||
TESTS=$((TESTS + 1))
|
||||
local install_img="source.archipelago-foundation.org/lfg2025/filebrowser:v2.27.0"
|
||||
# Check if already installed
|
||||
local fb_state
|
||||
fb_state=$(ssh $SSH_OPTS "$SSH_HOST" "podman inspect filebrowser --format '{{.State.Status}}' 2>/dev/null || echo 'none'")
|
||||
if [ "$fb_state" = "none" ]; then
|
||||
info "Installing filebrowser..."
|
||||
local install_result
|
||||
install_result=$(rpc "package.install" "{\"id\":\"filebrowser\",\"dockerImage\":\"$install_img\"}")
|
||||
if echo "$install_result" | grep -q '"success"'; then
|
||||
pass "package.install filebrowser: success"
|
||||
else
|
||||
fail "package.install filebrowser: $(echo "$install_result" | python3 -c 'import sys,json; print(json.load(sys.stdin).get("error",{}).get("message","unknown"))' 2>/dev/null)"
|
||||
fi
|
||||
else
|
||||
pass "filebrowser already installed ($fb_state)"
|
||||
fi
|
||||
|
||||
# Test 4: Stop with grace period
|
||||
TESTS=$((TESTS + 1))
|
||||
local stop_result
|
||||
stop_result=$(rpc "package.stop" '{"id":"filebrowser"}')
|
||||
sleep 2
|
||||
fb_state=$(ssh $SSH_OPTS "$SSH_HOST" "podman inspect filebrowser --format '{{.State.Status}}' 2>/dev/null || echo 'unknown'")
|
||||
if [ "$fb_state" = "exited" ] || [ "$fb_state" = "stopped" ]; then
|
||||
pass "package.stop: filebrowser → $fb_state"
|
||||
else
|
||||
fail "package.stop: expected stopped, got $fb_state"
|
||||
fi
|
||||
|
||||
# Test 5: Start
|
||||
TESTS=$((TESTS + 1))
|
||||
rpc "package.start" '{"id":"filebrowser"}' >/dev/null
|
||||
sleep 3
|
||||
fb_state=$(ssh $SSH_OPTS "$SSH_HOST" "podman inspect filebrowser --format '{{.State.Status}}' 2>/dev/null || echo 'unknown'")
|
||||
if [ "$fb_state" = "running" ]; then
|
||||
pass "package.start: filebrowser → running"
|
||||
else
|
||||
fail "package.start: expected running, got $fb_state"
|
||||
fi
|
||||
|
||||
# Test 6: Restart tracker persisted
|
||||
TESTS=$((TESTS + 1))
|
||||
local tracker
|
||||
tracker=$(ssh $SSH_OPTS "$SSH_HOST" "cat /var/lib/archipelago/restart-tracker.json 2>/dev/null")
|
||||
if [ -n "$tracker" ] && echo "$tracker" | python3 -c "import sys,json; json.load(sys.stdin)" 2>/dev/null; then
|
||||
pass "restart-tracker.json: valid JSON"
|
||||
else
|
||||
pass "restart-tracker.json: empty (no failures — healthy)"
|
||||
fi
|
||||
|
||||
# Test 7: Systemd timers active
|
||||
TESTS=$((TESTS + 1))
|
||||
local timers
|
||||
timers=$(ssh $SSH_OPTS "$SSH_HOST" "systemctl list-timers --no-pager 2>/dev/null | grep -c archipelago")
|
||||
if [ "${timers:-0}" -ge 2 ]; then
|
||||
pass "Systemd timers: $timers active (doctor + reconcile)"
|
||||
else
|
||||
fail "Systemd timers: expected ≥2, got ${timers:-0}"
|
||||
fi
|
||||
|
||||
# Test 8: Container doctor runs cleanly
|
||||
TESTS=$((TESTS + 1))
|
||||
local doctor_exit
|
||||
ssh $SSH_OPTS "$SSH_HOST" "sudo /home/archipelago/archy/scripts/container-doctor.sh --local 2>&1 | tail -1"
|
||||
doctor_exit=$?
|
||||
if [ $doctor_exit -eq 0 ]; then
|
||||
pass "container-doctor.sh: clean exit"
|
||||
else
|
||||
fail "container-doctor.sh: exit code $doctor_exit"
|
||||
fi
|
||||
|
||||
# Summary
|
||||
header "Results"
|
||||
local passed=$((TESTS - FAILURES))
|
||||
echo -e " ${GREEN}$passed passed${NC} / ${RED}$FAILURES failed${NC} / $TESTS total"
|
||||
|
||||
# Cleanup temp cookie
|
||||
rm -f "$COOKIE" 2>/dev/null
|
||||
|
||||
return $FAILURES
|
||||
}
|
||||
|
||||
# ── Main ────────────────────────────────────────────────────────────────
|
||||
|
||||
echo ""
|
||||
echo "╔════════════════════════════════════════════════════════════════╗"
|
||||
echo "║ Container Orchestration Dev Loop ║"
|
||||
echo "╚════════════════════════════════════════════════════════════════╝"
|
||||
echo ""
|
||||
info "Target: $SSH_HOST"
|
||||
info "Mode: $($ONCE && echo 'single run' || echo 'interactive loop')"
|
||||
echo ""
|
||||
|
||||
# Check SSH
|
||||
if ! ssh $SSH_OPTS "$SSH_HOST" "echo ok" >/dev/null 2>&1; then
|
||||
fail "Cannot SSH to $SSH_HOST"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if $ONCE; then
|
||||
sync_and_build && run_smoke_tests
|
||||
exit $?
|
||||
fi
|
||||
|
||||
# Interactive loop
|
||||
while true; do
|
||||
sync_and_build && run_smoke_tests
|
||||
echo ""
|
||||
echo -e "${YELLOW}Press Enter to re-sync + re-test, Ctrl+C to stop${NC}"
|
||||
read -r
|
||||
done
|
||||
Executable
+409
@@ -0,0 +1,409 @@
|
||||
#!/bin/bash
|
||||
# Archipelago Development Server Starter
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
FRONTEND_DIR="$PROJECT_ROOT/neode-ui"
|
||||
BACKEND_DIR="$PROJECT_ROOT/core"
|
||||
|
||||
# Quietly kill a port — avoids EAGAIN by not piping through xargs
|
||||
kill_port() {
|
||||
local pids
|
||||
pids=$(lsof -ti:"$1" 2>/dev/null) || true
|
||||
if [ -n "$pids" ]; then
|
||||
echo "$pids" | while read -r pid; do
|
||||
kill -9 "$pid" 2>/dev/null || true
|
||||
done
|
||||
sleep 1
|
||||
fi
|
||||
}
|
||||
|
||||
cleanup_ports() {
|
||||
kill_port 5959
|
||||
kill_port 8100
|
||||
}
|
||||
|
||||
ensure_deps() {
|
||||
cd "$FRONTEND_DIR"
|
||||
if [ ! -d "node_modules" ]; then
|
||||
echo " Installing dependencies..."
|
||||
npm install
|
||||
fi
|
||||
}
|
||||
|
||||
if [ ! -d "$FRONTEND_DIR" ]; then
|
||||
echo "Frontend directory not found: $FRONTEND_DIR"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Archipelago Dev Server"
|
||||
echo ""
|
||||
|
||||
# Detect if running on a Linux dev machine (production-like mode available)
|
||||
IS_LINUX=false
|
||||
if [[ "$OSTYPE" == "linux"* ]]; then
|
||||
IS_LINUX=true
|
||||
fi
|
||||
|
||||
echo " 0) Boot branding dev (GRUB theme, Plymouth, installer — patch + QEMU)"
|
||||
echo " 1) Mock backend (UI dev — fastest, no Docker/Podman needed)"
|
||||
echo " 2) Full stack (Rust backend + frontend)"
|
||||
echo " 3) Setup mode (first-time password setup — mock)"
|
||||
echo " 4) Onboarding mode (onboarding flow — mock)"
|
||||
echo " 5) Existing user (login screen — mock)"
|
||||
echo " 6) Boot mode (simulated 25s startup — mock)"
|
||||
echo " 7) Testnet stack (signet Bitcoin + LND + ThunderHub via Podman)"
|
||||
echo " 8) Manual instructions"
|
||||
echo " 9) Container orchestration dev (live testing on .228)"
|
||||
if [ "$IS_LINUX" = true ]; then
|
||||
echo " 10) Production build (Linux only — build, install, restart all services)"
|
||||
echo " Mirrors ISO exactly: backend + frontend + Tor + WG + NostrVPN + nginx"
|
||||
fi
|
||||
echo ""
|
||||
read -p "Enter choice [0-10]: " choice
|
||||
|
||||
case $choice in
|
||||
0)
|
||||
echo ""
|
||||
echo "Boot Branding Dev"
|
||||
echo ""
|
||||
# Find an ISO to patch
|
||||
ISO=$(ls -t ~/Desktop/archipelago-dev-*.iso 2>/dev/null | head -1)
|
||||
if [ -z "$ISO" ]; then
|
||||
ISO=$(ls -t "$PROJECT_ROOT/image-recipe/results/archipelago-"*.iso 2>/dev/null | head -1)
|
||||
fi
|
||||
|
||||
DEV_BRANDING="$PROJECT_ROOT/image-recipe/dev-branding.sh"
|
||||
|
||||
if [ -z "$ISO" ] || [ ! -f "$ISO" ]; then
|
||||
echo " No ISO found to patch. Options:"
|
||||
echo ""
|
||||
echo " a) Preview GRUB background only (instant):"
|
||||
echo " python3 image-recipe/branding/generate-grub-background.py /tmp/grub-bg.png && open /tmp/grub-bg.png"
|
||||
echo ""
|
||||
echo " b) Download an ISO from FileBrowser (http://192.0.2.10:8083)"
|
||||
echo " then drop it on your Desktop and re-run this option."
|
||||
echo ""
|
||||
echo " Files you can edit:"
|
||||
echo " image-recipe/branding/grub-theme/background.png — GRUB boot background"
|
||||
echo " image-recipe/branding/grub-theme/theme.txt — GRUB menu colors/layout"
|
||||
echo " image-recipe/branding/plymouth-theme/logo.png — Plymouth boot logo"
|
||||
echo " image-recipe/branding/plymouth-theme/*.script — Plymouth animation"
|
||||
echo ""
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo " ISO: $ISO"
|
||||
echo " Edit these files, then this script patches and boots in QEMU:"
|
||||
echo " branding/grub-theme/background.png — GRUB background"
|
||||
echo " branding/grub-theme/theme.txt — GRUB menu theme"
|
||||
echo " branding/plymouth-theme/logo.png — Plymouth logo"
|
||||
echo ""
|
||||
|
||||
if [ -f "$DEV_BRANDING" ]; then
|
||||
exec bash "$DEV_BRANDING" "$ISO"
|
||||
else
|
||||
echo " dev-branding.sh not found at: $DEV_BRANDING"
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
1)
|
||||
echo ""
|
||||
echo "Starting frontend with mock backend..."
|
||||
cleanup_ports
|
||||
ensure_deps
|
||||
exec npm run dev:mock
|
||||
;;
|
||||
2)
|
||||
echo ""
|
||||
echo "Starting full stack (Rust backend + frontend)..."
|
||||
cleanup_ports
|
||||
|
||||
if [ ! -d "$BACKEND_DIR" ]; then
|
||||
echo "Backend directory not found: $BACKEND_DIR"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cd "$BACKEND_DIR"
|
||||
if ! cargo check --bin archipelago > /tmp/archipelago-backend-check.log 2>&1; then
|
||||
echo "Backend build check failed. See /tmp/archipelago-backend-check.log"
|
||||
echo "Falling back to mock backend."
|
||||
ensure_deps
|
||||
exec npm run dev:mock
|
||||
fi
|
||||
|
||||
echo " Starting Rust backend..."
|
||||
export ARCHIPELAGO_DATA_DIR=/tmp/archipelago-dev
|
||||
export ARCHIPELAGO_DEV_DATA_DIR=/tmp/archipelago-dev
|
||||
export ARCHIPELAGO_DEV_MODE=true
|
||||
export ARCHIPELAGO_BIND=127.0.0.1:5959
|
||||
export ARCHIPELAGO_LOG_LEVEL=debug
|
||||
export ARCHIPELAGO_BITCOIN_SIMULATION=mock
|
||||
|
||||
cargo run --bin archipelago > /tmp/archipelago-backend.log 2>&1 &
|
||||
BACKEND_PID=$!
|
||||
echo " Backend PID: $BACKEND_PID (logs: /tmp/archipelago-backend.log)"
|
||||
|
||||
echo " Waiting for backend on port 5959..."
|
||||
for i in $(seq 1 60); do
|
||||
if lsof -ti:5959 >/dev/null 2>&1; then break; fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
if ! lsof -ti:5959 >/dev/null 2>&1; then
|
||||
echo "Backend did not start. Falling back to mock."
|
||||
kill "$BACKEND_PID" 2>/dev/null || true
|
||||
ensure_deps
|
||||
exec npm run dev:mock
|
||||
fi
|
||||
|
||||
echo " Backend ready."
|
||||
trap "kill $BACKEND_PID 2>/dev/null" EXIT
|
||||
|
||||
ensure_deps
|
||||
exec npm run dev
|
||||
;;
|
||||
3)
|
||||
echo ""
|
||||
echo "Starting setup mode..."
|
||||
cleanup_ports
|
||||
ensure_deps
|
||||
VITE_DEV_MODE=setup exec npm run dev:mock
|
||||
;;
|
||||
4)
|
||||
echo ""
|
||||
echo "Starting onboarding mode..."
|
||||
cleanup_ports
|
||||
ensure_deps
|
||||
VITE_DEV_MODE=onboarding exec npm run dev:mock
|
||||
;;
|
||||
5)
|
||||
echo ""
|
||||
echo "Starting existing user mode..."
|
||||
cleanup_ports
|
||||
ensure_deps
|
||||
VITE_DEV_MODE=existing exec npm run dev:mock
|
||||
;;
|
||||
6)
|
||||
echo ""
|
||||
echo "Starting boot mode (25s simulated startup)..."
|
||||
cleanup_ports
|
||||
ensure_deps
|
||||
VITE_DEV_MODE=boot exec npm run dev:mock
|
||||
;;
|
||||
7)
|
||||
echo ""
|
||||
echo "Starting testnet stack (signet) via Podman/Docker..."
|
||||
|
||||
# Check for a working container runtime (binary exists AND daemon responds)
|
||||
RUNTIME=""
|
||||
COMPOSE=""
|
||||
if command -v docker &>/dev/null && docker ps &>/dev/null; then
|
||||
RUNTIME="docker"
|
||||
COMPOSE="docker compose"
|
||||
elif command -v podman &>/dev/null && podman ps &>/dev/null; then
|
||||
if command -v podman-compose &>/dev/null; then
|
||||
RUNTIME="podman"
|
||||
COMPOSE="podman-compose"
|
||||
else
|
||||
RUNTIME="podman"
|
||||
COMPOSE="podman compose"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -z "$RUNTIME" ]; then
|
||||
if command -v podman &>/dev/null; then
|
||||
echo " Podman machine not running — starting it..."
|
||||
if ! podman machine ls --format '{{.Name}}' 2>/dev/null | grep -q .; then
|
||||
echo " No Podman machine found — initializing..."
|
||||
podman machine init
|
||||
fi
|
||||
podman machine start
|
||||
if podman ps &>/dev/null; then
|
||||
if command -v podman-compose &>/dev/null; then
|
||||
RUNTIME="podman"
|
||||
COMPOSE="podman-compose"
|
||||
else
|
||||
RUNTIME="podman"
|
||||
COMPOSE="podman compose"
|
||||
fi
|
||||
else
|
||||
echo " Failed to start Podman machine."
|
||||
exit 1
|
||||
fi
|
||||
elif command -v docker &>/dev/null; then
|
||||
echo ""
|
||||
echo "Docker is installed but the daemon isn't running."
|
||||
echo "Start Docker Desktop and try again."
|
||||
exit 1
|
||||
else
|
||||
echo ""
|
||||
echo "No container runtime found. Install one:"
|
||||
echo " brew install podman podman-compose"
|
||||
echo " # or"
|
||||
echo " brew install --cask docker"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
echo " Using: $RUNTIME"
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
echo " Starting signet Bitcoin + LND + ThunderHub + Fedimint..."
|
||||
$COMPOSE -f docker-compose.testnet.yml up -d
|
||||
|
||||
echo ""
|
||||
echo " Testnet stack starting. Services:"
|
||||
echo " ThunderHub: http://localhost:3010 (password: thunderhub)"
|
||||
echo " Fedimint Guardian: http://localhost:18175"
|
||||
echo " LND REST: http://localhost:8080"
|
||||
echo " Bitcoin RPC: localhost:38332"
|
||||
echo ""
|
||||
echo " Get signet coins: https://signetfaucet.com"
|
||||
echo ""
|
||||
echo " Also starting mock frontend..."
|
||||
cleanup_ports
|
||||
ensure_deps
|
||||
exec npm run dev:mock
|
||||
;;
|
||||
8)
|
||||
echo ""
|
||||
echo "Manual Instructions"
|
||||
echo ""
|
||||
echo "UI development (mock backend, no Docker):"
|
||||
echo " cd $FRONTEND_DIR"
|
||||
echo " npm install && npm run dev:mock"
|
||||
echo ""
|
||||
echo "Dev modes (prepend to command):"
|
||||
echo " VITE_DEV_MODE=setup First-time setup flow"
|
||||
echo " VITE_DEV_MODE=onboarding Onboarding flow"
|
||||
echo " VITE_DEV_MODE=existing Login screen"
|
||||
echo " VITE_DEV_MODE=boot Boot sequence"
|
||||
echo ""
|
||||
echo "Testnet stack (requires Podman or Docker):"
|
||||
echo " podman compose -f docker-compose.testnet.yml up -d"
|
||||
echo ""
|
||||
echo "Full stack (requires Rust toolchain):"
|
||||
echo " Terminal 1: cd $BACKEND_DIR && cargo run --bin archipelago"
|
||||
echo " Terminal 2: cd $FRONTEND_DIR && npm run dev"
|
||||
echo ""
|
||||
echo "Access: http://localhost:8100 (password: password123)"
|
||||
;;
|
||||
9)
|
||||
echo ""
|
||||
echo "Container Orchestration Dev (live testing on .228)"
|
||||
echo "Syncs code, builds on server, runs orchestration smoke tests."
|
||||
echo ""
|
||||
exec "$SCRIPT_DIR/dev-container-test.sh"
|
||||
;;
|
||||
10)
|
||||
if [ "$IS_LINUX" != true ]; then
|
||||
echo "Production build is only available on Linux dev machines."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Production Build — mirrors ISO install exactly"
|
||||
echo ""
|
||||
|
||||
FAILED=0
|
||||
|
||||
# Step 1: Build backend
|
||||
echo "[1/5] Building Rust backend (release)..."
|
||||
cd "$BACKEND_DIR/archipelago"
|
||||
if cargo build --release 2>&1 | tail -3; then
|
||||
RELEASE_BIN="$BACKEND_DIR/target/release/archipelago"
|
||||
sudo cp "$RELEASE_BIN" /usr/local/bin/archipelago
|
||||
sudo chmod +x /usr/local/bin/archipelago
|
||||
echo " Backend installed: $(ls -lh /usr/local/bin/archipelago | awk '{print $5}')"
|
||||
else
|
||||
echo " FAILED: cargo build --release"
|
||||
FAILED=1
|
||||
fi
|
||||
|
||||
# Step 2: Type-check + build frontend
|
||||
echo "[2/5] Building frontend..."
|
||||
cd "$FRONTEND_DIR"
|
||||
if [ ! -d "node_modules" ]; then
|
||||
npm install
|
||||
fi
|
||||
if npx vue-tsc -b --noEmit 2>&1 | tail -3; then
|
||||
npm run build 2>&1 | tail -3
|
||||
sudo cp -r "$PROJECT_ROOT/web/dist/neode-ui/"* /opt/archipelago/web-ui/
|
||||
# Deploy AIUI (pre-built demo or source build)
|
||||
if [ -d "$PROJECT_ROOT/../AIUI/packages/app/dist" ]; then
|
||||
sudo cp -r "$PROJECT_ROOT/../AIUI/packages/app/dist/"* /opt/archipelago/web-ui/aiui/
|
||||
echo " AIUI deployed from source build"
|
||||
elif [ -d "$PROJECT_ROOT/demo/aiui" ]; then
|
||||
sudo mkdir -p /opt/archipelago/web-ui/aiui/
|
||||
sudo cp -r "$PROJECT_ROOT/demo/aiui/"* /opt/archipelago/web-ui/aiui/
|
||||
echo " AIUI deployed from demo/"
|
||||
fi
|
||||
echo " Frontend deployed to /opt/archipelago/web-ui/"
|
||||
else
|
||||
echo " FAILED: vue-tsc type check"
|
||||
FAILED=1
|
||||
fi
|
||||
|
||||
# Step 3: Sync configs from repo
|
||||
echo "[3/5] Syncing configs..."
|
||||
sudo cp "$PROJECT_ROOT/image-recipe/configs/archipelago.service" /etc/systemd/system/archipelago.service
|
||||
sudo cp "$PROJECT_ROOT/image-recipe/configs/nginx-archipelago.conf" /etc/nginx/sites-available/archipelago
|
||||
sudo cp "$PROJECT_ROOT/image-recipe/configs/snippets/"*.conf /etc/nginx/snippets/ 2>/dev/null
|
||||
for unit in archipelago-tor-helper.service archipelago-tor-helper.path archipelago-wg.service archipelago-wg-address.service nostr-relay.service nostr-vpn.service; do
|
||||
sudo cp "$PROJECT_ROOT/image-recipe/configs/$unit" "/etc/systemd/system/$unit"
|
||||
done
|
||||
sudo cp "$PROJECT_ROOT/scripts/tor-helper.sh" /opt/archipelago/scripts/tor-helper.sh
|
||||
sudo chmod +x /opt/archipelago/scripts/tor-helper.sh
|
||||
sudo cp "$PROJECT_ROOT/scripts/archipelago-wg" /usr/local/bin/archipelago-wg
|
||||
sudo chmod +x /usr/local/bin/archipelago-wg
|
||||
echo " Configs synced"
|
||||
|
||||
# Step 4: Sync Tor hostnames
|
||||
echo "[4/5] Syncing Tor hostnames..."
|
||||
for svc in archipelago bitcoin electrumx lnd btcpay mempool fedimint; do
|
||||
dir="/var/lib/archipelago/tor/hidden_service_$svc"
|
||||
if [ -f "$dir/hostname" ]; then
|
||||
sudo cp "$dir/hostname" "/var/lib/archipelago/tor-hostnames/$svc"
|
||||
fi
|
||||
done
|
||||
sudo chown -R "$(whoami)":"$(whoami)" /var/lib/archipelago/tor-hostnames 2>/dev/null
|
||||
|
||||
# Step 5: Reload and restart all services
|
||||
echo "[5/5] Restarting services..."
|
||||
sudo systemctl daemon-reload
|
||||
sudo nginx -t 2>&1 && sudo systemctl reload nginx
|
||||
sudo systemctl restart archipelago
|
||||
|
||||
# Verify
|
||||
echo ""
|
||||
echo "Service Status:"
|
||||
for svc in tor@default archipelago-wg archipelago-wg-address nostr-relay nostr-vpn archipelago-tor-helper.path archipelago nginx; do
|
||||
STATUS=$(systemctl is-active "$svc" 2>/dev/null)
|
||||
if [ "$STATUS" = "active" ]; then
|
||||
printf " %-30s active\n" "$svc"
|
||||
else
|
||||
printf " %-30s FAILED\n" "$svc"
|
||||
FAILED=1
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
if [ "$FAILED" -eq 0 ]; then
|
||||
HOST_IP=$(hostname -I 2>/dev/null | awk '{print $1}')
|
||||
ONION=$(cat /var/lib/archipelago/tor-hostnames/archipelago 2>/dev/null || echo "generating...")
|
||||
echo "All services running. Access:"
|
||||
echo " LAN: http://$HOST_IP"
|
||||
echo " Tor: http://$ONION"
|
||||
echo " WG: 10.44.0.1"
|
||||
echo " RPC: http://127.0.0.1:5678/rpc/v1"
|
||||
else
|
||||
echo "Some services failed. Check: journalctl -u <service> --no-pager -n 20"
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
echo "Invalid choice"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
Executable
+1498
File diff suppressed because it is too large
Load Diff
Executable
+45
@@ -0,0 +1,45 @@
|
||||
#!/usr/bin/env bash
|
||||
# One-shot node-side repair: pull the current companion-UI manifests
|
||||
# (session_passthrough on the gated ports) from the public repo, install
|
||||
# them into every location the daemon reads, restart, and report.
|
||||
#
|
||||
# Run on a node:
|
||||
# curl -sf https://source.archipelago-foundation.org/lfg2025/archy/raw/branch/main/scripts/fix-companion-manifests.sh | bash
|
||||
#
|
||||
# Idempotent and safe to re-run. Needs passwordless sudo (fleet default).
|
||||
set -u
|
||||
|
||||
BASE="https://source.archipelago-foundation.org/lfg2025/archy/raw/branch/main/apps"
|
||||
RUNTIME="/opt/archipelago/web-ui/archipelago-runtime/apps"
|
||||
updated=0
|
||||
|
||||
for app in lnd-ui bitcoin-ui electrs-ui fips-ui; do
|
||||
tmp="/tmp/${app}-manifest.yml"
|
||||
if ! curl -sf --max-time 30 "$BASE/$app/manifest.yml" -o "$tmp"; then
|
||||
echo "✗ $app: download failed"; continue
|
||||
fi
|
||||
if ! grep -q session_passthrough "$tmp"; then
|
||||
echo "✗ $app: fetched file missing session_passthrough — refusing"; continue
|
||||
fi
|
||||
sudo cp "$tmp" "/opt/archipelago/apps/$app/manifest.yml" || { echo "✗ $app: install failed"; continue; }
|
||||
# The frontend's runtime payload is restored over /opt/archipelago/apps at
|
||||
# every daemon boot on nodes that carry it — update it too or the fix
|
||||
# reverts on the next restart.
|
||||
if [ -d "$RUNTIME/$app" ]; then
|
||||
sudo cp "$tmp" "$RUNTIME/$app/manifest.yml"
|
||||
fi
|
||||
echo "✓ $app updated"
|
||||
updated=$((updated + 1))
|
||||
done
|
||||
|
||||
if [ "$updated" -eq 0 ]; then
|
||||
echo "Nothing updated — not restarting."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
sudo systemctl restart archipelago
|
||||
echo "Daemon restarted; waiting for the gate…"
|
||||
sleep 15
|
||||
ip=$(hostname -I | tr ' ' '\n' | grep '^100\.' | head -1)
|
||||
code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 "http://$ip:18083/" 2>/dev/null)
|
||||
echo "ext :18083 -> $code (401 = gate holds the port: CORRECT)"
|
||||
Executable
+243
@@ -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 ==="
|
||||
@@ -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())
|
||||
Executable
+282
@@ -0,0 +1,282 @@
|
||||
#!/usr/bin/env bash
|
||||
# Generate releases/app-catalog.json — the REMOTE per-app version catalog that
|
||||
# decouples app updates from the binary OTA (see
|
||||
# core/.../container/app_catalog.rs and docs/dht-distribution-design.md).
|
||||
#
|
||||
# Nodes fetch this file over HTTP from the OVH origin (same host as the OTA
|
||||
# manifest), compare each app's catalog version against the running container
|
||||
# tag, and light up the per-app "Update" button — no node release required.
|
||||
#
|
||||
# The app_id -> image-variable mapping below MIRRORS
|
||||
# core/archipelago/src/container/image_versions.rs (image_var_for_app +
|
||||
# containers_for_stack). image_versions.rs is the canonical mapping; keep this in
|
||||
# sync when you add an app there.
|
||||
#
|
||||
# Usage:
|
||||
# scripts/generate-app-catalog.sh [output-path]
|
||||
# EMBED_MANIFESTS=0 scripts/generate-app-catalog.sh # version/image only (legacy)
|
||||
# # then publish: push releases/app-catalog.json to the OVH gitea (raw URL).
|
||||
#
|
||||
# EMBED_MANIFESTS (default ON, 2026-06-23): embed each app's full
|
||||
# apps/<id>/manifest.yml into its catalog entry's `manifest` field, so nodes
|
||||
# install from the signed registry alone (no OTA-shipped disk manifest). Consumed
|
||||
# by container::app_catalog + the orchestrator's load_manifests overlay
|
||||
# (origin-wins, disk = fallback). See docs/registry-manifest-design.md. The
|
||||
# migration window is over — every regen now embeds; set EMBED_MANIFESTS=0 only
|
||||
# to reproduce the old version/image-only catalog.
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
OUT="${1:-$ROOT/releases/app-catalog.json}"
|
||||
|
||||
# Export every *_IMAGE var (and ARCHY_REGISTRY) so python can read them.
|
||||
set -a
|
||||
# shellcheck disable=SC1091
|
||||
source "$ROOT/scripts/image-versions.sh"
|
||||
set +a
|
||||
|
||||
UPDATED="$(date -u +%Y-%m-%d)" OUT="$OUT" APPS_DIR="$ROOT/apps" \
|
||||
EMBED_MANIFESTS="${EMBED_MANIFESTS:-1}" python3 - <<'PY'
|
||||
import glob
|
||||
import json, os
|
||||
|
||||
try:
|
||||
import yaml
|
||||
except ImportError:
|
||||
yaml = None
|
||||
|
||||
def img(var):
|
||||
v = os.environ.get(var)
|
||||
return v if v else None
|
||||
|
||||
def tag(image):
|
||||
# version = tag after the LAST colon that follows the last slash
|
||||
if not image:
|
||||
return None
|
||||
tail = image.rsplit('/', 1)[-1]
|
||||
return tail.rsplit(':', 1)[1] if ':' in tail else 'latest'
|
||||
|
||||
# Single-container apps: app_id -> primary image variable.
|
||||
SINGLE = {
|
||||
"bitcoin-knots": "BITCOIN_KNOTS_IMAGE",
|
||||
"lnd": "LND_IMAGE",
|
||||
"electrumx": "ELECTRUMX_IMAGE",
|
||||
"bitcoin-ui": "BITCOIN_UI_IMAGE",
|
||||
"lnd-ui": "LND_UI_IMAGE",
|
||||
"electrs-ui": "ELECTRS_UI_IMAGE",
|
||||
"homeassistant": "HOMEASSISTANT_IMAGE",
|
||||
"grafana": "GRAFANA_IMAGE",
|
||||
"uptime-kuma": "UPTIME_KUMA_IMAGE",
|
||||
"jellyfin": "JELLYFIN_IMAGE",
|
||||
"photoprism": "PHOTOPRISM_IMAGE",
|
||||
"ollama": "OLLAMA_IMAGE",
|
||||
"vaultwarden": "VAULTWARDEN_IMAGE",
|
||||
"nextcloud": "NEXTCLOUD_IMAGE",
|
||||
"searxng": "SEARXNG_IMAGE",
|
||||
"cryptpad": "CRYPTPAD_IMAGE",
|
||||
"filebrowser": "FILEBROWSER_IMAGE",
|
||||
"nginx-proxy-manager": "NPM_IMAGE",
|
||||
"portainer": "PORTAINER_IMAGE",
|
||||
"tailscale": "TAILSCALE_IMAGE",
|
||||
"fedimint": "FEDIMINT_IMAGE",
|
||||
"fedimint-gateway": "FEDIMINT_GATEWAY_IMAGE",
|
||||
"nostr-rs-relay": "NOSTR_RS_RELAY_IMAGE",
|
||||
"nostr-vpn": "NOSTR_VPN_IMAGE",
|
||||
"fips": "FIPS_IMAGE",
|
||||
"routstr": "ROUTSTR_IMAGE",
|
||||
"adguardhome": "ADGUARDHOME_IMAGE",
|
||||
}
|
||||
|
||||
# Stack apps: app_id -> {container_name: image variable}. The FIRST entry is the
|
||||
# primary (its version drives the badge); it is also emitted as `image`.
|
||||
STACK = {
|
||||
"indeedhub": {
|
||||
"indeedhub": "INDEEDHUB_IMAGE",
|
||||
"indeedhub-api": "INDEEDHUB_API_IMAGE",
|
||||
"indeedhub-ffmpeg": "INDEEDHUB_FFMPEG_IMAGE",
|
||||
},
|
||||
"immich": {
|
||||
"immich_server": "IMMICH_SERVER_IMAGE",
|
||||
"immich_postgres": "IMMICH_POSTGRES_IMAGE",
|
||||
"immich_redis": "REDIS_IMAGE",
|
||||
},
|
||||
"penpot": {
|
||||
"penpot-frontend": "PENPOT_FRONTEND_IMAGE",
|
||||
"penpot-backend": "PENPOT_BACKEND_IMAGE",
|
||||
"penpot-exporter": "PENPOT_EXPORTER_IMAGE",
|
||||
"penpot-postgres": "PENPOT_POSTGRES_IMAGE",
|
||||
"penpot-valkey": "PENPOT_VALKEY_IMAGE",
|
||||
},
|
||||
"mempool": {
|
||||
"archy-mempool-web": "MEMPOOL_WEB_IMAGE",
|
||||
"mempool-api": "MEMPOOL_BACKEND_IMAGE",
|
||||
"archy-mempool-db": "MARIADB_IMAGE",
|
||||
},
|
||||
"btcpay": {
|
||||
"btcpay-server": "BTCPAY_IMAGE",
|
||||
"archy-nbxplorer": "NBXPLORER_IMAGE",
|
||||
"archy-btcpay-db": "BTCPAY_POSTGRES_IMAGE",
|
||||
},
|
||||
}
|
||||
|
||||
apps = {}
|
||||
for app_id, var in SINGLE.items():
|
||||
image = img(var)
|
||||
if image:
|
||||
apps[app_id] = {"version": tag(image), "image": image}
|
||||
|
||||
for app_id, comps in STACK.items():
|
||||
images = {name: img(var) for name, var in comps.items() if img(var)}
|
||||
if not images:
|
||||
continue
|
||||
primary_name = next(iter(comps)) # first listed = primary
|
||||
primary_image = img(comps[primary_name])
|
||||
entry = {"version": tag(primary_image)}
|
||||
if primary_image:
|
||||
entry["image"] = primary_image
|
||||
entry["images"] = images
|
||||
apps[app_id] = entry
|
||||
|
||||
# Opt-in (EMBED_MANIFESTS): embed each app's full manifest so nodes install from
|
||||
# the registry alone. The whole manifest document is embedded under `manifest`
|
||||
# (top-level `app:` preserved) — that is exactly what the Rust side deserializes
|
||||
# into an AppManifest. Apps not already in SINGLE/STACK get a new entry whose
|
||||
# version comes from the manifest. A bad embed is harmless: the node validates and
|
||||
# falls back to its disk manifest.
|
||||
# Embedded manifests must name a registry the DEPLOYED fleet trusts, which is
|
||||
# not necessarily the one the repo names. apps/*/manifest.yml moved to the public
|
||||
# domain in 8e814ca0, but releases/registry-trust-floor.json still lists only the
|
||||
# OVH host — the migration is ship-binary -> confirm-fleet -> promote-floor ->
|
||||
# regenerate, and the later steps have not happened. Embedding the repo's host
|
||||
# verbatim produced a catalog naming 78 untrusted refs, which would have made
|
||||
# every install in the field fail with "not from a trusted registry". The signer
|
||||
# refused it, which is how this was caught.
|
||||
#
|
||||
# So rewrite OUR registry host to whatever REGISTRY is generating against, and
|
||||
# leave every other host (docker.io, ghcr.io, ...) untouched. When the floor is
|
||||
# promoted, generating against the domain becomes a no-op here.
|
||||
REGISTRY = os.environ.get("ARCHY_REGISTRY", "source.archipelago-foundation.org/lfg2025")
|
||||
|
||||
_KNOWN_ARCHY_REGISTRY_HOSTS = (
|
||||
"source.archipelago-foundation.org/lfg2025",
|
||||
"146.59.87.168:3000/lfg2025",
|
||||
)
|
||||
|
||||
|
||||
def _retarget_registry(node):
|
||||
if isinstance(node, dict):
|
||||
return {k: _retarget_registry(v) for k, v in node.items()}
|
||||
if isinstance(node, list):
|
||||
return [_retarget_registry(v) for v in node]
|
||||
if isinstance(node, str):
|
||||
for host in _KNOWN_ARCHY_REGISTRY_HOSTS:
|
||||
if host != REGISTRY and node.startswith(host + "/"):
|
||||
return REGISTRY + node[len(host):]
|
||||
return node
|
||||
return node
|
||||
|
||||
|
||||
embedded = 0
|
||||
apps_dir = os.environ.get("APPS_DIR")
|
||||
if os.environ.get("EMBED_MANIFESTS") and apps_dir:
|
||||
if yaml is None:
|
||||
raise SystemExit("EMBED_MANIFESTS set but PyYAML is not available")
|
||||
for path in sorted(glob.glob(os.path.join(apps_dir, "*", "manifest.yml"))):
|
||||
with open(path) as fh:
|
||||
data = yaml.safe_load(fh)
|
||||
if not isinstance(data, dict) or not isinstance(data.get("app"), dict):
|
||||
continue
|
||||
app = data["app"]
|
||||
app_id = app.get("id")
|
||||
if not app_id:
|
||||
continue
|
||||
entry = apps.setdefault(str(app_id), {})
|
||||
entry.setdefault("version", str(app.get("version", "")) or "0")
|
||||
entry["manifest"] = _retarget_registry(data)
|
||||
embedded += 1
|
||||
|
||||
# Multi-version support (docs/bitcoin-multi-version-design.md §3 Phase 1):
|
||||
# curated, bounded `versions[]` a runner may install or switch to. The entry
|
||||
# marked default:true MUST equal the app's top-level catalog `version` (the
|
||||
# manifest version for embedded apps) so selecting it un-pins / tracks latest.
|
||||
#
|
||||
# ONLY list versions whose tagged image is actually published to the registry —
|
||||
# an unbuilt tag 404s on install. Extend each list as scripts/build-bitcoin-
|
||||
# image.sh (Phase 0) publishes more tagged images, e.g.:
|
||||
# {"version": "30.0", "image": f"{REGISTRY}/bitcoin:30.0"},
|
||||
# {"version": "27.2", "image": f"{REGISTRY}/bitcoin:27.2", "deprecated": True, "eol": "2026-12-31"},
|
||||
VERSIONS = {
|
||||
# Curated Core set (latest patch per major, current → 25). Images built +
|
||||
# verified (SHA-256 + OpenPGP, fail-closed) and pushed by
|
||||
# scripts/build-bitcoin-image.sh. `28.4.0` is the default (== the manifest's
|
||||
# top-level version) so existing/new installs are undisturbed; runners switch
|
||||
# up to 31.0 (e.g. for BIP-110 signalling) or down to 25.2 from the app's
|
||||
# "Version & Updates" card. Add the next release by building its image then
|
||||
# prepending it here.
|
||||
"bitcoin-core": [
|
||||
{"version": "latest", "image": f"{REGISTRY}/bitcoin:latest", "default": True},
|
||||
{"version": "31.0", "image": f"{REGISTRY}/bitcoin:31.0"},
|
||||
{"version": "30.2", "image": f"{REGISTRY}/bitcoin:30.2"},
|
||||
{"version": "29.3", "image": f"{REGISTRY}/bitcoin:29.3"},
|
||||
{"version": "29.2", "image": f"{REGISTRY}/bitcoin:29.2"},
|
||||
{"version": "28.4.0", "image": f"{REGISTRY}/bitcoin:28.4"},
|
||||
{"version": "27.2", "image": f"{REGISTRY}/bitcoin:27.2"},
|
||||
{"version": "26.2", "image": f"{REGISTRY}/bitcoin:26.2", "deprecated": True},
|
||||
{"version": "25.2", "image": f"{REGISTRY}/bitcoin:25.2", "deprecated": True},
|
||||
],
|
||||
# Knots: a real tagged build is now published, so it's selectable + pinnable
|
||||
# in the Knots app interface. `latest` is the default (== the manifest's
|
||||
# floating tag) so selecting it un-pins / tracks latest — this MUST match the
|
||||
# top-level catalog version (L167-168) or the card can't reach "latest" and
|
||||
# selecting the highlighted default would instead pin+recreate. Pinning
|
||||
# 29.3.knots20260508 moves a runner off the floating tag.
|
||||
# `latest` is the default and points at the NEWEST published dated image
|
||||
# (not the bare :latest tag) so "Always use the latest version" installs the
|
||||
# newest build on fixed-binary nodes, while UNPINNED nodes still resolve via
|
||||
# the manifest's floating :latest tag (kept on the legacy image until the
|
||||
# entrypoint-render fix is fleet-deployed — see
|
||||
# the bitcoin multi-version design).
|
||||
# NO "latest" pseudo-version here, and the default is pinned. The entry
|
||||
# marked default:true used to be {"version": "latest"} pointing at
|
||||
# 29.3.knots20260508 — so a fresh install, or anyone picking "latest",
|
||||
# silently got the BIP110/RDTS build. That build HALTS until an operator
|
||||
# sets consensusrules=rdts: node 100.64.204.114 runs it and is frozen at
|
||||
# block 961,692 (blocks AND headers static, 11 peers, unpruned) while
|
||||
# reporting itself synced, whereas the nodes on 20260210 sit at the tip.
|
||||
# A default that can move across a consensus boundary is a fleet-wide
|
||||
# stall waiting to happen, so the default is an explicit, non-RDTS build
|
||||
# and moving it is a deliberate consensus decision.
|
||||
"bitcoin-knots": [
|
||||
{"version": "29.3.knots20260210",
|
||||
"image": f"{REGISTRY}/bitcoin-knots:29.3.knots20260210", "default": True},
|
||||
{"version": "29.3.knots20260508",
|
||||
"image": f"{REGISTRY}/bitcoin-knots:29.3.knots20260508"},
|
||||
{"version": "29.3.knots20260507",
|
||||
"image": f"{REGISTRY}/bitcoin-knots:29.3.knots20260507"},
|
||||
{"version": "29.2.knots20251110",
|
||||
"image": f"{REGISTRY}/bitcoin-knots:29.2.knots20251110"},
|
||||
],
|
||||
}
|
||||
for app_id, versions in VERSIONS.items():
|
||||
if app_id in apps and versions:
|
||||
apps[app_id]["versions"] = versions
|
||||
# The default/latest entry MUST equal the app's top-level catalog
|
||||
# `version` (commit 169ff2e2) so selecting the highlighted default
|
||||
# un-pins / tracks latest instead of pinning+recreating. Enforce it here
|
||||
# rather than relying on the manifest version matching.
|
||||
default_entry = next((v for v in versions if v.get("default")), None)
|
||||
if default_entry:
|
||||
apps[app_id]["version"] = default_entry["version"]
|
||||
|
||||
catalog = {
|
||||
"schema": 1,
|
||||
"updated": os.environ["UPDATED"],
|
||||
"apps": dict(sorted(apps.items())),
|
||||
}
|
||||
|
||||
with open(os.environ["OUT"], "w") as f:
|
||||
json.dump(catalog, f, indent=2)
|
||||
f.write("\n")
|
||||
suffix = f" (embedded {embedded} manifests)" if embedded else ""
|
||||
print(f"Wrote {os.environ['OUT']} with {len(apps)} apps{suffix}")
|
||||
PY
|
||||
@@ -0,0 +1,143 @@
|
||||
#!/bin/bash
|
||||
# Container image versions — single source of truth
|
||||
# Source this file from all scripts that create containers
|
||||
#
|
||||
# Usage: source /opt/archipelago/image-versions.sh 2>/dev/null || true
|
||||
# source "$(dirname "$0")/image-versions.sh" 2>/dev/null || true
|
||||
#
|
||||
# Tags MUST match what's actually in the registry at source.archipelago-foundation.org/lfg2025/
|
||||
# Run: podman images --format '{{.Repository}}:{{.Tag}}' | grep 'source.archipelago-foundation.org' | sort
|
||||
# to verify against the registry.
|
||||
|
||||
# Archipelago app registries (primary + fallback)
|
||||
#
|
||||
# Honour a caller-supplied ARCHY_REGISTRY instead of overwriting it. Catalog
|
||||
# generation MUST be able to target a host inside releases/registry-trust-floor.json
|
||||
# — the hosts binaries already deployed to the fleet are known to trust. This
|
||||
# file's default is the public domain, which the fleet does NOT trust yet: the
|
||||
# migration is ship-binary -> confirm-fleet -> promote-in-floor -> regenerate,
|
||||
# and only step 0 has happened. Generating the catalog against an untrusted host
|
||||
# makes every install in the field fail with "not from a trusted registry"; the
|
||||
# signer refuses to sign such a catalog, which is how this was caught.
|
||||
ARCHY_REGISTRY="${ARCHY_REGISTRY:-source.archipelago-foundation.org/lfg2025}"
|
||||
# No fallback registry: the old tx1138 registry host was retired (2026-06-13); empty disables the fallback path.
|
||||
ARCHY_REGISTRY_FALLBACK=""
|
||||
|
||||
# Bitcoin stack
|
||||
# Pinned, not :latest — see apps/bitcoin-knots/manifest.yml. Knots 20260508
|
||||
# halts pending the BIP110/RDTS consensus decision, so a moving tag can freeze
|
||||
# the fleet's chain sync. Bumping this is a consensus decision.
|
||||
BITCOIN_KNOTS_IMAGE="$ARCHY_REGISTRY/bitcoin-knots:29.3.knots20260210"
|
||||
LND_IMAGE="$ARCHY_REGISTRY/lnd:v0.18.4-beta"
|
||||
ELECTRUMX_IMAGE="$ARCHY_REGISTRY/electrumx:v1.18.0"
|
||||
|
||||
# Mempool stack
|
||||
MEMPOOL_BACKEND_IMAGE="$ARCHY_REGISTRY/mempool-backend:v3.0.0"
|
||||
MEMPOOL_WEB_IMAGE="$ARCHY_REGISTRY/mempool-frontend:v3.0.1"
|
||||
MARIADB_IMAGE="$ARCHY_REGISTRY/mariadb:11.4.10"
|
||||
|
||||
# BTCPay
|
||||
BTCPAY_IMAGE="docker.io/btcpayserver/btcpayserver:2.4.2"
|
||||
NBXPLORER_IMAGE="$ARCHY_REGISTRY/nbxplorer:2.6.0"
|
||||
POSTGRES_IMAGE="$ARCHY_REGISTRY/postgres:15.17"
|
||||
BTCPAY_POSTGRES_IMAGE="$ARCHY_REGISTRY/postgres:15.17"
|
||||
|
||||
# Apps
|
||||
HOMEASSISTANT_IMAGE="$ARCHY_REGISTRY/home-assistant:2026.7.3"
|
||||
GRAFANA_IMAGE="$ARCHY_REGISTRY/grafana:10.2.0"
|
||||
UPTIME_KUMA_IMAGE="$ARCHY_REGISTRY/uptime-kuma:1"
|
||||
JELLYFIN_IMAGE="$ARCHY_REGISTRY/jellyfin:10.8.13"
|
||||
PHOTOPRISM_IMAGE="$ARCHY_REGISTRY/photoprism:240915"
|
||||
OLLAMA_IMAGE="$ARCHY_REGISTRY/ollama:latest"
|
||||
VAULTWARDEN_IMAGE="$ARCHY_REGISTRY/vaultwarden:1.30.0-alpine"
|
||||
NEXTCLOUD_IMAGE="$ARCHY_REGISTRY/nextcloud:29"
|
||||
SEARXNG_IMAGE="$ARCHY_REGISTRY/searxng:latest"
|
||||
# OnlyOffice removed — incompatible with rootless Podman (internal postgres/rabbitmq fail)
|
||||
# Replaced by CryptPad (single Node.js process, e2e encrypted)
|
||||
CRYPTPAD_IMAGE="$ARCHY_REGISTRY/cryptpad:2024.12.0"
|
||||
FILEBROWSER_IMAGE="$ARCHY_REGISTRY/filebrowser:v2.27.0"
|
||||
NPM_IMAGE="$ARCHY_REGISTRY/nginx-proxy-manager:latest"
|
||||
# 2.39.1 is what the fleet has actually been running via the moving :latest
|
||||
# tag, and it is the version that wrote their databases. Pinning back to
|
||||
# 2.19.4 (2 years older) made Portainer refuse to start the moment a
|
||||
# container was recreated: "database schema version does not align with the
|
||||
# server version" — it migrates a DB forward, never backward. Pinned
|
||||
# forward and published as a concrete tag so this is reproducible.
|
||||
PORTAINER_IMAGE="$ARCHY_REGISTRY/portainer:2.39.1"
|
||||
|
||||
# Networking
|
||||
TAILSCALE_IMAGE="$ARCHY_REGISTRY/tailscale:stable"
|
||||
NETBIRD_DASHBOARD_IMAGE="docker.io/netbirdio/dashboard:v2.38.0"
|
||||
NETBIRD_SERVER_IMAGE="docker.io/netbirdio/netbird-server:0.71.2"
|
||||
NETBIRD_PROXY_IMAGE="docker.io/library/nginx:1.27-alpine"
|
||||
ALPINE_TOR_IMAGE="$ARCHY_REGISTRY/alpine-tor:0.4.8.13"
|
||||
ADGUARDHOME_IMAGE="$ARCHY_REGISTRY/adguardhome:v0.107.55"
|
||||
|
||||
# Fedimint
|
||||
FEDIMINT_IMAGE="$ARCHY_REGISTRY/fedimintd:v0.10.0"
|
||||
FEDIMINT_GATEWAY_IMAGE="$ARCHY_REGISTRY/gatewayd:v0.10.0"
|
||||
# fmcd = Fedimint client daemon (iroh-capable, fedimint-client 0.8.2). Built
|
||||
# from minmoto/fmcd. Bundled on the ISO in BOTH modes (full CONTAINER_IMAGES
|
||||
# list and the unbundled core bundle) and auto-created by first-boot as a
|
||||
# baseline app so ecash works offline out of the box.
|
||||
# See docs/dual-ecash-design.md.
|
||||
FMCD_IMAGE="$ARCHY_REGISTRY/fmcd:0.8.1"
|
||||
|
||||
# Ark (bark)
|
||||
# barkd = Ark wallet daemon, packaged from the pinned upstream release binary
|
||||
# (apps/barkd/Dockerfile). Signet-only default config; keep the tag in
|
||||
# lockstep with core/archipelago/src/wallet/ark_client.rs REST shapes. Not in
|
||||
# the bundled CONTAINER_IMAGES list — install via the barkd app manifest.
|
||||
BARKD_IMAGE="$ARCHY_REGISTRY/barkd:0.3.0"
|
||||
|
||||
# Media
|
||||
REDIS_IMAGE="$ARCHY_REGISTRY/redis:7.4.8"
|
||||
|
||||
# Valkey (general purpose)
|
||||
VALKEY_IMAGE="$ARCHY_REGISTRY/valkey:8.1.6"
|
||||
|
||||
# Nostr
|
||||
NOSTR_RS_RELAY_IMAGE="$ARCHY_REGISTRY/nostr-rs-relay:0.9.0"
|
||||
STRFRY_IMAGE="$ARCHY_REGISTRY/strfry:1.0.4"
|
||||
NOSTR_VPN_IMAGE="$ARCHY_REGISTRY/nostr-vpn:v0.3.7"
|
||||
NOSTR_VPN_UI_IMAGE="$ARCHY_REGISTRY/nostr-vpn-ui:latest"
|
||||
FIPS_IMAGE="$ARCHY_REGISTRY/fips:v0.1.0"
|
||||
FIPS_UI_IMAGE="$ARCHY_REGISTRY/fips-ui:1.7.123-alpha"
|
||||
|
||||
# AI / Routing
|
||||
ROUTSTR_IMAGE="$ARCHY_REGISTRY/routstr:v0.4.3"
|
||||
|
||||
# Community / Gaming
|
||||
BOTFIGHTS_IMAGE="$ARCHY_REGISTRY/botfights:1.2.11"
|
||||
|
||||
# IndeedHub stack
|
||||
INDEEDHUB_IMAGE="$ARCHY_REGISTRY/indeedhub:1.0.0"
|
||||
INDEEDHUB_API_IMAGE="$ARCHY_REGISTRY/indeedhub-api:1.0.0"
|
||||
INDEEDHUB_FFMPEG_IMAGE="$ARCHY_REGISTRY/indeedhub-ffmpeg:1.0.0"
|
||||
MINIO_IMAGE="$ARCHY_REGISTRY/minio:RELEASE.2024-11-07T00-52-20Z"
|
||||
INDEEDHUB_POSTGRES_IMAGE="$ARCHY_REGISTRY/postgres:16.13-alpine"
|
||||
INDEEDHUB_REDIS_IMAGE="$ARCHY_REGISTRY/redis:7.4.8-alpine"
|
||||
|
||||
# Gitea (Git + Container Registry)
|
||||
GITEA_IMAGE="docker.io/gitea/gitea:1.23"
|
||||
|
||||
# DWN (Decentralized Web Node)
|
||||
|
||||
# Immich stack
|
||||
IMMICH_POSTGRES_IMAGE="$ARCHY_REGISTRY/immich-postgres:14-vectorchord0.4.3-pgvectors0.2.0"
|
||||
IMMICH_SERVER_IMAGE="$ARCHY_REGISTRY/immich-server:release"
|
||||
|
||||
# Penpot stack
|
||||
PENPOT_POSTGRES_IMAGE="$ARCHY_REGISTRY/postgres:15"
|
||||
PENPOT_VALKEY_IMAGE="$ARCHY_REGISTRY/valkey:8.1"
|
||||
PENPOT_BACKEND_IMAGE="$ARCHY_REGISTRY/penpot-backend:2.4"
|
||||
PENPOT_EXPORTER_IMAGE="$ARCHY_REGISTRY/penpot-exporter:2.4"
|
||||
PENPOT_FRONTEND_IMAGE="$ARCHY_REGISTRY/penpot-frontend:2.4"
|
||||
|
||||
# Custom UI containers (built from docker/ dirs, pushed to registry)
|
||||
BITCOIN_UI_IMAGE="$ARCHY_REGISTRY/bitcoin-ui:1.7.123-alpha"
|
||||
LND_UI_IMAGE="$ARCHY_REGISTRY/lnd-ui:1.7.123-alpha"
|
||||
ELECTRS_UI_IMAGE="$ARCHY_REGISTRY/electrs-ui:1.7.123-alpha"
|
||||
|
||||
# Base images
|
||||
NGINX_ALPINE_IMAGE="$ARCHY_REGISTRY/nginx:1.27.4-alpine"
|
||||
Executable
+633
@@ -0,0 +1,633 @@
|
||||
#!/usr/bin/env bash
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
# Archipelago Install TUI Demo — 80s Hacker Edition
|
||||
# Run: bash scripts/install-tui-demo.sh
|
||||
# Ctrl+C to exit at any time.
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
set -euo pipefail
|
||||
|
||||
# ── Colors — everything orange unless noted ─────────────────────
|
||||
ORANGE=$'\033[38;5;208m'
|
||||
ORANGE_DIM=$'\033[38;5;130m'
|
||||
ORANGE_BRIGHT=$'\033[38;5;214m'
|
||||
ORANGE_GLOW=$'\033[38;5;220m'
|
||||
GREEN=$'\033[32m'
|
||||
GREEN_DIM=$'\033[38;5;22m'
|
||||
GREEN_BRIGHT=$'\033[38;5;46m'
|
||||
WHITE=$'\033[1;37m'
|
||||
DIM=$'\033[38;5;242m'
|
||||
DIMMER=$'\033[38;5;238m'
|
||||
DARK=$'\033[38;5;235m'
|
||||
NC=$'\033[0m'
|
||||
BOLD=$'\033[1m'
|
||||
|
||||
# ── Terminal setup ──────────────────────────────────────────────
|
||||
TW=$(tput cols 2>/dev/null || echo 80)
|
||||
TH=$(tput lines 2>/dev/null || echo 24)
|
||||
[[ $TW -gt 100 ]] && TW=100
|
||||
|
||||
BW=56
|
||||
[[ $BW -gt $((TW - 4)) ]] && BW=$((TW - 4))
|
||||
INNER=$((BW - 2))
|
||||
|
||||
PAD=$(( (TW - BW) / 2 ))
|
||||
[[ $PAD -lt 0 ]] && PAD=0
|
||||
PADS=$(printf "%*s" "$PAD" "")
|
||||
|
||||
LOGO_W=43
|
||||
LOGO_PAD=$(( (TW - LOGO_W) / 2 ))
|
||||
[[ $LOGO_PAD -lt 0 ]] && LOGO_PAD=0
|
||||
LOGO_PADS=$(printf "%*s" "$LOGO_PAD" "")
|
||||
|
||||
cleanup() {
|
||||
tput cnorm 2>/dev/null
|
||||
tput sgr0 2>/dev/null
|
||||
echo ""
|
||||
}
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
# ── Primitives ──────────────────────────────────────────────────
|
||||
hide_cursor() { tput civis 2>/dev/null || true; }
|
||||
show_cursor() { tput cnorm 2>/dev/null || true; }
|
||||
goto() { printf "\033[%d;%dH" "$1" "$2"; }
|
||||
clear_line() { printf "\033[K"; }
|
||||
p() { printf "%s%b\n" "$PADS" "$1"; }
|
||||
pn() { printf "%s%b" "$PADS" "$1"; }
|
||||
|
||||
hrule() {
|
||||
local len=$((INNER < 50 ? INNER : 50))
|
||||
local hr=""
|
||||
for _ in $(seq 1 "$len"); do hr="${hr}*"; done
|
||||
p "${ORANGE_DIM}${hr}${NC}"
|
||||
}
|
||||
|
||||
# ── Hacker glyphs ──────────────────────────────────────────────
|
||||
HEXCHARS='0123456789abcdef'
|
||||
SPIN_FRAMES='⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏'
|
||||
|
||||
rand_hex() {
|
||||
local len="${1:-8}" out=""
|
||||
for _ in $(seq 1 "$len"); do
|
||||
out="${out}${HEXCHARS:RANDOM % 16:1}"
|
||||
done
|
||||
echo -n "$out"
|
||||
}
|
||||
|
||||
# ── Boot scan effect ───────────────────────────────────────────
|
||||
boot_scan() {
|
||||
clear
|
||||
hide_cursor
|
||||
|
||||
local messages=(
|
||||
"POST: memory check ............ 16384MB OK"
|
||||
"BIOS: AES-NI .................. detected"
|
||||
"UEFI: secure boot ............. disabled"
|
||||
"SATA: TOSHIBA MQ01ACF0 ........ 465.8G"
|
||||
"USB: boot media .............. verified"
|
||||
"NET: interface enp0s31f6 ...... link up"
|
||||
"INIT: loading archipelago ....."
|
||||
)
|
||||
|
||||
for i in $(seq 1 8); do
|
||||
local addr data
|
||||
addr=$(rand_hex 8)
|
||||
data=$(rand_hex 32)
|
||||
goto $i 1
|
||||
printf "%s%b0x%s %s%b" "$PADS" "$DARK" "$addr" "$data" "$NC"
|
||||
sleep 0.02
|
||||
done
|
||||
|
||||
local row=3
|
||||
for msg in "${messages[@]}"; do
|
||||
goto $row 1; clear_line
|
||||
pn "${ORANGE_DIM}"
|
||||
local i=0
|
||||
while [[ $i -lt ${#msg} ]]; do
|
||||
printf "%s" "${msg:$i:1}"
|
||||
i=$((i + 1))
|
||||
if [[ "${msg:$i:1}" == "." ]]; then sleep 0.005; else sleep 0.012; fi
|
||||
done
|
||||
printf "%b\n" "$NC"
|
||||
row=$((row + 1))
|
||||
sleep 0.05
|
||||
done
|
||||
|
||||
sleep 0.3
|
||||
for r in $(seq 1 $((row + 2))); do goto $r 1; clear_line; done
|
||||
sleep 0.2
|
||||
}
|
||||
|
||||
# ── ASCII Logo: A R C H I P E L A G O ─────────────────────────
|
||||
# 43 chars wide, 3 lines tall. Correct spelling!
|
||||
|
||||
LOGO_FRONT=(
|
||||
'▄▀█ █▀▄ █▀▀ █ █ █ █▀█ █▀▀ █ ▄▀█ █▀▀ █▀█'
|
||||
'█▀█ █▀▄ █ █▀█ █ █▀▀ ██▀ █ █▀█ █ █ █ █'
|
||||
'▀ ▀ ▀ ▀ ▀▀▀ ▀ ▀ ▀ ▀ ▀▀▀ ▀▀▀ ▀ ▀ ▀▀▀ ▀▀▀'
|
||||
)
|
||||
|
||||
# 3D shadow: draw shadow (dark, offset +1,+2) then front on top
|
||||
draw_logo_3d_at() {
|
||||
local row="$1" color="${2:-$ORANGE}"
|
||||
local front_col=$((LOGO_PAD + 1))
|
||||
local shadow_col=$((LOGO_PAD + 3))
|
||||
|
||||
for li in 0 1 2; do
|
||||
goto $((row + li + 1)) "$shadow_col"
|
||||
printf "%b%s%b" "$DARK" "${LOGO_FRONT[$li]}" "$NC"
|
||||
done
|
||||
for li in 0 1 2; do
|
||||
goto $((row + li)) "$front_col"
|
||||
printf "%b%s%b" "$color" "${LOGO_FRONT[$li]}" "$NC"
|
||||
done
|
||||
}
|
||||
|
||||
draw_logo_flat() {
|
||||
for line in "${LOGO_FRONT[@]}"; do
|
||||
printf "%s%b%s%b\n" "$LOGO_PADS" "${1:-$ORANGE}" "$line" "$NC"
|
||||
done
|
||||
}
|
||||
|
||||
# Decrypt reveal with 3D shadow
|
||||
logo_decrypt_reveal() {
|
||||
local row="$1"
|
||||
local iterations=7
|
||||
local scramble_chars='█▓▒░╳◆▀▄▌▐┃━╋╬╪'
|
||||
local front_col=$((LOGO_PAD + 1))
|
||||
local shadow_col=$((LOGO_PAD + 3))
|
||||
|
||||
# Draw shadow layer first (static, dark)
|
||||
for li in 0 1 2; do
|
||||
goto $((row + li + 1)) "$shadow_col"
|
||||
printf "%b%s%b" "$DARK" "${LOGO_FRONT[$li]}" "$NC"
|
||||
done
|
||||
|
||||
# Decrypt front layer
|
||||
for iter in $(seq 1 "$iterations"); do
|
||||
for li in 0 1 2; do
|
||||
local real="${LOGO_FRONT[$li]}"
|
||||
local out=""
|
||||
local len=${#real}
|
||||
local resolve=$(( iter * len / iterations ))
|
||||
local ci=0
|
||||
while [[ $ci -lt $len ]]; do
|
||||
local ch="${real:$ci:1}"
|
||||
if [[ $ci -lt $resolve ]]; then
|
||||
out="${out}${ch}"
|
||||
elif [[ "$ch" == " " ]]; then
|
||||
out="${out} "
|
||||
else
|
||||
out="${out}${scramble_chars:RANDOM % ${#scramble_chars}:1}"
|
||||
fi
|
||||
ci=$((ci + 1))
|
||||
done
|
||||
|
||||
local color="$DARK"
|
||||
case $iter in
|
||||
1) color="$DARK" ;; 2) color="$DIMMER" ;; 3) color="$DIM" ;;
|
||||
4) color="$ORANGE_DIM" ;; 5) color="$ORANGE_DIM" ;;
|
||||
6) color="$ORANGE" ;; 7) color="$ORANGE" ;;
|
||||
esac
|
||||
|
||||
goto $((row + li)) "$front_col"
|
||||
printf "%b%s%b" "$color" "$out" "$NC"
|
||||
done
|
||||
sleep 0.07
|
||||
done
|
||||
|
||||
# Glow pulse
|
||||
for color in "$ORANGE_BRIGHT" "$ORANGE_GLOW" "$ORANGE_BRIGHT" "$ORANGE"; do
|
||||
for li in 0 1 2; do
|
||||
goto $((row + li)) "$front_col"
|
||||
printf "\033[K%b%s%b" "$color" "${LOGO_FRONT[$li]}" "$NC"
|
||||
done
|
||||
sleep 0.05
|
||||
done
|
||||
}
|
||||
|
||||
# Quick glow pulse on existing logo
|
||||
logo_glow_pulse() {
|
||||
local row="$1" cycles="${2:-2}"
|
||||
local col=$((LOGO_PAD + 1))
|
||||
for _ in $(seq 1 "$cycles"); do
|
||||
for color in "$ORANGE" "$ORANGE_BRIGHT" "$ORANGE_GLOW" "$ORANGE_BRIGHT" "$ORANGE"; do
|
||||
for li in 0 1 2; do
|
||||
goto $((row + li)) "$col"
|
||||
printf "\033[K%b%s%b" "$color" "${LOGO_FRONT[$li]}" "$NC"
|
||||
done
|
||||
sleep 0.04
|
||||
done
|
||||
done
|
||||
}
|
||||
|
||||
# Celebration: logo strobes with color party
|
||||
logo_celebrate() {
|
||||
local row="$1"
|
||||
local col=$((LOGO_PAD + 1))
|
||||
local party_colors=("$ORANGE" "$ORANGE_GLOW" "$WHITE" "$ORANGE_BRIGHT" "$GREEN_BRIGHT" "$ORANGE_GLOW" "$ORANGE")
|
||||
for color in "${party_colors[@]}"; do
|
||||
for li in 0 1 2; do
|
||||
goto $((row + li)) "$col"
|
||||
printf "\033[K%b%s%b" "$color" "${LOGO_FRONT[$li]}" "$NC"
|
||||
done
|
||||
sleep 0.06
|
||||
done
|
||||
}
|
||||
|
||||
# Screen wipe transition
|
||||
screen_transition() {
|
||||
for r in $(seq 1 "$TH"); do
|
||||
goto "$r" 1
|
||||
printf "%b%*s%b" "$ORANGE_DIM" "$TW" "" "$NC"
|
||||
sleep 0.005
|
||||
done
|
||||
sleep 0.05
|
||||
clear
|
||||
}
|
||||
|
||||
# CRT power-on scan line
|
||||
crt_on() {
|
||||
hide_cursor; clear
|
||||
for r in $(seq 1 "$TH"); do
|
||||
goto "$r" 1
|
||||
printf "%b%*s%b" "$ORANGE_DIM" "$TW" "" "$NC"
|
||||
if [[ $r -gt 1 ]]; then goto $((r - 1)) 1; clear_line; fi
|
||||
sleep 0.008
|
||||
done
|
||||
goto "$TH" 1; clear_line
|
||||
sleep 0.1; clear
|
||||
}
|
||||
|
||||
# ── Phase data ─────────────────────────────────────────────────
|
||||
PHASE_NAMES=(
|
||||
"Checking tools"
|
||||
"Detecting disks"
|
||||
"Creating partitions"
|
||||
"Formatting partitions"
|
||||
"Installing base system"
|
||||
"Encrypting data partition"
|
||||
"Installing bootloader"
|
||||
)
|
||||
PHASE_DETAILS=(
|
||||
"parted, mkfs, cryptsetup"
|
||||
"/dev/sda (465.8G) — TOSHIBA MQ01ACF0"
|
||||
"BIOS boot + EFI + root + data"
|
||||
"FAT32, ext4, LUKS2"
|
||||
"debootstrap → Debian 13 minimal"
|
||||
"AES-256-XTS (AES-NI detected)"
|
||||
"GRUB: BIOS + UEFI hybrid"
|
||||
)
|
||||
PHASE_DURATIONS=(8 6 12 10 40 15 10)
|
||||
|
||||
# ── Header (logo + right-aligned subtitle) ────────────────────
|
||||
# "bitcoin node os" right-aligned to match logo's right edge
|
||||
SUBTITLE_PAD=$(printf "%*s" $((LOGO_PAD + LOGO_W - 15)) "")
|
||||
HEADER_LINES=6 # 3 logo + shadow row + subtitle + blank
|
||||
|
||||
draw_header() {
|
||||
draw_logo_flat
|
||||
printf "%s%b%s%b\n" "$SUBTITLE_PAD" "$ORANGE_DIM" "bitcoin node os" "$NC"
|
||||
p ""
|
||||
}
|
||||
|
||||
draw_header_3d() {
|
||||
local start_row="$1"
|
||||
local logo_row=$((start_row))
|
||||
# Blank for logo + shadow + subtitle
|
||||
goto "$start_row" 1
|
||||
for _ in $(seq 1 5); do p ""; done
|
||||
printf "%s%b%s%b\n" "$SUBTITLE_PAD" "$ORANGE_DIM" "bitcoin node os" "$NC"
|
||||
|
||||
sleep 0.15
|
||||
logo_decrypt_reveal "$logo_row"
|
||||
}
|
||||
|
||||
# ── Phase drawing (all orange) ─────────────────────────────────
|
||||
draw_phase_pending() {
|
||||
p " ${DIMMER}[${1}/7] ${PHASE_NAMES[$(($1-1))]}${NC}"
|
||||
}
|
||||
|
||||
draw_phase_running() {
|
||||
p " ${ORANGE}[${1}/7] ${PHASE_NAMES[$(($1-1))]}${NC} ${ORANGE_BRIGHT}◐${NC}"
|
||||
}
|
||||
|
||||
draw_phase_done() {
|
||||
local name="${PHASE_NAMES[$(($1-1))]}"
|
||||
local dot_count=$((34 - ${#name}))
|
||||
[[ $dot_count -lt 2 ]] && dot_count=2
|
||||
local dots=""
|
||||
for _ in $(seq 1 "$dot_count"); do dots="${dots}."; done
|
||||
p " ${ORANGE_DIM}[${1}/7] ${name} ${DARK}${dots}${NC} ${ORANGE_BRIGHT}✓${NC}"
|
||||
}
|
||||
|
||||
draw_phase_done_compact() {
|
||||
p " ${ORANGE_DIM}[${1}/7] ${PHASE_NAMES[$(($1-1))]}${NC} ${ORANGE_BRIGHT}✓${NC}"
|
||||
}
|
||||
|
||||
simulate_work() {
|
||||
local ticks=$1 row=$2 phase=$3 fi=0
|
||||
local col=$((PAD + 2 + 10 + ${#PHASE_NAMES[$((phase-1))]} + 2))
|
||||
for _ in $(seq 1 "$ticks"); do
|
||||
goto "$row" "$col"
|
||||
printf "%b%s%b" "$ORANGE" "${SPIN_FRAMES:fi%10:1}" "$NC"
|
||||
fi=$((fi + 1))
|
||||
sleep 0.1
|
||||
done
|
||||
}
|
||||
|
||||
simulate_work_with_bar() {
|
||||
local ticks=$1 row=$2 phase=$3 bar_row=$4 fi=0
|
||||
local col=$((PAD + 2 + 10 + ${#PHASE_NAMES[$((phase-1))]} + 2))
|
||||
local bar_width=36
|
||||
|
||||
# Bouncing ₿ — DVD screensaver style
|
||||
local b_row=$((bar_row + 3)) b_col=$((PAD + 4))
|
||||
local b_dr=1 b_dc=1
|
||||
local b_min_row=$((bar_row + 3))
|
||||
local b_max_row=$((TH - 2))
|
||||
[[ $b_max_row -lt $((b_min_row + 3)) ]] && b_max_row=$((b_min_row + 3))
|
||||
local b_min_col=$((PAD + 2))
|
||||
local b_max_col=$((PAD + BW - 2))
|
||||
local b_colors=("$ORANGE" "$ORANGE_BRIGHT" "$ORANGE_GLOW" "$ORANGE_DIM" "$WHITE")
|
||||
local b_ci=0
|
||||
local b_prev_row=$b_row b_prev_col=$b_col
|
||||
|
||||
for t in $(seq 1 "$ticks"); do
|
||||
goto "$row" "$col"
|
||||
printf "%b%s%b" "$ORANGE" "${SPIN_FRAMES:fi%10:1}" "$NC"
|
||||
fi=$((fi + 1))
|
||||
|
||||
local pct=$(( t * 100 / ticks ))
|
||||
local filled=$(( pct * bar_width / 100 ))
|
||||
local empty=$(( bar_width - filled ))
|
||||
local bar_f="" bar_e=""
|
||||
for _ in $(seq 1 "$filled" 2>/dev/null); do bar_f="${bar_f}█"; done
|
||||
for _ in $(seq 1 "$empty" 2>/dev/null); do bar_e="${bar_e}░"; done
|
||||
|
||||
goto "$bar_row" 1; clear_line
|
||||
p " ${ORANGE}${bar_f}${DARK}${bar_e}${NC} ${ORANGE_DIM}${pct}%%${NC}"
|
||||
|
||||
goto "$b_prev_row" "$b_prev_col"; printf " "
|
||||
|
||||
b_row=$((b_row + b_dr)); b_col=$((b_col + b_dc))
|
||||
|
||||
if [[ $b_row -ge $b_max_row ]] || [[ $b_row -le $b_min_row ]]; then
|
||||
b_dr=$(( -b_dr )); b_row=$((b_row + b_dr))
|
||||
b_ci=$(( (b_ci + 1) % ${#b_colors[@]} ))
|
||||
fi
|
||||
if [[ $b_col -ge $b_max_col ]] || [[ $b_col -le $b_min_col ]]; then
|
||||
b_dc=$(( -b_dc )); b_col=$((b_col + b_dc))
|
||||
b_ci=$(( (b_ci + 1) % ${#b_colors[@]} ))
|
||||
fi
|
||||
|
||||
goto "$b_row" "$b_col"
|
||||
printf "%b₿%b" "${b_colors[$b_ci]}" "$NC"
|
||||
b_prev_row=$b_row; b_prev_col=$b_col
|
||||
|
||||
sleep 0.1
|
||||
done
|
||||
|
||||
goto "$b_prev_row" "$b_prev_col"; printf " "
|
||||
goto "$bar_row" 1; clear_line
|
||||
for r in $(seq "$b_min_row" "$b_max_row"); do goto "$r" 1; clear_line; done
|
||||
}
|
||||
|
||||
typewrite() {
|
||||
local text="$1" delay="${2:-0.025}"
|
||||
pn "${ORANGE}"
|
||||
local i=0
|
||||
while [[ $i -lt ${#text} ]]; do
|
||||
printf "%s" "${text:$i:1}"
|
||||
i=$((i + 1))
|
||||
sleep "$delay"
|
||||
done
|
||||
printf "%b\n" "$NC"
|
||||
}
|
||||
|
||||
# ── SCREEN 1: Welcome ─────────────────────────────────────────
|
||||
screen_welcome() {
|
||||
crt_on
|
||||
boot_scan
|
||||
|
||||
clear
|
||||
hide_cursor
|
||||
|
||||
local start_row=$(( (TH - 16) / 2 ))
|
||||
[[ $start_row -lt 2 ]] && start_row=2
|
||||
|
||||
draw_header_3d "$start_row"
|
||||
|
||||
local prompt_row=$((start_row + HEADER_LINES + 2))
|
||||
goto "$prompt_row" 1
|
||||
local prompt_text=" Press Enter to install │ Ctrl+C for shell"
|
||||
pn "${ORANGE_DIM}"
|
||||
local i=0
|
||||
while [[ $i -lt ${#prompt_text} ]]; do
|
||||
printf "%s" "${prompt_text:$i:1}"
|
||||
i=$((i + 1))
|
||||
sleep 0.018
|
||||
done
|
||||
printf "%b" "$NC"
|
||||
|
||||
# Logo breathing while cursor blinks
|
||||
local logo_at=$((start_row + 1))
|
||||
local front_col=$((LOGO_PAD + 1))
|
||||
for _ in $(seq 1 3); do
|
||||
goto "$prompt_row" $((PAD + ${#prompt_text} + 2))
|
||||
printf "%b▌%b" "$ORANGE" "$NC"
|
||||
for li in 0 1 2; do
|
||||
goto $((logo_at + li)) "$front_col"
|
||||
printf "\033[K%b%s%b" "$ORANGE_BRIGHT" "${LOGO_FRONT[$li]}" "$NC"
|
||||
done
|
||||
sleep 0.4
|
||||
goto "$prompt_row" $((PAD + ${#prompt_text} + 2))
|
||||
printf " "
|
||||
for li in 0 1 2; do
|
||||
goto $((logo_at + li)) "$front_col"
|
||||
printf "\033[K%b%s%b" "$ORANGE" "${LOGO_FRONT[$li]}" "$NC"
|
||||
done
|
||||
sleep 0.4
|
||||
done
|
||||
}
|
||||
|
||||
# ── SCREEN 2: Disk Detection ──────────────────────────────────
|
||||
screen_disk_detect() {
|
||||
screen_transition
|
||||
hide_cursor
|
||||
|
||||
local row=2
|
||||
goto $row 1
|
||||
draw_header
|
||||
row=$((row + HEADER_LINES))
|
||||
|
||||
goto $row 1
|
||||
draw_phase_running 1
|
||||
simulate_work 8 $row 1
|
||||
goto $row 1; draw_phase_done 1
|
||||
row=$((row + 2))
|
||||
|
||||
goto $row 1
|
||||
draw_phase_running 2
|
||||
simulate_work 6 $row 2
|
||||
goto $row 1; draw_phase_done 2
|
||||
row=$((row + 2))
|
||||
|
||||
goto $row 1
|
||||
typewrite " Found: /dev/sda (465.8G) — TOSHIBA MQ01ACF0" 0.02
|
||||
row=$((row + 2))
|
||||
|
||||
goto $row 1; hrule; row=$((row + 2))
|
||||
|
||||
goto $row 1
|
||||
p "${ORANGE} ⚠ All data on /dev/sda will be erased.${NC}"
|
||||
row=$((row + 2))
|
||||
|
||||
goto $row 1
|
||||
p "${ORANGE_DIM} Press Enter to install │ Ctrl+C to cancel${NC}"
|
||||
|
||||
for _ in $(seq 1 4); do
|
||||
goto $row $((PAD + 49))
|
||||
printf "%b▌%b" "$ORANGE" "$NC"
|
||||
sleep 0.4
|
||||
goto $row $((PAD + 49))
|
||||
printf " "
|
||||
sleep 0.4
|
||||
done
|
||||
}
|
||||
|
||||
# ── SCREEN 3: Installation ────────────────────────────────────
|
||||
screen_install() {
|
||||
screen_transition
|
||||
hide_cursor
|
||||
|
||||
local row=2
|
||||
goto $row 1
|
||||
draw_header
|
||||
local phase_start=$((row + HEADER_LINES))
|
||||
|
||||
for i in $(seq 0 6); do
|
||||
goto $((phase_start + i * 2)) 1
|
||||
draw_phase_pending $((i + 1))
|
||||
done
|
||||
|
||||
local bar_row=$((phase_start + 14 + 1))
|
||||
goto $bar_row 1; hrule
|
||||
local status_row=$((bar_row + 2))
|
||||
|
||||
for i in $(seq 0 6); do
|
||||
local pr=$((phase_start + i * 2))
|
||||
local pnum=$((i + 1))
|
||||
local dur=${PHASE_DURATIONS[$i]}
|
||||
|
||||
goto $pr 1; clear_line
|
||||
draw_phase_running $pnum
|
||||
|
||||
goto $status_row 1; clear_line
|
||||
p " ${ORANGE_DIM}${PHASE_DETAILS[$i]}${NC}"
|
||||
|
||||
if [[ $dur -gt 15 ]]; then
|
||||
simulate_work_with_bar "$dur" "$pr" "$pnum" "$((status_row - 1))"
|
||||
else
|
||||
simulate_work "$dur" "$pr" "$pnum"
|
||||
fi
|
||||
|
||||
goto $pr 1; clear_line
|
||||
draw_phase_done $pnum
|
||||
done
|
||||
|
||||
goto $((bar_row + 1)) 1; clear_line
|
||||
goto $status_row 1; clear_line
|
||||
|
||||
logo_glow_pulse 3 2
|
||||
sleep 0.3
|
||||
}
|
||||
|
||||
# ── SCREEN 4: Complete ─────────────────────────────────────────
|
||||
screen_complete() {
|
||||
screen_transition
|
||||
hide_cursor
|
||||
|
||||
local row=2
|
||||
goto $row 1
|
||||
draw_header
|
||||
logo_celebrate 3
|
||||
row=$((row + HEADER_LINES))
|
||||
|
||||
for i in $(seq 1 7); do
|
||||
goto $row 1
|
||||
draw_phase_done_compact $i
|
||||
row=$((row + 1))
|
||||
done
|
||||
|
||||
row=$((row + 1))
|
||||
goto $row 1; hrule; row=$((row + 2))
|
||||
|
||||
# Success flash
|
||||
for color in "$ORANGE_DIM" "$ORANGE" "$ORANGE_BRIGHT" "$ORANGE"; do
|
||||
goto $row 1; clear_line
|
||||
p " ${color}✓ Installation Complete${NC}"
|
||||
sleep 0.06
|
||||
done
|
||||
row=$((row + 2))
|
||||
|
||||
goto $row 1
|
||||
typewrite " After reboot, access from any device:" 0.02
|
||||
row=$((row + 2))
|
||||
|
||||
# URL in orange
|
||||
goto $row 1
|
||||
p " ${ORANGE}http://192.0.2.11${NC}"
|
||||
row=$((row + 2))
|
||||
|
||||
# Credentials — white, NOT orange (user request)
|
||||
goto $row 1
|
||||
p " ${WHITE}SSH ssh archipelago@192.0.2.11${NC}"
|
||||
row=$((row + 1))
|
||||
goto $row 1
|
||||
p " ${WHITE}Password archipelago${NC}"
|
||||
row=$((row + 1))
|
||||
goto $row 1
|
||||
p " ${WHITE}Web Login create your password on first visit${NC}"
|
||||
row=$((row + 2))
|
||||
|
||||
goto $row 1; hrule; row=$((row + 2))
|
||||
|
||||
for _ in $(seq 1 5); do
|
||||
goto $row 1; clear_line
|
||||
p "${ORANGE}${BOLD} >>> REMOVE THE USB DRIVE NOW <<<${NC}"
|
||||
sleep 0.5
|
||||
goto $row 1; clear_line
|
||||
p "${ORANGE_DIM} >>> REMOVE THE USB DRIVE NOW <<<${NC}"
|
||||
sleep 0.5
|
||||
done
|
||||
goto $row 1; clear_line
|
||||
p "${ORANGE}${BOLD} >>> REMOVE THE USB DRIVE NOW <<<${NC}"
|
||||
|
||||
goto $((row + 2)) 1
|
||||
p "${ORANGE_DIM} Press Enter to reboot${NC}"
|
||||
sleep 3
|
||||
}
|
||||
|
||||
# ── Main ───────────────────────────────────────────────────────
|
||||
main() {
|
||||
echo ""
|
||||
echo " ${ORANGE}▌${NC} ${ORANGE}Archipelago Install TUI Demo${NC}"
|
||||
echo " ${ORANGE_DIM} Each screen auto-advances. Ctrl+C to exit.${NC}"
|
||||
echo ""
|
||||
sleep 2
|
||||
|
||||
screen_welcome
|
||||
sleep 0.3
|
||||
screen_disk_detect
|
||||
sleep 0.3
|
||||
screen_install
|
||||
sleep 0.3
|
||||
screen_complete
|
||||
|
||||
show_cursor
|
||||
echo ""
|
||||
p "${ORANGE_DIM}** Demo complete **${NC}"
|
||||
echo ""
|
||||
}
|
||||
|
||||
main "$@"
|
||||
Executable
+131
@@ -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"
|
||||
Executable
+225
@@ -0,0 +1,225 @@
|
||||
#!/bin/bash
|
||||
# Shared utility functions for Archipelago scripts
|
||||
#
|
||||
# Source this from any script:
|
||||
# source "$(dirname "$0")/lib/common.sh"
|
||||
#
|
||||
# Provides: logging, SSH helpers, health checks, disk checks, memory limits
|
||||
|
||||
# Guard against double-sourcing
|
||||
[ -n "${_ARCHY_COMMON_LOADED:-}" ] && return 0
|
||||
_ARCHY_COMMON_LOADED=1
|
||||
|
||||
# ── Colored logging ─────────────────────────────────────────────────────
|
||||
|
||||
log_info() { echo -e "\033[0;32m[INFO]\033[0m $(date '+%H:%M:%S') $*"; }
|
||||
log_warn() { echo -e "\033[0;33m[WARN]\033[0m $(date '+%H:%M:%S') $*"; }
|
||||
log_error() { echo -e "\033[0;31m[ERROR]\033[0m $(date '+%H:%M:%S') $*"; }
|
||||
|
||||
# ── SSH wrapper with deploy key ─────────────────────────────────────────
|
||||
|
||||
# Usage: ssh_cmd <host> <command...>
|
||||
# Uses the standard deploy key and safe defaults.
|
||||
ssh_cmd() {
|
||||
local host="$1"; shift
|
||||
local key="${ARCHIPELAGO_SSH_KEY:-$HOME/.ssh/archipelago-deploy}"
|
||||
ssh -i "$key" \
|
||||
-o StrictHostKeyChecking=no \
|
||||
-o ConnectTimeout=10 \
|
||||
-o ServerAliveInterval=15 \
|
||||
-o ServerAliveCountMax=4 \
|
||||
"archipelago@${host}" "$@"
|
||||
}
|
||||
|
||||
# Usage: scp_cmd <src> <dest>
|
||||
# Wraps scp with the same deploy key and options.
|
||||
scp_cmd() {
|
||||
local key="${ARCHIPELAGO_SSH_KEY:-$HOME/.ssh/archipelago-deploy}"
|
||||
scp -i "$key" \
|
||||
-o StrictHostKeyChecking=no \
|
||||
-o ConnectTimeout=10 \
|
||||
"$@"
|
||||
}
|
||||
|
||||
# ── Health check ────────────────────────────────────────────────────────
|
||||
|
||||
# Wait for an HTTP health endpoint to respond successfully.
|
||||
# Usage: wait_for_health <host> [max_wait_seconds] [path]
|
||||
wait_for_health() {
|
||||
local host="$1" max_wait="${2:-60}" path="${3:-/health}"
|
||||
local waited=0
|
||||
while [ $waited -lt $max_wait ]; do
|
||||
if curl -sf "http://${host}${path}" >/dev/null 2>&1; then
|
||||
log_info "Health check passed for ${host}"
|
||||
return 0
|
||||
fi
|
||||
sleep 2
|
||||
waited=$((waited + 2))
|
||||
done
|
||||
log_error "Health check failed for ${host} after ${max_wait}s"
|
||||
return 1
|
||||
}
|
||||
|
||||
# ── Disk space check ───────────────────────────────────────────────────
|
||||
|
||||
# Check that disk usage on a remote host is below a threshold.
|
||||
# Usage: check_disk_space <host> [max_percent]
|
||||
check_disk_space() {
|
||||
local host="$1" max_pct="${2:-85}"
|
||||
local pct
|
||||
pct=$(ssh_cmd "$host" "df / | tail -1 | awk '{print \$(NF-1)}' | tr -d '%'" 2>/dev/null)
|
||||
if [ -n "$pct" ] && [ "$pct" -gt "$max_pct" ] 2>/dev/null; then
|
||||
log_error "Disk at ${pct}% on ${host} (max ${max_pct}%)"
|
||||
return 1
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
# ── Memory limit calculator ────────────────────────────────────────────
|
||||
|
||||
# Returns the memory limit for a container by name.
|
||||
# Checks /etc/archipelago/memory-limits.conf first (override), then falls
|
||||
# back to built-in defaults. Mirrors the pattern in first-boot-containers.sh.
|
||||
#
|
||||
# Low-memory mode: set LOW_MEM=true before calling to get reduced limits
|
||||
# on certain heavy containers.
|
||||
#
|
||||
# Usage: mem_limit <container-name>
|
||||
mem_limit() {
|
||||
local name="$1"
|
||||
|
||||
# Allow per-host overrides via config file
|
||||
local limit
|
||||
limit=$(grep "^${name}=" /etc/archipelago/memory-limits.conf 2>/dev/null | cut -d= -f2)
|
||||
if [ -n "$limit" ]; then
|
||||
echo "$limit"
|
||||
return
|
||||
fi
|
||||
|
||||
# Built-in defaults (keep in sync with first-boot-containers.sh and Rust package config)
|
||||
local low="${LOW_MEM:-false}"
|
||||
case "$name" in
|
||||
bitcoin|bitcoin-core|bitcoin-knots) $low && echo "4g" || echo "8g" ;;
|
||||
ollama) $low && echo "1g" || echo "4g" ;;
|
||||
lnd) echo "512m" ;;
|
||||
electrumx|mempool-electrs|electrs) echo "4g" ;;
|
||||
nextcloud) echo "1g" ;;
|
||||
immich_server) echo "1g" ;;
|
||||
btcpay-server|btcpayserver) echo "1g" ;;
|
||||
homeassistant) echo "512m" ;;
|
||||
fedimint) echo "512m" ;;
|
||||
fedimint-gateway) echo "512m" ;;
|
||||
photoprism) $low && echo "512m" || echo "1g" ;;
|
||||
mempool-api) echo "512m" ;;
|
||||
jellyfin) echo "1g" ;;
|
||||
searxng) echo "512m" ;;
|
||||
archy-btcpay-db) echo "512m" ;;
|
||||
archy-nbxplorer) echo "512m" ;;
|
||||
archy-mempool-db) echo "512m" ;;
|
||||
archy-mempool-web) echo "256m" ;;
|
||||
grafana) echo "256m" ;;
|
||||
vaultwarden) echo "256m" ;;
|
||||
uptime-kuma) echo "256m" ;;
|
||||
filebrowser) echo "256m" ;;
|
||||
portainer) echo "256m" ;;
|
||||
nginx-proxy-manager) echo "256m" ;;
|
||||
immich_postgres) echo "256m" ;;
|
||||
immich_redis) echo "128m" ;;
|
||||
tailscale) echo "256m" ;;
|
||||
penpot-postgres) echo "256m" ;;
|
||||
penpot-valkey) echo "128m" ;;
|
||||
penpot-backend) echo "512m" ;;
|
||||
penpot-exporter) echo "256m" ;;
|
||||
penpot-frontend) echo "256m" ;;
|
||||
nostr-rs-relay) echo "256m" ;;
|
||||
strfry) echo "256m" ;;
|
||||
indeedhub|archy-bitcoin-ui|archy-lnd-ui|archy-electrs-ui) echo "128m" ;;
|
||||
*) echo "512m" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# ── Wait for container readiness ───────────────────────────────────────
|
||||
|
||||
# Wait for a container health check command to succeed.
|
||||
# Usage: wait_for_container <name> <check_cmd> [max_wait_seconds]
|
||||
wait_for_container() {
|
||||
local name="$1" check_cmd="$2" max_wait="${3:-30}"
|
||||
local waited=0
|
||||
while [ $waited -lt $max_wait ]; do
|
||||
if eval "$check_cmd" 2>/dev/null; then
|
||||
log_info "$name is ready (${waited}s)"
|
||||
return 0
|
||||
fi
|
||||
sleep 2
|
||||
waited=$((waited + 2))
|
||||
done
|
||||
log_warn "$name not ready after ${max_wait}s"
|
||||
return 1
|
||||
}
|
||||
|
||||
# ── Same-host deploy safety guard (2026-07-31 incident, widened 13-09) ──
|
||||
|
||||
# Refuse a same-host deploy whose resolved source and destination differ.
|
||||
#
|
||||
# The 2026-07-31 incident: a same-host `rsync --delete` deploy whose source
|
||||
# was INSIDE the destination (a worktree under the main checkout) mirrored
|
||||
# the worktree onto the main checkout and deleted ~1810 tracked files, a
|
||||
# running dev server, and two sessions' uncommitted work. The original fix
|
||||
# refused only that containment shape (source-in-destination or
|
||||
# destination-in-source). It missed SIBLING directories that share a parent
|
||||
# but neither contains the other — e.g. archy-phase13 (this worktree) as
|
||||
# source and archy (the main checkout, TARGET_DIR's resolved symlink
|
||||
# target) as destination — which is the identical rsync --delete hazard
|
||||
# through a shape the old two-case guard let through.
|
||||
#
|
||||
# This function takes two ALREADY-RESOLVED (`readlink -f`) absolute paths
|
||||
# and makes no SSH calls itself — same-host detection stays in the caller
|
||||
# (deploy-to-target.sh already does it via /etc/machine-id). It returns 0
|
||||
# only when the two paths are equal; every other case is refused, not just
|
||||
# the two containment shapes. Any resolved-path mismatch on the same host
|
||||
# is the same rsync --delete hazard regardless of shape, so "refuse unless
|
||||
# equal" is both the widening and a simplification.
|
||||
#
|
||||
# Usage: assert_safe_same_host_deploy <local_src> <remote_dst>
|
||||
assert_safe_same_host_deploy() {
|
||||
local local_src="$1" remote_dst="$2"
|
||||
|
||||
if [ -z "$local_src" ] || [ -z "$remote_dst" ]; then
|
||||
log_error "assert_safe_same_host_deploy: both paths are required (local_src='$local_src' remote_dst='$remote_dst')"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [ "$local_src" = "$remote_dst" ]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo "FATAL: refusing to deploy. Source '$local_src' and destination '$remote_dst' are on the same host but resolve to DIFFERENT paths." >&2
|
||||
echo " 'rsync --delete' would mirror one onto the other and delete whatever the source lacks — the 2026-07-31 data-loss incident (and the sibling-directory shape its original fix missed)." >&2
|
||||
echo " Run this script from the deploy destination itself ('$remote_dst'), not from a worktree, sibling checkout, or copy." >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
# ── Section timing ─────────────────────────────────────────────────────
|
||||
|
||||
# Track elapsed time for deploy sections.
|
||||
# Usage:
|
||||
# section_start "Building frontend"
|
||||
# ... do work ...
|
||||
# section_end
|
||||
_SECTION_START=0
|
||||
_SECTION_NAME=""
|
||||
|
||||
section_start() {
|
||||
_SECTION_NAME="${1:-}"
|
||||
_SECTION_START=$(date +%s)
|
||||
[ -n "$_SECTION_NAME" ] && log_info "$_SECTION_NAME"
|
||||
}
|
||||
|
||||
section_end() {
|
||||
local elapsed=$(( $(date +%s) - _SECTION_START ))
|
||||
if [ -n "$_SECTION_NAME" ]; then
|
||||
log_info "$_SECTION_NAME done (${elapsed}s)"
|
||||
else
|
||||
echo " (${elapsed}s)"
|
||||
fi
|
||||
}
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
# App proxies for HTTPS - avoids mixed content when embedding apps from HTTPS page
|
||||
# Complete list for all apps that may be launched from the UI
|
||||
location /app/grafana/ {
|
||||
proxy_pass http://127.0.0.1:3000/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_hide_header X-Frame-Options;
|
||||
proxy_hide_header Content-Security-Policy;
|
||||
}
|
||||
location = /app/uptime-kuma/ {
|
||||
return 302 /app/uptime-kuma/dashboard;
|
||||
}
|
||||
location /app/uptime-kuma/ {
|
||||
proxy_pass http://127.0.0.1:3002/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-Prefix /app/uptime-kuma;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_redirect / /app/uptime-kuma/;
|
||||
proxy_hide_header X-Frame-Options;
|
||||
proxy_hide_header Content-Security-Policy;
|
||||
}
|
||||
location /app/gitea/ {
|
||||
proxy_pass http://127.0.0.1:3001/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_hide_header X-Frame-Options;
|
||||
proxy_hide_header Content-Security-Policy;
|
||||
}
|
||||
location /app/searxng/ {
|
||||
proxy_pass http://127.0.0.1:8888/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_hide_header X-Frame-Options;
|
||||
proxy_hide_header Content-Security-Policy;
|
||||
}
|
||||
location /app/portainer/ {
|
||||
proxy_pass http://127.0.0.1:9000/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_hide_header X-Frame-Options;
|
||||
proxy_hide_header Content-Security-Policy;
|
||||
}
|
||||
location /app/filebrowser/ {
|
||||
client_max_body_size 10G;
|
||||
proxy_pass http://127.0.0.1:8083/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_hide_header X-Frame-Options;
|
||||
proxy_hide_header Content-Security-Policy;
|
||||
proxy_request_buffering off;
|
||||
}
|
||||
location /app/endurain/ {
|
||||
proxy_pass http://127.0.0.1:8080/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_hide_header X-Frame-Options;
|
||||
proxy_hide_header Content-Security-Policy;
|
||||
}
|
||||
location /app/lnd/ {
|
||||
proxy_pass http://127.0.0.1:18083/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_hide_header X-Frame-Options;
|
||||
proxy_hide_header Content-Security-Policy;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
}
|
||||
location /app/jellyfin/ {
|
||||
proxy_pass http://127.0.0.1:8096/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_hide_header X-Frame-Options;
|
||||
proxy_hide_header Content-Security-Policy;
|
||||
}
|
||||
location /app/photoprism/ {
|
||||
proxy_pass http://127.0.0.1:2342/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_hide_header X-Frame-Options;
|
||||
proxy_hide_header Content-Security-Policy;
|
||||
}
|
||||
location /app/mempool/ {
|
||||
proxy_pass http://127.0.0.1:4080/;
|
||||
proxy_http_version 1.1;
|
||||
# mempool's UI is websocket-driven (/api/v1/ws). Without forwarding the
|
||||
# upgrade, the page loads but never connects — the backend can be fully
|
||||
# healthy and every REST probe green while the user sees a dead UI
|
||||
# (2026-08-09). 101 through this proxy is the only honest health signal.
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_hide_header X-Frame-Options;
|
||||
proxy_hide_header Content-Security-Policy;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
}
|
||||
location /app/fedimint/ {
|
||||
proxy_pass http://127.0.0.1:8175/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_hide_header X-Frame-Options;
|
||||
proxy_hide_header Content-Security-Policy;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
}
|
||||
location /app/tailscale/ {
|
||||
proxy_pass http://127.0.0.1:8240/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_hide_header X-Frame-Options;
|
||||
proxy_hide_header Content-Security-Policy;
|
||||
}
|
||||
location /app/ollama/ {
|
||||
proxy_pass http://127.0.0.1:11434/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_hide_header X-Frame-Options;
|
||||
proxy_hide_header Content-Security-Policy;
|
||||
}
|
||||
location /app/bitcoin-ui/ {
|
||||
proxy_pass http://127.0.0.1:8334/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_hide_header X-Frame-Options;
|
||||
proxy_hide_header Content-Security-Policy;
|
||||
}
|
||||
location /app/electrs/ {
|
||||
proxy_pass http://127.0.0.1:50002/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_hide_header X-Frame-Options;
|
||||
proxy_hide_header Content-Security-Policy;
|
||||
}
|
||||
location /app/electrumx/ {
|
||||
proxy_pass http://127.0.0.1:50002/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_hide_header X-Frame-Options;
|
||||
proxy_hide_header Content-Security-Policy;
|
||||
}
|
||||
location /app/electrs-ui/ {
|
||||
proxy_pass http://127.0.0.1:50002/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_hide_header X-Frame-Options;
|
||||
proxy_hide_header Content-Security-Policy;
|
||||
}
|
||||
location /app/nginx-proxy-manager/ {
|
||||
proxy_pass http://127.0.0.1:8081/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_hide_header X-Frame-Options;
|
||||
proxy_hide_header Content-Security-Policy;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
Executable
+87
@@ -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!"
|
||||
Executable
+72
@@ -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
|
||||
Executable
+108
@@ -0,0 +1,108 @@
|
||||
#!/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"
|
||||
|
||||
# Version metadata beside the APK: the dashboard's companion overlay reads
|
||||
# this to show which version the Download button installs. Extracted from
|
||||
# the gradle config that just built the APK, so it can never drift from it.
|
||||
META="${DEST%.apk}.json"
|
||||
V_NAME="$(sed -n 's/^[[:space:]]*versionName = "\(.*\)"/\1/p' Android/app/build.gradle.kts | head -1)"
|
||||
V_CODE="$(sed -n 's/^[[:space:]]*versionCode = \([0-9]*\).*/\1/p' Android/app/build.gradle.kts | head -1)"
|
||||
if [ -n "$V_NAME" ] && [ -n "$V_CODE" ]; then
|
||||
printf '{\n "versionName": "%s",\n "versionCode": %s\n}\n' "$V_NAME" "$V_CODE" > "$META"
|
||||
git add "$META"
|
||||
echo "publish-companion-apk: staged $META (v$V_NAME, build $V_CODE)" >&2
|
||||
else
|
||||
echo "publish-companion-apk: WARNING could not extract version from build.gradle.kts — $META not updated" >&2
|
||||
fi
|
||||
|
||||
# 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
|
||||
Executable
+133
@@ -0,0 +1,133 @@
|
||||
#!/usr/bin/env bash
|
||||
# Publish an Archipelago OTA release to a Gitea remote and verify downloads.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
VERSION="${1:-}"
|
||||
REMOTE="${2:-gitea-vps2}"
|
||||
|
||||
if [ -z "$VERSION" ]; then
|
||||
echo "Usage: $0 VERSION [remote]"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
VERSION_DIR="$PROJECT_ROOT/releases/v${VERSION}"
|
||||
BACKEND="$VERSION_DIR/archipelago"
|
||||
FRONTEND="$VERSION_DIR/archipelago-frontend-${VERSION}.tar.gz"
|
||||
|
||||
fail() { echo "Error: $*" >&2; exit 1; }
|
||||
|
||||
[ -f "$PROJECT_ROOT/releases/manifest.json" ] || fail "releases/manifest.json missing"
|
||||
[ -f "$BACKEND" ] || fail "backend artifact missing: $BACKEND"
|
||||
[ -f "$FRONTEND" ] || fail "frontend artifact missing: $FRONTEND"
|
||||
|
||||
"$SCRIPT_DIR/check-release-manifest.sh"
|
||||
|
||||
# §A supply-chain gate: never publish an unsigned OTA manifest. Fleet nodes
|
||||
# with the pinned release-root anchor refuse to auto-apply unsigned manifests,
|
||||
# and enforcement will tighten to hard-reject — an unsigned publish would
|
||||
# strand them. Grep proves presence; ceremony verify proves the crypto.
|
||||
# Release root ROTATED 2026-08-05; see create-release.sh. New root from
|
||||
# v1.7.123 onward.
|
||||
EXPECTED_DID="did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT"
|
||||
grep -q '"signature":' "$PROJECT_ROOT/releases/manifest.json" \
|
||||
&& grep -q "\"signed_by\": \"$EXPECTED_DID\"" "$PROJECT_ROOT/releases/manifest.json" \
|
||||
|| fail "releases/manifest.json is not signed by the release root — run: bash scripts/sign-manifest.sh"
|
||||
if [ -x "$PROJECT_ROOT/core/target/release/archipelago" ]; then
|
||||
"$PROJECT_ROOT/core/target/release/archipelago" ceremony verify "$PROJECT_ROOT/releases/manifest.json" \
|
||||
|| fail "manifest signature failed cryptographic verification"
|
||||
fi
|
||||
|
||||
remote_url=$(git -C "$PROJECT_ROOT" remote get-url "$REMOTE")
|
||||
# https is accepted as well as http. Requiring http:// meant the only remote
|
||||
# whose credential actually works for git push (the https one) was rejected,
|
||||
# while the http remote it forced you to use had a dead token — so publishing
|
||||
# failed on auth after the manifest had already passed every check
|
||||
# (v1.7.121-alpha, 2026-08-04). The scheme is carried through to the API URL
|
||||
# rather than assumed.
|
||||
case "$remote_url" in
|
||||
http://*@*|https://*@*) ;;
|
||||
*) fail "$REMOTE must be an authenticated http(s):// Gitea remote URL for API uploads" ;;
|
||||
esac
|
||||
|
||||
scheme=${remote_url%%://*}
|
||||
rest=${remote_url#*://}
|
||||
auth=${rest%%@*}
|
||||
host_path=${rest#*@}
|
||||
host=${host_path%%/*}
|
||||
repo_path=${host_path#*/}
|
||||
repo_path=${repo_path%.git}
|
||||
api="$scheme://$host/api/v1/repos/$repo_path"
|
||||
release_url="$api/releases/tags/v${VERSION}"
|
||||
|
||||
# ORDER MATTERS. The manifest is the trigger — nodes read releases/manifest.json
|
||||
# from branch main and try to download the named version the moment it appears.
|
||||
# So main (which carries the live manifest) must be pushed LAST, only after the
|
||||
# assets are uploaded and their bytes verified against the manifest. The tag is
|
||||
# pushed first because the Gitea release and its asset download URLs hang off it,
|
||||
# but the tag alone changes nothing for nodes.
|
||||
#
|
||||
# This used to push main and the tag together, up front, then upload assets. That
|
||||
# left the manifest live for the entire upload+verify window — and on 2026-08-07
|
||||
# an upload failed inside that window, so every polling node briefly advertised a
|
||||
# v1.7.126-alpha update whose binary 500'd and whose tarball did not exist.
|
||||
echo "Pushing tag v${VERSION} to $REMOTE (not main yet)..."
|
||||
git -C "$PROJECT_ROOT" push "$REMOTE" "refs/tags/v${VERSION}"
|
||||
|
||||
release_json=$(curl -fsS -u "$auth" "$release_url" || true)
|
||||
if [ -z "$release_json" ]; then
|
||||
echo "Creating Gitea release v${VERSION}..."
|
||||
release_body=$(python3 - "$VERSION" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
|
||||
version = sys.argv[1]
|
||||
print(json.dumps({
|
||||
"tag_name": f"v{version}",
|
||||
"target_commitish": "main",
|
||||
"name": f"v{version}",
|
||||
"body": f"Archipelago v{version} release artifacts for OTA updates.",
|
||||
"draft": False,
|
||||
"prerelease": True,
|
||||
}))
|
||||
PY
|
||||
)
|
||||
release_json=$(curl -fsS -u "$auth" -H 'Content-Type: application/json' -d "$release_body" "$api/releases")
|
||||
fi
|
||||
|
||||
release_id=$(printf '%s' "$release_json" | python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])')
|
||||
|
||||
asset_names=$(curl -fsS -u "$auth" "$api/releases/$release_id/assets" | python3 -c 'import json,sys; print("\n".join(a["name"] for a in json.load(sys.stdin)))')
|
||||
upload_asset() {
|
||||
local path="$1"
|
||||
local name="$2"
|
||||
if printf '%s\n' "$asset_names" | grep -Fxq "$name"; then
|
||||
echo "Asset $name already exists; leaving it in place."
|
||||
return
|
||||
fi
|
||||
echo "Uploading $name..."
|
||||
curl --fail --show-error --silent --http1.1 --connect-timeout 20 --max-time 900 \
|
||||
-u "$auth" \
|
||||
-F "attachment=@$path" \
|
||||
"$api/releases/$release_id/assets?name=$name" >/dev/null
|
||||
asset_names=$(printf '%s\n%s\n' "$asset_names" "$name")
|
||||
}
|
||||
|
||||
upload_asset "$BACKEND" "archipelago"
|
||||
upload_asset "$FRONTEND" "archipelago-frontend-${VERSION}.tar.gz"
|
||||
|
||||
echo "Verifying public download URLs (full GET + size + sha256)..."
|
||||
# Delegated to check-release-assets.sh so the same verifier is used here and by
|
||||
# hand during recovery. It fails hard on the first bad asset — the previous
|
||||
# inline `while read` ran in a pipe subshell, where a `fail` (exit) killed only
|
||||
# the subshell and let this script march on to "published and verified".
|
||||
"$PROJECT_ROOT/scripts/check-release-assets.sh" "$PROJECT_ROOT/releases/manifest.json" \
|
||||
|| fail "asset verification failed — NOT pushing main. The manifest stays off the branch nodes read, so no node sees a version it cannot fetch. Repair the assets and re-run."
|
||||
|
||||
# Assets are proven fetchable — only now does the manifest become live.
|
||||
echo "Assets verified. Pushing main to $REMOTE (this makes v${VERSION} live)..."
|
||||
git -C "$PROJECT_ROOT" push "$REMOTE" main
|
||||
|
||||
echo "Release v${VERSION} published and verified on $REMOTE."
|
||||
Executable
+923
@@ -0,0 +1,923 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Archipelago Container Reconciler
|
||||
# Ensures every container matches the canonical spec from container-specs.sh.
|
||||
# Safe to run repeatedly (idempotent). Run on any node.
|
||||
#
|
||||
# Usage:
|
||||
# sudo ./reconcile-containers.sh # Fix everything
|
||||
# sudo ./reconcile-containers.sh --check-only # Audit only, no changes
|
||||
# sudo ./reconcile-containers.sh --force # Override user-stopped
|
||||
# sudo ./reconcile-containers.sh --force-recreate # Recreate matched containers
|
||||
# sudo ./reconcile-containers.sh --tier=2 # Only reconcile tier 2
|
||||
# sudo ./reconcile-containers.sh --container=lnd # Only reconcile lnd
|
||||
#
|
||||
set -o pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
|
||||
# ── Parse arguments ──────────────────────────────────────────────────
|
||||
CHECK_ONLY=false
|
||||
FORCE=false
|
||||
FORCE_RECREATE=false
|
||||
CREATE_MISSING=false
|
||||
FILTER_TIER=""
|
||||
FILTER_CONTAINER=""
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--check-only) CHECK_ONLY=true ;;
|
||||
--force) FORCE=true ;;
|
||||
--force-recreate) FORCE_RECREATE=true ;;
|
||||
--create-missing) CREATE_MISSING=true ;;
|
||||
--tier=*) FILTER_TIER="${arg#*=}" ;;
|
||||
--container=*) FILTER_CONTAINER="${arg#*=}" ;;
|
||||
-h|--help)
|
||||
echo "Usage: $0 [--check-only] [--force] [--force-recreate] [--create-missing] [--tier=N] [--container=NAME]"
|
||||
echo ""
|
||||
echo " --check-only Audit only, no changes."
|
||||
echo " --force Override user-stopped state."
|
||||
echo " --force-recreate Recreate matched existing containers even if they"
|
||||
echo " otherwise match the spec. Use with --container or"
|
||||
echo " --tier for scoped image/config refreshes."
|
||||
echo " --create-missing Override SPEC_OPTIONAL for containers that have on-disk"
|
||||
echo " data but no live container (recovery from failed updates)."
|
||||
echo " --tier=N Only reconcile containers in tier N."
|
||||
echo " --container=NAME Only reconcile the named container (spec key)."
|
||||
exit 0 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# ── Colors ───────────────────────────────────────────────────────────
|
||||
RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[0;33m'
|
||||
BLUE='\033[0;34m' CYAN='\033[0;36m' BOLD='\033[1m'
|
||||
NC='\033[0m'
|
||||
|
||||
ok() { echo -e " ${GREEN}[OK]${NC} $*"; }
|
||||
fixed() { echo -e " ${CYAN}[FIXED]${NC} $*"; }
|
||||
skip() { echo -e " ${YELLOW}[SKIP]${NC} $*"; }
|
||||
fail() { echo -e " ${RED}[FAIL]${NC} $*"; }
|
||||
info() { echo -e " ${BLUE}[INFO]${NC} $*"; }
|
||||
header(){ echo -e "\n${BOLD}$*${NC}"; }
|
||||
|
||||
# ── Source specs ─────────────────────────────────────────────────────
|
||||
source "$SCRIPT_DIR/container-specs.sh" || { echo "Cannot source container-specs.sh"; exit 1; }
|
||||
detect_environment
|
||||
|
||||
PORT_ALLOC_FILE="/var/lib/archipelago/port-allocations.env"
|
||||
[ -f "$PORT_ALLOC_FILE" ] && . "$PORT_ALLOC_FILE"
|
||||
|
||||
port_available() {
|
||||
local port="$1"
|
||||
ss -ltn 2>/dev/null | awk -v p=":$port" '$4 == p || $4 ~ p "$" { found=1 } END { exit found ? 1 : 0 }'
|
||||
}
|
||||
|
||||
alloc_port() {
|
||||
local key="$1" preferred="$2" var="PORT_${key//[^A-Za-z0-9]/_}" cur=""
|
||||
eval "cur=\${$var:-}"
|
||||
if [ -n "$cur" ] && port_available "$cur"; then
|
||||
printf '%s' "$cur"
|
||||
return
|
||||
fi
|
||||
if port_available "$preferred"; then
|
||||
cur="$preferred"
|
||||
else
|
||||
cur=""
|
||||
for p in $(seq 8085 9999); do
|
||||
if port_available "$p"; then cur="$p"; break; fi
|
||||
done
|
||||
fi
|
||||
[ -n "$cur" ] || cur="$preferred"
|
||||
sudo mkdir -p "$(dirname "$PORT_ALLOC_FILE")" 2>/dev/null || true
|
||||
if ! grep -q "^$var=" "$PORT_ALLOC_FILE" 2>/dev/null; then
|
||||
printf '%s=%s\n' "$var" "$cur" | sudo tee -a "$PORT_ALLOC_FILE" >/dev/null
|
||||
fi
|
||||
printf '%s' "$cur"
|
||||
}
|
||||
|
||||
# ── Podman command ───────────────────────────────────────────────────
|
||||
# Run as archipelago user — podman sees rootless containers directly.
|
||||
# Use sudo only for chown/mkdir operations.
|
||||
PODMAN="podman"
|
||||
PODMAN_IMAGE_CHECK_TIMEOUT="${PODMAN_IMAGE_CHECK_TIMEOUT:-10}"
|
||||
|
||||
podman_bounded() {
|
||||
timeout --kill-after=2s "${PODMAN_IMAGE_CHECK_TIMEOUT}s" "$PODMAN" "$@"
|
||||
}
|
||||
|
||||
# ── Pre-flight ───────────────────────────────────────────────────────
|
||||
header "╔══════════════════════════════════════════════════╗"
|
||||
header "║ ARCHIPELAGO CONTAINER RECONCILER ║"
|
||||
header "╚══════════════════════════════════════════════════╝"
|
||||
echo ""
|
||||
info "Host: $(hostname) ($HOST_IP)"
|
||||
info "Disk: ${DISK_GB}GB | RAM: ${TOTAL_MEM_MB}MB | Low-mem: $LOW_MEM"
|
||||
info "Mode: $($CHECK_ONLY && echo 'CHECK ONLY (no changes)' || echo 'APPLY FIXES')"
|
||||
echo ""
|
||||
|
||||
# Ensure archy-net exists
|
||||
if ! $PODMAN network exists archy-net 2>/dev/null; then
|
||||
if $CHECK_ONLY; then
|
||||
info "archy-net missing (would create)"
|
||||
else
|
||||
$PODMAN network create archy-net 2>/dev/null && info "Created archy-net" || fail "Cannot create archy-net"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Load user-stopped list
|
||||
USER_STOPPED_FILE="/var/lib/archipelago/user-stopped.json"
|
||||
USER_STOPPED=""
|
||||
if [ -f "$USER_STOPPED_FILE" ]; then
|
||||
USER_STOPPED=$(cat "$USER_STOPPED_FILE" 2>/dev/null)
|
||||
fi
|
||||
is_user_stopped() {
|
||||
[ "$FORCE" = "true" ] && return 1
|
||||
echo "$USER_STOPPED" | grep -q "\"$1\"" 2>/dev/null
|
||||
}
|
||||
|
||||
# ── Inspection helpers ───────────────────────────────────────────────
|
||||
container_exists() {
|
||||
# Avoid SIGPIPE-from-grep-q failing under `set -o pipefail`.
|
||||
local names
|
||||
names=$($PODMAN ps -a --format '{{.Names}}' 2>/dev/null)
|
||||
echo "$names" | grep -qx "$1"
|
||||
}
|
||||
|
||||
container_running() {
|
||||
local names
|
||||
names=$($PODMAN ps --format '{{.Names}}' 2>/dev/null)
|
||||
echo "$names" | grep -qx "$1"
|
||||
}
|
||||
|
||||
container_image() {
|
||||
$PODMAN inspect "$1" --format '{{.ImageName}}' 2>/dev/null
|
||||
}
|
||||
|
||||
container_image_id() {
|
||||
$PODMAN inspect "$1" --format '{{.Image}}' 2>/dev/null
|
||||
}
|
||||
|
||||
spec_image_id() {
|
||||
podman_bounded image inspect "$SPEC_IMAGE" --format '{{.Id}}' 2>/dev/null
|
||||
}
|
||||
|
||||
container_network() {
|
||||
# Use actual Networks map — NetworkMode is unreliable (always shows 'bridge' in rootless)
|
||||
local nets
|
||||
nets=$($PODMAN inspect "$1" --format '{{range $k,$v := .NetworkSettings.Networks}}{{$k}} {{end}}' 2>/dev/null)
|
||||
# Return first network name, trimmed
|
||||
echo "$nets" | awk '{print $1}'
|
||||
}
|
||||
|
||||
container_memory() {
|
||||
$PODMAN inspect "$1" --format '{{.HostConfig.Memory}}' 2>/dev/null
|
||||
}
|
||||
|
||||
container_health_cmd() {
|
||||
$PODMAN inspect "$1" --format '{{with .Config.Healthcheck}}{{range .Test}}{{println .}}{{end}}{{end}}' 2>/dev/null \
|
||||
| awk 'NR > 1 { print }' \
|
||||
| paste -sd ' ' -
|
||||
}
|
||||
|
||||
normalize_health_cmd() {
|
||||
printf '%s' "$1" | sed 's/\\"/"/g; s/[[:space:]][[:space:]]*/ /g; s/^ //; s/ $//'
|
||||
}
|
||||
|
||||
host_port_listening() {
|
||||
local port="$1"
|
||||
ss -ltn 2>/dev/null | awk -v p=":$port" '
|
||||
$4 == p || $4 ~ p "$" { found=1 }
|
||||
END { exit found ? 0 : 1 }
|
||||
'
|
||||
}
|
||||
|
||||
prepare_bind_source() {
|
||||
local source="$1"
|
||||
[ -n "$source" ] || return 0
|
||||
|
||||
case "$source" in
|
||||
/run/user/*/podman/podman.sock)
|
||||
if [ ! -S "$source" ]; then
|
||||
local runtime_dir="${source%/podman/podman.sock}"
|
||||
XDG_RUNTIME_DIR="$runtime_dir" systemctl --user start podman.socket 2>/dev/null || true
|
||||
for _ in 1 2 3 4 5 6 7 8 9 10; do
|
||||
[ -S "$source" ] && return 0
|
||||
sleep 0.25
|
||||
done
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
case "$source" in
|
||||
/var/lib/archipelago/*)
|
||||
sudo mkdir -p "$source" 2>/dev/null
|
||||
;;
|
||||
*)
|
||||
# Non-data bind mounts can be files/sockets/devices. Creating the full
|
||||
# path would turn e.g. podman.sock into a directory and break Portainer.
|
||||
if [ -e "$source" ]; then
|
||||
return 0
|
||||
fi
|
||||
fail "bind source missing: $source"
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
ensure_catatonit() {
|
||||
command -v catatonit >/dev/null 2>&1 && return 0
|
||||
$CHECK_ONLY && { info "catatonit missing (would install)"; return 0; }
|
||||
|
||||
if command -v apt-get >/dev/null 2>&1; then
|
||||
sudo apt-get update >/dev/null 2>&1 || true
|
||||
sudo apt-get install -y catatonit >/dev/null 2>&1 || true
|
||||
elif command -v dnf >/dev/null 2>&1; then
|
||||
sudo dnf install -y catatonit >/dev/null 2>&1 || true
|
||||
elif command -v apk >/dev/null 2>&1; then
|
||||
sudo apk add catatonit >/dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
command -v catatonit >/dev/null 2>&1 || { fail "catatonit missing; Portainer compose builds may fail"; return 1; }
|
||||
}
|
||||
|
||||
ensure_portainer_host_paths() {
|
||||
ensure_catatonit
|
||||
if $CHECK_ONLY; then
|
||||
[ -d /var/lib/archipelago/portainer/compose ] || info "Portainer compose dir missing (would create)"
|
||||
[ -e /data ] || info "/data host path missing (would link to /var/lib/archipelago/portainer)"
|
||||
return 0
|
||||
fi
|
||||
|
||||
sudo mkdir -p /var/lib/archipelago/portainer/compose 2>/dev/null || true
|
||||
sudo chown -R 1000:1000 /var/lib/archipelago/portainer 2>/dev/null || true
|
||||
if [ ! -e /data ]; then
|
||||
sudo ln -s /var/lib/archipelago/portainer /data 2>/dev/null || true
|
||||
elif [ -d /data ] && [ ! -L /data ] && [ ! -e /data/compose ]; then
|
||||
sudo ln -s /var/lib/archipelago/portainer/compose /data/compose 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
|
||||
container_has_mount() {
|
||||
local name="$1" source="$2" target="$3"
|
||||
$PODMAN inspect "$name" --format '{{range .Mounts}}{{println .Source "|" .Destination}}{{end}}' 2>/dev/null \
|
||||
| awk -F'|' -v src="$source" -v dst="$target" '
|
||||
{ gsub(/[[:space:]]+$/, "", $1); gsub(/^[[:space:]]+/, "", $2); }
|
||||
$1 == src && $2 == dst { found=1 }
|
||||
END { exit found ? 0 : 1 }
|
||||
'
|
||||
}
|
||||
|
||||
# Read one environment variable's current value from a running/stopped container.
|
||||
# Returns empty string if the var is not set.
|
||||
container_env_val() {
|
||||
local name="$1" key="$2"
|
||||
$PODMAN inspect "$name" --format '{{range .Config.Env}}{{println .}}{{end}}' 2>/dev/null \
|
||||
| awk -F= -v k="$key" '$1==k { sub(/^[^=]+=/, ""); print; exit }'
|
||||
}
|
||||
|
||||
# Env keys whose values bake network topology into the container. If the spec's
|
||||
# value for one of these keys ever differs from the running container's value
|
||||
# (host IP changed, DHCP lease rotated, LAN re-subnetted, container dependency
|
||||
# moved between archy-net and bridge), the container MUST be recreated.
|
||||
# This is the systemic fix for the fedimint April-11 stale-IP class of bug
|
||||
# where a container's URL env was never reconciled after network changes.
|
||||
#
|
||||
# Match by suffix to keep the list small. Covers:
|
||||
# *_URL (FM_P2P_URL, FM_API_URL, FM_BITCOIND_URL, NBXPLORER_BTCRPCURL, ...)
|
||||
# *_HOST (BTCPAY_HOST, CORE_RPC_HOST, ...)
|
||||
# *_ENDPOINT (NBXPLORER_BTCNODEENDPOINT, ...)
|
||||
URL_ENV_SUFFIXES="_URL _HOST _ENDPOINT"
|
||||
|
||||
image_exists() {
|
||||
podman_bounded image exists "$1" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
resolve_spec_image() {
|
||||
image_exists "$SPEC_IMAGE" && return
|
||||
|
||||
local image_path image_name image_tag candidate repo
|
||||
image_path="${SPEC_IMAGE#*/}"
|
||||
image_name="${SPEC_IMAGE##*/}"
|
||||
image_tag="${image_name#*:}"
|
||||
image_name="${image_name%%:*}"
|
||||
|
||||
for candidate in \
|
||||
"${ARCHY_REGISTRY_FALLBACK:-}/${image_path}" \
|
||||
"80.71.235.15:3000/archipelago/${image_name}:${image_tag}" \
|
||||
"80.71.235.15:3000/lfg2025/${image_name}:${image_tag}"; do
|
||||
case "$candidate" in /*) continue;; esac
|
||||
if image_exists "$candidate"; then
|
||||
info "$SPEC_NAME — using local image alias $candidate"
|
||||
SPEC_IMAGE="$candidate"
|
||||
return
|
||||
fi
|
||||
done
|
||||
|
||||
repo=$(podman_bounded images --format '{{.Repository}}:{{.Tag}}' 2>/dev/null \
|
||||
| grep -E "/${image_name}:${image_tag}$" \
|
||||
| head -1 || true)
|
||||
if [ -n "$repo" ]; then
|
||||
info "$SPEC_NAME — using local image alias $repo"
|
||||
SPEC_IMAGE="$repo"
|
||||
fi
|
||||
}
|
||||
|
||||
# Convert memory string to bytes for comparison
|
||||
mem_to_bytes() {
|
||||
local m="$1"
|
||||
case "$m" in
|
||||
*g|*G) echo $(( ${m%[gG]} * 1073741824 )) ;;
|
||||
*m|*M) echo $(( ${m%[mM]} * 1048576 )) ;;
|
||||
*) echo "$m" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# ── Build podman run command from spec ───────────────────────────────
|
||||
build_run_cmd() {
|
||||
local cmd="$PODMAN run -d --name $SPEC_NAME"
|
||||
cmd+=" --restart $SPEC_RESTART"
|
||||
|
||||
# Network
|
||||
if [ "$SPEC_NETWORK" = "host" ]; then
|
||||
cmd+=" --network=host"
|
||||
elif [ "$SPEC_NETWORK" = "archy-net" ]; then
|
||||
cmd+=" --network archy-net"
|
||||
fi
|
||||
|
||||
# Memory
|
||||
[ -n "$SPEC_MEMORY" ] && cmd+=" --memory=$SPEC_MEMORY"
|
||||
|
||||
# Capabilities
|
||||
cmd+=" --cap-drop ALL"
|
||||
for cap in $SPEC_CAPS; do
|
||||
cmd+=" --cap-add $cap"
|
||||
done
|
||||
|
||||
# Security
|
||||
[ -n "$SPEC_SECURITY" ] && cmd+=" --security-opt $SPEC_SECURITY"
|
||||
|
||||
# Read-only
|
||||
[ "$SPEC_READONLY" = "true" ] && cmd+=" --read-only"
|
||||
|
||||
# Tmpfs
|
||||
for t in $SPEC_TMPFS; do
|
||||
cmd+=" --tmpfs $t"
|
||||
done
|
||||
|
||||
# Health check
|
||||
if [ -n "$SPEC_HEALTH_CMD" ]; then
|
||||
cmd+=" --health-cmd=\"$SPEC_HEALTH_CMD\" --health-interval=120s --health-timeout=10s --health-retries=3"
|
||||
fi
|
||||
|
||||
# Ports
|
||||
for p in $SPEC_PORTS; do
|
||||
cmd+=" -p $p"
|
||||
done
|
||||
|
||||
# Volumes
|
||||
for v in $SPEC_VOLUMES; do
|
||||
cmd+=" -v $v"
|
||||
done
|
||||
|
||||
# Environment
|
||||
for e in $SPEC_ENV; do
|
||||
cmd+=" -e \"$e\""
|
||||
done
|
||||
|
||||
# Image
|
||||
cmd+=" $SPEC_IMAGE"
|
||||
|
||||
# Custom args
|
||||
[ -n "$SPEC_CUSTOM_ARGS" ] && cmd+=" $SPEC_CUSTOM_ARGS"
|
||||
|
||||
# Entrypoint override
|
||||
[ -n "$SPEC_ENTRYPOINT" ] && cmd+=" $SPEC_ENTRYPOINT"
|
||||
|
||||
echo "$cmd"
|
||||
}
|
||||
|
||||
# ── Counters ─────────────────────────────────────────────────────────
|
||||
COUNT_OK=0 COUNT_FIXED=0 COUNT_CREATED=0 COUNT_SKIPPED=0 COUNT_FAILED=0
|
||||
FAILED_LIST=""
|
||||
|
||||
# ── Reconcile one container ──────────────────────────────────────────
|
||||
reconcile() {
|
||||
local name="$1"
|
||||
|
||||
if ! load_spec "$name"; then
|
||||
skip "$name — no spec defined"
|
||||
COUNT_SKIPPED=$((COUNT_SKIPPED + 1))
|
||||
return
|
||||
fi
|
||||
|
||||
if [ -n "$SPEC_SKIP_REASON" ]; then
|
||||
skip "$name — $SPEC_SKIP_REASON"
|
||||
COUNT_SKIPPED=$((COUNT_SKIPPED + 1))
|
||||
return
|
||||
fi
|
||||
|
||||
[ "$name" = "portainer" ] && ensure_portainer_host_paths
|
||||
|
||||
# Filter by tier
|
||||
[ -n "$FILTER_TIER" ] && [ "$SPEC_TIER" != "$FILTER_TIER" ] && return
|
||||
|
||||
# User-stopped
|
||||
if is_user_stopped "$name"; then
|
||||
skip "$name — user-stopped"
|
||||
COUNT_SKIPPED=$((COUNT_SKIPPED + 1))
|
||||
fix_ownership "$name"
|
||||
return
|
||||
fi
|
||||
|
||||
# Optional apps: only reconcile if already installed (container exists).
|
||||
# The install RPC creates the container; the reconciler just keeps it running.
|
||||
# --create-missing overrides this so we can recover from failed-update rollbacks
|
||||
# that deleted a container without restoring it (on-disk data still present).
|
||||
if [ "$SPEC_OPTIONAL" = "true" ] && ! container_exists "$name" && ! $CREATE_MISSING; then
|
||||
skip "$name — not installed"
|
||||
COUNT_SKIPPED=$((COUNT_SKIPPED + 1))
|
||||
return
|
||||
fi
|
||||
|
||||
# Resolve registry aliases before create/recreate. ISOs and older installers
|
||||
# may seed the same image under a fallback registry tag.
|
||||
resolve_spec_image
|
||||
|
||||
# Local images: skip if image doesn't exist and container doesn't exist
|
||||
if [ "$SPEC_LOCAL_IMAGE" = "true" ]; then
|
||||
if ! image_exists "$SPEC_IMAGE" && ! container_exists "$name"; then
|
||||
skip "$name — image not available"
|
||||
COUNT_SKIPPED=$((COUNT_SKIPPED + 1))
|
||||
return
|
||||
fi
|
||||
fi
|
||||
|
||||
# Check dependencies
|
||||
for dep in $SPEC_DEPENDS; do
|
||||
if ! container_running "$dep"; then
|
||||
skip "$name — dependency $dep not running"
|
||||
COUNT_SKIPPED=$((COUNT_SKIPPED + 1))
|
||||
return
|
||||
fi
|
||||
done
|
||||
|
||||
local action="OK"
|
||||
local reasons=""
|
||||
|
||||
if container_exists "$name"; then
|
||||
local cur_image cur_image_id want_image_id cur_network cur_memory
|
||||
cur_image=$(container_image "$name")
|
||||
cur_image_id=$(container_image_id "$name")
|
||||
want_image_id=$(spec_image_id)
|
||||
cur_network=$(container_network "$name")
|
||||
cur_memory=$(container_memory "$name")
|
||||
local spec_memory_bytes expected_network
|
||||
|
||||
spec_memory_bytes=$(mem_to_bytes "$SPEC_MEMORY")
|
||||
|
||||
if [ "$FORCE_RECREATE" = "true" ]; then
|
||||
action="RECREATE"
|
||||
reasons+="force-recreate "
|
||||
fi
|
||||
|
||||
# Same-tag local rebuilds leave running containers on the old image ID.
|
||||
# Recreate when the currently tagged spec image points at a different ID.
|
||||
if [ "$action" = "OK" ] && [ -n "$want_image_id" ] && [ -n "$cur_image_id" ] && [ "$cur_image_id" != "$want_image_id" ]; then
|
||||
action="RECREATE"
|
||||
reasons+="image-id "
|
||||
fi
|
||||
|
||||
# Check network mismatch
|
||||
# For archy-net and host: exact match required
|
||||
# For bridge/default: accept any non-archy-net, non-host network
|
||||
if [ "$SPEC_NETWORK" = "archy-net" ]; then
|
||||
if [ "$cur_network" != "archy-net" ]; then
|
||||
action="RECREATE"
|
||||
reasons+="network($cur_network→archy-net) "
|
||||
fi
|
||||
elif [ "$SPEC_NETWORK" = "host" ]; then
|
||||
if [ "$cur_network" != "host" ]; then
|
||||
action="RECREATE"
|
||||
reasons+="network($cur_network→host) "
|
||||
fi
|
||||
else
|
||||
# Default/bridge: anything that isn't archy-net or host is fine
|
||||
if [ "$cur_network" = "archy-net" ] || [ "$cur_network" = "host" ]; then
|
||||
action="RECREATE"
|
||||
reasons+="network($cur_network→bridge) "
|
||||
fi
|
||||
fi
|
||||
|
||||
# Check memory limit (0 = no limit)
|
||||
if [ "${cur_memory:-0}" = "0" ] && [ "${spec_memory_bytes:-0}" != "0" ]; then
|
||||
action="RECREATE"
|
||||
reasons+="memory(none→$SPEC_MEMORY) "
|
||||
fi
|
||||
|
||||
# Healthcheck drift matters: a stale check can leave an otherwise working
|
||||
# service permanently unhealthy (for example ElectrumX images do not ship
|
||||
# curl, so the healthcheck must use python's socket module).
|
||||
if [ "$action" = "OK" ] && [ -n "$SPEC_HEALTH_CMD" ]; then
|
||||
local cur_health spec_health
|
||||
cur_health=$(normalize_health_cmd "$(container_health_cmd "$name")")
|
||||
spec_health=$(normalize_health_cmd "$SPEC_HEALTH_CMD")
|
||||
if [ "$cur_health" != "$spec_health" ]; then
|
||||
action="RECREATE"
|
||||
reasons+="healthcheck "
|
||||
fi
|
||||
fi
|
||||
|
||||
# Check URL/HOST env drift — catches stale network topology baked into
|
||||
# container env (fedimint April-11 bug: FM_P2P_URL pointed at old IP).
|
||||
# Only checks URL-shaped keys; other env drift (passwords rotated, etc.)
|
||||
# is intentionally ignored to avoid thrashing.
|
||||
if [ "$action" = "OK" ] && [ -n "$SPEC_ENV" ]; then
|
||||
for kv in $SPEC_ENV; do
|
||||
local env_key="${kv%%=*}"
|
||||
local env_val_spec="${kv#*=}"
|
||||
local is_url_key=false
|
||||
for suffix in $URL_ENV_SUFFIXES; do
|
||||
case "$env_key" in *"$suffix") is_url_key=true; break ;; esac
|
||||
done
|
||||
[ "$is_url_key" = "true" ] || continue
|
||||
local env_val_cur
|
||||
env_val_cur=$(container_env_val "$name" "$env_key")
|
||||
if [ "$env_val_cur" != "$env_val_spec" ]; then
|
||||
action="RECREATE"
|
||||
reasons+="env($env_key:$env_val_cur→$env_val_spec) "
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
# Check bind mounts. This catches companion UIs recreated from older specs,
|
||||
# especially bitcoin-ui: its image intentionally does not bake nginx.conf,
|
||||
# so the rendered RPC proxy config must be mounted from the host.
|
||||
if [ "$action" = "OK" ] && [ -n "$SPEC_VOLUMES" ]; then
|
||||
for v in $SPEC_VOLUMES; do
|
||||
local mount_source mount_rest mount_target
|
||||
mount_source="${v%%:*}"
|
||||
mount_rest="${v#*:}"
|
||||
mount_target="${mount_rest%%:*}"
|
||||
[ -n "$mount_source" ] && [ -n "$mount_target" ] || continue
|
||||
if ! container_has_mount "$name" "$mount_source" "$mount_target"; then
|
||||
action="RECREATE"
|
||||
reasons+="mount($mount_target) "
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
# Rootless Podman can occasionally leave a container running while its
|
||||
# rootlessport listener is gone. The container still looks healthy in
|
||||
# `podman ps`, but host-network UIs and backend status probes fail against
|
||||
# 127.0.0.1. Treat missing host listeners as spec drift.
|
||||
if [ "$action" = "OK" ] && [ -n "$SPEC_PORTS" ]; then
|
||||
for p in $SPEC_PORTS; do
|
||||
local host_port="${p%%:*}"
|
||||
[ -n "$host_port" ] || continue
|
||||
if ! host_port_listening "$host_port"; then
|
||||
action="RECREATE"
|
||||
reasons+="port($host_port-not-listening) "
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
# Check if running
|
||||
if ! container_running "$name" && [ "$action" = "OK" ]; then
|
||||
action="START"
|
||||
reasons+="not-running "
|
||||
fi
|
||||
else
|
||||
action="CREATE"
|
||||
reasons+="missing "
|
||||
fi
|
||||
|
||||
# Fix ownership regardless
|
||||
fix_ownership "$name"
|
||||
|
||||
case "$action" in
|
||||
OK)
|
||||
ok "$name"
|
||||
COUNT_OK=$((COUNT_OK + 1))
|
||||
;;
|
||||
START)
|
||||
if $CHECK_ONLY; then
|
||||
info "$name — would start ($reasons)"
|
||||
else
|
||||
if $PODMAN start "$name" >/dev/null 2>&1; then
|
||||
fixed "$name — started ($reasons)"
|
||||
else
|
||||
fail "$name — start failed"
|
||||
COUNT_FAILED=$((COUNT_FAILED + 1))
|
||||
FAILED_LIST+=" $name"
|
||||
return
|
||||
fi
|
||||
fi
|
||||
COUNT_FIXED=$((COUNT_FIXED + 1))
|
||||
;;
|
||||
RECREATE)
|
||||
if $CHECK_ONLY; then
|
||||
info "$name — would recreate ($reasons)"
|
||||
else
|
||||
info "$name — recreating ($reasons)"
|
||||
$PODMAN stop "$name" >/dev/null 2>&1
|
||||
$PODMAN rm "$name" >/dev/null 2>&1
|
||||
if eval "$(build_run_cmd)" >/dev/null 2>&1; then
|
||||
fixed "$name — recreated ($reasons)"
|
||||
else
|
||||
fail "$name — recreate failed: $(eval "$(build_run_cmd)" 2>&1 | tail -1)"
|
||||
COUNT_FAILED=$((COUNT_FAILED + 1))
|
||||
FAILED_LIST+=" $name"
|
||||
return
|
||||
fi
|
||||
fi
|
||||
COUNT_FIXED=$((COUNT_FIXED + 1))
|
||||
;;
|
||||
CREATE)
|
||||
if $CHECK_ONLY; then
|
||||
info "$name — would create ($reasons)"
|
||||
else
|
||||
for v in $SPEC_VOLUMES; do
|
||||
local host_dir="${v%%:*}"
|
||||
prepare_bind_source "$host_dir" || {
|
||||
COUNT_FAILED=$((COUNT_FAILED + 1))
|
||||
FAILED_LIST+=" $name"
|
||||
return
|
||||
}
|
||||
done
|
||||
if eval "$(build_run_cmd)" >/dev/null 2>&1; then
|
||||
fixed "$name — created"
|
||||
else
|
||||
fail "$name — create failed"
|
||||
COUNT_FAILED=$((COUNT_FAILED + 1))
|
||||
FAILED_LIST+=" $name"
|
||||
return
|
||||
fi
|
||||
fi
|
||||
COUNT_CREATED=$((COUNT_CREATED + 1))
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# ── Fix ownership ────────────────────────────────────────────────────
|
||||
fix_ownership() {
|
||||
local name="$1"
|
||||
[ -z "$SPEC_DATA_DIR" ] && return
|
||||
[ ! -d "$SPEC_DATA_DIR" ] && return
|
||||
[ "$SPEC_DATA_UID" = "100000:100000" ] && return
|
||||
|
||||
local expected_uid="${SPEC_DATA_UID%%:*}"
|
||||
local current_uid
|
||||
current_uid=$(stat -c '%u' "$SPEC_DATA_DIR" 2>/dev/null)
|
||||
|
||||
if [ "$current_uid" != "$expected_uid" ]; then
|
||||
if $CHECK_ONLY; then
|
||||
info "$name — ownership: $current_uid → $SPEC_DATA_UID"
|
||||
else
|
||||
sudo chown -R "$SPEC_DATA_UID" "$SPEC_DATA_DIR" 2>/dev/null
|
||||
info "$name — fixed ownership → $SPEC_DATA_UID"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Ensure secrets exist ─────────────────────────────────────────────
|
||||
ensure_secrets() {
|
||||
local SECRETS_DIR="/var/lib/archipelago/secrets"
|
||||
sudo mkdir -p "$SECRETS_DIR" 2>/dev/null
|
||||
sudo chmod 700 "$SECRETS_DIR" 2>/dev/null
|
||||
|
||||
for svc in bitcoin-rpc-password mempool-db-password btcpay-db-password mysql-root-db-password; do
|
||||
if [ ! -f "$SECRETS_DIR/$svc" ]; then
|
||||
if $CHECK_ONLY; then
|
||||
info "Would generate secret: $svc"
|
||||
else
|
||||
openssl rand -hex 16 | sudo tee "$SECRETS_DIR/$svc" >/dev/null
|
||||
sudo chmod 600 "$SECRETS_DIR/$svc"
|
||||
info "Generated secret: $svc"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
# FED-07: the gateway's bcrypt credential is generated by the daemon's
|
||||
# container::secrets::ensure_gateway_credential (bcrypt, 0600, idempotent,
|
||||
# self-healing) — this script no longer generates it locally (that removed
|
||||
# the htpasswd host dependency AND the shipped-default fallback that used
|
||||
# to fire when htpasswd was absent). It never shipped, and never ships,
|
||||
# a fallback value.
|
||||
#
|
||||
# Legacy migration only: earlier builds wrote the plaintext under
|
||||
# fedimint-gateway-password instead of the manifest's canonical
|
||||
# fedimint-gateway-hash.pw name. Carry a pre-existing value forward under
|
||||
# the canonical name rather than regenerating (migrations never destroy
|
||||
# data or rotate a working credential); the legacy file is left in place —
|
||||
# plan 01-16 owns its retirement.
|
||||
if [ -f "$SECRETS_DIR/fedimint-gateway-password" ] && [ ! -f "$SECRETS_DIR/fedimint-gateway-hash.pw" ]; then
|
||||
if $CHECK_ONLY; then
|
||||
info "Would migrate legacy fedimint-gateway-password -> fedimint-gateway-hash.pw"
|
||||
else
|
||||
sudo cp "$SECRETS_DIR/fedimint-gateway-password" "$SECRETS_DIR/fedimint-gateway-hash.pw"
|
||||
sudo chmod 600 "$SECRETS_DIR/fedimint-gateway-hash.pw"
|
||||
info "Migrated legacy fedimint-gateway-password -> fedimint-gateway-hash.pw"
|
||||
fi
|
||||
fi
|
||||
if [ ! -f "$SECRETS_DIR/fedimint-gateway-hash" ]; then
|
||||
info "fedimint-gateway credential not yet generated — the daemon will generate a per-install bcrypt hash (no shipped default); the gateway container is skipped until then"
|
||||
fi
|
||||
|
||||
# Reload after generation
|
||||
detect_environment
|
||||
}
|
||||
|
||||
# ── Ensure bitcoin.conf ─────────────────────────────────────────────
|
||||
ensure_bitcoin_conf() {
|
||||
local BITCOIN_CONF="/var/lib/archipelago/bitcoin/bitcoin.conf"
|
||||
sudo mkdir -p /var/lib/archipelago/bitcoin 2>/dev/null
|
||||
if [ ! -f "$BITCOIN_CONF" ] || ! sudo grep -q "^rpcauth=" "$BITCOIN_CONF" 2>/dev/null; then
|
||||
if ! $CHECK_ONLY && [ -n "$BITCOIN_RPC_PASS" ]; then
|
||||
local salt hash rpcauth
|
||||
salt=$(openssl rand -hex 16)
|
||||
hash=$(echo -n "$BITCOIN_RPC_PASS" | openssl dgst -sha256 -hmac "$salt" -hex 2>/dev/null | awk '{print $NF}')
|
||||
rpcauth="${BITCOIN_RPC_USER}:${salt}\$${hash}"
|
||||
# Only rpcauth + printtoconsole here — all other options are in SPEC_CUSTOM_ARGS
|
||||
# to avoid duplicate bind conflicts. printtoconsole=0: datadir debug.log
|
||||
# already has everything; console duplication spammed journald during IBD.
|
||||
sudo tee "$BITCOIN_CONF" >/dev/null << BTCEOF
|
||||
rpcauth=${rpcauth}
|
||||
printtoconsole=0
|
||||
BTCEOF
|
||||
info "Generated bitcoin.conf"
|
||||
fi
|
||||
fi
|
||||
# Strip duplicate server/rpc/listen lines from existing conf files to avoid
|
||||
# conflicts with custom args. Knots can persist runtime args in
|
||||
# bitcoin_rw.conf, so clean both files.
|
||||
for conf in "$BITCOIN_CONF" "/var/lib/archipelago/bitcoin/bitcoin_rw.conf"; do
|
||||
if [ -f "$conf" ]; then
|
||||
sudo sed -i '/^server=/d; /^txindex=/d; /^rpcbind=/d; /^rpcallowip=/d; /^rpcport=/d; /^listen=/d; /^bind=/d; /^dbcache=/d; /^rpcthreads=/d; /^rpcworkqueue=/d' "$conf" 2>/dev/null
|
||||
fi
|
||||
done
|
||||
sudo chown -R 100101:100101 /var/lib/archipelago/bitcoin 2>/dev/null
|
||||
}
|
||||
|
||||
# ── Ensure lnd.conf ─────────────────────────────────────────────────
|
||||
ensure_lnd_conf() {
|
||||
local LND_CONF="/var/lib/archipelago/lnd/lnd.conf"
|
||||
sudo mkdir -p /var/lib/archipelago/lnd 2>/dev/null
|
||||
if [ ! -f "$LND_CONF" ] && [ -n "$BITCOIN_RPC_PASS" ]; then
|
||||
if ! $CHECK_ONLY; then
|
||||
sudo tee "$LND_CONF" >/dev/null << LNDEOF
|
||||
[Application Options]
|
||||
listen=0.0.0.0:9735
|
||||
rpclisten=0.0.0.0:10009
|
||||
restlisten=0.0.0.0:8080
|
||||
debuglevel=info
|
||||
noseedbackup=true
|
||||
|
||||
[Bitcoin]
|
||||
bitcoin.mainnet=true
|
||||
bitcoin.node=bitcoind
|
||||
|
||||
[Bitcoind]
|
||||
bitcoind.rpchost=bitcoin-knots:8332
|
||||
bitcoind.rpcuser=$BITCOIN_RPC_USER
|
||||
bitcoind.rpcpass=$BITCOIN_RPC_PASS
|
||||
bitcoind.rpcpolling=true
|
||||
bitcoind.estimatemode=ECONOMICAL
|
||||
|
||||
[autopilot]
|
||||
autopilot.active=false
|
||||
LNDEOF
|
||||
info "Generated lnd.conf"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Ensure bitcoin-ui nginx.conf ────────────────────────────────────
|
||||
ensure_bitcoin_ui_nginx_conf() {
|
||||
local CONF_DIR="/var/lib/archipelago/bitcoin-ui"
|
||||
local CONF_PATH="$CONF_DIR/nginx.conf"
|
||||
[ -n "$BITCOIN_RPC_PASS" ] || return
|
||||
if $CHECK_ONLY; then
|
||||
[ -f "$CONF_PATH" ] || info "Would generate bitcoin-ui nginx.conf"
|
||||
return
|
||||
fi
|
||||
|
||||
local auth_b64 tmp
|
||||
auth_b64=$(printf '%s' "${BITCOIN_RPC_USER}:${BITCOIN_RPC_PASS}" | base64 | tr -d '\n')
|
||||
sudo mkdir -p "$CONF_DIR" 2>/dev/null
|
||||
tmp="${CONF_PATH}.tmp.$$"
|
||||
sudo tee "$tmp" >/dev/null << EOF
|
||||
server {
|
||||
# Loopback ONLY — this is the fourth copy of this declaration (the others
|
||||
# are the Rust template in container/bitcoin_ui_nginx.conf.template, the
|
||||
# image, and the manifest). Host networking means this nginx binds the
|
||||
# HOST's address, so \`listen 8334;\` served the Bitcoin screen on every
|
||||
# interface with no login. The app gate owns the external addresses now.
|
||||
listen 127.0.0.1:8334;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location /bitcoin-rpc/ {
|
||||
proxy_pass http://127.0.0.1:8332/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host \$host;
|
||||
proxy_set_header X-Real-IP \$remote_addr;
|
||||
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||||
proxy_set_header Authorization "Basic ${auth_b64}";
|
||||
add_header Access-Control-Allow-Origin *;
|
||||
add_header Access-Control-Allow-Methods "POST, GET, OPTIONS";
|
||||
add_header Access-Control-Allow-Headers "Content-Type, Authorization";
|
||||
if (\$request_method = OPTIONS) { return 204; }
|
||||
}
|
||||
|
||||
location /bitcoin-status {
|
||||
proxy_pass http://127.0.0.1:5678/bitcoin-status;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host \$host;
|
||||
proxy_set_header X-Real-IP \$remote_addr;
|
||||
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||||
add_header Cache-Control "no-store";
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files \$uri \$uri/ /index.html;
|
||||
}
|
||||
}
|
||||
EOF
|
||||
if ! sudo cmp -s "$tmp" "$CONF_PATH" 2>/dev/null; then
|
||||
sudo mv "$tmp" "$CONF_PATH"
|
||||
sudo chmod 644 "$CONF_PATH"
|
||||
info "Generated bitcoin-ui nginx.conf"
|
||||
else
|
||||
sudo rm -f "$tmp"
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Ensure BTCPay databases ─────────────────────────────────────────
|
||||
ensure_btcpay_db() {
|
||||
if container_running "archy-btcpay-db"; then
|
||||
$PODMAN exec archy-btcpay-db psql -U postgres -tc \
|
||||
"SELECT 1 FROM pg_database WHERE datname='nbxplorer'" 2>/dev/null | grep -q 1 || \
|
||||
$PODMAN exec archy-btcpay-db psql -U postgres -c \
|
||||
"CREATE DATABASE nbxplorer;" 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════
|
||||
# MAIN
|
||||
# ══════════════════════════════════════════════════════════════════════
|
||||
START_TIME=$(date +%s)
|
||||
|
||||
header "Phase 0: Prerequisites"
|
||||
ensure_secrets
|
||||
detect_environment
|
||||
ensure_bitcoin_conf
|
||||
ensure_lnd_conf
|
||||
ensure_bitcoin_ui_nginx_conf
|
||||
|
||||
TIER_NAMES=("Databases" "Core Infrastructure" "Services" "Applications" "Frontend UIs")
|
||||
|
||||
for tier in 0 1 2 3 4; do
|
||||
[ -n "$FILTER_TIER" ] && [ "$FILTER_TIER" != "$tier" ] && continue
|
||||
|
||||
header "Tier $tier: ${TIER_NAMES[$tier]}"
|
||||
|
||||
for name in "${ALL_CONTAINER_SPECS[@]}"; do
|
||||
[ -n "$FILTER_CONTAINER" ] && [ "$name" != "$FILTER_CONTAINER" ] && continue
|
||||
|
||||
# Load spec to check tier before reconciling
|
||||
if load_spec "$name" && [ "$SPEC_TIER" = "$tier" ]; then
|
||||
reconcile "$name"
|
||||
fi
|
||||
done
|
||||
|
||||
# After databases, ensure BTCPay DB schemas exist
|
||||
[ "$tier" = "0" ] && ensure_btcpay_db
|
||||
|
||||
# Brief pause between tiers
|
||||
[ "$tier" -lt 4 ] && ! $CHECK_ONLY && sleep 2
|
||||
done
|
||||
|
||||
# ── Summary ──────────────────────────────────────────────────────────
|
||||
ELAPSED=$(( $(date +%s) - START_TIME ))
|
||||
TOTAL=$((COUNT_OK + COUNT_FIXED + COUNT_CREATED + COUNT_SKIPPED + COUNT_FAILED))
|
||||
|
||||
echo ""
|
||||
header "╔══════════════════════════════════════════════════╗"
|
||||
header "║ RECONCILIATION REPORT ║"
|
||||
header "╚══════════════════════════════════════════════════╝"
|
||||
echo ""
|
||||
echo -e " Total: ${BOLD}$TOTAL${NC}"
|
||||
echo -e " OK: ${GREEN}$COUNT_OK${NC}"
|
||||
echo -e " Fixed: ${CYAN}$COUNT_FIXED${NC}"
|
||||
echo -e " Created: ${CYAN}$COUNT_CREATED${NC}"
|
||||
echo -e " Skipped: ${YELLOW}$COUNT_SKIPPED${NC}"
|
||||
echo -e " Failed: ${RED}$COUNT_FAILED${NC}"
|
||||
[ -n "$FAILED_LIST" ] && echo -e " Failed: ${RED}$FAILED_LIST${NC}"
|
||||
echo -e " Duration: ${ELAPSED}s"
|
||||
echo ""
|
||||
|
||||
[ "$COUNT_FAILED" -gt 0 ] && exit 1
|
||||
exit 0
|
||||
@@ -0,0 +1,109 @@
|
||||
# Resilience Harness
|
||||
|
||||
Black-box state-machine tester for archipelago app containers.
|
||||
|
||||
Drives the live RPC against a real archipelago + podman runtime on a target
|
||||
host. For each app in `app-catalog/catalog.json`, runs every state transition
|
||||
a user could trigger and asserts the system stays in the expected state.
|
||||
|
||||
## Why this exists
|
||||
|
||||
We shipped v1.7.43-alpha on .228 with three independent bugs that no unit test
|
||||
caught:
|
||||
|
||||
1. `indeedhub-api` crashlooped 8500+ times because `stacks.rs` was missing 5
|
||||
env vars (`QUEUE_HOST`/`QUEUE_PORT`/`DATABASE_PORT`/`S3_PRIVATE_BUCKET_NAME`/
|
||||
`AES_MASTER_SECRET`) — the install "succeeded" (containers running) but the
|
||||
API never became healthy.
|
||||
2. `bitcoin-ui` shipped with a stale baked-in `Authorization: Basic …` header
|
||||
from the registry image, so every `/bitcoin-rpc/` call returned 401.
|
||||
3. The container-absence scanner evicted apps from the UI 14 seconds into
|
||||
install (before image pull finished).
|
||||
|
||||
All three were exactly the kind of bug a "did the user-visible flow actually
|
||||
work end to end?" test would catch — and the kind a single-file unit test
|
||||
will never catch. This harness is the gate.
|
||||
|
||||
## Running
|
||||
|
||||
Against the .228 test node:
|
||||
|
||||
scripts/resilience/resilience.sh archipelago@192.0.2.10
|
||||
|
||||
Or non-interactive (CI):
|
||||
|
||||
RESILIENCE_SSH_PASS=… RESILIENCE_UI_PASS=… \
|
||||
scripts/resilience/resilience.sh archipelago@192.0.2.10
|
||||
|
||||
Filters:
|
||||
|
||||
# Smoke test (3 apps, no reboot, ~15min)
|
||||
scripts/resilience/resilience.sh archipelago@192.0.2.10 smoke
|
||||
|
||||
# Single app
|
||||
scripts/resilience/resilience.sh archipelago@192.0.2.10 bitcoin-knots
|
||||
|
||||
# Subset
|
||||
scripts/resilience/resilience.sh archipelago@192.0.2.10 bitcoin-knots,lnd
|
||||
|
||||
Without a filter, the harness sweeps **every** app in the catalog
|
||||
(~24 apps × 7 per-app transitions + 2 batch transitions) and runs the
|
||||
batch transitions (archipelago.service restart, host reboot) at the end.
|
||||
Full sweep is ~3-4 hours and **reboots the target host** as part of the
|
||||
run — only point it at a dedicated test node.
|
||||
|
||||
## What it tests
|
||||
|
||||
Per-app transitions:
|
||||
|
||||
| # | Transition | Pass criteria |
|
||||
|---|----------------------|------------------------------------------------|
|
||||
| 1 | install | All containers reach `running` within 10 min |
|
||||
| 2 | ui_probe | HTTP 2xx/3xx via `https://<host>/app/<id>/` |
|
||||
| 3 | auth_probe | (bitcoin-rpc only) returns 200 not 401 |
|
||||
| 4 | stop | All containers reach `exited` state |
|
||||
| 5 | start | All containers reach `running` state |
|
||||
| 6 | restart | All containers `running` after restart |
|
||||
| 7 | uninstall | All containers absent, no residue |
|
||||
|
||||
Batch transitions (full sweep only):
|
||||
|
||||
| # | Transition | Pass criteria |
|
||||
|---|-------------------------------|-------------------------------------|
|
||||
| 8 | archipelago.service restart | Container set unchanged across |
|
||||
| 9 | host reboot | Container set unchanged across |
|
||||
|
||||
Coverage by design — discovery rather than encoded metadata. The harness
|
||||
snapshots `podman ps -a` before install, again after install stabilizes,
|
||||
and the difference IS this app's container set. Works equally well for
|
||||
single-container apps and 7-container stacks (indeedhub) without per-app
|
||||
configuration.
|
||||
|
||||
## Output
|
||||
|
||||
JSON-lines results at `scripts/resilience/reports/<run_ts>/results.jsonl`:
|
||||
|
||||
{"ts":"…","app":"bitcoin-knots","transition":"install","status":"PASS","detail":"bitcoin-knots,archy-bitcoin-ui"}
|
||||
{"ts":"…","app":"bitcoin-knots","transition":"auth_probe","status":"PASS","detail":"bitcoin-rpc HTTP 200"}
|
||||
|
||||
Exit code: `0` if every cell green, `1` if any red, `2` if setup failed
|
||||
before tests began. Use as a release gate — refuse to tag if any cell red.
|
||||
|
||||
## Auth flow
|
||||
|
||||
The harness uses the same `auth.login` RPC that the UI uses, then carries
|
||||
`session=…` and `csrf_token=…` cookies plus the `X-CSRF-Token` header on
|
||||
every subsequent call. Re-logs in after archipelago.service restart and
|
||||
host reboot.
|
||||
|
||||
## Caveats / known gaps
|
||||
|
||||
- App proxy probe (`/app/<id>/`) only validates the proxy responds — for
|
||||
apps with deeper protocol behavior (lnd, fedimint, mempool) this only
|
||||
catches "container alive, proxy reachable", not "the protocol is healthy".
|
||||
- Multi-container stack assertions: the harness checks **every** new
|
||||
container is `running`, so it would catch the indeedhub-api restart loop
|
||||
while postgres/redis/minio looked fine.
|
||||
- Host reboot test is destructive and slow — runs once at end of full sweep.
|
||||
- `package.start`/`stop`/`restart` RPC methods may not exist for all apps;
|
||||
failures are recorded and the harness continues.
|
||||
Executable
+297
@@ -0,0 +1,297 @@
|
||||
#!/bin/bash
|
||||
# Resilience harness shared helpers.
|
||||
# Sourced by resilience.sh — do not invoke directly.
|
||||
|
||||
# Required env (set by resilience.sh before sourcing):
|
||||
# TARGET — ssh target, e.g. archipelago@192.0.2.10
|
||||
# RPC_URL — http://<host>:5678/rpc/v1
|
||||
# COOKIE_JAR — path for curl cookie store
|
||||
# SSH_PASS — sshpass password
|
||||
# UI_PASS — archipelago UI password
|
||||
# OUT_DIR — report output dir
|
||||
|
||||
# ── ssh ─────────────────────────────────────────────────────────
|
||||
ssh_run() {
|
||||
# -n: redirect stdin from /dev/null so ssh doesn't gobble up our parent's
|
||||
# stdin. Without this, ssh inside a `while read … done <<< "$LIST"`
|
||||
# consumes the heredoc on the first call, ending the loop after one
|
||||
# iteration. Cost us a smoke run that only tested filebrowser instead
|
||||
# of all three smoke apps.
|
||||
sshpass -p "$SSH_PASS" ssh -n -o StrictHostKeyChecking=accept-new \
|
||||
-o ConnectTimeout=10 -o LogLevel=ERROR "$TARGET" "$@"
|
||||
}
|
||||
|
||||
# Run a command and tolerate ssh failure (host rebooting, etc.).
|
||||
ssh_try() {
|
||||
sshpass -p "$SSH_PASS" ssh -n -o StrictHostKeyChecking=accept-new \
|
||||
-o ConnectTimeout=5 -o LogLevel=ERROR "$TARGET" "$@" 2>/dev/null || echo "__SSH_FAIL__"
|
||||
}
|
||||
|
||||
ssh_wait_ready() {
|
||||
local deadline=$(($(date +%s) + ${1:-180}))
|
||||
while [ "$(date +%s)" -lt "$deadline" ]; do
|
||||
if [ "$(ssh_try 'echo OK')" = "OK" ]; then return 0; fi
|
||||
sleep 3
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# ── rpc ─────────────────────────────────────────────────────────
|
||||
rpc_login() {
|
||||
local resp
|
||||
resp=$(curl -ksS -c "$COOKIE_JAR" -H "Content-Type: application/json" \
|
||||
-d "{\"jsonrpc\":\"2.0\",\"method\":\"auth.login\",\"params\":{\"password\":\"$UI_PASS\"},\"id\":1}" \
|
||||
"$RPC_URL")
|
||||
if echo "$resp" | jq -e '.error' >/dev/null 2>&1; then
|
||||
echo "ERROR: login failed: $(echo "$resp" | jq -c .)" >&2
|
||||
return 1
|
||||
fi
|
||||
CSRF_TOKEN=$(awk '/csrf_token/ {print $7}' "$COOKIE_JAR" | head -1)
|
||||
[ -n "$CSRF_TOKEN" ] || { echo "ERROR: no CSRF token after login" >&2; return 1; }
|
||||
export CSRF_TOKEN
|
||||
}
|
||||
|
||||
# Make an RPC call. Args: method, json_params, timeout_secs (optional, default 90).
|
||||
# Prints raw JSON response. Caller asserts success via jq.
|
||||
#
|
||||
# CSRF rotates per-response: the server may issue a new csrf_token on every
|
||||
# state-changing call, so we re-read it from the cookie jar before each call
|
||||
# rather than caching the value from login. Also retries once on nginx-served
|
||||
# BACKEND_UNAVAILABLE (5xx fallback) for transient stalls.
|
||||
rpc_call() {
|
||||
local method="$1"
|
||||
# NOTE: don't use ${2:-{}} — bash matches the first unescaped `}` as the
|
||||
# end of the expansion, so the trailing `}` becomes a literal char and
|
||||
# corrupts every params value into invalid JSON. Use an if-check instead.
|
||||
local params="${2-}"
|
||||
[ -z "$params" ] && params='{}'
|
||||
local timeout="${3:-90}"
|
||||
local attempt
|
||||
for attempt in 1 2 3 4; do
|
||||
local csrf
|
||||
csrf=$(awk '/^[^#]/ && /csrf_token/ {print $7; exit}' "$COOKIE_JAR")
|
||||
local resp
|
||||
resp=$(curl -ksS -b "$COOKIE_JAR" -c "$COOKIE_JAR" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-CSRF-Token: $csrf" \
|
||||
-d "{\"jsonrpc\":\"2.0\",\"method\":\"$method\",\"params\":$params,\"id\":1}" \
|
||||
--max-time "$timeout" \
|
||||
"$RPC_URL")
|
||||
# Retry on transient errors:
|
||||
# BACKEND_UNAVAILABLE — nginx 5xx fallback (archipelago briefly stalled)
|
||||
# 429 — nginx rate limiter exceeded (burst=40 in /etc/nginx/sites-enabled/*)
|
||||
if echo "$resp" | jq -e '.error.code == "BACKEND_UNAVAILABLE" or .error.code == 429' >/dev/null 2>&1; then
|
||||
[ "$attempt" -eq 4 ] && { echo "$resp"; return; }
|
||||
# Exponential-ish backoff: 5s, 15s, 30s. Plenty of time for the
|
||||
# nginx rate window (1s) and any archipelago restart to clear.
|
||||
sleep $((attempt * 10))
|
||||
continue
|
||||
fi
|
||||
echo "$resp"
|
||||
return
|
||||
done
|
||||
}
|
||||
|
||||
# After a service restart the session may need re-establishing.
|
||||
rpc_relogin_if_needed() {
|
||||
local probe
|
||||
probe=$(rpc_call "package.list" '{}' 2>/dev/null)
|
||||
if echo "$probe" | jq -e '.error.code == -32001' >/dev/null 2>&1; then
|
||||
rpc_login || return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# ── per-app metadata ────────────────────────────────────────────
|
||||
# Mappings the harness needs that aren't expressible from catalog.json alone:
|
||||
# multi-container stack rosters, alias/variant container names (bitcoin-knots
|
||||
# vs bitcoin-core install the same slots), and the actual nginx UI proxy path
|
||||
# (which often differs from /app/<id>/, e.g. `bitcoin-knots` → `/app/bitcoin-ui/`).
|
||||
#
|
||||
# Keep these tables in sync with the install code in package/stacks.rs and
|
||||
# the `*_IMAGE` companion handling in install.rs (the `archy-<x>-ui` set).
|
||||
|
||||
# Containers an app installs. Used for app_already_installed detection AND
|
||||
# for state assertions when the snapshot-diff falls back (variant apps don't
|
||||
# create new containers when their alternate is already present).
|
||||
expected_containers_for() {
|
||||
case "$1" in
|
||||
bitcoin-knots) echo "bitcoin-knots archy-bitcoin-ui" ;;
|
||||
bitcoin-core) echo "bitcoin-core archy-bitcoin-ui" ;;
|
||||
lnd) echo "lnd archy-lnd-ui" ;;
|
||||
electrumx|electrs|mempool-electrs)
|
||||
echo "electrs archy-electrs-ui" ;;
|
||||
btcpay-server) echo "archy-btcpay-server archy-btcpay-db archy-nbxplorer archy-btcpay-ui" ;;
|
||||
mempool) echo "mempool archy-mempool-web archy-mempool-db" ;;
|
||||
immich) echo "immich_server immich_machine_learning immich_postgres immich_redis" ;;
|
||||
penpot|penpot-frontend)
|
||||
echo "penpot-frontend penpot-backend penpot-exporter penpot-postgres penpot-redis" ;;
|
||||
indeedhub) echo "indeedhub indeedhub-api indeedhub-ffmpeg indeedhub-postgres indeedhub-redis indeedhub-minio indeedhub-relay" ;;
|
||||
*) echo "$1" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# UI proxy URL path on the HTTPS frontend. Most apps live at /app/<id>/ but
|
||||
# Bitcoin/LND/Electrs proxy through their UI companion containers, and BTCPay
|
||||
# uses its own short path.
|
||||
ui_proxy_path_for() {
|
||||
case "$1" in
|
||||
bitcoin-knots|bitcoin-core) echo "/app/bitcoin-ui/" ;;
|
||||
electrumx|electrs) echo "/app/electrumx/" ;;
|
||||
lnd) echo "/app/lnd-ui/" ;;
|
||||
btcpay-server) echo "/app/btcpay/" ;;
|
||||
*) echo "/app/$1/" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Authenticated probe for credentialed UIs. Echoes the HTTP status code if
|
||||
# defined, otherwise returns 1 (caller records SKIP). PASS = code in
|
||||
# {200,401,403} for endpoints that prove the proxy reaches the backend
|
||||
# (401/403 from app's own auth ≠ 502 from broken proxy).
|
||||
auth_probe_for() {
|
||||
local app="$1"
|
||||
local host; host="$(echo "$TARGET" | cut -d@ -f2)"
|
||||
case "$app" in
|
||||
bitcoin-knots|bitcoin-core)
|
||||
# Direct bitcoin-rpc proxy on :8334 inside .228 — credential
|
||||
# plumbing is the .228 bug we just shipped, must return 200.
|
||||
ssh_run 'curl -s -o /dev/null -w "%{http_code}" --max-time 5 -X POST http://127.0.0.1:8334/bitcoin-rpc/ -H "Content-Type: application/json" -d "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"getblockchaininfo\",\"params\":[]}"'
|
||||
;;
|
||||
btcpay-server)
|
||||
# BTCPay's own auth returns 401 for unauthenticated API calls;
|
||||
# 502 means proxy broken / backend down.
|
||||
curl -ks -o /dev/null -w "%{http_code}" --max-time 5 \
|
||||
"https://$host/app/btcpay/api/v1/server/info"
|
||||
;;
|
||||
lnd)
|
||||
# LND has a /lnd-connect-info passthrough on archipelago itself —
|
||||
# returns lndconnect URI when LND is up. 200 = backend reachable.
|
||||
curl -ks -o /dev/null -w "%{http_code}" --max-time 5 \
|
||||
"https://$host/lnd-connect-info"
|
||||
;;
|
||||
electrumx|electrs)
|
||||
# ElectrumX is plain TCP (electrum protocol) — no HTTPS auth path.
|
||||
# archipelago exposes /electrs-status which queries the daemon.
|
||||
curl -ks -o /dev/null -w "%{http_code}" --max-time 5 \
|
||||
"https://$host/electrs-status"
|
||||
;;
|
||||
*)
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Whether an auth_probe HTTP code counts as a pass.
|
||||
auth_probe_pass_codes() {
|
||||
case "$1" in
|
||||
bitcoin-knots|bitcoin-core) echo "200" ;;
|
||||
btcpay-server) echo "200 401 403" ;;
|
||||
lnd|electrumx|electrs) echo "200" ;;
|
||||
*) echo "200" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# ── probes (state assertions) ───────────────────────────────────
|
||||
# Returns container Status string ("running","exited","absent",…).
|
||||
probe_container_state() {
|
||||
local name="$1"
|
||||
ssh_run "podman inspect '$name' --format '{{.State.Status}}' 2>/dev/null || echo absent"
|
||||
}
|
||||
|
||||
# Returns RestartCount as integer.
|
||||
probe_container_restart_count() {
|
||||
local name="$1"
|
||||
ssh_run "podman inspect '$name' --format '{{.RestartCount}}' 2>/dev/null || echo -1"
|
||||
}
|
||||
|
||||
# Probe the app's UI proxy on the HTTPS frontend. Returns HTTP code.
|
||||
# Uses ui_proxy_path_for so apps with non-default proxy paths (bitcoin-ui,
|
||||
# lnd-ui, electrs-ui, btcpay) get probed at the right URL.
|
||||
probe_app_proxy() {
|
||||
local app_id="$1"
|
||||
local host
|
||||
host="$(echo "$TARGET" | cut -d@ -f2)"
|
||||
local path
|
||||
path=$(ui_proxy_path_for "$app_id")
|
||||
curl -ks -o /dev/null -w "%{http_code}" --max-time 5 "https://$host$path" || echo "000"
|
||||
}
|
||||
|
||||
# Check that ZERO containers are leftover for this app — catches uninstall residue.
|
||||
probe_no_residue() {
|
||||
local prefix="$1"
|
||||
ssh_run "podman ps -a --format '{{.Names}}' | grep -E '^${prefix}(-|$)' | wc -l"
|
||||
}
|
||||
|
||||
# ── waiters ─────────────────────────────────────────────────────
|
||||
# Wait for the package's state in the RPC list to match expected, with timeout.
|
||||
wait_for_package_state() {
|
||||
local pkg="$1"; local want="$2"; local timeout="${3:-300}"
|
||||
local deadline=$(($(date +%s) + timeout))
|
||||
while [ "$(date +%s)" -lt "$deadline" ]; do
|
||||
local got
|
||||
got=$(rpc_call "package.list" '{}' \
|
||||
| jq -r ".result.package_data[\"$pkg\"].state // \"absent\"")
|
||||
case "$want" in
|
||||
Running) [ "$got" = "Running" ] && return 0 ;;
|
||||
Stopped) [ "$got" = "Stopped" ] && return 0 ;;
|
||||
absent) [ "$got" = "absent" ] && return 0 ;;
|
||||
esac
|
||||
sleep 4
|
||||
done
|
||||
echo "TIMEOUT waiting for $pkg → $want (last seen: $got)" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
# Wait for podman state of a specific container.
|
||||
wait_for_container_state() {
|
||||
local name="$1"; local want="$2"; local timeout="${3:-180}"
|
||||
local deadline=$(($(date +%s) + timeout))
|
||||
while [ "$(date +%s)" -lt "$deadline" ]; do
|
||||
local got
|
||||
got=$(probe_container_state "$name")
|
||||
[ "$got" = "$want" ] && return 0
|
||||
sleep 3
|
||||
done
|
||||
echo "TIMEOUT waiting for container $name → $want (last seen: $got)" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
# Wait until restart count is stable for `stable_secs` seconds — proxy for "no crashloop".
|
||||
wait_restart_count_stable() {
|
||||
local name="$1"; local stable_secs="${2:-30}"; local timeout="${3:-180}"
|
||||
local deadline=$(($(date +%s) + timeout))
|
||||
local last; local last_change_ts
|
||||
last=$(probe_container_restart_count "$name")
|
||||
last_change_ts=$(date +%s)
|
||||
while [ "$(date +%s)" -lt "$deadline" ]; do
|
||||
sleep 5
|
||||
local now
|
||||
now=$(probe_container_restart_count "$name")
|
||||
if [ "$now" != "$last" ]; then
|
||||
last="$now"
|
||||
last_change_ts=$(date +%s)
|
||||
elif [ $(( $(date +%s) - last_change_ts )) -ge "$stable_secs" ]; then
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
echo "TIMEOUT waiting for $name restart-count stable (last=$last)" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
# ── result recording ────────────────────────────────────────────
|
||||
# Append a result row to the JSON-lines report.
|
||||
# Args: app_id, transition, status (PASS/FAIL/SKIP), detail
|
||||
record() {
|
||||
local app="$1"; local transition="$2"; local status="$3"; local detail="${4:-}"
|
||||
local ts
|
||||
ts=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||
jq -nc --arg ts "$ts" --arg app "$app" --arg t "$transition" --arg s "$status" --arg d "$detail" \
|
||||
'{ts:$ts, app:$app, transition:$t, status:$s, detail:$d}' >> "$OUT_DIR/results.jsonl"
|
||||
local marker
|
||||
case "$status" in
|
||||
PASS) marker="✅" ;;
|
||||
FAIL) marker="❌" ;;
|
||||
SKIP) marker="⏭" ;;
|
||||
*) marker="•" ;;
|
||||
esac
|
||||
printf '%s [%-15s] %-30s %s%s\n' "$marker" "$app" "$transition" "$status" "${detail:+ — $detail}"
|
||||
}
|
||||
Executable
+523
@@ -0,0 +1,523 @@
|
||||
#!/bin/bash
|
||||
# Archipelago resilience harness — black-box state-machine tester for app containers.
|
||||
#
|
||||
# Drives the live archipelago RPC against a real podman runtime on a target
|
||||
# host. For each app in the catalog, runs every state transition a user could
|
||||
# trigger (install / probe / stop / start / restart / archipelago-restart /
|
||||
# host-reboot / uninstall / reinstall / vanish-watch) and asserts the system
|
||||
# remains in the expected state at every step.
|
||||
#
|
||||
# Usage:
|
||||
# scripts/resilience/resilience.sh archipelago@192.0.2.10 [filter]
|
||||
#
|
||||
# `filter` is a comma-separated list of app IDs (or "smoke" for the curated
|
||||
# fast subset). Default: every app in app-catalog/catalog.json.
|
||||
#
|
||||
# Exit codes:
|
||||
# 0 every cell green
|
||||
# 1 any cell red — release should not ship
|
||||
# 2 setup/auth error before tests began
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
# ── args ─────────────────────────────────────────────────────────
|
||||
TARGET="${1:?usage: $0 <user@host> [filter]}"
|
||||
FILTER="${2:-}"
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||
HERE="$ROOT/scripts/resilience"
|
||||
RUN_TS="$(date -u +%Y%m%dT%H%M%SZ)"
|
||||
OUT_DIR="$HERE/reports/$RUN_TS"
|
||||
mkdir -p "$OUT_DIR"
|
||||
COOKIE_JAR="$OUT_DIR/cookies.txt"
|
||||
|
||||
HOST="$(echo "$TARGET" | cut -d@ -f2)"
|
||||
# RPC reaches archipelago through nginx on 443 (which proxies to localhost:5678).
|
||||
# Direct :5678 is bound to 127.0.0.1 on the target so we can't curl it from here.
|
||||
RPC_URL="https://$HOST/rpc/v1"
|
||||
|
||||
export TARGET RPC_URL COOKIE_JAR OUT_DIR
|
||||
|
||||
# shellcheck source=lib.sh
|
||||
. "$HERE/lib.sh"
|
||||
|
||||
# ── credentials ──────────────────────────────────────────────────
|
||||
# Pull from env first (so this script can be called from CI). Fall back to
|
||||
# interactive prompts.
|
||||
SSH_PASS="${RESILIENCE_SSH_PASS:-}"
|
||||
UI_PASS="${RESILIENCE_UI_PASS:-}"
|
||||
if [ -z "$SSH_PASS" ]; then
|
||||
read -rsp "SSH password for $TARGET: " SSH_PASS; echo
|
||||
fi
|
||||
if [ -z "$UI_PASS" ]; then
|
||||
read -rsp "Archipelago UI password: " UI_PASS; echo
|
||||
fi
|
||||
export SSH_PASS UI_PASS
|
||||
|
||||
command -v sshpass >/dev/null || { echo "sshpass required"; exit 2; }
|
||||
command -v jq >/dev/null || { echo "jq required"; exit 2; }
|
||||
|
||||
ssh_run 'echo ok' >/dev/null || { echo "ssh to $TARGET failed"; exit 2; }
|
||||
rpc_login || exit 2
|
||||
|
||||
echo "Resilience harness — target $TARGET, run $RUN_TS"
|
||||
echo "Output: $OUT_DIR/results.jsonl"
|
||||
echo "─────────────────────────────────────────────────────────────"
|
||||
|
||||
# ── catalog & filter ─────────────────────────────────────────────
|
||||
CATALOG="$ROOT/app-catalog/catalog.json"
|
||||
ALL_APPS=$(jq -r '.apps[].id' "$CATALOG")
|
||||
|
||||
# Topo-sort the catalog by `requires`. Outputs app IDs in install order
|
||||
# (deps first, then dependents). Kahn's algorithm via python — keeps the
|
||||
# bash side simple and the deps logic obvious for next-time-readers.
|
||||
topo_order() {
|
||||
python3 -c "
|
||||
import json
|
||||
with open('$CATALOG') as f: c = json.load(f)
|
||||
deps = {a['id']: list(a.get('requires', [])) for a in c['apps']}
|
||||
order = []
|
||||
remaining = set(deps)
|
||||
while remaining:
|
||||
ready = sorted(a for a in remaining if all(d not in remaining for d in deps[a]))
|
||||
if not ready: # cycle (shouldn't happen) — emit whatever's left
|
||||
order.extend(sorted(remaining)); break
|
||||
order.extend(ready); remaining.difference_update(ready)
|
||||
print('\n'.join(order))
|
||||
"
|
||||
}
|
||||
|
||||
apps_to_test() {
|
||||
local order; order=$(topo_order)
|
||||
if [ -z "$FILTER" ]; then
|
||||
# Full sweep — but skip bitcoin-core since it shares container slots
|
||||
# with bitcoin-knots; testing both back-to-back would just churn the
|
||||
# same containers. bitcoin-knots is the canonical entry.
|
||||
echo "$order" | grep -v '^bitcoin-core$'
|
||||
elif [ "$FILTER" = "smoke" ]; then
|
||||
# Fast subset exercising the bug classes we just fixed:
|
||||
# single-container, multi-container stack, credentialed UI.
|
||||
echo -e "filebrowser\nbitcoin-knots\nindeedhub"
|
||||
else
|
||||
echo "$order" | grep -E "^($(echo "$FILTER" | tr ',' '|'))$"
|
||||
fi
|
||||
}
|
||||
|
||||
# Resolve `requires` chain for $1 in install-order (deps first).
|
||||
deps_for_app() {
|
||||
local app="$1"
|
||||
python3 -c "
|
||||
import json
|
||||
with open('$CATALOG') as f: c = json.load(f)
|
||||
deps_map = {a['id']: list(a.get('requires', [])) for a in c['apps']}
|
||||
visited, order = set(), []
|
||||
def visit(x):
|
||||
if x in visited or x not in deps_map: return
|
||||
visited.add(x)
|
||||
for d in deps_map.get(x, []): visit(d)
|
||||
order.append(x)
|
||||
for d in deps_map.get('$app', []): visit(d)
|
||||
print('\n'.join(order))
|
||||
"
|
||||
}
|
||||
|
||||
# ── per-app transitions ──────────────────────────────────────────
|
||||
# Diff helper: capture container names matching a sane prefix for $app_id.
|
||||
# Approach: snapshot before install, snapshot after, take the difference =
|
||||
# this app's containers.
|
||||
snapshot_containers() {
|
||||
ssh_run "podman ps -a --format '{{.Names}}' | sort"
|
||||
}
|
||||
|
||||
# Whether $app currently has ALL of its expected containers running. Uses
|
||||
# the per-app metadata table in lib.sh (expected_containers_for) so variant
|
||||
# apps (bitcoin-knots/bitcoin-core sharing slots) and stacks are detected
|
||||
# correctly. Falls back to name-prefix match for apps the table doesn't know.
|
||||
#
|
||||
# Returns true only when every expected container is present. Earlier
|
||||
# versions returned true on ANY match — that caused dep installs (e.g.
|
||||
# bitcoin-knots required by btcpay) to be declared "installed" as soon as
|
||||
# the backend container appeared, before the UI companion (archy-bitcoin-ui)
|
||||
# was up. The before-snapshot then missed the companion, the after-snapshot
|
||||
# caught it, and it leaked into the dependent app's "new containers" set,
|
||||
# false-positive-FAILing stop/uninstall when the companion (correctly) did
|
||||
# not respond to the dependent app's package.stop.
|
||||
app_already_installed() {
|
||||
local app="$1"
|
||||
local snap; snap=$(snapshot_containers)
|
||||
local expected
|
||||
expected=$(expected_containers_for "$app")
|
||||
if [ -n "$expected" ] && [ "$expected" != "$app" ]; then
|
||||
local c missing=0
|
||||
for c in $expected; do
|
||||
echo "$snap" | grep -qxF "$c" || missing=1
|
||||
done
|
||||
[ "$missing" -eq 0 ] && return 0
|
||||
# Fall through to prefix match if the expected_containers list has
|
||||
# gaps; a partial install still counts as "installed enough" for
|
||||
# preclean purposes.
|
||||
fi
|
||||
# Generic prefix fallback for apps not in the expected_containers_for table.
|
||||
echo "$snap" | grep -qE "^(${app}|${app}-|archy-${app}|archy-${app}-)"
|
||||
}
|
||||
|
||||
# Install missing deps for $app via the regular install path. Idempotent —
|
||||
# already-installed deps are skipped. Records dep_install per dep so we can
|
||||
# tell from the report whether the bitcoin pre-req was actually green by the
|
||||
# time lnd's matrix started.
|
||||
ensure_deps_installed() {
|
||||
local app="$1"
|
||||
local dep
|
||||
for dep in $(deps_for_app "$app"); do
|
||||
if app_already_installed "$dep"; then
|
||||
continue
|
||||
fi
|
||||
echo " · dep install: $dep (required by $app)"
|
||||
local img ver resp
|
||||
img=$(jq -r --arg id "$dep" '.apps[] | select(.id==$id) | .dockerImage // ""' "$CATALOG")
|
||||
ver=$(jq -r --arg id "$dep" '.apps[] | select(.id==$id) | .version // ""' "$CATALOG")
|
||||
if [ -z "$img" ]; then
|
||||
record "$app" "dep_$dep" FAIL "no dockerImage in catalog for dep $dep"
|
||||
return 1
|
||||
fi
|
||||
resp=$(rpc_call "package.install" "$(jq -nc \
|
||||
--arg id "$dep" --arg img "$img" --arg ver "$ver" \
|
||||
'{id:$id, dockerImage:$img, version:$ver}')")
|
||||
if echo "$resp" | jq -e '.error' >/dev/null 2>&1; then
|
||||
record "$app" "dep_$dep" FAIL "rpc error: $(echo "$resp" | jq -c '.error')"
|
||||
return 1
|
||||
fi
|
||||
# Wait for at least one expected container to appear running.
|
||||
local deadline=$(($(date +%s) + 600))
|
||||
while [ "$(date +%s)" -lt "$deadline" ]; do
|
||||
if app_already_installed "$dep"; then
|
||||
record "$app" "dep_$dep" PASS "installed"
|
||||
break
|
||||
fi
|
||||
sleep 5
|
||||
done
|
||||
if ! app_already_installed "$dep"; then
|
||||
record "$app" "dep_$dep" FAIL "containers did not appear within 10min"
|
||||
return 1
|
||||
fi
|
||||
done
|
||||
return 0
|
||||
}
|
||||
|
||||
# Pre-clean: if the app is currently installed, uninstall it and wait for
|
||||
# all containers to disappear. We can't measure install correctness without
|
||||
# starting from a clean slate. Fail-soft — if the uninstall RPC errors we
|
||||
# log but proceed; the install step will catch any residual state.
|
||||
preclean_app() {
|
||||
local app="$1"
|
||||
if ! app_already_installed "$app"; then
|
||||
return 0
|
||||
fi
|
||||
echo " · pre-clean: $app already installed, uninstalling first"
|
||||
local resp; resp=$(rpc_call "package.uninstall" "{\"id\":\"$app\"}")
|
||||
if echo "$resp" | jq -e '.error' >/dev/null 2>&1; then
|
||||
echo " pre-clean uninstall RPC error: $(echo "$resp" | jq -c '.error')"
|
||||
fi
|
||||
# Multi-container stacks (indeedhub: 7, immich: 5, mempool: 3, btcpay: 6)
|
||||
# take noticeably longer to tear down than single-container apps. 240s was
|
||||
# too tight for indeedhub's 7-container teardown — bump to 10 min for
|
||||
# safety; per-container timeout is still bounded inside archipelago itself.
|
||||
local deadline=$(($(date +%s) + 600))
|
||||
while [ "$(date +%s)" -lt "$deadline" ]; do
|
||||
if ! app_already_installed "$app"; then return 0; fi
|
||||
sleep 5
|
||||
done
|
||||
echo " pre-clean: timeout waiting for $app to uninstall"
|
||||
return 1
|
||||
}
|
||||
|
||||
# Run the full per-app matrix. Records a row per transition.
|
||||
run_app_matrix() {
|
||||
local app="$1"
|
||||
echo
|
||||
echo "═══ $app ═══"
|
||||
|
||||
if ! ensure_deps_installed "$app"; then
|
||||
record "$app" install FAIL "dep install failed; skipping rest of matrix"
|
||||
return
|
||||
fi
|
||||
preclean_app "$app" || record "$app" preclean FAIL "uninstall before test did not complete"
|
||||
|
||||
# ── 01 install ───────────────────────────────────────────────
|
||||
local before after new_containers
|
||||
before=$(snapshot_containers)
|
||||
# The install handler requires `id` + `dockerImage` from the catalog
|
||||
# entry. Match what the UI passes (Discover.vue / MarketplaceAppDetails.vue).
|
||||
local docker_image version
|
||||
docker_image=$(jq -r --arg id "$app" '.apps[] | select(.id==$id) | .dockerImage // ""' "$CATALOG")
|
||||
version=$(jq -r --arg id "$app" '.apps[] | select(.id==$id) | .version // ""' "$CATALOG")
|
||||
if [ -z "$docker_image" ]; then
|
||||
record "$app" install FAIL "no dockerImage in catalog for $app"
|
||||
return
|
||||
fi
|
||||
local install_resp
|
||||
install_resp=$(rpc_call "package.install" "$(jq -nc \
|
||||
--arg id "$app" --arg img "$docker_image" --arg ver "$version" \
|
||||
'{id:$id, dockerImage:$img, version:$ver}')")
|
||||
if echo "$install_resp" | jq -e '.error' >/dev/null 2>&1; then
|
||||
record "$app" install FAIL "rpc error: $(echo "$install_resp" | jq -c '.error')"
|
||||
return # cannot continue this app
|
||||
fi
|
||||
|
||||
# Wait for the EXPECTED containers (per expected_containers_for) to all
|
||||
# appear. The old "snapshot stable for 10s + count > before" heuristic
|
||||
# terminated early on apps with deps: e.g. mempool's wait would break
|
||||
# when archy-electrs-ui (electrumx dep companion) appeared, long before
|
||||
# mempool's own containers were created (those take ~10min to pull and
|
||||
# start). Waiting on the expected-set is exact, not heuristic.
|
||||
#
|
||||
# Cap at 15 minutes — mempool stack with cold image cache needs ~12 min.
|
||||
local expected; expected=$(expected_containers_for "$app")
|
||||
local deadline=$(($(date +%s) + 900))
|
||||
while [ "$(date +%s)" -lt "$deadline" ]; do
|
||||
after=$(snapshot_containers)
|
||||
local missing=0
|
||||
for c in $expected; do
|
||||
echo "$after" | grep -qxF "$c" || missing=1
|
||||
done
|
||||
[ "$missing" -eq 0 ] && break
|
||||
sleep 5
|
||||
done
|
||||
new_containers=$(comm -13 <(echo "$before") <(echo "$after"))
|
||||
if [ -z "$new_containers" ]; then
|
||||
record "$app" install FAIL "no containers created within 10min"
|
||||
return
|
||||
fi
|
||||
# Assert each new container is in 'running' state.
|
||||
local install_ok=1; local detail=""
|
||||
while read -r c; do
|
||||
[ -z "$c" ] && continue
|
||||
local s
|
||||
s=$(probe_container_state "$c")
|
||||
if [ "$s" != "running" ]; then
|
||||
install_ok=0
|
||||
detail="$detail $c=$s"
|
||||
fi
|
||||
done <<< "$new_containers"
|
||||
if [ "$install_ok" -eq 1 ]; then
|
||||
record "$app" install PASS "$(echo "$new_containers" | tr '\n' ',' | sed 's/,$//')"
|
||||
else
|
||||
record "$app" install FAIL "containers not running:$detail"
|
||||
fi
|
||||
|
||||
# ── 02 ui_probe ──────────────────────────────────────────────
|
||||
# Retry with backoff — install just finished, but the app's backend
|
||||
# (fedimint, immich, mempool stack) may take 30+s to be ready to serve
|
||||
# HTTP. Probing immediately false-positive-FAILed those apps; pass on
|
||||
# first 2xx/3xx within 60s.
|
||||
local code
|
||||
local ui_deadline=$(($(date +%s) + 60))
|
||||
while :; do
|
||||
code=$(probe_app_proxy "$app")
|
||||
[[ "$code" =~ ^(2[0-9][0-9]|3[0-9][0-9])$ ]] && break
|
||||
[ "$(date +%s)" -ge "$ui_deadline" ] && break
|
||||
sleep 5
|
||||
done
|
||||
# Accept all 2xx/3xx — proxy reaches backend, app may redirect to login,
|
||||
# serve OAuth flow (307), or use 308 permanent. 401/403 still fail because
|
||||
# those mean "backend reached, app rejected request" which is the
|
||||
# credential-plumbing failure mode we DO want to catch.
|
||||
if [[ "$code" =~ ^(2[0-9][0-9]|3[0-9][0-9])$ ]]; then
|
||||
record "$app" ui_probe PASS "HTTP $code"
|
||||
else
|
||||
record "$app" ui_probe FAIL "HTTP $code (expected 2xx/3xx, retried 60s)"
|
||||
fi
|
||||
|
||||
# ── 03 auth_probe (only for apps with a credentialed/data endpoint) ──
|
||||
# Same backoff treatment: bitcoin-ui's nginx config bind-mount is
|
||||
# picked up at start, but the bitcoin-core backend may not have
|
||||
# accepted RPC connections yet on a fresh install.
|
||||
local probe_code; local pass_codes
|
||||
pass_codes=$(auth_probe_pass_codes "$app")
|
||||
if probe_code=$(auth_probe_for "$app" 2>/dev/null) && [ -n "$probe_code" ]; then
|
||||
local auth_deadline=$(($(date +%s) + 60))
|
||||
while :; do
|
||||
echo " $pass_codes " | grep -qF " $probe_code " && break
|
||||
[ "$(date +%s)" -ge "$auth_deadline" ] && break
|
||||
sleep 5
|
||||
probe_code=$(auth_probe_for "$app" 2>/dev/null) || break
|
||||
done
|
||||
if echo " $pass_codes " | grep -qF " $probe_code "; then
|
||||
record "$app" auth_probe PASS "HTTP $probe_code"
|
||||
else
|
||||
record "$app" auth_probe FAIL "HTTP $probe_code (expected one of: $pass_codes; retried 60s — credential plumbing broken)"
|
||||
fi
|
||||
else
|
||||
record "$app" auth_probe SKIP "no authenticated probe defined"
|
||||
fi
|
||||
|
||||
# ── 04 stop ──────────────────────────────────────────────────
|
||||
local stop_resp
|
||||
stop_resp=$(rpc_call "package.stop" "{\"id\":\"$app\"}")
|
||||
if echo "$stop_resp" | jq -e '.error' >/dev/null 2>&1; then
|
||||
record "$app" stop FAIL "rpc error: $(echo "$stop_resp" | jq -c '.error')"
|
||||
else
|
||||
local all_stopped=1
|
||||
while read -r c; do
|
||||
[ -z "$c" ] && continue
|
||||
wait_for_container_state "$c" "exited" 60 || all_stopped=0
|
||||
done <<< "$new_containers"
|
||||
if [ "$all_stopped" -eq 1 ]; then
|
||||
record "$app" stop PASS
|
||||
else
|
||||
record "$app" stop FAIL "not all containers reached exited state"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── 05 start ─────────────────────────────────────────────────
|
||||
local start_resp
|
||||
start_resp=$(rpc_call "package.start" "{\"id\":\"$app\"}")
|
||||
if echo "$start_resp" | jq -e '.error' >/dev/null 2>&1; then
|
||||
record "$app" start FAIL "rpc error: $(echo "$start_resp" | jq -c '.error')"
|
||||
else
|
||||
local all_started=1
|
||||
while read -r c; do
|
||||
[ -z "$c" ] && continue
|
||||
wait_for_container_state "$c" "running" 90 || all_started=0
|
||||
done <<< "$new_containers"
|
||||
if [ "$all_started" -eq 1 ]; then
|
||||
record "$app" start PASS
|
||||
else
|
||||
record "$app" start FAIL "not all containers reached running state"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── 06 restart_container ─────────────────────────────────────
|
||||
# `package.restart` returns immediately and spawns the actual restart.
|
||||
# `podman restart -t <stop_timeout>` blocks for up to stop_timeout
|
||||
# seconds (e.g. 600s for bitcoin-core). Polling once after sleep 5
|
||||
# races on slow-stopping apps and false-positive-FAILs them. Poll
|
||||
# each container up to 90s for "running" instead.
|
||||
local restart_resp
|
||||
restart_resp=$(rpc_call "package.restart" "{\"id\":\"$app\"}")
|
||||
if echo "$restart_resp" | jq -e '.error' >/dev/null 2>&1; then
|
||||
record "$app" restart FAIL "rpc error: $(echo "$restart_resp" | jq -c '.error')"
|
||||
else
|
||||
local all_running=1
|
||||
while read -r c; do
|
||||
[ -z "$c" ] && continue
|
||||
wait_for_container_state "$c" "running" 90 || all_running=0
|
||||
done <<< "$new_containers"
|
||||
if [ "$all_running" -eq 1 ]; then
|
||||
record "$app" restart PASS
|
||||
else
|
||||
record "$app" restart FAIL "container not running 90s after restart"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── 09 uninstall (skip 07 archipelago-restart and 08 host-reboot
|
||||
# here — those are batch tests run once across all installed apps) ─
|
||||
local uninst_resp
|
||||
uninst_resp=$(rpc_call "package.uninstall" "{\"id\":\"$app\"}")
|
||||
if echo "$uninst_resp" | jq -e '.error' >/dev/null 2>&1; then
|
||||
record "$app" uninstall FAIL "rpc error: $(echo "$uninst_resp" | jq -c '.error')"
|
||||
else
|
||||
# Wait for all this-app containers to be absent.
|
||||
local all_gone=1
|
||||
while read -r c; do
|
||||
[ -z "$c" ] && continue
|
||||
wait_for_container_state "$c" "absent" 120 || all_gone=0
|
||||
done <<< "$new_containers"
|
||||
if [ "$all_gone" -eq 1 ]; then
|
||||
record "$app" uninstall PASS
|
||||
else
|
||||
record "$app" uninstall FAIL "not all containers removed"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# ── batch transitions (run after per-app loop) ───────────────────
|
||||
batch_archipelago_service_restart() {
|
||||
echo
|
||||
echo "═══ batch: archipelago.service restart ═══"
|
||||
local before; before=$(snapshot_containers)
|
||||
if ! ssh_run 'sudo systemctl restart archipelago'; then
|
||||
record "_batch" archipelago_restart FAIL "systemctl restart errored"
|
||||
return
|
||||
fi
|
||||
ssh_wait_ready 60 || { record "_batch" archipelago_restart FAIL "ssh did not return"; return; }
|
||||
sleep 30 # let containers re-stabilize
|
||||
rpc_login || { record "_batch" archipelago_restart FAIL "rpc relogin failed"; return; }
|
||||
local after; after=$(snapshot_containers)
|
||||
if [ "$before" = "$after" ]; then
|
||||
record "_batch" archipelago_restart PASS "container set unchanged"
|
||||
else
|
||||
record "_batch" archipelago_restart FAIL "container set drifted across restart"
|
||||
fi
|
||||
}
|
||||
|
||||
batch_host_reboot() {
|
||||
echo
|
||||
echo "═══ batch: host reboot ═══"
|
||||
local before; before=$(snapshot_containers)
|
||||
ssh_run 'sudo systemctl reboot' || true # ssh disconnects immediately
|
||||
sleep 30
|
||||
# 5 min was too short — .228 took ~9min for full BIOS+kernel+systemd+
|
||||
# rootless-podman boot. 12 min gives margin for slower hardware.
|
||||
ssh_wait_ready 720 || { record "_batch" host_reboot FAIL "host did not come back in 12min"; return; }
|
||||
sleep 60 # let containers auto-restart
|
||||
rpc_login || { record "_batch" host_reboot FAIL "rpc unreachable after reboot"; return; }
|
||||
local after; after=$(snapshot_containers)
|
||||
if [ "$before" = "$after" ]; then
|
||||
record "_batch" host_reboot PASS "all containers came back"
|
||||
else
|
||||
local missing
|
||||
missing=$(comm -23 <(echo "$before") <(echo "$after") | tr '\n' ',' | sed 's/,$//')
|
||||
record "_batch" host_reboot FAIL "missing: $missing"
|
||||
fi
|
||||
|
||||
# ── L3 per-boot health gate ──────────────────────────────────
|
||||
# Container-set equality proves the right containers exist; os-audit proves
|
||||
# the node is actually *healthy* after the reboot: RPC up, OTA not wedged
|
||||
# (FM12), every app reachable with valid launch metadata, FM-guards green.
|
||||
# This is the per-boot building block os-audit.sh was written to be.
|
||||
if [ -x "$ROOT/tests/lifecycle/os-audit.sh" ]; then
|
||||
echo "── per-boot os-audit gate ──"
|
||||
if ARCHY_HOST="$HOST" ARCHY_SCHEME=https ARCHY_PASSWORD="$UI_PASS" ARCHY_LOCAL=0 \
|
||||
"$ROOT/tests/lifecycle/os-audit.sh" >"$OUT_DIR/os-audit-postboot.log" 2>&1; then
|
||||
record "_batch" host_reboot_osaudit PASS "os-audit green after reboot"
|
||||
else
|
||||
record "_batch" host_reboot_osaudit FAIL "os-audit not green after reboot (see $OUT_DIR/os-audit-postboot.log)"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# ── main ─────────────────────────────────────────────────────────
|
||||
APPS_LIST=$(apps_to_test)
|
||||
if [ -z "$APPS_LIST" ]; then
|
||||
echo "no apps match filter '$FILTER'" >&2; exit 2
|
||||
fi
|
||||
|
||||
while read -r app; do
|
||||
[ -z "$app" ] && continue
|
||||
run_app_matrix "$app"
|
||||
done <<< "$APPS_LIST"
|
||||
|
||||
# Batch transitions only run on full sweep (skip in filtered/smoke mode).
|
||||
if [ -z "$FILTER" ]; then
|
||||
batch_archipelago_service_restart
|
||||
batch_host_reboot
|
||||
fi
|
||||
|
||||
# ── summary ──────────────────────────────────────────────────────
|
||||
echo
|
||||
echo "═══ summary ═══"
|
||||
count_status() {
|
||||
local pat="$1"
|
||||
[ -s "$OUT_DIR/results.jsonl" ] || { echo 0; return; }
|
||||
awk -v pat="$pat" '$0 ~ pat { n++ } END { print n+0 }' "$OUT_DIR/results.jsonl"
|
||||
}
|
||||
PASS=$(count_status '"status":"PASS"')
|
||||
FAIL=$(count_status '"status":"FAIL"')
|
||||
SKIP=$(count_status '"status":"SKIP"')
|
||||
TOTAL=$((PASS + FAIL + SKIP))
|
||||
echo "PASS: $PASS / FAIL: $FAIL / SKIP: $SKIP / TOTAL: $TOTAL"
|
||||
echo "Report: $OUT_DIR/results.jsonl"
|
||||
|
||||
[ "$FAIL" -eq 0 ] || exit 1
|
||||
exit 0
|
||||
@@ -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"
|
||||
Executable
+521
@@ -0,0 +1,521 @@
|
||||
#!/usr/bin/env bash
|
||||
# Post-install + onboarding + container lifecycle E2E tests.
|
||||
# Run on an installed Archipelago node (SSH or local).
|
||||
#
|
||||
# Usage: bash run-post-install-tests.sh --password-stdin # read password from stdin (preferred)
|
||||
# bash run-post-install-tests.sh [password] # argv form; visible in `ps`, local use only
|
||||
# bash run-post-install-tests.sh --phase1-only # Install checks only (no auth)
|
||||
#
|
||||
# Tests:
|
||||
# Phase 1: Install verification (services, files, logs) — safe, no side effects
|
||||
# Phase 2: Onboarding (password setup, auth flow) — creates user account
|
||||
# Phase 3: Container lifecycle (install 3 apps, start/stop/health) — needs auth
|
||||
set -u
|
||||
|
||||
PHASE1_ONLY=false
|
||||
PASSWORD=""
|
||||
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--phase1-only) PHASE1_ONLY=true ;;
|
||||
--password-stdin) IFS= read -r PASSWORD || true ;;
|
||||
*) PASSWORD="$arg" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ "$PHASE1_ONLY" = false ] && [ -z "$PASSWORD" ]; then
|
||||
echo "ERROR: no password supplied. Use --password-stdin, pass one as an argument," >&2
|
||||
echo " or run --phase1-only for the no-auth install checks." >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
BASE="http://127.0.0.1:5678"
|
||||
JAR="/tmp/e2e-cookies.txt"
|
||||
rm -f "$JAR"
|
||||
PC=0; FC=0; SC=0
|
||||
|
||||
pass() { PC=$((PC + 1)); printf "\033[32m ✓ %s\033[0m\n" "$1"; }
|
||||
fail() { FC=$((FC + 1)); printf "\033[31m ✗ %s — %s\033[0m\n" "$1" "${2:-}"; }
|
||||
skip() { SC=$((SC + 1)); printf "\033[33m ⊘ %s\033[0m\n" "$1"; }
|
||||
section() { printf "\n\033[1m━━━ %s ━━━\033[0m\n" "$1"; }
|
||||
|
||||
# Extract CSRF token from cookie jar
|
||||
get_csrf() {
|
||||
grep 'csrf_token' "$JAR" 2>/dev/null | awk '{print $NF}'
|
||||
}
|
||||
|
||||
rpc() {
|
||||
local method="$1"
|
||||
local params="${2:-"{}"}"
|
||||
local csrf
|
||||
csrf=$(get_csrf)
|
||||
local csrf_header=""
|
||||
if [ -n "$csrf" ]; then
|
||||
csrf_header="-H X-CSRF-Token:${csrf}"
|
||||
fi
|
||||
curl -s -b "$JAR" -c "$JAR" \
|
||||
-H "Content-Type: application/json" \
|
||||
$csrf_header \
|
||||
-d "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"$method\",\"params\":$params}" \
|
||||
"${BASE}/rpc/v1" 2>/dev/null
|
||||
}
|
||||
|
||||
rpc_ok() {
|
||||
local resp="$1"
|
||||
[ -z "$resp" ] && return 1
|
||||
echo "$resp" | grep -q '"error":null' && return 0
|
||||
echo "$resp" | grep -q '"error"' && return 1
|
||||
return 0
|
||||
}
|
||||
|
||||
rpc_result() {
|
||||
echo "$1" | python3 -c "import sys,json; d=json.load(sys.stdin); print(json.dumps(d.get('result','')))" 2>/dev/null
|
||||
}
|
||||
|
||||
wait_for_server() {
|
||||
local max_wait=60
|
||||
local waited=0
|
||||
while [ $waited -lt $max_wait ]; do
|
||||
if curl -sf "${BASE}/health" >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
sleep 2
|
||||
waited=$((waited + 2))
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
echo ""
|
||||
echo "╔══════════════════════════════════════════════╗"
|
||||
echo "║ Archipelago Post-Install E2E Test Suite ║"
|
||||
echo "╚══════════════════════════════════════════════╝"
|
||||
echo ""
|
||||
echo "Target: ${BASE}"
|
||||
echo "Date: $(date -u '+%Y-%m-%d %H:%M:%S UTC')"
|
||||
|
||||
# ═══════════════════════════════════════════
|
||||
# PHASE 1: Install Verification
|
||||
# ═══════════════════════════════════════════
|
||||
section "Phase 1: Install Verification"
|
||||
|
||||
# 1.1 — Critical files exist
|
||||
for f in /usr/local/bin/archipelago \
|
||||
/opt/archipelago/web-ui/index.html \
|
||||
/etc/nginx/sites-available/archipelago \
|
||||
/etc/archipelago/ssl/archipelago.crt \
|
||||
/opt/archipelago/scripts/image-versions.sh; do
|
||||
if [ -f "$f" ]; then
|
||||
pass "File exists: $f"
|
||||
else
|
||||
fail "File missing" "$f"
|
||||
fi
|
||||
done
|
||||
|
||||
# 1.1b — Every enabled archipelago/nostr unit must have an existing ExecStart
|
||||
# payload. v1.7.104 shipped nostr-relay.service without its binary (3s
|
||||
# crash-loop forever) and archipelago-diag.service without its script
|
||||
# (203/EXEC) — catch that class of ISO defect on first boot, by generic rule.
|
||||
for unit in /etc/systemd/system/archipelago*.service /etc/systemd/system/nostr*.service; do
|
||||
[ -f "$unit" ] || continue
|
||||
systemctl is-enabled "$(basename "$unit")" >/dev/null 2>&1 || continue
|
||||
exec_bin=$(grep -m1 '^ExecStart=' "$unit" | sed 's/^ExecStart=[-+@!:]*//' | awk '{print $1}')
|
||||
case "$exec_bin" in
|
||||
/*) if [ -e "$exec_bin" ]; then
|
||||
pass "Unit payload exists: $(basename "$unit") → $exec_bin"
|
||||
else
|
||||
fail "Unit payload missing" "$(basename "$unit") → $exec_bin"
|
||||
fi ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# 1.2 — Critical services active
|
||||
for svc in archipelago nginx; do
|
||||
if systemctl is-active "$svc" >/dev/null 2>&1; then
|
||||
pass "Service active: $svc"
|
||||
else
|
||||
fail "Service not active" "$svc"
|
||||
fi
|
||||
done
|
||||
|
||||
# 1.3 — Services enabled
|
||||
for svc in archipelago nginx archipelago-load-images archipelago-first-boot-containers; do
|
||||
if systemctl is-enabled "$svc" >/dev/null 2>&1; then
|
||||
pass "Service enabled: $svc"
|
||||
else
|
||||
fail "Service not enabled" "$svc"
|
||||
fi
|
||||
done
|
||||
|
||||
# 1.4 — Podman available for archipelago user
|
||||
if runuser -u archipelago -- bash -c 'export XDG_RUNTIME_DIR=/run/user/1000 && podman --version' >/dev/null 2>&1; then
|
||||
pass "Podman available (rootless, archipelago user)"
|
||||
else
|
||||
fail "Podman not available" "rootless podman for archipelago user"
|
||||
fi
|
||||
|
||||
# 1.5 — Linger enabled
|
||||
if [ -f /var/lib/systemd/linger/archipelago ]; then
|
||||
pass "Linger enabled for archipelago"
|
||||
else
|
||||
fail "Linger not enabled" "/var/lib/systemd/linger/archipelago missing"
|
||||
fi
|
||||
|
||||
# 1.6 — Backend not in dev mode
|
||||
if systemctl cat archipelago 2>/dev/null | grep -q 'DEV_MODE=true'; then
|
||||
fail "DEV_MODE enabled" "ARCHIPELAGO_DEV_MODE=true found in service file"
|
||||
else
|
||||
pass "DEV_MODE disabled (production mode)"
|
||||
fi
|
||||
|
||||
# 1.7 — Backend running as correct user
|
||||
SVC_USER=$(systemctl show -p User archipelago 2>/dev/null | cut -d= -f2)
|
||||
if [ "$SVC_USER" = "archipelago" ]; then
|
||||
pass "Backend runs as user: archipelago"
|
||||
elif [ "$SVC_USER" = "root" ]; then
|
||||
fail "Backend runs as root" "Should be User=archipelago"
|
||||
else
|
||||
skip "Cannot determine backend user ($SVC_USER)"
|
||||
fi
|
||||
|
||||
# 1.8 — Health endpoint responds
|
||||
if curl -sf "${BASE}/health" >/dev/null 2>&1; then
|
||||
pass "Health endpoint responds"
|
||||
else
|
||||
fail "Health endpoint" "No response from ${BASE}/health"
|
||||
fi
|
||||
|
||||
# 1.9 — Web UI loads via nginx
|
||||
HTTP_CODE=$(curl -sk -o /dev/null -w "%{http_code}" "https://localhost/" 2>/dev/null)
|
||||
if [ "$HTTP_CODE" = "200" ]; then
|
||||
pass "Web UI loads via nginx (HTTPS)"
|
||||
else
|
||||
fail "Web UI not accessible" "HTTPS returned $HTTP_CODE"
|
||||
fi
|
||||
|
||||
# 1.10 — Nginx config test
|
||||
if nginx -t 2>/dev/null; then
|
||||
pass "Nginx config valid"
|
||||
else
|
||||
fail "Nginx config" "nginx -t failed"
|
||||
fi
|
||||
|
||||
# ── Phase 1 exit point ──
|
||||
if [ "$PHASE1_ONLY" = "true" ]; then
|
||||
section "Results (Phase 1 only)"
|
||||
TOTAL=$((PC + FC + SC))
|
||||
printf "\n \033[32mPassed: %d\033[0m \033[31mFailed: %d\033[0m \033[33mSkipped: %d\033[0m Total: %d\n\n" "$PC" "$FC" "$SC" "$TOTAL"
|
||||
[ "$FC" -gt 0 ] && echo " Phase 1: SOME CHECKS FAILED" && exit 1
|
||||
echo " Phase 1: ALL CHECKS PASSED"
|
||||
echo " Run without --phase1-only to test onboarding + containers"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ═══════════════════════════════════════════
|
||||
# PHASE 2: Onboarding & Auth
|
||||
# ═══════════════════════════════════════════
|
||||
section "Phase 2: Onboarding & Auth"
|
||||
|
||||
# Wait for server
|
||||
if ! wait_for_server; then
|
||||
fail "Server not ready" "Timed out after 60s"
|
||||
section "Results"
|
||||
echo " Passed: $PC Failed: $FC Skipped: $SC"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 2.1 — Check setup status (should be false on fresh install)
|
||||
SETUP_RESP=$(rpc "auth.isSetup")
|
||||
if rpc_ok "$SETUP_RESP"; then
|
||||
IS_SETUP=$(echo "$SETUP_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin).get('result',False))" 2>/dev/null)
|
||||
if [ "$IS_SETUP" = "True" ] || [ "$IS_SETUP" = "true" ]; then
|
||||
pass "auth.isSetup returns true (user exists)"
|
||||
# Already set up — just login
|
||||
LOGIN=$(rpc "auth.login" "{\"password\":\"$PASSWORD\"}")
|
||||
if rpc_ok "$LOGIN"; then
|
||||
pass "auth.login (existing user)"
|
||||
else
|
||||
# Try default dev password
|
||||
LOGIN=$(rpc "auth.login" '{"password":"password123"}')
|
||||
if rpc_ok "$LOGIN"; then
|
||||
pass "auth.login (dev password)"
|
||||
PASSWORD="password123"
|
||||
else
|
||||
fail "auth.login" "Cannot authenticate"
|
||||
fi
|
||||
fi
|
||||
else
|
||||
pass "auth.isSetup returns false (fresh install)"
|
||||
# 2.2 — Set up password
|
||||
SETUP=$(rpc "auth.setup" "{\"password\":\"$PASSWORD\"}")
|
||||
if rpc_ok "$SETUP"; then
|
||||
pass "auth.setup (password created)"
|
||||
else
|
||||
fail "auth.setup" "$SETUP"
|
||||
fi
|
||||
# 2.3 — Login with new password
|
||||
LOGIN=$(rpc "auth.login" "{\"password\":\"$PASSWORD\"}")
|
||||
if rpc_ok "$LOGIN"; then
|
||||
pass "auth.login (new password)"
|
||||
else
|
||||
fail "auth.login" "$LOGIN"
|
||||
fi
|
||||
fi
|
||||
else
|
||||
fail "auth.isSetup" "$SETUP_RESP"
|
||||
fi
|
||||
|
||||
# 2.4 — Onboarding status
|
||||
OB_RESP=$(rpc "auth.isOnboardingComplete")
|
||||
if rpc_ok "$OB_RESP"; then
|
||||
pass "auth.isOnboardingComplete responds"
|
||||
else
|
||||
fail "auth.isOnboardingComplete" "$OB_RESP"
|
||||
fi
|
||||
|
||||
# 2.5 — Node DID available
|
||||
DID_RESP=$(rpc "node.did")
|
||||
if rpc_ok "$DID_RESP"; then
|
||||
pass "node.did (DID generated)"
|
||||
else
|
||||
fail "node.did" "$DID_RESP"
|
||||
fi
|
||||
|
||||
# 2.6 — Server info
|
||||
INFO_RESP=$(rpc "server.info")
|
||||
if rpc_ok "$INFO_RESP"; then
|
||||
pass "server.info responds"
|
||||
else
|
||||
# Try alternate method name
|
||||
INFO_RESP=$(rpc "system.info")
|
||||
if rpc_ok "$INFO_RESP"; then
|
||||
pass "system.info responds"
|
||||
else
|
||||
skip "server.info / system.info (may not exist)"
|
||||
fi
|
||||
fi
|
||||
|
||||
# 2.7 — Mark onboarding complete
|
||||
OB_COMPLETE=$(rpc "auth.onboardingComplete")
|
||||
if rpc_ok "$OB_COMPLETE"; then
|
||||
pass "auth.onboardingComplete"
|
||||
else
|
||||
skip "auth.onboardingComplete (may already be done)"
|
||||
fi
|
||||
|
||||
# ═══════════════════════════════════════════
|
||||
# PHASE 3: Container Lifecycle
|
||||
# ═══════════════════════════════════════════
|
||||
section "Phase 3: Container Lifecycle"
|
||||
|
||||
# Source image versions for dockerImage URLs
|
||||
source /opt/archipelago/scripts/image-versions.sh 2>/dev/null || true
|
||||
|
||||
# Test with 3 lightweight standalone containers
|
||||
# package.install expects: {"id": "app_id", "dockerImage": "registry/image:tag"}
|
||||
# container-start/stop/status expect: {"app_id": "name"}
|
||||
declare -a APPS=("filebrowser" "searxng" "grafana")
|
||||
declare -a IMAGES=("${FILEBROWSER_IMAGE:-}" "${SEARXNG_IMAGE:-}" "${GRAFANA_IMAGE:-}")
|
||||
|
||||
# 3.1 — List containers (baseline)
|
||||
LIST_RESP=$(rpc "container-list")
|
||||
if rpc_ok "$LIST_RESP"; then
|
||||
pass "container-list (baseline)"
|
||||
else
|
||||
fail "container-list" "$LIST_RESP"
|
||||
fi
|
||||
|
||||
for i in 0 1 2; do
|
||||
APP="${APPS[$i]}"
|
||||
IMAGE="${IMAGES[$i]}"
|
||||
|
||||
section "Container: $APP"
|
||||
|
||||
if [ -z "$IMAGE" ]; then
|
||||
fail "$APP — image variable empty" "image-versions.sh missing or incomplete"
|
||||
continue
|
||||
fi
|
||||
|
||||
# 3.2 — Install container via package.install RPC
|
||||
# Check if already exists first
|
||||
EXISTING=$(rpc "container-list")
|
||||
if echo "$EXISTING" | grep -q "\"$APP\""; then
|
||||
pass "$APP already installed (skipping install)"
|
||||
else
|
||||
INSTALL_RESP=$(rpc "package.install" "{\"id\":\"$APP\",\"dockerImage\":\"$IMAGE\"}")
|
||||
if rpc_ok "$INSTALL_RESP"; then
|
||||
pass "$APP installed"
|
||||
else
|
||||
ERR_MSG=$(echo "$INSTALL_RESP" | python3 -c "import sys,json; d=json.load(sys.stdin); e=d.get('error',{}); print(e.get('message','unknown') if isinstance(e,dict) else str(e))" 2>/dev/null)
|
||||
fail "$APP install" "$ERR_MSG"
|
||||
continue
|
||||
fi
|
||||
fi
|
||||
|
||||
# Wait for container to start (pull + create + start)
|
||||
echo " ... waiting for $APP to start"
|
||||
for attempt in $(seq 1 15); do
|
||||
sleep 2
|
||||
STATUS_RESP=$(rpc "container-list")
|
||||
if echo "$STATUS_RESP" | grep -q "\"$APP\"" && echo "$STATUS_RESP" | grep -q '"running"'; then
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
# 3.3 — Verify running
|
||||
LIST_NOW=$(rpc "container-list")
|
||||
if echo "$LIST_NOW" | grep -q "\"$APP\""; then
|
||||
if echo "$LIST_NOW" | python3 -c "
|
||||
import sys,json
|
||||
data = json.load(sys.stdin).get('result',[])
|
||||
if isinstance(data, list):
|
||||
for c in data:
|
||||
if c.get('name','') == '$APP' or '$APP' in c.get('name',''):
|
||||
print(c.get('state','unknown'))
|
||||
sys.exit(0)
|
||||
print('not-found')
|
||||
" 2>/dev/null | grep -q "running"; then
|
||||
pass "$APP running after install"
|
||||
else
|
||||
fail "$APP not running" "Check container-list output"
|
||||
fi
|
||||
else
|
||||
fail "$APP not in container list" ""
|
||||
continue
|
||||
fi
|
||||
|
||||
# 3.4 — Stop container
|
||||
STOP_RESP=$(rpc "container-stop" "{\"app_id\":\"$APP\"}")
|
||||
if rpc_ok "$STOP_RESP"; then
|
||||
pass "$APP stopped"
|
||||
else
|
||||
fail "$APP stop" "$STOP_RESP"
|
||||
fi
|
||||
|
||||
sleep 3
|
||||
|
||||
# 3.5 — Verify stopped
|
||||
LIST_NOW=$(rpc "container-list")
|
||||
STATE=$(echo "$LIST_NOW" | python3 -c "
|
||||
import sys,json
|
||||
data = json.load(sys.stdin).get('result',[])
|
||||
if isinstance(data, list):
|
||||
for c in data:
|
||||
if c.get('name','') == '$APP' or '$APP' in c.get('name',''):
|
||||
print(c.get('state','unknown'))
|
||||
sys.exit(0)
|
||||
print('not-found')
|
||||
" 2>/dev/null)
|
||||
if [ "$STATE" = "exited" ] || [ "$STATE" = "stopped" ]; then
|
||||
pass "$APP confirmed stopped"
|
||||
else
|
||||
fail "$APP not stopped" "State: $STATE"
|
||||
fi
|
||||
|
||||
# 3.6 — Restart container
|
||||
START_RESP=$(rpc "container-start" "{\"app_id\":\"$APP\"}")
|
||||
if rpc_ok "$START_RESP"; then
|
||||
pass "$APP restarted"
|
||||
else
|
||||
fail "$APP restart" "$START_RESP"
|
||||
fi
|
||||
|
||||
sleep 5
|
||||
|
||||
# 3.7 — Verify running again
|
||||
LIST_NOW=$(rpc "container-list")
|
||||
STATE=$(echo "$LIST_NOW" | python3 -c "
|
||||
import sys,json
|
||||
data = json.load(sys.stdin).get('result',[])
|
||||
if isinstance(data, list):
|
||||
for c in data:
|
||||
if c.get('name','') == '$APP' or '$APP' in c.get('name',''):
|
||||
print(c.get('state','unknown'))
|
||||
sys.exit(0)
|
||||
print('not-found')
|
||||
" 2>/dev/null)
|
||||
if [ "$STATE" = "running" ]; then
|
||||
pass "$APP running after restart"
|
||||
else
|
||||
fail "$APP not running after restart" "State: $STATE"
|
||||
fi
|
||||
|
||||
# 3.8 — Health check
|
||||
HEALTH_RESP=$(rpc "container-health" "{\"app_id\":\"$APP\"}")
|
||||
if rpc_ok "$HEALTH_RESP"; then
|
||||
pass "$APP health responds"
|
||||
else
|
||||
skip "$APP health (may need warm-up time)"
|
||||
fi
|
||||
done
|
||||
|
||||
# 3.9 — Final container list (should show all 3)
|
||||
LIST_RESP=$(rpc "container-list")
|
||||
if rpc_ok "$LIST_RESP"; then
|
||||
COUNT=$(echo "$LIST_RESP" | python3 -c "import sys,json; r=json.load(sys.stdin).get('result',[]); print(len(r) if isinstance(r,list) else 0)" 2>/dev/null)
|
||||
if [ "${COUNT:-0}" -ge 3 ]; then
|
||||
pass "container-list shows $COUNT containers (>= 3)"
|
||||
else
|
||||
fail "container-list" "Only $COUNT containers (expected >= 3)"
|
||||
fi
|
||||
else
|
||||
fail "container-list (final)" "$LIST_RESP"
|
||||
fi
|
||||
|
||||
# ═══════════════════════════════════════════
|
||||
# PHASE 4: Log Verification
|
||||
# ═══════════════════════════════════════════
|
||||
section "Phase 4: Log Verification"
|
||||
|
||||
# 4.1 — First-boot log exists and completed
|
||||
if [ -f /var/log/archipelago-first-boot.log ]; then
|
||||
if grep -q "first-boot complete" /var/log/archipelago-first-boot.log 2>/dev/null; then
|
||||
pass "First-boot log: completed"
|
||||
else
|
||||
fail "First-boot log" "Did not complete — check /var/log/archipelago-first-boot.log"
|
||||
fi
|
||||
else
|
||||
fail "First-boot log" "/var/log/archipelago-first-boot.log missing"
|
||||
fi
|
||||
|
||||
# 4.2 — Diagnostics log exists
|
||||
if [ -f /var/log/archipelago-first-boot-diag.log ]; then
|
||||
pass "Diagnostics log exists"
|
||||
else
|
||||
skip "Diagnostics log (/var/log/archipelago-first-boot-diag.log)"
|
||||
fi
|
||||
|
||||
# 4.3 — No critical errors in backend journal
|
||||
CRIT_ERRORS=$(journalctl -u archipelago --no-pager -p err -b 2>/dev/null | grep -v "Failed to read LND\|Failed to query getblockchain\|Cannot connect to Podman" | head -5)
|
||||
if [ -z "$CRIT_ERRORS" ]; then
|
||||
pass "No unexpected backend errors in journal"
|
||||
else
|
||||
fail "Backend errors in journal" "$(echo "$CRIT_ERRORS" | head -1)"
|
||||
fi
|
||||
|
||||
# 4.4 — image-versions.sh is accessible
|
||||
if [ -f /opt/archipelago/scripts/image-versions.sh ]; then
|
||||
if source /opt/archipelago/scripts/image-versions.sh 2>/dev/null && [ -n "$FILEBROWSER_IMAGE" ]; then
|
||||
pass "image-versions.sh loads correctly"
|
||||
else
|
||||
fail "image-versions.sh" "Cannot source or FILEBROWSER_IMAGE empty"
|
||||
fi
|
||||
else
|
||||
fail "image-versions.sh" "Not found at /opt/archipelago/scripts/"
|
||||
fi
|
||||
|
||||
# ═══════════════════════════════════════════
|
||||
# Results
|
||||
# ═══════════════════════════════════════════
|
||||
section "Results"
|
||||
TOTAL=$((PC + FC + SC))
|
||||
echo ""
|
||||
printf " \033[32mPassed: %d\033[0m \033[31mFailed: %d\033[0m \033[33mSkipped: %d\033[0m Total: %d\n" "$PC" "$FC" "$SC" "$TOTAL"
|
||||
echo ""
|
||||
|
||||
if [ "$FC" -gt 0 ]; then
|
||||
echo " ❌ SOME TESTS FAILED"
|
||||
exit 1
|
||||
else
|
||||
echo " ✅ ALL TESTS PASSED"
|
||||
exit 0
|
||||
fi
|
||||
Executable
+85
@@ -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
|
||||
Executable
+567
@@ -0,0 +1,567 @@
|
||||
#!/bin/bash
|
||||
# host-secrets-audit.sh — does THIS node run the fleet-shared, image-baked SSH
|
||||
# host keys and TLS private key, or its own?
|
||||
#
|
||||
# Audit finding F-03 / phase 10 KEY-02, deployed half (decision D-06).
|
||||
#
|
||||
# 10-03 fixed the ISO builder: the rootfs no longer carries identity material
|
||||
# and first-boot regeneration fails closed. Nodes already in the field never
|
||||
# receive any of that — the first-boot script is installed by the installer,
|
||||
# not shipped by OTA — and a node that hit the old fail-open path
|
||||
# (`WARNING: TLS regeneration failed, keeping baked key` plus an unconditional
|
||||
# `touch $MARKER`) is running key material that every downloader of that ISO
|
||||
# also holds, and will never try again. This script is how such a node is
|
||||
# found, and how it is fixed.
|
||||
#
|
||||
# ── SAFETY MODEL (D-06: detect-report-then-apply) ────────────────────────────
|
||||
# --detect (default) read-only. Writes only its own verdict file. Always
|
||||
# exits 0: detection is informational and must never fail
|
||||
# a boot.
|
||||
# --apply prints what it WOULD do and exits 0 having touched
|
||||
# nothing. A mistyped invocation is inert.
|
||||
# --apply --yes rotates — and only if the detect pass returned `shared`.
|
||||
# A node whose verdict is `per-node` cannot have its keys
|
||||
# rotated by this script even by explicit command.
|
||||
#
|
||||
# The boot unit (image-recipe/configs/archipelago-host-secrets-audit.service)
|
||||
# runs --detect only and contains no apply path.
|
||||
#
|
||||
# ── THIS IS A SANCTIONED KEY PRODUCER. THERE ARE NOW THREE. ─────────────────
|
||||
# Do not unify them, and do not let their parameters drift apart:
|
||||
# 1. gen_tls()/gen_ssh() in image-recipe/_archived/build-auto-installer-iso.sh
|
||||
# — first boot, on the node, from the ISO.
|
||||
# 2. TlsMaterial::regenerate() in core/archipelago/src/api/rpc/system/handlers.rs
|
||||
# — TLS only, re-minted after `server.set-name` so the SAN matches.
|
||||
# 3. rotate_tls()/rotate_ssh() below — deployed nodes, operator-driven, once.
|
||||
# All three: rsa:2048, 3650 days, the same subject and the same SAN set, stage
|
||||
# to `.new` siblings of the destination (same directory, so the final mv is a
|
||||
# rename(2) and therefore atomic), parse both halves back AND prove they are a
|
||||
# matching pair, then swap. A key from one generation beside a cert from
|
||||
# another passes both individual parse checks and still breaks nginx.
|
||||
#
|
||||
# Producer 3 has to exist separately: producer 1 lives inside an ISO build
|
||||
# script that is not present on a deployed node, and producer 2 does TLS only —
|
||||
# nothing in the daemon has ever rotated an SSH host key.
|
||||
#
|
||||
# ── TEST SEAM ───────────────────────────────────────────────────────────────
|
||||
# HOST_SECRETS_ROOT prefixes every absolute path, exactly as
|
||||
# FIRST_BOOT_SECRETS_ROOT does for 10-03's first-boot script. Unset in
|
||||
# production the expansion is empty and behaviour is byte-identical; set, it is
|
||||
# what makes tests/first-boot-secrets/rotation-tests.sh able to force a
|
||||
# `shared` node into existence and drive a real rotation against it.
|
||||
#
|
||||
# Usage:
|
||||
# host-secrets-audit.sh [--detect] [--json] [--quiet]
|
||||
# host-secrets-audit.sh --apply [--yes]
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="${HOST_SECRETS_ROOT:-}"
|
||||
|
||||
MARKER="$ROOT/var/lib/archipelago/.secrets-regenerated"
|
||||
FAILED_RECORD="$ROOT/var/lib/archipelago/first-boot-secrets.failed"
|
||||
FIRST_BOOT_LOG="$ROOT/var/log/archipelago-first-boot-secrets.log"
|
||||
STRIPPED_MARKER="$ROOT/opt/archipelago/rootfs-identity-stripped"
|
||||
LUKS_KEY="$ROOT/root/.luks-archipelago.key"
|
||||
MACHINE_ID="$ROOT/etc/machine-id"
|
||||
SSH_DIR="$ROOT/etc/ssh"
|
||||
SSL_DIR="$ROOT/etc/archipelago/ssl"
|
||||
TLS_KEY="$SSL_DIR/archipelago.key"
|
||||
TLS_CRT="$SSL_DIR/archipelago.crt"
|
||||
STATE_DIR="$ROOT/var/lib/archipelago"
|
||||
AUDIT_JSON="$STATE_DIR/host-secrets-audit.json"
|
||||
ROTATION_JSON="$STATE_DIR/host-key-rotation.json"
|
||||
CONSOLE="$ROOT/dev/console"
|
||||
|
||||
# A key regenerated at first boot carries an mtime within seconds of the
|
||||
# anchor. A key baked into the image carries the image build time — days or
|
||||
# weeks earlier. 300s absorbs the spread between the anchor being touched and
|
||||
# the last key being written, without being wide enough to hide a build-time
|
||||
# key.
|
||||
ANCHOR_SKEW_SECONDS=300
|
||||
|
||||
MODE="detect"
|
||||
CONFIRMED=0
|
||||
QUIET=0
|
||||
EMIT_JSON=0
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--detect) MODE="detect" ;;
|
||||
--apply) MODE="apply" ;;
|
||||
--yes) CONFIRMED=1 ;;
|
||||
--json) EMIT_JSON=1 ;;
|
||||
--quiet) QUIET=1 ;;
|
||||
-h|--help)
|
||||
sed -n '2,50p' "$0"
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "host-secrets-audit: unknown argument: $1" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
say() { [ "$QUIET" = 1 ] || echo "$*"; }
|
||||
|
||||
# Evidence must name production paths, not the harness's temp root.
|
||||
disp() { printf '%s' "${1#"$ROOT"}"; }
|
||||
|
||||
json_escape() { printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g'; }
|
||||
|
||||
json_array() {
|
||||
local first=1 item
|
||||
printf '['
|
||||
for item in "$@"; do
|
||||
[ "$first" = 1 ] || printf ', '
|
||||
first=0
|
||||
printf '"%s"' "$(json_escape "$item")"
|
||||
done
|
||||
printf ']'
|
||||
}
|
||||
|
||||
mtime_of() { stat -c %Y "$1" 2>/dev/null || true; }
|
||||
|
||||
now_iso() { date -u +%Y-%m-%dT%H:%M:%SZ; }
|
||||
|
||||
# ── Fingerprints ────────────────────────────────────────────────────────────
|
||||
# Fingerprints of PUBLIC keys are public data (T-10-35: accept). The private
|
||||
# keys are never read by this script except by the generators that replace
|
||||
# them.
|
||||
|
||||
ssh_fingerprints() {
|
||||
local f
|
||||
for f in "$SSH_DIR"/ssh_host_*_key.pub; do
|
||||
[ -e "$f" ] || continue
|
||||
ssh-keygen -lf "$f" 2>/dev/null | sed "s|^|$(disp "$f"): |" || true
|
||||
done
|
||||
}
|
||||
|
||||
tls_fingerprint() {
|
||||
[ -s "$TLS_CRT" ] || return 0
|
||||
openssl x509 -in "$TLS_CRT" -noout -fingerprint -sha256 2>/dev/null \
|
||||
| sed 's/^.*=//' || true
|
||||
}
|
||||
|
||||
# ── Detection ───────────────────────────────────────────────────────────────
|
||||
# Outputs (globals, so --apply can reuse the pass without re-running it):
|
||||
# VERDICT per-node | shared | fail-closed-missing | unknown
|
||||
# EVIDENCE[] one string per signal that fired, each naming its file
|
||||
# SSH_SHARED 1 when this node's SSH host keys are believed image-baked
|
||||
# TLS_SHARED 1 when this node's TLS key is believed image-baked
|
||||
|
||||
VERDICT="unknown"
|
||||
EVIDENCE=()
|
||||
SSH_SHARED=0
|
||||
TLS_SHARED=0
|
||||
|
||||
detect() {
|
||||
VERDICT="unknown"
|
||||
EVIDENCE=()
|
||||
SSH_SHARED=0
|
||||
TLS_SHARED=0
|
||||
|
||||
local ssh_keys=() f
|
||||
for f in "$SSH_DIR"/ssh_host_*_key; do
|
||||
[ -e "$f" ] || continue
|
||||
ssh_keys+=("$f")
|
||||
done
|
||||
|
||||
local have_ssh=0 have_tls=0
|
||||
[ "${#ssh_keys[@]}" -gt 0 ] && have_ssh=1
|
||||
[ -s "$TLS_KEY" ] && have_tls=1
|
||||
|
||||
# Signal 4 — rootfs provenance. Recorded on every run because it changes
|
||||
# what missing material MEANS, and a reader of the JSON needs that context
|
||||
# regardless of the verdict.
|
||||
local stripped=0
|
||||
if [ -e "$STRIPPED_MARKER" ]; then
|
||||
stripped=1
|
||||
EVIDENCE+=("provenance: $(disp "$STRIPPED_MARKER") present — this rootfs shipped identity-free (10-03 or later ISO)")
|
||||
else
|
||||
EVIDENCE+=("provenance: $(disp "$STRIPPED_MARKER") absent — this rootfs predates the 10-03 identity strip, so baked material is possible")
|
||||
fi
|
||||
|
||||
# Signal 3 — 10-03's durable failure record.
|
||||
local failed_record=0
|
||||
if [ -e "$FAILED_RECORD" ]; then
|
||||
failed_record=1
|
||||
EVIDENCE+=("failure record: $(disp "$FAILED_RECORD") present — first-boot generation reported failure and did not silently continue")
|
||||
fi
|
||||
|
||||
# ── Precedence step 1: is the material even there? ──────────────────────
|
||||
# Missing material can never be SHARED material. On a stripped rootfs this
|
||||
# is fail-closed working as designed; without the provenance marker it is
|
||||
# still missing, and saying so is more honest than guessing.
|
||||
if [ "$have_ssh" = 0 ] || [ "$have_tls" = 0 ]; then
|
||||
[ "$have_ssh" = 0 ] && EVIDENCE+=("missing: no $(disp "$SSH_DIR")/ssh_host_*_key on this node")
|
||||
[ "$have_tls" = 0 ] && EVIDENCE+=("missing: $(disp "$TLS_KEY") is absent or empty")
|
||||
if [ "$stripped" = 0 ]; then
|
||||
EVIDENCE+=("note: provenance marker absent, so 'fail-closed' is inferred from the absence itself, not from a build-time guarantee")
|
||||
fi
|
||||
VERDICT="fail-closed-missing"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# ── Precedence step 2: the fail-open fingerprint ────────────────────────
|
||||
# `.secrets-regenerated` present AND a WARNING: line in the first-boot log
|
||||
# is precisely what the pre-10-03 fail-open path produced (builder :1647,
|
||||
# :1659, :1663). This is direct evidence, not an inference from timestamps,
|
||||
# so it outranks the mtime signal — and the two WARNING strings name which
|
||||
# class survived, so the rotation can be narrowed to it.
|
||||
if [ -e "$MARKER" ] && [ -f "$FIRST_BOOT_LOG" ] && grep -q 'WARNING:' "$FIRST_BOOT_LOG" 2>/dev/null; then
|
||||
local tls_warn=0 ssh_warn=0
|
||||
grep -q 'WARNING: TLS regeneration failed' "$FIRST_BOOT_LOG" 2>/dev/null && tls_warn=1
|
||||
grep -q 'WARNING: ssh-keygen -A failed' "$FIRST_BOOT_LOG" 2>/dev/null && ssh_warn=1
|
||||
if [ "$tls_warn" = 0 ] && [ "$ssh_warn" = 0 ]; then
|
||||
# An unrecognised WARNING. Do not narrow on a guess.
|
||||
tls_warn=1
|
||||
ssh_warn=1
|
||||
EVIDENCE+=("fail-open fingerprint: $(disp "$MARKER") present and $(disp "$FIRST_BOOT_LOG") carries an unrecognised WARNING: line — both key classes treated as shared")
|
||||
else
|
||||
EVIDENCE+=("fail-open fingerprint: $(disp "$MARKER") present and $(disp "$FIRST_BOOT_LOG") records the first-boot generator giving up and keeping the baked key")
|
||||
fi
|
||||
[ "$tls_warn" = 1 ] && { TLS_SHARED=1; EVIDENCE+=("shared: $(disp "$TLS_KEY") — the first-boot log says TLS regeneration failed and the baked key was kept"); }
|
||||
[ "$ssh_warn" = 1 ] && { SSH_SHARED=1; EVIDENCE+=("shared: $(disp "$SSH_DIR")/ssh_host_*_key — the first-boot log says ssh-keygen -A failed and the baked host keys were kept"); }
|
||||
VERDICT="shared"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# ── Precedence step 3: the mtime anchor ─────────────────────────────────
|
||||
local anchor="" anchor_kind=""
|
||||
if [ -e "$MARKER" ]; then
|
||||
anchor="$MARKER"; anchor_kind="first-boot regeneration marker"
|
||||
elif [ -e "$LUKS_KEY" ]; then
|
||||
anchor="$LUKS_KEY"; anchor_kind="LUKS key written by the installer with dd if=/dev/urandom"
|
||||
elif [ -s "$MACHINE_ID" ]; then
|
||||
anchor="$MACHINE_ID"; anchor_kind="machine-id, populated on this node's first boot"
|
||||
fi
|
||||
|
||||
if [ -z "$anchor" ]; then
|
||||
EVIDENCE+=("no anchor: none of $(disp "$MARKER"), $(disp "$LUKS_KEY"), $(disp "$MACHINE_ID") is usable, so this node's first boot cannot be dated")
|
||||
VERDICT="unknown"
|
||||
return 0
|
||||
fi
|
||||
|
||||
local anchor_mtime
|
||||
anchor_mtime=$(mtime_of "$anchor")
|
||||
if [ -z "$anchor_mtime" ]; then
|
||||
EVIDENCE+=("no anchor: $(disp "$anchor") exists but could not be stat'd")
|
||||
VERDICT="unknown"
|
||||
return 0
|
||||
fi
|
||||
EVIDENCE+=("anchor: $(disp "$anchor") ($anchor_kind), mtime $(date -u -d "@$anchor_mtime" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || echo "$anchor_mtime")")
|
||||
|
||||
older_than_anchor() {
|
||||
local file="$1" m age
|
||||
m=$(mtime_of "$file")
|
||||
[ -n "$m" ] || return 1
|
||||
age=$((anchor_mtime - m))
|
||||
[ "$age" -gt "$ANCHOR_SKEW_SECONDS" ]
|
||||
}
|
||||
|
||||
for f in "${ssh_keys[@]}"; do
|
||||
if older_than_anchor "$f"; then
|
||||
SSH_SHARED=1
|
||||
EVIDENCE+=("shared: $(disp "$f") mtime is $(( anchor_mtime - $(mtime_of "$f") ))s older than the anchor (threshold ${ANCHOR_SKEW_SECONDS}s) — it came from the image, not from this node's first boot")
|
||||
fi
|
||||
done
|
||||
if older_than_anchor "$TLS_KEY"; then
|
||||
TLS_SHARED=1
|
||||
EVIDENCE+=("shared: $(disp "$TLS_KEY") mtime is $(( anchor_mtime - $(mtime_of "$TLS_KEY") ))s older than the anchor (threshold ${ANCHOR_SKEW_SECONDS}s) — it came from the image, not from this node's first boot")
|
||||
fi
|
||||
|
||||
if [ "$SSH_SHARED" = 1 ] || [ "$TLS_SHARED" = 1 ]; then
|
||||
VERDICT="shared"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Never claim per-node while the node's own generator's last word was
|
||||
# failure. A clean-looking mtime is not evidence that generation succeeded.
|
||||
if [ "$failed_record" = 1 ]; then
|
||||
EVIDENCE+=("withholding per-node: every key is newer than the anchor, but $(disp "$FAILED_RECORD") stands, so success is not established")
|
||||
VERDICT="unknown"
|
||||
return 0
|
||||
fi
|
||||
|
||||
EVIDENCE+=("per-node: every SSH host key and the TLS key is newer than the anchor, so all of it was generated on this node")
|
||||
VERDICT="per-node"
|
||||
return 0
|
||||
}
|
||||
|
||||
write_audit_json() {
|
||||
local fps=() fp tls_fp
|
||||
while IFS= read -r fp; do [ -n "$fp" ] && fps+=("$fp"); done < <(ssh_fingerprints)
|
||||
tls_fp=$(tls_fingerprint)
|
||||
|
||||
mkdir -p "$STATE_DIR" 2>/dev/null || true
|
||||
local tmp="$AUDIT_JSON.tmp.$$"
|
||||
{
|
||||
printf '{\n'
|
||||
printf ' "verdict": "%s",\n' "$(json_escape "$VERDICT")"
|
||||
printf ' "checked_at": "%s",\n' "$(now_iso)"
|
||||
printf ' "evidence": %s,\n' "$(json_array "${EVIDENCE[@]}")"
|
||||
printf ' "ssh_host_key_fingerprints": %s,\n' "$(json_array "${fps[@]+"${fps[@]}"}")"
|
||||
printf ' "tls_cert_sha256": "%s"\n' "$(json_escape "$tls_fp")"
|
||||
printf '}\n'
|
||||
} > "$tmp"
|
||||
chmod 0644 "$tmp"
|
||||
mv -f "$tmp" "$AUDIT_JSON"
|
||||
}
|
||||
|
||||
human_line() {
|
||||
case "$VERDICT" in
|
||||
per-node)
|
||||
say "host-secrets: per-node — this node's SSH host keys and TLS key were generated here." ;;
|
||||
shared)
|
||||
say "host-secrets: SHARED — this node is running image-baked key material that every downloader of its ISO also holds. Rotate it: host-secrets-audit.sh --apply --yes" ;;
|
||||
fail-closed-missing)
|
||||
say "host-secrets: fail-closed-missing — key material is absent. Generation never succeeded; this node is not serving on a shared key, it is not serving." ;;
|
||||
*)
|
||||
say "host-secrets: unknown — not enough on-disk evidence to date this node's first boot." ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# ── Rotation ────────────────────────────────────────────────────────────────
|
||||
|
||||
# Same pair check as both other producers. Parsing each half back proves each
|
||||
# is well-formed; it does NOT prove they belong together, and a key from one
|
||||
# generation beside a cert from another passes both individual checks and then
|
||||
# breaks nginx.
|
||||
tls_pair_matches() {
|
||||
local key="$1" crt="$2" kp cp
|
||||
kp=$(openssl pkey -in "$key" -pubout 2>/dev/null) || return 1
|
||||
cp=$(openssl x509 -in "$crt" -noout -pubkey 2>/dev/null) || return 1
|
||||
[ -n "$kp" ] || return 1
|
||||
[ "$kp" = "$cp" ]
|
||||
}
|
||||
|
||||
TLS_STAGE_KEY="$SSL_DIR/archipelago.key.rotnew"
|
||||
TLS_STAGE_CRT="$SSL_DIR/archipelago.crt.rotnew"
|
||||
SSH_STAGE_DIR=""
|
||||
|
||||
cleanup_staging() {
|
||||
rm -f "$TLS_STAGE_KEY" "$TLS_STAGE_CRT" 2>/dev/null || true
|
||||
[ -n "$SSH_STAGE_DIR" ] && rm -rf "$SSH_STAGE_DIR" 2>/dev/null || true
|
||||
}
|
||||
|
||||
# STAGE ONLY. Touches nothing live. Parameters kept identical to the other two
|
||||
# producers — see the header. Do not let rsa:2048/3650 drift here alone.
|
||||
stage_tls() {
|
||||
local node_name
|
||||
node_name=$(hostname 2>/dev/null || echo archipelago)
|
||||
mkdir -p "$SSL_DIR" || return 1
|
||||
rm -f "$TLS_STAGE_KEY" "$TLS_STAGE_CRT"
|
||||
openssl req -x509 -nodes -days 3650 -newkey rsa:2048 \
|
||||
-keyout "$TLS_STAGE_KEY" -out "$TLS_STAGE_CRT" \
|
||||
-subj "/C=XX/ST=Bitcoin/L=Node/O=Archipelago/CN=${node_name}" \
|
||||
-addext "subjectAltName=DNS:${node_name},DNS:${node_name}.local,DNS:archipelago,DNS:archipelago.local,DNS:localhost,IP:127.0.0.1" \
|
||||
>/dev/null 2>&1 || return 1
|
||||
[ -s "$TLS_STAGE_KEY" ] && [ -s "$TLS_STAGE_CRT" ] || return 1
|
||||
tls_pair_matches "$TLS_STAGE_KEY" "$TLS_STAGE_CRT" || return 1
|
||||
chmod 600 "$TLS_STAGE_KEY"
|
||||
return 0
|
||||
}
|
||||
|
||||
stage_ssh() {
|
||||
SSH_STAGE_DIR=$(mktemp -d) || return 1
|
||||
mkdir -p "$SSH_STAGE_DIR/etc/ssh"
|
||||
ssh-keygen -A -f "$SSH_STAGE_DIR" >/dev/null 2>&1 || return 1
|
||||
ls "$SSH_STAGE_DIR"/etc/ssh/ssh_host_*_key >/dev/null 2>&1 || return 1
|
||||
return 0
|
||||
}
|
||||
|
||||
swap_tls() {
|
||||
mv -f "$TLS_STAGE_KEY" "$TLS_KEY" || return 1
|
||||
mv -f "$TLS_STAGE_CRT" "$TLS_CRT" || return 1
|
||||
chmod 600 "$TLS_KEY"
|
||||
return 0
|
||||
}
|
||||
|
||||
# Overwrite in place rather than rm-then-mv. rm-then-mv opens a window — small,
|
||||
# but real — in which the node has ZERO host keys on disk; sshd restarting into
|
||||
# that window is unrecoverable on a remote machine. mv onto the existing path
|
||||
# is a rename(2), so each key is replaced atomically and the directory is never
|
||||
# empty. Only after every staged key has landed are leftovers of key types the
|
||||
# new set does not include removed — leaving a stale ssh_host_dsa_key behind
|
||||
# would leave shared material behind, which is the whole point of rotating.
|
||||
swap_ssh() {
|
||||
local f base staged=()
|
||||
for f in "$SSH_STAGE_DIR"/etc/ssh/ssh_host_*; do
|
||||
[ -e "$f" ] || continue
|
||||
base=$(basename "$f")
|
||||
mv -f "$f" "$SSH_DIR/$base" || return 1
|
||||
staged+=("$base")
|
||||
done
|
||||
[ "${#staged[@]}" -gt 0 ] || return 1
|
||||
for f in "$SSH_DIR"/ssh_host_*; do
|
||||
[ -e "$f" ] || continue
|
||||
base=$(basename "$f")
|
||||
local keep=0 s
|
||||
for s in "${staged[@]}"; do [ "$s" = "$base" ] && keep=1; done
|
||||
[ "$keep" = 0 ] && rm -f "$f"
|
||||
done
|
||||
return 0
|
||||
}
|
||||
|
||||
# reload, NEVER restart. THIS IS THE SINGLE MOST IMPORTANT LINE IN THIS FILE:
|
||||
# a reload re-execs the sshd listener while already-forked session children
|
||||
# keep running, so the operator's own SSH session survives its own rotation. A
|
||||
# restart kills every session, and on a remote node reached only over SSH that
|
||||
# is unrecoverable without physical console access.
|
||||
reload_sshd() {
|
||||
systemctl reload ssh >/dev/null 2>&1 || systemctl reload sshd >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
reload_nginx() {
|
||||
systemctl reload nginx >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
shout() {
|
||||
echo "$*"
|
||||
[ -w "$CONSOLE" ] && printf '%s\n' "$*" > "$CONSOLE" 2>/dev/null || true
|
||||
}
|
||||
|
||||
write_rotation_json() {
|
||||
# $1 = "pre" (old only) or "post" (old + new)
|
||||
local phase="$1"
|
||||
mkdir -p "$STATE_DIR" 2>/dev/null || true
|
||||
local tmp="$ROTATION_JSON.tmp.$$"
|
||||
{
|
||||
printf '{\n'
|
||||
printf ' "rotated_at": "%s",\n' "$(json_escape "$ROTATED_AT")"
|
||||
printf ' "old_ssh_fingerprints": %s,\n' "$(json_array "${OLD_SSH_FPS[@]+"${OLD_SSH_FPS[@]}"}")"
|
||||
if [ "$phase" = "pre" ]; then
|
||||
printf ' "old_tls_sha256": "%s"\n' "$(json_escape "$OLD_TLS_FP")"
|
||||
else
|
||||
printf ' "old_tls_sha256": "%s",\n' "$(json_escape "$OLD_TLS_FP")"
|
||||
printf ' "new_ssh_fingerprints": %s,\n' "$(json_array "${NEW_SSH_FPS[@]+"${NEW_SSH_FPS[@]}"}")"
|
||||
printf ' "new_tls_sha256": "%s"\n' "$(json_escape "$NEW_TLS_FP")"
|
||||
fi
|
||||
printf '}\n'
|
||||
} > "$tmp"
|
||||
chmod 0644 "$tmp"
|
||||
mv -f "$tmp" "$ROTATION_JSON"
|
||||
}
|
||||
|
||||
ROTATED_AT=""
|
||||
OLD_SSH_FPS=()
|
||||
OLD_TLS_FP=""
|
||||
NEW_SSH_FPS=()
|
||||
NEW_TLS_FP=""
|
||||
|
||||
apply_rotation() {
|
||||
trap cleanup_staging EXIT
|
||||
|
||||
# Step 1 — stage EVERYTHING first. If any generation fails we abort before
|
||||
# touching anything live and exit non-zero. A partial rotation is the
|
||||
# failure mode that loses access, so there is no path here in which one
|
||||
# class is swapped and the other has not been generated yet.
|
||||
if [ "$TLS_SHARED" = 1 ]; then
|
||||
if ! stage_tls; then
|
||||
echo "host-secrets: ABORTED — could not generate a replacement TLS keypair. Nothing was changed." >&2
|
||||
cleanup_staging
|
||||
return 1
|
||||
fi
|
||||
say "staged: replacement TLS keypair"
|
||||
fi
|
||||
if [ "$SSH_SHARED" = 1 ]; then
|
||||
if ! stage_ssh; then
|
||||
echo "host-secrets: ABORTED — could not generate a replacement SSH host-key set. Nothing was changed." >&2
|
||||
cleanup_staging
|
||||
return 1
|
||||
fi
|
||||
say "staged: replacement SSH host-key set"
|
||||
fi
|
||||
|
||||
# Step 2 — record the OLD fingerprints BEFORE the swap. An operator who
|
||||
# loses access anyway can still identify what changed; after the swap the
|
||||
# old material is gone and unrecoverable.
|
||||
ROTATED_AT=$(now_iso)
|
||||
OLD_SSH_FPS=()
|
||||
while IFS= read -r line; do [ -n "$line" ] && OLD_SSH_FPS+=("$line"); done < <(ssh_fingerprints)
|
||||
OLD_TLS_FP=$(tls_fingerprint)
|
||||
write_rotation_json pre
|
||||
say "recorded old fingerprints to $(disp "$ROTATION_JSON") before touching anything"
|
||||
|
||||
# Step 3 — TLS first. The web UI going down is recoverable over SSH; SSH
|
||||
# going down on a remote node is not. Do the recoverable one first.
|
||||
if [ "$TLS_SHARED" = 1 ]; then
|
||||
if ! swap_tls; then
|
||||
echo "host-secrets: TLS swap failed. SSH host keys were NOT touched." >&2
|
||||
cleanup_staging
|
||||
return 1
|
||||
fi
|
||||
reload_nginx
|
||||
say "rotated: TLS keypair, nginx reloaded"
|
||||
fi
|
||||
|
||||
# Step 4 — SSH, then reload (never restart; see reload_sshd).
|
||||
if [ "$SSH_SHARED" = 1 ]; then
|
||||
if ! swap_ssh; then
|
||||
echo "host-secrets: SSH swap failed partway. Check $(disp "$SSH_DIR") before disconnecting." >&2
|
||||
cleanup_staging
|
||||
return 1
|
||||
fi
|
||||
reload_sshd
|
||||
say "rotated: SSH host keys, sshd reloaded (your current session is intentionally unaffected)"
|
||||
fi
|
||||
|
||||
# Step 5 — new fingerprints on the record, on stdout and on the console,
|
||||
# then re-run detect so the verdict file reflects the post-rotation state.
|
||||
NEW_SSH_FPS=()
|
||||
while IFS= read -r line; do [ -n "$line" ] && NEW_SSH_FPS+=("$line"); done < <(ssh_fingerprints)
|
||||
NEW_TLS_FP=$(tls_fingerprint)
|
||||
write_rotation_json post
|
||||
|
||||
shout "host-secrets: ROTATED $ROTATED_AT — new host key fingerprints for this node:"
|
||||
for line in "${NEW_SSH_FPS[@]+"${NEW_SSH_FPS[@]}"}"; do shout " $line"; done
|
||||
[ -n "$NEW_TLS_FP" ] && shout " TLS cert sha256: $NEW_TLS_FP"
|
||||
shout "host-secrets: every known_hosts entry for this node is now stale. Update it against the fingerprints above, never by blindly accepting whatever is offered."
|
||||
|
||||
detect
|
||||
write_audit_json
|
||||
human_line
|
||||
|
||||
cleanup_staging
|
||||
trap - EXIT
|
||||
return 0
|
||||
}
|
||||
|
||||
# ── Main ────────────────────────────────────────────────────────────────────
|
||||
|
||||
detect
|
||||
|
||||
if [ "$MODE" = "detect" ]; then
|
||||
write_audit_json
|
||||
human_line
|
||||
[ "$EMIT_JSON" = 1 ] && cat "$AUDIT_JSON"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# --apply. Deliberately writes NOTHING — not even its own verdict file — until
|
||||
# --yes is given and a rotation actually starts. "Touches nothing" is a
|
||||
# property worth being able to state without a footnote, and a footnote is what
|
||||
# "except for one file it rewrites" would be.
|
||||
if [ "$VERDICT" != "shared" ]; then
|
||||
human_line
|
||||
say "host-secrets: nothing to rotate (verdict is '$VERDICT', not 'shared'). No changes made."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "$CONFIRMED" != 1 ]; then
|
||||
say "host-secrets: DRY RUN — this node's verdict is 'shared'. Nothing has been changed."
|
||||
say ""
|
||||
say "Would rotate:"
|
||||
[ "$TLS_SHARED" = 1 ] && say " - TLS keypair at $(disp "$TLS_KEY") (+ cert), then reload nginx"
|
||||
[ "$SSH_SHARED" = 1 ] && say " - every $(disp "$SSH_DIR")/ssh_host_*_key, then reload (not restart) sshd"
|
||||
say ""
|
||||
say "Old fingerprints would be written to $(disp "$ROTATION_JSON") before the swap."
|
||||
say "This is ONE-WAY: every known_hosts entry for this node breaks and the old key is destroyed."
|
||||
say "Re-run with --yes from a session you are willing to lose."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
apply_rotation
|
||||
Executable
+343
@@ -0,0 +1,343 @@
|
||||
#!/usr/bin/env bash
|
||||
# Rotate this node's LND macaroons after the /lnd-connect-info leak.
|
||||
#
|
||||
# WHY THIS EXISTS
|
||||
# GET /lnd-connect-info used to answer unauthenticated callers with the LND
|
||||
# admin macaroon, the TLS cert, the gRPC/REST ports and the node's onion
|
||||
# address. Anything that reached port 18083 — any fips0 mesh peer, LAN host
|
||||
# or Tailscale peer — could take it. Every macaroon on an affected node must
|
||||
# be treated as known to an attacker.
|
||||
#
|
||||
# WHAT ROTATION ACTUALLY DOES
|
||||
# LND derives every macaroon it issues from a root key kept in macaroons.db.
|
||||
# Remove that root key and the macaroon files, restart, and LND mints a fresh
|
||||
# root key and a fresh set of macaroons on unlock. Every previously issued
|
||||
# macaroon — including any the attacker holds — stops verifying.
|
||||
#
|
||||
# WHY YOUR FUNDS AND CHANNELS SURVIVE
|
||||
# Macaroons are bearer tokens, not keys. Coins live in wallet.db and channel
|
||||
# state in channel.db; channels are secured by the node's identity and channel
|
||||
# keys, none of which are derived from the macaroon root key. This script
|
||||
# never touches, moves or opens either database. The wallet is not re-created,
|
||||
# the seed is not re-entered, and no channel is closed or force-closed.
|
||||
#
|
||||
# The only interruption is the LND restart itself, which is the same event as
|
||||
# a reboot or an update — peers reconnect and channels resume. What this
|
||||
# script verifies is exactly that: it records the node's identity pubkey and
|
||||
# its channel counts BEFORE, and aborts loudly if either differs after.
|
||||
#
|
||||
# Note it does NOT assert wallet.db is byte-identical, which would be the
|
||||
# wrong test: btcwallet records chain-sync progress inside wallet.db, so the
|
||||
# file legitimately changes on every start. Asserting byte-identity would fire
|
||||
# a frightening false alarm on a completely healthy rotation.
|
||||
#
|
||||
# WHAT IT DELIBERATELY NEVER DOES
|
||||
# It never reads, prints, logs or copies a macaroon's CONTENT. Everything it
|
||||
# reports is a SHA-256 digest or a file size, which is enough to prove the
|
||||
# material changed without disclosing it to the terminal, the scrollback or
|
||||
# whoever is reading over your shoulder.
|
||||
#
|
||||
# THE ORDERING GUARD
|
||||
# Rotating before the leak is patched is worse than useless: the new macaroon
|
||||
# is readable through the same open door within seconds, and you would think
|
||||
# you were safe. So this script REFUSES to rotate unless the running binary
|
||||
# carries the fix. Override only if you genuinely know better.
|
||||
set -uo pipefail
|
||||
|
||||
LND_DIR="/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet"
|
||||
BIN="/usr/local/bin/archipelago"
|
||||
CONTAINER="lnd"
|
||||
APPLY=no; ASSUME_YES=no; FORCE_UNPATCHED=no
|
||||
|
||||
usage() {
|
||||
cat <<'USAGE'
|
||||
Usage: rotate-lnd-macaroon.sh [--apply] [--yes] [--force-unpatched]
|
||||
|
||||
(no flags) Detect and report only. Changes nothing. THE DEFAULT.
|
||||
--apply Perform the rotation. Requires --yes as well.
|
||||
--yes Confirm a destructive-to-credentials action.
|
||||
--force-unpatched Rotate even though the running binary lacks the
|
||||
/lnd-connect-info fix. You will almost certainly be
|
||||
re-leaking the new macaroon. Not recommended.
|
||||
|
||||
Exit: 0 ok / verdict clean, 1 error or aborted, 2 rotation needed (detect mode).
|
||||
USAGE
|
||||
}
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--apply) APPLY=yes; shift ;;
|
||||
--yes) ASSUME_YES=yes; shift ;;
|
||||
--force-unpatched) FORCE_UNPATCHED=yes; shift ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) echo "unknown argument: $1" >&2; usage; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
say() { printf '%s\n' "$*"; }
|
||||
die() { printf 'ERROR: %s\n' "$*" >&2; exit 1; }
|
||||
|
||||
# Digest helper. Uses sudo because LND's data dir is 0700 and owned by the
|
||||
# container's mapped uid. Prints ONLY a digest, never a byte of content.
|
||||
digest() {
|
||||
sudo sha256sum "$1" 2>/dev/null | awk '{print $1}'
|
||||
}
|
||||
|
||||
# Enumerate the macaroon material. This MUST run under sudo rather than as a
|
||||
# shell glob: the LND data dir is 0700 owned by the container's mapped uid, so
|
||||
# `"$LND_DIR"/*.macaroon` does not expand in this (unprivileged) shell. It
|
||||
# would silently stay literal, making both the backup loop and the removal
|
||||
# loop no-ops while every surrounding step still reported success.
|
||||
macaroon_files() {
|
||||
sudo find "$LND_DIR" -maxdepth 1 \
|
||||
\( -name '*.macaroon' -o -name 'macaroons.db' \) 2>/dev/null
|
||||
}
|
||||
|
||||
say "── LND macaroon rotation ─────────────────────────────────────────"
|
||||
|
||||
sudo test -d "$LND_DIR" || die "LND data dir not found at $LND_DIR — is LND installed on this node?"
|
||||
|
||||
# ── The ordering guard ────────────────────────────────────────────────
|
||||
# The fix adds this exact string to the binary. Its presence is the only
|
||||
# machine-checkable evidence that the door is shut on THIS node.
|
||||
PATCH_MARKER="/auth/session-check"
|
||||
if sudo grep -qa -- "$PATCH_MARKER" "$BIN" 2>/dev/null; then
|
||||
say "patch status : PRESENT — the running binary carries the /lnd-connect-info fix"
|
||||
PATCHED=yes
|
||||
else
|
||||
say "patch status : ABSENT — this binary still leaks /lnd-connect-info"
|
||||
PATCHED=no
|
||||
fi
|
||||
|
||||
# ── Live reachability check ───────────────────────────────────────────
|
||||
# Proves the hole from the outside rather than trusting the marker alone.
|
||||
LEAK_CODE=$(curl -s -m 5 -o /dev/null -w '%{http_code}' http://127.0.0.1:18083/lnd-connect-info 2>/dev/null || echo "000")
|
||||
case "$LEAK_CODE" in
|
||||
200) say "live probe : LEAKING — unauthenticated GET returned 200" ;;
|
||||
401|403) say "live probe : closed — unauthenticated GET returned $LEAK_CODE" ;;
|
||||
000) say "live probe : lnd-ui not reachable on :18083 (app may be stopped)" ;;
|
||||
*) say "live probe : unauthenticated GET returned $LEAK_CODE" ;;
|
||||
esac
|
||||
|
||||
OLD_ADMIN=$(digest "$LND_DIR/admin.macaroon")
|
||||
OLD_ROOT=$(digest "$LND_DIR/macaroons.db")
|
||||
say "admin.macaroon : ${OLD_ADMIN:-<absent>}"
|
||||
say "macaroons.db : ${OLD_ROOT:-<absent>}"
|
||||
|
||||
# Node identity + channel census BEFORE. `lncli` runs INSIDE the container and
|
||||
# reads the macaroon off its own disk, so the secret never crosses into this
|
||||
# script, its output, or the operator's scrollback.
|
||||
#
|
||||
# It reports active AND inactive channel counts separately, because only their
|
||||
# SUM is a safety property. `num_active_channels` counts channels whose peer is
|
||||
# currently online, so it legitimately dips straight after any restart while
|
||||
# peers reconnect — asserting on it alone would abort a perfectly healthy
|
||||
# rotation. What must not change is the total number of channels the node
|
||||
# holds, and its identity.
|
||||
lnd_state() {
|
||||
podman exec "$CONTAINER" lncli --network=mainnet getinfo 2>/dev/null \
|
||||
| python3 -c 'import json,sys
|
||||
try: d=json.load(sys.stdin)
|
||||
except Exception: raise SystemExit(1)
|
||||
print("%s %s %s %s" % (d.get("identity_pubkey",""), d.get("num_active_channels",0),
|
||||
d.get("num_inactive_channels",0), d.get("num_pending_channels",0)))' 2>/dev/null
|
||||
}
|
||||
OLD_STATE=$(lnd_state)
|
||||
if [ -n "$OLD_STATE" ]; then
|
||||
set -- $OLD_STATE
|
||||
OLD_PUBKEY="$1"; OLD_ACTIVE="$2"; OLD_INACTIVE="$3"; OLD_PENDING="$4"
|
||||
OLD_TOTAL=$((OLD_ACTIVE + OLD_INACTIVE))
|
||||
say "node identity : ${OLD_PUBKEY:0:16}…"
|
||||
say "channels : $OLD_TOTAL open ($OLD_ACTIVE active, $OLD_INACTIVE inactive), $OLD_PENDING pending (must survive)"
|
||||
else
|
||||
OLD_PUBKEY=""; OLD_ACTIVE=""; OLD_INACTIVE=""; OLD_PENDING=""; OLD_TOTAL=""
|
||||
say "channels : could not read LND state (locked or down) — see below"
|
||||
fi
|
||||
|
||||
if [ "$APPLY" != yes ]; then
|
||||
say
|
||||
say "Detect-only. Re-run with --apply --yes to rotate."
|
||||
[ "$PATCHED" = yes ] || say "Patch this node FIRST, or the new macaroon leaks immediately."
|
||||
exit 2
|
||||
fi
|
||||
|
||||
[ "$ASSUME_YES" = yes ] || die "--apply requires --yes (this invalidates every existing macaroon)"
|
||||
|
||||
if [ "$PATCHED" != yes ] && [ "$FORCE_UNPATCHED" != yes ]; then
|
||||
die "refusing to rotate on an unpatched node — the new macaroon would leak through the same hole. Deploy the fix first, or pass --force-unpatched if you truly intend this."
|
||||
fi
|
||||
|
||||
# ── Back up, so a mistake is recoverable ──────────────────────────────
|
||||
# Kept 0700 and OUTSIDE the dir LND rescans. Still secret material: it is the
|
||||
# old root key. Delete it once you have confirmed every client re-paired.
|
||||
STAMP=$(date -u +%Y%m%dT%H%M%SZ)
|
||||
BACKUP="/var/lib/archipelago/lnd/macaroon-rotation-$STAMP"
|
||||
sudo mkdir -p "$BACKUP" || die "could not create $BACKUP"
|
||||
sudo chmod 700 "$BACKUP"
|
||||
mapfile -t MAC_FILES < <(macaroon_files)
|
||||
[ "${#MAC_FILES[@]}" -gt 0 ] || die "no macaroon material found in $LND_DIR — nothing to rotate, and restarting LND for no reason would be a pointless outage"
|
||||
|
||||
say
|
||||
say "backing up old macaroon material to $BACKUP (0700, contents never printed)"
|
||||
for f in "${MAC_FILES[@]}"; do
|
||||
sudo cp -a "$f" "$BACKUP/" || die "backup of $(basename "$f") failed — aborting before any deletion"
|
||||
say " backed up $(basename "$f")"
|
||||
done
|
||||
|
||||
# Count what actually landed. A backup that silently copied nothing is the one
|
||||
# failure mode that would make the deletion below unrecoverable.
|
||||
BACKED_UP=$(sudo find "$BACKUP" -maxdepth 1 -type f 2>/dev/null | wc -l)
|
||||
[ "$BACKED_UP" -eq "${#MAC_FILES[@]}" ] \
|
||||
|| die "backup incomplete — $BACKED_UP of ${#MAC_FILES[@]} files in $BACKUP. Refusing to delete anything."
|
||||
|
||||
say "stopping $CONTAINER"
|
||||
podman stop "$CONTAINER" >/dev/null 2>&1 || say " (stop reported non-zero; continuing to check state)"
|
||||
|
||||
# Only now, with a verified backup in hand, remove the credential material.
|
||||
say "removing macaroon root key and issued macaroons"
|
||||
for f in "${MAC_FILES[@]}"; do
|
||||
sudo rm -f "$f" || die "could not remove $(basename "$f") — restore from $BACKUP"
|
||||
done
|
||||
|
||||
say "starting $CONTAINER — archipelago auto-unlocks and LND re-mints on unlock"
|
||||
podman start "$CONTAINER" >/dev/null 2>&1 || die "could not start $CONTAINER — restore from $BACKUP"
|
||||
|
||||
# ── Wait for regeneration ─────────────────────────────────────────────
|
||||
printf 'waiting for a fresh admin.macaroon'
|
||||
NEW_ADMIN=""
|
||||
for _ in $(seq 1 60); do
|
||||
sleep 5
|
||||
NEW_ADMIN=$(digest "$LND_DIR/admin.macaroon")
|
||||
[ -n "$NEW_ADMIN" ] && break
|
||||
printf '.'
|
||||
done
|
||||
printf '\n'
|
||||
|
||||
[ -n "$NEW_ADMIN" ] || die "no new admin.macaroon after 5 minutes. LND may not have unlocked. Old material is intact in $BACKUP — restore it there and investigate before retrying."
|
||||
|
||||
# ── Verify: credentials changed, node and channels did not ────────────
|
||||
FAIL=""
|
||||
|
||||
[ "$NEW_ADMIN" != "$OLD_ADMIN" ] || FAIL="$FAIL admin-macaroon-UNCHANGED"
|
||||
|
||||
# Deliberately NOT a wallet.db byte-identity check. btcwallet records chain
|
||||
# sync progress inside wallet.db, so that file legitimately changes on every
|
||||
# start; asserting equality would fire a frightening false alarm on a
|
||||
# completely healthy rotation. The meaningful invariant is that this is still
|
||||
# the SAME Lightning node holding the SAME channels — so assert that instead.
|
||||
printf 'waiting for LND to report its state'
|
||||
NEW_STATE=""
|
||||
for _ in $(seq 1 60); do
|
||||
NEW_STATE=$(lnd_state)
|
||||
[ -n "$NEW_STATE" ] && break
|
||||
printf '.'
|
||||
sleep 5
|
||||
done
|
||||
printf '\n'
|
||||
|
||||
if [ -n "$OLD_PUBKEY" ]; then
|
||||
if [ -n "$NEW_STATE" ]; then
|
||||
set -- $NEW_STATE
|
||||
NEW_PUBKEY="$1"; NEW_ACTIVE="$2"; NEW_INACTIVE="$3"; NEW_PENDING="$4"
|
||||
NEW_TOTAL=$((NEW_ACTIVE + NEW_INACTIVE))
|
||||
[ "$NEW_PUBKEY" = "$OLD_PUBKEY" ] || FAIL="$FAIL NODE-IDENTITY-CHANGED"
|
||||
[ "$NEW_TOTAL" = "$OLD_TOTAL" ] || FAIL="$FAIL OPEN-CHANNELS-$OLD_TOTAL-to-$NEW_TOTAL"
|
||||
[ "$NEW_PENDING" = "$OLD_PENDING" ] || FAIL="$FAIL PENDING-CHANNELS-$OLD_PENDING-to-$NEW_PENDING"
|
||||
say
|
||||
say "node identity : ${NEW_PUBKEY:0:16}… (unchanged)"
|
||||
say "channels : $NEW_TOTAL open ($NEW_ACTIVE active, $NEW_INACTIVE inactive), $NEW_PENDING pending"
|
||||
if [ "$NEW_ACTIVE" != "$OLD_ACTIVE" ]; then
|
||||
say " active count differs from before ($OLD_ACTIVE -> $NEW_ACTIVE) — this is"
|
||||
say " normal for a few minutes after any restart while peers reconnect."
|
||||
fi
|
||||
else
|
||||
FAIL="$FAIL LND-STATE-UNREADABLE-AFTER"
|
||||
fi
|
||||
else
|
||||
say
|
||||
say "channels : NOT VERIFIED — LND state was already unreadable before the"
|
||||
say " rotation, so there is no baseline to compare against."
|
||||
say " Check 'lncli getinfo' yourself before trusting this run."
|
||||
fi
|
||||
|
||||
say
|
||||
say "new admin.macaroon : $NEW_ADMIN"
|
||||
|
||||
if [ -n "$FAIL" ]; then
|
||||
say
|
||||
die "rotation verification FAILED:$FAIL — old material is in $BACKUP"
|
||||
fi
|
||||
|
||||
# ── BTCPay's inline copy ──────────────────────────────────────────────
|
||||
# BTCPay reaches the internal LND node with a connection string that carries
|
||||
# the macaroon INLINE as hex, not as a file path: LND's datadir is owned by its
|
||||
# container's mapped uid, so btcpay cannot bind-mount the file. That copy is
|
||||
# therefore now a dead credential, and nothing else will notice — the daemon
|
||||
# only regenerates this secret when LND's TLS *cert* thumbprint changes, which
|
||||
# macaroon rotation does not touch. The node keeps looking healthy (btcpay up,
|
||||
# LND up) while every Lightning invoice BTCPay tries to create fails.
|
||||
#
|
||||
# Deleting the secret file gets the daemon to regenerate it from the new
|
||||
# macaroon on its next reconcile tick. That is necessary but NOT sufficient, and
|
||||
# the difference matters: the RUNNING container still holds the dead value, and
|
||||
# the periodic reconciler only ever runs in `ExistingOnly` mode, where env drift
|
||||
# on a restart-sensitive app (btcpay-server is one) is detected and then
|
||||
# deliberately skipped — "leaving running restart-sensitive app untouched". So
|
||||
# the container has to be recreated on purpose. The dashboard path
|
||||
# (Settings → Lightning credentials) does this itself by flagging the app as
|
||||
# credential-rotated; a shell script cannot reach that in-process flag, so it
|
||||
# removes the container instead and lets the orchestrator's own desired-state
|
||||
# recovery rebuild it around unchanged data, ports and volumes.
|
||||
#
|
||||
# Nothing is printed but a path — never the value.
|
||||
BTCPAY_SECRET="/var/lib/archipelago/secrets/btcpay-lnd-connection"
|
||||
BTCPAY_NOTE=no
|
||||
if sudo test -f "$BTCPAY_SECRET"; then
|
||||
if sudo rm -f "$BTCPAY_SECRET"; then
|
||||
say
|
||||
say "btcpay : removed its stale connection string ($BTCPAY_SECRET)."
|
||||
say " The daemon regenerates it from the new macaroon within a minute."
|
||||
BTCPAY_NOTE=yes
|
||||
if podman container exists btcpay-server 2>/dev/null; then
|
||||
say " Recreating btcpay-server so it stops using the dead one."
|
||||
podman stop btcpay-server >/dev/null 2>&1 || true
|
||||
if podman rm -f btcpay-server >/dev/null 2>&1; then
|
||||
say " Removed; the orchestrator rebuilds it around its existing"
|
||||
say " data (it was running, so desired-state recovery restores it)."
|
||||
else
|
||||
say " ⚠ could not remove btcpay-server. Its Lightning payments will"
|
||||
say " fail until it is recreated."
|
||||
BTCPAY_NOTE=warn
|
||||
fi
|
||||
fi
|
||||
else
|
||||
say
|
||||
say "btcpay : ⚠ could not remove $BTCPAY_SECRET. BTCPay is still holding"
|
||||
say " the OLD macaroon, so its Lightning payments will fail until"
|
||||
say " that file is deleted and btcpay-server is recreated."
|
||||
BTCPAY_NOTE=warn
|
||||
fi
|
||||
fi
|
||||
|
||||
say
|
||||
say "✅ Rotated. Every macaroon issued before now no longer verifies."
|
||||
say
|
||||
say "WHAT BREAKS, AND WHAT TO DO:"
|
||||
say " Anything paired with the old admin macaroon must be re-paired — most"
|
||||
say " importantly Zeus or any other remote wallet. Open the LND app in the UI"
|
||||
say " and scan the new pairing QR; it serves the new macaroon."
|
||||
say
|
||||
say " Your funds and channels are untouched: the node kept its identity and"
|
||||
say " no channel was closed."
|
||||
if [ "${BTCPAY_NOTE:-no}" != no ]; then
|
||||
say
|
||||
say " CONFIRM BTCPAY CAME BACK. A silent failure here looks identical to success:"
|
||||
say " btcpay stays up and healthy while every Lightning payment it tries fails."
|
||||
say " podman inspect btcpay-server --format '{{.Created}}' # should be just now"
|
||||
say " sudo test -f $BTCPAY_SECRET && echo regenerated"
|
||||
fi
|
||||
say
|
||||
say " Once every client is re-paired, delete the backup — it holds the OLD"
|
||||
say " root key, which is still sensitive:"
|
||||
say " sudo rm -rf $BACKUP"
|
||||
exit 0
|
||||
Executable
+296
@@ -0,0 +1,296 @@
|
||||
#!/usr/bin/env bash
|
||||
# Archipelago RPC exposure probe — audit item C-6 / KEY-01 (F-01).
|
||||
#
|
||||
# Answers two DIFFERENT questions that the audit's original C-6 command
|
||||
# conflated, and that must never be conflated again:
|
||||
#
|
||||
# 1. EXPOSURE — is the *unauthenticated* RPC surface reachable from
|
||||
# this vantage point at all? Measured with
|
||||
# `auth.isOnboardingComplete`, which really is on the
|
||||
# unauthenticated allowlist
|
||||
# (core/archipelago/src/api/rpc/middleware.rs:9).
|
||||
# A 200 means the door F-01 depends on is open from here.
|
||||
#
|
||||
# 2. SESSION ENFORCEMENT — is the session check still rejecting everything
|
||||
# that is NOT allowlisted? Measured with `seed.status`,
|
||||
# which is deliberately absent from the allowlist, so a
|
||||
# 401 is the *correct* result.
|
||||
#
|
||||
# Why this matters: the audit (docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md,
|
||||
# C-6) probes with `seed.status` and calls a 200 a failure. But `seed.status`
|
||||
# is not allowlisted, so it returns 401 by design — the audit's failure
|
||||
# criterion can never fire, and the probe would report the surface as CLOSED
|
||||
# while F-01's actual door stands wide open. This script fixes that.
|
||||
#
|
||||
# SAFETY RULES
|
||||
# * Default mode is read-only BY CONSTRUCTION: the request method is taken
|
||||
# from the fixed READONLY_METHODS array and never from an argument.
|
||||
# * Every mutating request lives inside one explicit `--destructive` branch.
|
||||
# * `--destructive` issues a real `seed.restore`. Against a node WITHOUT the
|
||||
# 10-01 gate that DESTROYS the node's identity. Disposable nodes only.
|
||||
# * No real key material is ever handled: the refusal check uses the
|
||||
# published BIP-39 all-`abandon` + `art` test vector. This script never
|
||||
# generates and never prints a mnemonic.
|
||||
# * No node address, onion address, username or password is embedded here.
|
||||
# Record node LABELS in evidence documents, not raw addresses.
|
||||
#
|
||||
# Usage:
|
||||
# scripts/security/rpc-exposure-probe.sh --target <host|onion|ULA> \
|
||||
# [--scheme http|https] [--port N] [--label <name>] [--insecure] \
|
||||
# [--destructive]
|
||||
#
|
||||
# Over Tor: torsocks scripts/security/rpc-exposure-probe.sh --target <onion> ...
|
||||
#
|
||||
# Exit codes:
|
||||
# 0 every control behaved as expected
|
||||
# 1 a control failed (seed.status was not 401, or --destructive was not refused)
|
||||
# 2 usage error
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# ── The ONLY methods the default path may ever call. Read-only, no side
|
||||
# effects, none of them mutate identity. Never build this from an argument.
|
||||
READONLY_METHODS=("health" "auth.isOnboardingComplete" "seed.status")
|
||||
|
||||
# Published BIP-39 test vector (32 bytes of 0x00). Public test data — NOT a
|
||||
# real mnemonic, and deliberately unlike audit item C-5, which mints real ones.
|
||||
TEST_MNEMONIC_WORDS='["abandon","abandon","abandon","abandon","abandon","abandon","abandon","abandon","abandon","abandon","abandon","abandon","abandon","abandon","abandon","abandon","abandon","abandon","abandon","abandon","abandon","abandon","abandon","art"]'
|
||||
|
||||
# The refusal prefix emitted by 10-01's gate
|
||||
# (core/archipelago/src/api/rpc/onboarding_gate.rs). Load-bearing: the error
|
||||
# sanitizer (middleware.rs:47-71) only lets messages with a known prefix
|
||||
# through, and "Not supported" is on that list.
|
||||
REFUSAL_PREFIX="Not supported:"
|
||||
|
||||
TARGET=""
|
||||
SCHEME="http"
|
||||
PORT="80"
|
||||
LABEL="unlabelled"
|
||||
DESTRUCTIVE=0
|
||||
INSECURE=0
|
||||
TIMEOUT=15
|
||||
FAIL=0
|
||||
|
||||
if [ -t 1 ]; then
|
||||
C_RED=$'\033[31m'; C_GRN=$'\033[32m'; C_YEL=$'\033[33m'; C_OFF=$'\033[0m'
|
||||
else
|
||||
C_RED=""; C_GRN=""; C_YEL=""; C_OFF=""
|
||||
fi
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
rpc-exposure-probe.sh — measure the unauthenticated Archipelago RPC surface (C-6 / KEY-01)
|
||||
|
||||
Usage:
|
||||
rpc-exposure-probe.sh --target <host|onion|ULA> [options]
|
||||
|
||||
Options:
|
||||
--target <host> Host, onion address or IPv6 ULA to probe. Required.
|
||||
Bare IPv6 addresses are bracketed automatically.
|
||||
--scheme http|https Default: http
|
||||
--port <n> Default: 80
|
||||
--label <name> Vantage-point label printed on every verdict line
|
||||
(e.g. lan, tor, mesh, loopback). Default: unlabelled
|
||||
--insecure Accept a self-signed TLS certificate (https only).
|
||||
--destructive Enable the ONE mutating branch: the KEY-01 refusal
|
||||
check. DISPOSABLE NODES ONLY.
|
||||
--help This text.
|
||||
|
||||
Checks in default (read-only) mode — four requests total:
|
||||
1. health on /rpc/v1 liveness from this vantage point
|
||||
2. auth.isOnboardingComplete on /rpc/v1 EXPOSURE signal; 200 = the
|
||||
unauthenticated surface is
|
||||
reachable from here (C-6)
|
||||
3. seed.status on /rpc/v1 SESSION-ENFORCEMENT control;
|
||||
anything but 401 is CRITICAL
|
||||
4. auth.isOnboardingComplete on /rpc/ same exposure signal on nginx's
|
||||
second proxy path
|
||||
|
||||
With --destructive, one extra request:
|
||||
5. seed.restore with the published BIP-39 all-abandon/art test vector.
|
||||
PASS only if the response carries an error beginning "Not supported:".
|
||||
|
||||
Safety rules:
|
||||
* Read-only mode cannot mutate identity: methods come from a fixed array,
|
||||
never from an argument.
|
||||
* --destructive issues a real seed.restore. On a node WITHOUT 10-01's gate
|
||||
this DESTROYS that node's identity. Never run it against a node in use.
|
||||
* No real mnemonic is ever generated, handled or printed by this script.
|
||||
* Never paste raw node/onion/ULA addresses into committed evidence — record
|
||||
the --label and the status codes.
|
||||
|
||||
Byte-identity check (run ON the node, not from here — this script has no
|
||||
node-local file access):
|
||||
before: sudo sha256sum /var/lib/archipelago/identity/node_key /var/lib/archipelago/identity/nostr_secret
|
||||
after: sudo sha256sum /var/lib/archipelago/identity/node_key /var/lib/archipelago/identity/nostr_secret
|
||||
The two outputs must match character for character.
|
||||
|
||||
Exit codes: 0 ok · 1 a control failed · 2 usage error
|
||||
EOF
|
||||
}
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--target) TARGET="${2:-}"; shift 2 ;;
|
||||
--scheme) SCHEME="${2:-}"; shift 2 ;;
|
||||
--port) PORT="${2:-}"; shift 2 ;;
|
||||
--label) LABEL="${2:-}"; shift 2 ;;
|
||||
--insecure) INSECURE=1; shift ;;
|
||||
--destructive) DESTRUCTIVE=1; shift ;;
|
||||
--help|-h) usage; exit 0 ;;
|
||||
*) echo "unknown argument: $1" >&2; usage >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ -z "$TARGET" ]; then
|
||||
echo "error: --target is required" >&2
|
||||
usage >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
case "$SCHEME" in
|
||||
http|https) ;;
|
||||
*) echo "error: --scheme must be http or https" >&2; exit 2 ;;
|
||||
esac
|
||||
|
||||
# Bracket bare IPv6 / ULA targets so the mesh transport can be probed.
|
||||
HOSTPART="$TARGET"
|
||||
case "$TARGET" in
|
||||
\[*\]) ;;
|
||||
*:*) HOSTPART="[$TARGET]" ;;
|
||||
esac
|
||||
|
||||
BASE="${SCHEME}://${HOSTPART}:${PORT}"
|
||||
|
||||
CURL_OPTS=(-sS --max-time "$TIMEOUT" -H 'Content-Type: application/json')
|
||||
if [ "$SCHEME" = "https" ] && [ "$INSECURE" = "1" ]; then
|
||||
CURL_OPTS+=(--insecure)
|
||||
fi
|
||||
|
||||
BODY_FILE="$(mktemp)"
|
||||
cleanup() { rm -f "$BODY_FILE"; }
|
||||
trap cleanup EXIT
|
||||
|
||||
# rpc_call <path> <method> <params-json>
|
||||
# Sets HTTP_CODE and RESP_BODY. Never fails the script on a network error;
|
||||
# an unreachable vantage point is a RESULT, not a crash.
|
||||
HTTP_CODE=""
|
||||
RESP_BODY=""
|
||||
rpc_call() {
|
||||
local path="$1" method="$2" params="$3"
|
||||
local payload
|
||||
payload=$(printf '{"jsonrpc":"2.0","id":1,"method":"%s","params":%s}' "$method" "$params")
|
||||
# NOTE: curl's %{http_code} is already "000" when no response arrived, so
|
||||
# the failure fallback must REPLACE the captured value, never append to it
|
||||
# (a `|| echo 000` here yields "000000" and misroutes every verdict).
|
||||
if ! HTTP_CODE=$(curl "${CURL_OPTS[@]}" -o "$BODY_FILE" -w '%{http_code}' \
|
||||
-X POST "${BASE}${path}" -d "$payload" 2>"${BODY_FILE}.err"); then
|
||||
HTTP_CODE="000"
|
||||
fi
|
||||
case "$HTTP_CODE" in
|
||||
[0-9][0-9][0-9]) ;;
|
||||
*) HTTP_CODE="000" ;;
|
||||
esac
|
||||
RESP_BODY=$(cat "$BODY_FILE" 2>/dev/null || true)
|
||||
if [ "$HTTP_CODE" = "000" ]; then
|
||||
RESP_BODY=$(cat "${BODY_FILE}.err" 2>/dev/null || true)
|
||||
fi
|
||||
rm -f "${BODY_FILE}.err"
|
||||
}
|
||||
|
||||
verdict() {
|
||||
# verdict <colour> <tag> <method> <code> <note>
|
||||
printf '[%s] %-30s %-4s %s%-10s%s %s\n' \
|
||||
"$LABEL" "$3" "$4" "$1" "$2" "$C_OFF" "$5"
|
||||
}
|
||||
|
||||
echo "RPC exposure probe — label=${LABEL} endpoint=${BASE}"
|
||||
echo " audit item C-6 · KEY-01 (F-01) · read-only mode$([ "$DESTRUCTIVE" = "1" ] && echo " + DESTRUCTIVE")"
|
||||
echo
|
||||
|
||||
# ── 1. Liveness ──────────────────────────────────────────────────────
|
||||
rpc_call "/rpc/v1" "${READONLY_METHODS[0]}" "null"
|
||||
case "$HTTP_CODE" in
|
||||
200) verdict "$C_GRN" "REACHABLE" "${READONLY_METHODS[0]}" "$HTTP_CODE" "endpoint answers from this vantage point" ;;
|
||||
000) verdict "$C_YEL" "UNREACHABLE" "${READONLY_METHODS[0]}" "---" "no answer: ${RESP_BODY:0:120}" ;;
|
||||
429) verdict "$C_YEL" "RATELIMIT" "${READONLY_METHODS[0]}" "$HTTP_CODE" "rate limited — rerun later, this is not a refusal" ;;
|
||||
*) verdict "$C_YEL" "UNEXPECTED" "${READONLY_METHODS[0]}" "$HTTP_CODE" "endpoint answered but not 200" ;;
|
||||
esac
|
||||
|
||||
# ── 2. EXPOSURE signal (the honest C-6 measurement) ──────────────────
|
||||
rpc_call "/rpc/v1" "${READONLY_METHODS[1]}" "null"
|
||||
case "$HTTP_CODE" in
|
||||
200) verdict "$C_YEL" "EXPOSED" "${READONLY_METHODS[1]}" "$HTTP_CODE" "unauthenticated RPC surface IS reachable from here (C-6 result)" ;;
|
||||
000) verdict "$C_YEL" "UNREACHABLE" "${READONLY_METHODS[1]}" "---" "no answer: ${RESP_BODY:0:120}" ;;
|
||||
429) verdict "$C_YEL" "RATELIMIT" "${READONLY_METHODS[1]}" "$HTTP_CODE" "rate limited — rerun later" ;;
|
||||
*) verdict "$C_GRN" "NOT-EXPOSED" "${READONLY_METHODS[1]}" "$HTTP_CODE" "unauthenticated surface did not answer 200 from here" ;;
|
||||
esac
|
||||
|
||||
# ── 3. SESSION-ENFORCEMENT control ───────────────────────────────────
|
||||
rpc_call "/rpc/v1" "${READONLY_METHODS[2]}" "null"
|
||||
case "$HTTP_CODE" in
|
||||
401) verdict "$C_GRN" "PASS" "${READONLY_METHODS[2]}" "$HTTP_CODE" "session enforcement active for non-allowlisted methods" ;;
|
||||
000) verdict "$C_YEL" "UNREACHABLE" "${READONLY_METHODS[2]}" "---" "no answer: ${RESP_BODY:0:120}" ;;
|
||||
429) verdict "$C_YEL" "RATELIMIT" "${READONLY_METHODS[2]}" "$HTTP_CODE" "rate limited — inconclusive, rerun later" ;;
|
||||
*) verdict "$C_RED" "CRITICAL" "${READONLY_METHODS[2]}" "$HTTP_CODE" "expected 401 — session enforcement is NOT working"
|
||||
FAIL=1 ;;
|
||||
esac
|
||||
|
||||
# ── 4. Same exposure signal on nginx's second proxy path ─────────────
|
||||
rpc_call "/rpc/" "${READONLY_METHODS[1]}" "null"
|
||||
case "$HTTP_CODE" in
|
||||
200) verdict "$C_YEL" "EXPOSED" "${READONLY_METHODS[1]} (/rpc/)" "$HTTP_CODE" "alternate proxy path also reachable" ;;
|
||||
000) verdict "$C_YEL" "UNREACHABLE" "${READONLY_METHODS[1]} (/rpc/)" "---" "no answer: ${RESP_BODY:0:120}" ;;
|
||||
429) verdict "$C_YEL" "RATELIMIT" "${READONLY_METHODS[1]} (/rpc/)" "$HTTP_CODE" "rate limited — rerun later" ;;
|
||||
*) verdict "$C_GRN" "NOT-EXPOSED" "${READONLY_METHODS[1]} (/rpc/)" "$HTTP_CODE" "alternate proxy path did not answer 200" ;;
|
||||
esac
|
||||
|
||||
# ── 5. KEY-01 refusal check — the ONE mutating branch ────────────────
|
||||
if [ "$DESTRUCTIVE" = "1" ]; then
|
||||
echo
|
||||
echo "${C_RED}================================================================${C_OFF}"
|
||||
echo "${C_RED} DESTRUCTIVE MODE — this issues a REAL seed.restore.${C_OFF}"
|
||||
echo "${C_RED} Against a node WITHOUT 10-01's gate this REPLACES that node's${C_OFF}"
|
||||
echo "${C_RED} identity (node_key, nostr_secret, fips_key). DISPOSABLE NODES ONLY.${C_OFF}"
|
||||
echo "${C_RED}================================================================${C_OFF}"
|
||||
echo
|
||||
echo "Capture the identity digest ON the node before and after this run:"
|
||||
echo " sudo sha256sum /var/lib/archipelago/identity/node_key /var/lib/archipelago/identity/nostr_secret"
|
||||
echo
|
||||
|
||||
rpc_call "/rpc/v1" "seed.restore" "{\"words\":${TEST_MNEMONIC_WORDS}}"
|
||||
case "$HTTP_CODE" in
|
||||
000)
|
||||
verdict "$C_YEL" "UNREACHABLE" "seed.restore" "---" "no answer: ${RESP_BODY:0:120}"
|
||||
;;
|
||||
429)
|
||||
verdict "$C_YEL" "RATELIMIT" "seed.restore" "$HTTP_CODE" "rate limited — INCONCLUSIVE, this is not a refusal"
|
||||
;;
|
||||
*)
|
||||
if printf '%s' "$RESP_BODY" | grep -q "$REFUSAL_PREFIX"; then
|
||||
verdict "$C_GRN" "REFUSED" "seed.restore" "$HTTP_CODE" "gate refused with the expected '${REFUSAL_PREFIX}' prefix"
|
||||
elif printf '%s' "$RESP_BODY" | grep -q '"result"[[:space:]]*:[[:space:]]*[^n]'; then
|
||||
verdict "$C_RED" "ACCEPTED" "seed.restore" "$HTTP_CODE" "IDENTITY WAS REPLACED — the gate is absent or bypassed"
|
||||
FAIL=1
|
||||
else
|
||||
verdict "$C_RED" "UNKNOWN" "seed.restore" "$HTTP_CODE" "neither the refusal prefix nor a result — inspect manually"
|
||||
FAIL=1
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
echo
|
||||
echo "Response body (verbatim, for the evidence record):"
|
||||
printf ' %s\n' "${RESP_BODY:0:600}"
|
||||
echo
|
||||
echo "Now re-run the digest command ON the node. The two outputs MUST match:"
|
||||
echo " sudo sha256sum /var/lib/archipelago/identity/node_key /var/lib/archipelago/identity/nostr_secret"
|
||||
fi
|
||||
|
||||
echo
|
||||
if [ "$FAIL" = "0" ]; then
|
||||
echo "${C_GRN}All controls behaved as expected.${C_OFF} (An EXPOSED verdict is a recorded"
|
||||
echo "finding, not a control failure — that is what C-6 exists to measure.)"
|
||||
else
|
||||
echo "${C_RED}A control FAILED — see the CRITICAL/ACCEPTED/UNKNOWN line above.${C_OFF}"
|
||||
fi
|
||||
exit "$FAIL"
|
||||
Executable
+512
@@ -0,0 +1,512 @@
|
||||
#!/bin/bash
|
||||
# Self-update: pull latest code from the OVH Gitea (source.archipelago-foundation.org) and apply
|
||||
# Designed to run on installed Archipelago nodes (as archipelago user)
|
||||
#
|
||||
# Usage:
|
||||
# ./self-update.sh # Check + apply if available
|
||||
# ./self-update.sh --check # Check only, don't apply
|
||||
# ./self-update.sh --force # Apply even if already up to date
|
||||
#
|
||||
# The script:
|
||||
# 1. Pulls latest code from origin (source.archipelago-foundation.org)
|
||||
# 2. Builds the Rust backend (release mode)
|
||||
# 3. Builds the Vue frontend (production mode)
|
||||
# 4. Installs the new binary and web UI
|
||||
# 5. Restarts the archipelago service
|
||||
# 6. Verifies health after restart
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
REPO_DIR="$HOME/archy"
|
||||
BACKEND_DIR="$REPO_DIR/core"
|
||||
FRONTEND_DIR="$REPO_DIR/neode-ui"
|
||||
INSTALL_BIN="/usr/local/bin/archipelago"
|
||||
INSTALL_WEB="/opt/archipelago/web-ui"
|
||||
STATE_FILE="/var/lib/archipelago/update_state.json"
|
||||
LOG_FILE="/var/lib/archipelago/update.log"
|
||||
LOCK_FILE="/tmp/archipelago-update.lock"
|
||||
|
||||
# Colors
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m'
|
||||
|
||||
log() { echo -e "${BLUE}[$(date '+%H:%M:%S')]${NC} $*" | tee -a "$LOG_FILE"; }
|
||||
ok() { echo -e "${GREEN}[$(date '+%H:%M:%S')] OK${NC} $*" | tee -a "$LOG_FILE"; }
|
||||
err() { echo -e "${RED}[$(date '+%H:%M:%S')] ERROR${NC} $*" | tee -a "$LOG_FILE"; }
|
||||
warn(){ echo -e "${YELLOW}[$(date '+%H:%M:%S')] WARN${NC} $*" | tee -a "$LOG_FILE"; }
|
||||
|
||||
cleanup() {
|
||||
rm -f "$LOCK_FILE"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
# Prevent concurrent updates
|
||||
if [ -f "$LOCK_FILE" ]; then
|
||||
pid=$(cat "$LOCK_FILE" 2>/dev/null)
|
||||
if kill -0 "$pid" 2>/dev/null; then
|
||||
err "Update already in progress (PID $pid)"
|
||||
exit 1
|
||||
fi
|
||||
warn "Stale lock file found, removing"
|
||||
rm -f "$LOCK_FILE"
|
||||
fi
|
||||
echo $$ > "$LOCK_FILE"
|
||||
|
||||
# Parse args
|
||||
CHECK_ONLY=false
|
||||
FORCE=false
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--check) CHECK_ONLY=true; shift ;;
|
||||
--force) FORCE=true; shift ;;
|
||||
*) shift ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Ensure repo exists
|
||||
if [ ! -d "$REPO_DIR/.git" ]; then
|
||||
err "Repo not found at $REPO_DIR"
|
||||
err "Clone it first: git clone https://source.archipelago-foundation.org/lfg2025/archy ~/archy"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cd "$REPO_DIR"
|
||||
|
||||
if ! command -v nano >/dev/null 2>&1; then
|
||||
log "Installing nano for Archipelago terminal..."
|
||||
if sudo apt-get update -qq 2>>"$LOG_FILE" && sudo apt-get install -y -qq nano 2>>"$LOG_FILE"; then
|
||||
ok "nano installed"
|
||||
else
|
||||
warn "Unable to install nano automatically; continuing update"
|
||||
fi
|
||||
fi
|
||||
|
||||
if ! command -v ping >/dev/null 2>&1; then
|
||||
log "Installing iputils-ping..."
|
||||
if sudo apt-get update -qq 2>>"$LOG_FILE" && sudo apt-get install -y -qq iputils-ping 2>>"$LOG_FILE"; then
|
||||
ok "ping installed"
|
||||
else
|
||||
warn "Unable to install ping automatically; continuing update"
|
||||
fi
|
||||
fi
|
||||
|
||||
if ! command -v esptool >/dev/null 2>&1; then
|
||||
log "Installing esptool for LoRa radio firmware flashing..."
|
||||
if sudo apt-get update -qq 2>>"$LOG_FILE" && sudo apt-get install -y -qq esptool 2>>"$LOG_FILE"; then
|
||||
ok "esptool installed"
|
||||
else
|
||||
warn "Unable to install esptool automatically; radio firmware flashing will be unavailable"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Debian's esptool package (4.7.0+dfsg-0.1) ships without the precompiled
|
||||
# esp32s3 "stub flasher" blob (stripped for DFSG compliance — no
|
||||
# buildable-from-source path Debian could verify). Without it, esptool's
|
||||
# normal stub-loader mode fails outright (FileNotFoundError), and the ROM
|
||||
# bootloader fallback (--no-stub) doesn't implement a full-chip erase at
|
||||
# all — confirmed live 2026-07-23 flashing a real Heltec V4, both ways.
|
||||
# Fetching the exact same file from the matching upstream esptool release
|
||||
# tag restores full (and correct) flashing behavior — it's the same
|
||||
# open-source codebase, just the one blob Debian's packaging couldn't
|
||||
# include.
|
||||
if command -v esptool >/dev/null 2>&1; then
|
||||
STUB_DIR="/usr/lib/python3/dist-packages/esptool/targets/stub_flasher"
|
||||
STUB_FILE="$STUB_DIR/stub_flasher_32s3.json"
|
||||
if [ ! -f "$STUB_FILE" ]; then
|
||||
log "Fetching esptool's esp32s3 stub flasher (missing from the Debian package)..."
|
||||
ESPTOOL_VERSION=$(esptool version 2>/dev/null | tail -1 | tr -d ' \t')
|
||||
if [ -n "$ESPTOOL_VERSION" ] && sudo curl -fsSL -o "$STUB_FILE" \
|
||||
"https://raw.githubusercontent.com/espressif/esptool/v${ESPTOOL_VERSION}/esptool/targets/stub_flasher/stub_flasher_32s3.json" \
|
||||
2>>"$LOG_FILE"; then
|
||||
sudo chmod 644 "$STUB_FILE"
|
||||
ok "esp32s3 stub flasher installed"
|
||||
else
|
||||
sudo rm -f "$STUB_FILE" 2>/dev/null
|
||||
warn "Unable to fetch esp32s3 stub flasher; LoRa firmware flashing will be unavailable"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# Build-time prerequisites for reticulum-daemon/build.sh's PyInstaller step
|
||||
# below (discovered the hard way: ensurepip needs python3-venv, and
|
||||
# PyInstaller itself needs objdump + libpython3.13.so at build time — none
|
||||
# of these are pulled in by a bare `python3` package on Debian trixie).
|
||||
for pkg in python3-venv binutils libpython3.13; do
|
||||
if ! dpkg -s "$pkg" >/dev/null 2>&1; then
|
||||
log "Installing $pkg (reticulum-daemon build prerequisite)..."
|
||||
sudo apt-get update -qq 2>>"$LOG_FILE" && sudo apt-get install -y -qq "$pkg" 2>>"$LOG_FILE" \
|
||||
|| warn "Unable to install $pkg automatically; reticulum-daemon tools build may fail"
|
||||
fi
|
||||
done
|
||||
|
||||
# Fetch latest
|
||||
log "Fetching from origin..."
|
||||
git fetch origin main --quiet 2>>"$LOG_FILE"
|
||||
|
||||
# Check if there are updates
|
||||
LOCAL=$(git rev-parse HEAD)
|
||||
REMOTE=$(git rev-parse origin/main)
|
||||
|
||||
if [ "$LOCAL" = "$REMOTE" ] && [ "$FORCE" = "false" ]; then
|
||||
ok "Already up to date ($LOCAL)"
|
||||
if [ "$CHECK_ONLY" = "true" ]; then
|
||||
echo '{"update_available": false, "current": "'"$LOCAL"'"}'
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Calculate what changed
|
||||
COMMITS_BEHIND=$(git rev-list HEAD..origin/main --count)
|
||||
log "Update available: $COMMITS_BEHIND commits behind"
|
||||
log " Local: $LOCAL"
|
||||
log " Remote: $REMOTE"
|
||||
|
||||
if [ "$CHECK_ONLY" = "true" ]; then
|
||||
CHANGELOG=$(git log HEAD..origin/main --oneline --no-merges | head -20)
|
||||
echo '{"update_available": true, "current": "'"$LOCAL"'", "latest": "'"$REMOTE"'", "commits_behind": '"$COMMITS_BEHIND"'}'
|
||||
echo ""
|
||||
echo "Changes:"
|
||||
echo "$CHANGELOG"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Backup current binary
|
||||
BACKUP_DIR="/var/lib/archipelago/update-backup"
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
if [ -f "$INSTALL_BIN" ]; then
|
||||
cp "$INSTALL_BIN" "$BACKUP_DIR/archipelago.bak"
|
||||
log "Backed up current binary"
|
||||
fi
|
||||
|
||||
# Pull latest code
|
||||
log "Pulling latest code..."
|
||||
git pull origin main --ff-only 2>>"$LOG_FILE" || {
|
||||
err "Git pull failed — local changes? Run: git reset --hard origin/main"
|
||||
exit 1
|
||||
}
|
||||
|
||||
NEW_VERSION=$(git rev-parse --short HEAD)
|
||||
log "Now at: $NEW_VERSION"
|
||||
|
||||
# Build backend
|
||||
log "Building Rust backend (release)..."
|
||||
cd "$BACKEND_DIR"
|
||||
if cargo build --release --workspace 2>>"$LOG_FILE"; then
|
||||
ok "Backend built successfully"
|
||||
else
|
||||
err "Backend build failed — rolling back"
|
||||
cd "$REPO_DIR"
|
||||
git reset --hard "$LOCAL" 2>>"$LOG_FILE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Install binary
|
||||
BUILT_BIN="$BACKEND_DIR/target/release/archipelago"
|
||||
if [ ! -f "$BUILT_BIN" ]; then
|
||||
err "Built binary not found at $BUILT_BIN"
|
||||
exit 1
|
||||
fi
|
||||
sudo cp "$BUILT_BIN" "$INSTALL_BIN"
|
||||
sudo chmod +x "$INSTALL_BIN"
|
||||
ok "Backend installed"
|
||||
|
||||
# Build + install reticulum-daemon tools (archy-reticulum-daemon, archy-rnodeconf).
|
||||
# Non-fatal: archipelago falls back to its dev venv path if the packaged
|
||||
# binaries aren't present, so a missing/failed build here degrades mesh
|
||||
# Reticulum support rather than breaking the update. This mirrors
|
||||
# the existing manual-deploy step, which until now was the
|
||||
# only path that ever installed these — a node that only ever received OTA
|
||||
# self-updates had neither binary.
|
||||
if [ -f "$REPO_DIR/reticulum-daemon/build.sh" ]; then
|
||||
log "Building reticulum-daemon tools (archy-reticulum-daemon, archy-rnodeconf)..."
|
||||
if (cd "$REPO_DIR/reticulum-daemon" && ./build.sh) 2>>"$LOG_FILE"; then
|
||||
for tool in archy-reticulum-daemon archy-rnodeconf; do
|
||||
if [ -f "$REPO_DIR/reticulum-daemon/dist/$tool" ]; then
|
||||
sudo cp "$REPO_DIR/reticulum-daemon/dist/$tool" /usr/local/bin/
|
||||
sudo chmod +x "/usr/local/bin/$tool"
|
||||
ok "$tool installed"
|
||||
else
|
||||
warn "$tool not built — leaving existing /usr/local/bin/$tool (if any) in place"
|
||||
fi
|
||||
done
|
||||
else
|
||||
warn "reticulum-daemon tools build failed — continuing without updating them"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Build frontend
|
||||
log "Building Vue frontend (production)..."
|
||||
cd "$FRONTEND_DIR"
|
||||
npm ci --silent 2>>"$LOG_FILE" || npm install --silent 2>>"$LOG_FILE"
|
||||
if npm run build 2>>"$LOG_FILE"; then
|
||||
ok "Frontend built successfully"
|
||||
else
|
||||
err "Frontend build failed — backend already updated, service may need manual fix"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Install frontend (always ship fresh AIUI from demo/aiui; preserve claude-login.html)
|
||||
BUILT_WEB="$REPO_DIR/web/dist/neode-ui"
|
||||
if [ -d "$BUILT_WEB" ]; then
|
||||
# Bake AIUI into the built tree so rsync --delete does not wipe it.
|
||||
# demo/aiui is the canonical AIUI bundle checked into the repo; copying
|
||||
# it here means every self-update ships a matching AIUI version instead
|
||||
# of preserving whatever stale copy happened to be on disk (which is
|
||||
# empty on nodes where an earlier ad-hoc deploy blew it away).
|
||||
if [ -d "$REPO_DIR/demo/aiui" ] && [ -f "$REPO_DIR/demo/aiui/index.html" ]; then
|
||||
log "Staging AIUI bundle from demo/aiui into frontend dist..."
|
||||
rm -rf "$BUILT_WEB/aiui"
|
||||
cp -r "$REPO_DIR/demo/aiui" "$BUILT_WEB/aiui"
|
||||
else
|
||||
warn "demo/aiui not found in repo; existing /opt/archipelago/web-ui/aiui will be wiped by rsync --delete"
|
||||
fi
|
||||
# Sync new files, preserving claude-login.html (per-node admin bookmark)
|
||||
sudo rsync -a --delete \
|
||||
--exclude 'claude-login.html' \
|
||||
"$BUILT_WEB/" "$INSTALL_WEB/"
|
||||
ok "Frontend installed"
|
||||
else
|
||||
warn "Frontend build output not found at $BUILT_WEB — skipping"
|
||||
fi
|
||||
|
||||
# Update helper scripts in /opt/archipelago/scripts/
|
||||
# These are canonical home; keep a copy at /opt/archipelago/image-versions.sh
|
||||
# for backward compatibility with older binaries that still look there.
|
||||
SCRIPTS_DEST="/opt/archipelago/scripts"
|
||||
sudo mkdir -p "$SCRIPTS_DEST"
|
||||
for script in image-versions.sh reconcile-containers.sh container-specs.sh container-doctor.sh sync-npm-public-hosts.sh app-surface-smoke-test.sh bitcoin-stack-lifecycle-test.sh; do
|
||||
src="$REPO_DIR/scripts/$script"
|
||||
if [ -f "$src" ]; then
|
||||
sudo install -m 755 "$src" "$SCRIPTS_DEST/$script"
|
||||
ok "Updated $script"
|
||||
else
|
||||
warn "Missing $src — skipping"
|
||||
fi
|
||||
done
|
||||
# Legacy path for image-versions.sh (older binaries looked here first)
|
||||
if [ -f "$REPO_DIR/scripts/image-versions.sh" ]; then
|
||||
sudo cp "$REPO_DIR/scripts/image-versions.sh" /opt/archipelago/image-versions.sh
|
||||
fi
|
||||
|
||||
# Sync app manifests and app-local build contexts into the canonical
|
||||
# production manifest root. The backend orchestrator loads install specs from
|
||||
# /opt/archipelago/apps; updating only the binary/frontend can leave a node
|
||||
# with new installer logic but stale or missing app manifests.
|
||||
APPS_DEST="/opt/archipelago/apps"
|
||||
if [ -d "$REPO_DIR/apps" ]; then
|
||||
sudo mkdir -p "$APPS_DEST"
|
||||
sudo rsync -a --delete "$REPO_DIR/apps/" "$APPS_DEST/"
|
||||
ok "App manifests synced"
|
||||
else
|
||||
warn "Apps directory not found at $REPO_DIR/apps — install manifests may be stale"
|
||||
fi
|
||||
|
||||
# Update first-boot-containers.sh too (the canonical first-boot orchestrator).
|
||||
# Nodes run it once on install, but keeping a fresh copy on disk means any
|
||||
# future boot or reconciler invocation uses current port specs and caps.
|
||||
if [ -f "$REPO_DIR/scripts/first-boot-containers.sh" ]; then
|
||||
sudo install -m 755 "$REPO_DIR/scripts/first-boot-containers.sh" \
|
||||
"$SCRIPTS_DEST/first-boot-containers.sh"
|
||||
fi
|
||||
|
||||
# Sync UI container source trees (docker/bitcoin-ui, docker/lnd-ui,
|
||||
# docker/electrs-ui) into /opt/archipelago/docker/<name>/. If any file in a
|
||||
# UI tree changed since last update, rebuild that image and recreate its
|
||||
# container using the spec from container-specs.sh. This is what prevented
|
||||
# the lnd-ui port mismatch from reaching nodes through OTA: self-update used
|
||||
# to update only the backend + frontend, never the UI container images.
|
||||
UI_DOCKER_DEST="/opt/archipelago/docker"
|
||||
sudo mkdir -p "$UI_DOCKER_DEST"
|
||||
UI_REBUILD_LIST=""
|
||||
# fips-ui and fedimint-ui are synced but NOT added to UI_REBUILD_LIST below:
|
||||
# container-specs.sh has no spec for either (and their container names break
|
||||
# the archy-<ui> assumption — the FIPS one is plain `fips-ui`). Their rebuilds
|
||||
# come from elsewhere — the daemon's companion installer for fedimint-ui, the
|
||||
# orchestrator's build context for fips-ui — but BOTH read
|
||||
# /opt/archipelago/docker/<ui>, and nothing was ever updating that directory.
|
||||
# So source edits to those two trees reached nodes through no path at all:
|
||||
# their nginx kept listening on 0.0.0.0 and served the Guardian and FIPS
|
||||
# screens unauthenticated on every interface (found by scanning a test node
|
||||
# from outside, 2026-08-05 — the in-node audit could not see them).
|
||||
for ui in bitcoin-ui lnd-ui electrs-ui fips-ui fedimint-ui; do
|
||||
src="$REPO_DIR/docker/$ui"
|
||||
dst="$UI_DOCKER_DEST/$ui"
|
||||
[ -d "$src" ] || continue
|
||||
# Hash source tree to decide if rebuild is needed. Any content change
|
||||
# (Dockerfile, nginx.conf, index.html, assets) triggers a rebuild.
|
||||
# Hash file contents only (not paths or metadata) so src and dst match
|
||||
# when their contents are identical regardless of directory prefix.
|
||||
src_hash=$( (cd "$src" && find . -type f | LC_ALL=C sort | xargs sha256sum 2>/dev/null) | sha256sum | cut -d' ' -f1)
|
||||
dst_hash=""
|
||||
if [ -d "$dst" ]; then
|
||||
dst_hash=$( (cd "$dst" && find . -type f | LC_ALL=C sort | xargs sha256sum 2>/dev/null) | sha256sum | cut -d' ' -f1)
|
||||
fi
|
||||
if [ "$src_hash" != "$dst_hash" ]; then
|
||||
log "UI source changed for $ui; syncing"
|
||||
sudo rsync -a --delete "$src/" "$dst/"
|
||||
case "$ui" in
|
||||
# Rebuilt below from container-specs.sh.
|
||||
bitcoin-ui|lnd-ui|electrs-ui)
|
||||
UI_REBUILD_LIST="$UI_REBUILD_LIST $ui" ;;
|
||||
# Synced only — rebuilt by the daemon (companion installer /
|
||||
# orchestrator build context), which watches this directory.
|
||||
# Adding them to the rebuild list would fail: no spec exists and
|
||||
# the container names are not archy-<ui>.
|
||||
*)
|
||||
log " $ui synced; rebuild is owned by the daemon" ;;
|
||||
esac
|
||||
else
|
||||
ok "UI source unchanged for $ui"
|
||||
fi
|
||||
done
|
||||
|
||||
# Rebuild changed UI images + recreate containers as the archipelago user
|
||||
# (rootless podman storage lives under ~archipelago). Port mappings and caps
|
||||
# come from scripts/container-specs.sh so spec drift can't sneak in.
|
||||
if [ -n "$UI_REBUILD_LIST" ]; then
|
||||
log "Rebuilding UI containers:$UI_REBUILD_LIST"
|
||||
# shellcheck disable=SC1091
|
||||
# container-specs.sh provides load_spec_archy-<ui> and mem_limit <name>.
|
||||
SPECS="$SCRIPTS_DEST/container-specs.sh"
|
||||
if [ ! -f "$SPECS" ]; then
|
||||
warn "container-specs.sh missing at $SPECS; skipping UI rebuild"
|
||||
else
|
||||
for ui in $UI_REBUILD_LIST; do
|
||||
cname="archy-$ui"
|
||||
log " rebuilding $cname from $UI_DOCKER_DEST/$ui"
|
||||
# Build image as archipelago user so it lands in the right store.
|
||||
if ! sudo -u archipelago bash -c "
|
||||
export XDG_RUNTIME_DIR=/run/user/\$(id -u archipelago)
|
||||
cd '$UI_DOCKER_DEST/$ui' &&
|
||||
podman build --no-cache -t 'localhost/$ui:local' . >>'$LOG_FILE' 2>&1
|
||||
"; then
|
||||
err " build failed for $ui; keeping existing container"
|
||||
continue
|
||||
fi
|
||||
# Recreate container using spec from container-specs.sh.
|
||||
if ! sudo -u archipelago bash -c "
|
||||
export XDG_RUNTIME_DIR=/run/user/\$(id -u archipelago)
|
||||
source '$SPECS'
|
||||
load_spec_$cname || { echo 'spec load failed for $cname'; exit 1; }
|
||||
podman stop '$cname' 2>/dev/null || true
|
||||
podman rm '$cname' 2>/dev/null || true
|
||||
PORT_ARG=''
|
||||
[ -n \"\$SPEC_PORTS\" ] && PORT_ARG=\"-p \$SPEC_PORTS\"
|
||||
NET_ARG=''
|
||||
[ \"\$SPEC_NETWORK\" = 'host' ] && NET_ARG='--network host'
|
||||
CAP_ARGS='--cap-drop ALL'
|
||||
for c in \$SPEC_CAPS; do CAP_ARGS=\"\$CAP_ARGS --cap-add \$c\"; done
|
||||
podman run -d --name '$cname' \$PORT_ARG \$NET_ARG \\
|
||||
--user 0:0 \$CAP_ARGS \\
|
||||
--memory=\"\$SPEC_MEMORY\" \\
|
||||
--restart unless-stopped \\
|
||||
--security-opt \"\$SPEC_SECURITY\" \\
|
||||
'localhost/$ui:local' >>'$LOG_FILE' 2>&1
|
||||
"; then
|
||||
err " recreate failed for $cname"
|
||||
continue
|
||||
fi
|
||||
ok " $cname rebuilt and running"
|
||||
done
|
||||
fi
|
||||
fi
|
||||
|
||||
# Update kiosk display helpers used by HDMI/TV installs.
|
||||
if [ -f "$REPO_DIR/image-recipe/configs/archipelago-kiosk-launcher.sh" ]; then
|
||||
sudo install -m 755 "$REPO_DIR/image-recipe/configs/archipelago-kiosk-launcher.sh" \
|
||||
/usr/local/bin/archipelago-kiosk-launcher
|
||||
ok "Updated archipelago-kiosk-launcher"
|
||||
fi
|
||||
|
||||
# Update systemd services if changed
|
||||
SYSTEMD_UNITS_CHANGED=false
|
||||
for unit in archipelago.service archipelago-fips.service archipelago-kiosk.service archipelago-kiosk-watchdog.service; do
|
||||
src="$REPO_DIR/image-recipe/configs/$unit"
|
||||
dst="/etc/systemd/system/$unit"
|
||||
[ -f "$src" ] || continue
|
||||
if [ ! -f "$dst" ] || ! diff -q "$src" "$dst" &>/dev/null; then
|
||||
sudo install -m 644 "$src" "$dst"
|
||||
SYSTEMD_UNITS_CHANGED=true
|
||||
ok "Updated $unit"
|
||||
fi
|
||||
done
|
||||
if [ "$SYSTEMD_UNITS_CHANGED" = "true" ]; then
|
||||
sudo systemctl daemon-reload
|
||||
fi
|
||||
|
||||
# Keep the doctor timer/service current too. Container uptime fixes rely on
|
||||
# these units as much as on the helper scripts themselves.
|
||||
DOCTOR_UNITS_CHANGED=false
|
||||
for unit in archipelago-doctor.service archipelago-doctor.timer; do
|
||||
src="$REPO_DIR/image-recipe/configs/$unit"
|
||||
dst="/etc/systemd/system/$unit"
|
||||
[ -f "$src" ] || continue
|
||||
if [ ! -f "$dst" ] || ! diff -q "$src" "$dst" &>/dev/null; then
|
||||
sudo install -m 644 "$src" "$dst"
|
||||
DOCTOR_UNITS_CHANGED=true
|
||||
ok "Updated $unit"
|
||||
fi
|
||||
done
|
||||
if [ "$DOCTOR_UNITS_CHANGED" = "true" ]; then
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now archipelago-doctor.timer 2>>"$LOG_FILE" || \
|
||||
warn "Failed to enable archipelago-doctor.timer"
|
||||
fi
|
||||
|
||||
# Install/refresh tmpfiles.d rules. The logs rule creates
|
||||
# /var/log/archipelago/ + container-installs.log with archipelago:archipelago
|
||||
# ownership so the non-root backend can append install audit lines.
|
||||
# Apply immediately so existing nodes don't need a reboot.
|
||||
if [ -f "$REPO_DIR/image-recipe/configs/archipelago-tmpfiles.conf" ]; then
|
||||
sudo install -m 644 "$REPO_DIR/image-recipe/configs/archipelago-tmpfiles.conf" \
|
||||
/usr/lib/tmpfiles.d/archipelago-logs.conf
|
||||
sudo systemd-tmpfiles --create /usr/lib/tmpfiles.d/archipelago-logs.conf 2>/dev/null || true
|
||||
ok "Log tmpfiles rule installed"
|
||||
fi
|
||||
|
||||
# Restart service
|
||||
log "Restarting archipelago service..."
|
||||
sudo systemctl restart archipelago
|
||||
|
||||
# Wait for health
|
||||
log "Waiting for backend health..."
|
||||
for i in $(seq 1 30); do
|
||||
if curl -sf http://127.0.0.1:5678/health > /dev/null 2>&1; then
|
||||
ok "Backend healthy after ${i}s"
|
||||
break
|
||||
fi
|
||||
if [ "$i" = "30" ]; then
|
||||
err "Backend failed to start within 30s"
|
||||
warn "Rolling back binary..."
|
||||
if [ -f "$BACKUP_DIR/archipelago.bak" ]; then
|
||||
sudo cp "$BACKUP_DIR/archipelago.bak" "$INSTALL_BIN"
|
||||
sudo systemctl restart archipelago
|
||||
err "Rolled back to previous binary"
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
# Update state file for the UI
|
||||
python3 -c "
|
||||
import json, datetime
|
||||
state = {
|
||||
'current_version': '$NEW_VERSION',
|
||||
'last_check': datetime.datetime.utcnow().isoformat() + 'Z',
|
||||
'available_update': None,
|
||||
'update_in_progress': False,
|
||||
'rollback_available': True,
|
||||
'schedule': 'daily_check'
|
||||
}
|
||||
with open('$STATE_FILE', 'w') as f:
|
||||
json.dump(state, f, indent=2)
|
||||
" 2>/dev/null || true
|
||||
|
||||
echo ""
|
||||
ok "Update complete: $LOCAL -> $NEW_VERSION"
|
||||
log "Changelog:"
|
||||
git log "$LOCAL".."$NEW_VERSION" --oneline --no-merges | head -10 | tee -a "$LOG_FILE"
|
||||
Executable
+223
@@ -0,0 +1,223 @@
|
||||
#!/usr/bin/env bash
|
||||
# Per-node certificate authority.
|
||||
#
|
||||
# WHY THIS EXISTS
|
||||
#
|
||||
# The node used to serve a bare self-signed leaf (setup-https-dev.sh). A browser
|
||||
# can be told to trust that, but the exception is granted per ORIGIN — scheme +
|
||||
# host + PORT. The dashboard on :443 and an app on :8334 are different origins,
|
||||
# so each app port needed its own click-through, and a cert interstitial CANNOT
|
||||
# be accepted inside an iframe: the embedded app just fails.
|
||||
#
|
||||
# A CA fixes that structurally. The user installs ONE certificate; every leaf it
|
||||
# signs is then trusted, on every port, with no further prompts. Ports are not
|
||||
# part of a certificate's identity — one leaf with the right SANs covers every
|
||||
# port on the host — so this is what makes gated apps embeddable over HTTPS.
|
||||
#
|
||||
# The CA private key never leaves the node and signs nothing but this node's own
|
||||
# leaf. Installing it means trusting THIS node, not a third party.
|
||||
#
|
||||
# Idempotent: re-running reuses an existing CA and only reissues the leaf (which
|
||||
# is what you want when the node gains an address). Pass --force-ca to start over
|
||||
# — that invalidates every copy users have already installed.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SSL_DIR="${ARCHY_SSL_DIR:-/etc/archipelago/ssl}"
|
||||
CA_CRT="$SSL_DIR/ca.crt"
|
||||
CA_KEY="$SSL_DIR/ca.key"
|
||||
CA_SRL="$SSL_DIR/ca.srl"
|
||||
LEAF_CRT="$SSL_DIR/archipelago.crt"
|
||||
LEAF_KEY="$SSL_DIR/archipelago.key"
|
||||
|
||||
CA_DAYS="${ARCHY_CA_DAYS:-3650}"
|
||||
# Public CAs cap leaves at 398 days and browsers enforce it. That limit applies
|
||||
# to publicly-trusted roots, not a privately-installed one, but a shorter leaf
|
||||
# still bounds the damage from a key leak — and reissuing costs nothing here
|
||||
# because this script is re-run on address changes anyway.
|
||||
LEAF_DAYS="${ARCHY_LEAF_DAYS:-397}"
|
||||
|
||||
FORCE_CA=false
|
||||
[ "${1:-}" = "--force-ca" ] && FORCE_CA=true
|
||||
|
||||
NODE_NAME="$(hostname -s 2>/dev/null || echo archipelago)"
|
||||
|
||||
log() { echo " $*"; }
|
||||
|
||||
mkdir -p "$SSL_DIR"
|
||||
chmod 755 "$SSL_DIR"
|
||||
|
||||
# --- Subject alternative names -----------------------------------------------
|
||||
# Every name/address the node can be reached by must be in the leaf, because a
|
||||
# certificate is scoped to names, not ports. Missing one here means that access
|
||||
# path still throws a warning even after the CA is installed.
|
||||
collect_sans() {
|
||||
local -a dns=() ips=()
|
||||
|
||||
dns+=("archipelago.local" "$NODE_NAME" "$NODE_NAME.local" "localhost")
|
||||
|
||||
# Tailscale gives a stable MagicDNS name; include it so tailnet access is clean.
|
||||
if command -v tailscale >/dev/null 2>&1; then
|
||||
local ts_name
|
||||
ts_name="$(tailscale status --json 2>/dev/null \
|
||||
| python3 -c 'import json,sys; d=json.load(sys.stdin); print((d.get("Self") or {}).get("DNSName","").rstrip("."))' 2>/dev/null || true)"
|
||||
[ -n "$ts_name" ] && dns+=("$ts_name")
|
||||
fi
|
||||
|
||||
# Every non-loopback address the host currently holds, plus loopback itself.
|
||||
ips+=("127.0.0.1" "::1")
|
||||
while read -r addr; do
|
||||
[ -n "$addr" ] && ips+=("$addr")
|
||||
done < <(ip -o addr show scope global 2>/dev/null | awk '{print $4}' | cut -d/ -f1 | sort -u)
|
||||
|
||||
local out="" i=1 j=1
|
||||
for d in $(printf '%s\n' "${dns[@]}" | awk 'NF' | sort -u); do
|
||||
out="${out}DNS.$i:$d,"; i=$((i+1))
|
||||
done
|
||||
for a in $(printf '%s\n' "${ips[@]}" | awk 'NF' | sort -u); do
|
||||
out="${out}IP.$j:$a,"; j=$((j+1))
|
||||
done
|
||||
echo "${out%,}"
|
||||
}
|
||||
|
||||
SAN="$(collect_sans)"
|
||||
[ -z "$SAN" ] && { echo "ERROR: no SANs resolved — refusing to issue a useless cert" >&2; exit 1; }
|
||||
|
||||
# --- CA ----------------------------------------------------------------------
|
||||
if [ "$FORCE_CA" = true ] && [ -f "$CA_CRT" ]; then
|
||||
log "--force-ca: replacing the existing CA (previously installed copies stop working)"
|
||||
rm -f "$CA_CRT" "$CA_KEY" "$CA_SRL"
|
||||
fi
|
||||
|
||||
if [ -f "$CA_CRT" ] && [ -f "$CA_KEY" ]; then
|
||||
log "Reusing the existing node CA (installed copies keep working)"
|
||||
else
|
||||
log "Creating this node's certificate authority…"
|
||||
openssl req -x509 -nodes -newkey rsa:4096 -sha256 -days "$CA_DAYS" \
|
||||
-keyout "$CA_KEY" -out "$CA_CRT" \
|
||||
-subj "/CN=Archipelago Node CA ($NODE_NAME)/O=Archipelago/OU=Node CA" \
|
||||
-addext "basicConstraints=critical,CA:TRUE,pathlen:0" \
|
||||
-addext "keyUsage=critical,keyCertSign,cRLSign" 2>/dev/null
|
||||
chmod 600 "$CA_KEY"
|
||||
chmod 644 "$CA_CRT"
|
||||
fi
|
||||
|
||||
# --- Leaf --------------------------------------------------------------------
|
||||
log "Issuing the server certificate for: $SAN"
|
||||
TMP="$(mktemp -d)"
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
|
||||
openssl req -nodes -newkey rsa:2048 -sha256 \
|
||||
-keyout "$TMP/leaf.key" -out "$TMP/leaf.csr" \
|
||||
-subj "/CN=$NODE_NAME/O=Archipelago" 2>/dev/null
|
||||
|
||||
cat >"$TMP/leaf.ext" <<EOF
|
||||
basicConstraints=CA:FALSE
|
||||
keyUsage=critical,digitalSignature,keyEncipherment
|
||||
extendedKeyUsage=serverAuth
|
||||
subjectAltName=$SAN
|
||||
EOF
|
||||
|
||||
openssl x509 -req -in "$TMP/leaf.csr" -CA "$CA_CRT" -CAkey "$CA_KEY" \
|
||||
-CAcreateserial -CAserial "$CA_SRL" \
|
||||
-out "$TMP/leaf.crt" -days "$LEAF_DAYS" -sha256 -extfile "$TMP/leaf.ext" 2>/dev/null
|
||||
|
||||
# Swap in place only once both halves exist, so a failure mid-run cannot leave
|
||||
# nginx pointing at a cert whose key is gone.
|
||||
install -m 644 "$TMP/leaf.crt" "$LEAF_CRT"
|
||||
install -m 600 "$TMP/leaf.key" "$LEAF_KEY"
|
||||
|
||||
# The leaf key has TWO readers with different privileges: nginx's master
|
||||
# process (root) and the archipelago daemon (User=archipelago), which needs it
|
||||
# to terminate TLS on gated app ports. Root-only 0600 silently costs the daemon
|
||||
# its TLS — it logs "Permission denied" and every app port quietly stays plain
|
||||
# HTTP, which is exactly the fail-open shape the gate is built to avoid. So the
|
||||
# key is group-readable by the service user and nothing wider.
|
||||
SERVICE_USER="${ARCHY_SERVICE_USER:-archipelago}"
|
||||
if getent group "$SERVICE_USER" >/dev/null 2>&1; then
|
||||
chgrp "$SERVICE_USER" "$LEAF_KEY" && chmod 640 "$LEAF_KEY"
|
||||
log "Key readable by group $SERVICE_USER (0640) — the daemon needs it for app-port TLS"
|
||||
elif getent passwd "$SERVICE_USER" >/dev/null 2>&1; then
|
||||
# User exists without an eponymous group — fall back to its primary group.
|
||||
PRIMARY="$(id -gn "$SERVICE_USER" 2>/dev/null || true)"
|
||||
if [ -n "$PRIMARY" ]; then
|
||||
chgrp "$PRIMARY" "$LEAF_KEY" && chmod 640 "$LEAF_KEY"
|
||||
log "Key readable by group $PRIMARY (0640)"
|
||||
fi
|
||||
else
|
||||
log "No '$SERVICE_USER' user on this host — key left root-only (0600)"
|
||||
fi
|
||||
|
||||
# The dashboard serves this for download; it is a public certificate, never the key.
|
||||
install -m 644 "$CA_CRT" "$SSL_DIR/ca-download.crt"
|
||||
|
||||
FP="$(openssl x509 -in "$CA_CRT" -noout -fingerprint -sha256 | cut -d= -f2)"
|
||||
log "CA fingerprint (SHA-256): $FP"
|
||||
|
||||
# --- nginx HTTPS listener -----------------------------------------------------
|
||||
# The CA is only useful if something actually serves TLS. Bind the dashboard's
|
||||
# HTTPS on this host's LAN addresses ONLY: tailscaled already owns :443 on the
|
||||
# tailnet addresses (with its own Let's Encrypt cert), so a plain
|
||||
# `listen 443 default_server` binds 0.0.0.0 and fails with EADDRINUSE — nginx
|
||||
# then keeps running the OLD config and the reload looks like it worked.
|
||||
# Observed exactly that on archi-dev-box.
|
||||
#
|
||||
# Port 80 keeps serving: nodes are reached by IP on LANs where forcing a
|
||||
# redirect would strand anyone who has not installed the CA yet.
|
||||
ensure_nginx_https() {
|
||||
local site="${ARCHY_NGINX_SITE:-/etc/nginx/sites-enabled/archipelago}"
|
||||
[ -f "$site" ] || { log "No nginx site at $site — skipping HTTPS listener"; return; }
|
||||
|
||||
local addrs
|
||||
addrs="$(ip -o -4 addr show scope global 2>/dev/null | awk '{print $4}' | cut -d/ -f1 \
|
||||
| grep -vE '^100\.(6[4-9]|[7-9][0-9]|1[01][0-9]|12[0-7])\.' | sort -u)"
|
||||
[ -z "$addrs" ] && { log "No LAN address — skipping HTTPS listener"; return; }
|
||||
|
||||
if grep -q 'listen .*:443 ssl' "$site"; then
|
||||
log "nginx HTTPS listener already present"
|
||||
return
|
||||
fi
|
||||
log "Adding nginx HTTPS listener on: $(echo "$addrs" | tr '\n' ' ')"
|
||||
python3 - "$site" "$addrs" <<'PYEOF'
|
||||
import sys, re, pathlib
|
||||
site, addrs = pathlib.Path(sys.argv[1]), sys.argv[2].split()
|
||||
s = site.read_text(); lines = s.split('\n')
|
||||
start = next(i for i,l in enumerate(lines) if l.strip() == 'server {')
|
||||
depth = 0; end = None
|
||||
for i in range(start, len(lines)):
|
||||
depth += lines[i].count('{') - lines[i].count('}')
|
||||
if depth == 0 and i > start:
|
||||
end = i; break
|
||||
block = lines[start:end+1]
|
||||
https = []
|
||||
for l in block:
|
||||
if re.match(r'\s*listen 80 default_server;', l):
|
||||
https += [f' listen {a}:443 ssl;' for a in addrs]
|
||||
https += [' ssl_certificate /etc/archipelago/ssl/archipelago.crt;',
|
||||
' ssl_certificate_key /etc/archipelago/ssl/archipelago.key;',
|
||||
' ssl_protocols TLSv1.2 TLSv1.3;']
|
||||
continue
|
||||
if re.match(r'\s*listen \[::\]:80 default_server;', l):
|
||||
continue
|
||||
https.append(l)
|
||||
lines = lines[:end+1] + [''] + https + lines[end+1:]
|
||||
site.write_text('\n'.join(lines))
|
||||
PYEOF
|
||||
}
|
||||
|
||||
ensure_nginx_https
|
||||
|
||||
if command -v systemctl >/dev/null 2>&1 && systemctl is-active --quiet nginx; then
|
||||
if nginx -t >/dev/null 2>&1; then
|
||||
systemctl reload nginx && log "nginx reloaded"
|
||||
else
|
||||
echo "WARNING: nginx config test failed — NOT reloading. Certs are in place; fix nginx and reload." >&2
|
||||
fi
|
||||
fi
|
||||
|
||||
cat <<EOF
|
||||
|
||||
Done. Install $CA_CRT on each device that should reach this node without warnings.
|
||||
The dashboard serves it at /ca.crt (Settings → Node certificate).
|
||||
Verify the fingerprint above matches what the dashboard shows before trusting it.
|
||||
EOF
|
||||
Executable
+55
@@ -0,0 +1,55 @@
|
||||
#!/usr/bin/env bash
|
||||
# One-step release-catalog signer.
|
||||
#
|
||||
# Run: bash scripts/sign-catalog.sh
|
||||
# Then: paste your 24-word release master mnemonic, press Enter, then Ctrl-D.
|
||||
#
|
||||
# It signs releases/app-catalog.json in place and checks the signature was made
|
||||
# by the expected release-root key. Your mnemonic is read from the terminal only
|
||||
# (never stored, never in shell history, never passed to Claude).
|
||||
set -euo pipefail
|
||||
|
||||
REPO="/home/archipelago/Projects/archy"
|
||||
CATALOG="$REPO/releases/app-catalog.json"
|
||||
EXPECTED_DID="did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT"
|
||||
|
||||
# Use ONLY the prebuilt signer. If it isn't ready, stop cleanly — never compile
|
||||
# here (compiling caused the earlier hangs). Claude builds it in the background.
|
||||
BIN="/tmp/archy-sign-bin/release/archipelago"
|
||||
if [[ ! -x "$BIN" ]]; then
|
||||
echo "⏳ The signer isn't ready yet — Claude is still building it."
|
||||
echo " Wait until Claude says 'READY', then run this again. Nothing was changed."
|
||||
exit 0
|
||||
fi
|
||||
SIGN=("$BIN" ceremony sign "$CATALOG")
|
||||
|
||||
# Preflight BEFORE asking for the mnemonic. Signing is the point of no return:
|
||||
# a signed catalog is authoritative for every node, and its image refs override
|
||||
# the on-disk manifests. If it names a registry host the deployed fleet does not
|
||||
# trust, every install fails "not from a trusted registry" — so catch that here
|
||||
# rather than after publication.
|
||||
if ! python3 "$REPO/scripts/check-catalog-registry-trust.py" --repo "$REPO"; then
|
||||
echo
|
||||
echo "✋ Refusing to sign. Nothing was changed and your mnemonic was not requested."
|
||||
exit 1
|
||||
fi
|
||||
echo
|
||||
|
||||
echo "════════════════════════════════════════════════════════════════"
|
||||
echo " Paste your 24-word release master mnemonic below, press Enter,"
|
||||
echo " then press Ctrl-D on a new line."
|
||||
echo "════════════════════════════════════════════════════════════════"
|
||||
"${SIGN[@]}"
|
||||
|
||||
# Verify the signature is present and made by the expected key.
|
||||
echo
|
||||
if grep -q "\"signed_by\": \"$EXPECTED_DID\"" "$CATALOG" \
|
||||
&& grep -q '"signature":' "$CATALOG"; then
|
||||
echo "✅ SUCCESS — catalog signed by the correct release-root key."
|
||||
echo " Tell Claude \"signed\" and it will commit + push for you."
|
||||
else
|
||||
echo "❌ Something is off — the catalog is NOT signed by the expected key."
|
||||
echo " Expected signer: $EXPECTED_DID"
|
||||
echo " Do NOT commit. Check the mnemonic and re-run, or ask Claude."
|
||||
exit 1
|
||||
fi
|
||||
Executable
+65
@@ -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
|
||||
Executable
+51
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env bash
|
||||
# One-step OTA-manifest signer (counterpart to sign-catalog.sh).
|
||||
#
|
||||
# Run: bash scripts/sign-manifest.sh
|
||||
# Then: paste your 24-word release master mnemonic, press Enter, then Ctrl-D.
|
||||
#
|
||||
# Signs releases/manifest.json in place and cryptographically verifies the
|
||||
# result against the pinned release-root anchor. The mnemonic is read from the
|
||||
# terminal only (never stored, never in shell history, never passed to Claude).
|
||||
#
|
||||
# Normally create-release.sh signs the manifest inline; this script exists for
|
||||
# re-signing (e.g. a manifest edited after creation) or signing on a box where
|
||||
# the release run was non-interactive.
|
||||
#
|
||||
# The release root was rotated 2026-08-05. From v1.7.123 this signs with the
|
||||
# NEW mnemonic and the signer's own anchor already pins that key, so no
|
||||
# ARCHY_RELEASE_ROOT_PUBKEY override is needed (it was, for .122 only).
|
||||
set -euo pipefail
|
||||
|
||||
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
MANIFEST="$REPO/releases/manifest.json"
|
||||
|
||||
# Use ONLY a prebuilt signer — never compile here (compiling caused hangs in
|
||||
# the earlier catalog ceremony). Prefer the repo's release build.
|
||||
BIN=""
|
||||
for candidate in "$REPO/core/target/release/archipelago" /tmp/archy-sign-bin/release/archipelago; do
|
||||
if [[ -x "$candidate" ]]; then BIN="$candidate"; break; fi
|
||||
done
|
||||
if [[ -z "$BIN" ]]; then
|
||||
echo "⏳ No prebuilt signer found. Build one first:"
|
||||
echo " (cd core && cargo build --release -p archipelago)"
|
||||
echo " Nothing was changed."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "════════════════════════════════════════════════════════════════"
|
||||
echo " Paste your 24-word release master mnemonic below, press Enter,"
|
||||
echo " then press Ctrl-D on a new line."
|
||||
echo "════════════════════════════════════════════════════════════════"
|
||||
"$BIN" ceremony sign "$MANIFEST"
|
||||
|
||||
echo
|
||||
if "$BIN" ceremony verify "$MANIFEST"; then
|
||||
echo "✅ SUCCESS — manifest signed by the pinned release root."
|
||||
echo " Commit + push releases/manifest.json (and release-manifest.json if present)."
|
||||
cp "$MANIFEST" "$REPO/release-manifest.json" 2>/dev/null || true
|
||||
else
|
||||
echo "❌ Signature did NOT verify against the pinned release-root anchor."
|
||||
echo " Do NOT commit. Check the mnemonic and re-run."
|
||||
exit 1
|
||||
fi
|
||||
Executable
+90
@@ -0,0 +1,90 @@
|
||||
#!/bin/bash
|
||||
# Smoke test for Archipelago — verifies critical endpoints
|
||||
# Usage: ./scripts/smoke-test.sh [host]
|
||||
# Exit 0 if all pass, exit 1 on any failure.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
HOST="${1:-${ARCHY_HOST:-}}"
|
||||
if [ -z "$HOST" ]; then
|
||||
echo "usage: $0 <node-host> (or set ARCHY_HOST)" >&2
|
||||
exit 2
|
||||
fi
|
||||
PASS=0
|
||||
FAIL=0
|
||||
FAILURES=""
|
||||
|
||||
check() {
|
||||
local name="$1" cmd="$2"
|
||||
if eval "$cmd" >/dev/null 2>&1; then
|
||||
echo " ✓ $name"
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
echo " ✗ $name"
|
||||
FAIL=$((FAIL + 1))
|
||||
FAILURES="$FAILURES\n - $name"
|
||||
fi
|
||||
}
|
||||
|
||||
echo "=== Archipelago Smoke Test ==="
|
||||
echo "Target: $HOST"
|
||||
echo ""
|
||||
|
||||
# 1. Health endpoint
|
||||
check "GET /health returns OK" \
|
||||
"curl -sf http://${HOST}/health | grep -q '\"status\"'"
|
||||
|
||||
# 2. Login via RPC
|
||||
SESSION=$(curl -sf -X POST "http://${HOST}/rpc/v1" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"method":"auth.login","params":{"password":"'"${TEST_PASSWORD:-password123}"'"}}' \
|
||||
-c - 2>/dev/null | grep session | awk '{print $NF}' || echo "")
|
||||
|
||||
if [ -n "$SESSION" ]; then
|
||||
echo " ✓ Login via RPC"
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
echo " ✗ Login via RPC"
|
||||
FAIL=$((FAIL + 1))
|
||||
FAILURES="$FAILURES\n - Login via RPC"
|
||||
fi
|
||||
|
||||
# 3. Authenticated RPC call
|
||||
check "server.get-info returns JSON" \
|
||||
"curl -sf -X POST http://${HOST}/rpc/v1 \
|
||||
-H 'Content-Type: application/json' \
|
||||
-b 'session=${SESSION}' \
|
||||
-d '{\"method\":\"server.get-info\"}' | grep -q '\"result\"'"
|
||||
|
||||
# 4. Container list
|
||||
check "container.list returns JSON" \
|
||||
"curl -sf -X POST http://${HOST}/rpc/v1 \
|
||||
-H 'Content-Type: application/json' \
|
||||
-b 'session=${SESSION}' \
|
||||
-d '{\"method\":\"container.list\"}' | grep -q '\"result\"'"
|
||||
|
||||
# 5. WebSocket upgrade
|
||||
check "WebSocket upgrade (101)" \
|
||||
"curl -sf -o /dev/null -w '%{http_code}' \
|
||||
-H 'Upgrade: websocket' -H 'Connection: Upgrade' \
|
||||
-H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==' \
|
||||
-H 'Sec-WebSocket-Version: 13' \
|
||||
http://${HOST}/ws/db | grep -q '101'"
|
||||
|
||||
# 6. Static assets served
|
||||
check "Frontend index.html served" \
|
||||
"curl -sf http://${HOST}/ | grep -q '<div id=\"app\"'"
|
||||
|
||||
# 7. Onboarding check (unauthenticated)
|
||||
check "auth.isOnboardingComplete RPC" \
|
||||
"curl -sf -X POST http://${HOST}/rpc/v1 \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{\"method\":\"auth.isOnboardingComplete\"}' | grep -q '\"result\"'"
|
||||
|
||||
echo ""
|
||||
echo "=== Results: $PASS passed, $FAIL failed ==="
|
||||
if [ $FAIL -gt 0 ]; then
|
||||
echo -e "Failures:$FAILURES"
|
||||
exit 1
|
||||
fi
|
||||
exit 0
|
||||
@@ -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
|
||||
Executable
+116
@@ -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())
|
||||
Executable
+200
@@ -0,0 +1,200 @@
|
||||
#!/bin/bash
|
||||
# tor-helper.sh — Privileged Tor operations for the Archipelago backend.
|
||||
# Runs as root via systemd (archipelago-tor-helper.service), triggered by
|
||||
# a path unit watching /var/lib/archipelago/tor-config/tor-action.
|
||||
#
|
||||
# The backend writes a JSON action file, the path unit triggers this script.
|
||||
# This avoids calling sudo from within a NoNewPrivileges=yes service.
|
||||
set -euo pipefail
|
||||
|
||||
ACTION_FILE="/var/lib/archipelago/tor-config/tor-action"
|
||||
TORRC_STAGED="/var/lib/archipelago/tor-config/torrc.staged"
|
||||
RESULT_FILE="/var/lib/archipelago/tor-config/tor-result"
|
||||
HOSTNAMES_DIR="/var/lib/archipelago/tor-hostnames"
|
||||
|
||||
log() { echo "[tor-helper] $*"; }
|
||||
|
||||
write_result() {
|
||||
echo "$1" > "$RESULT_FILE"
|
||||
chown archipelago:archipelago "$RESULT_FILE" 2>/dev/null || true
|
||||
}
|
||||
|
||||
sync_hostnames() {
|
||||
mkdir -p "$HOSTNAMES_DIR"
|
||||
# Clear stale copies first
|
||||
rm -f "$HOSTNAMES_DIR"/* 2>/dev/null || true
|
||||
# Prefer /var/lib/tor (system Tor, authoritative) over /var/lib/archipelago/tor
|
||||
# Only copy from secondary if not already found in primary
|
||||
for base in /var/lib/tor /var/lib/archipelago/tor; do
|
||||
for dir in "$base"/hidden_service_*; do
|
||||
[ -d "$dir" ] || continue
|
||||
svc=$(basename "$dir" | sed 's/^hidden_service_//')
|
||||
echo "$svc" | grep -q '_old_' && continue
|
||||
# Skip if already synced from a higher-priority location
|
||||
[ -f "${HOSTNAMES_DIR}/${svc}" ] && continue
|
||||
if [ -f "$dir/hostname" ]; then
|
||||
cp "$dir/hostname" "${HOSTNAMES_DIR}/${svc}"
|
||||
log "Synced hostname: $svc ($base)"
|
||||
fi
|
||||
done
|
||||
done
|
||||
chown -R archipelago:archipelago "$HOSTNAMES_DIR" 2>/dev/null || true
|
||||
}
|
||||
|
||||
# ─── Main ─────────────────────────────────────────────────────────
|
||||
|
||||
if [ ! -f "$ACTION_FILE" ]; then
|
||||
log "No action file found"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
ACTION=$(cat "$ACTION_FILE")
|
||||
rm -f "$ACTION_FILE"
|
||||
|
||||
ACTION_TYPE=$(echo "$ACTION" | python3 -c "import sys,json; print(json.load(sys.stdin).get('action',''))" 2>/dev/null || echo "")
|
||||
|
||||
# Restart the daemon that actually serves, and report whether it really came up.
|
||||
#
|
||||
# Why the dashboard's "Restart Tor" button did nothing on 2026-08-09, in order
|
||||
# of importance — note it was NOT wrong-unit targeting: `systemctl restart tor`
|
||||
# does propagate to tor@default (measured on austin-sapien, MainPID changed).
|
||||
#
|
||||
# 1. RESET-FAILED WAS MISSING. Once tor@default has failed enough times
|
||||
# systemd latches "Start request repeated too quickly" and refuses to start
|
||||
# it at all; a plain restart is then a no-op no matter which unit you name.
|
||||
# All three broken nodes were in exactly that state.
|
||||
# 2. THE RESULT WAS ALWAYS {"ok":true}. This branch waited up to 30s for SOCKS
|
||||
# and then ignored the outcome; the `restart` branch slept 3s and claimed
|
||||
# success. So the UI reported "restarted" over a dead daemon.
|
||||
# 3. The underlying torrc was unbindable, so every restart failed anyway —
|
||||
# fixed separately in the torrc generator.
|
||||
#
|
||||
# tor@default is targeted explicitly because it is the unit that carries the
|
||||
# state worth resetting; single-instance installs have only `tor`, hence the
|
||||
# detection and fallback.
|
||||
#
|
||||
# Returns 0 only when SOCKS answers. Callers must not report success without it.
|
||||
restart_tor_daemon() {
|
||||
local unit=tor
|
||||
if systemctl list-unit-files 'tor@*.service' 2>/dev/null | grep -q 'tor@'; then
|
||||
unit=tor@default
|
||||
fi
|
||||
systemctl reset-failed "$unit" 2>/dev/null || true
|
||||
if ! systemctl restart "$unit" 2>/dev/null; then
|
||||
if [ "$unit" != tor ]; then
|
||||
log "restart of $unit failed — falling back to tor.service"
|
||||
systemctl reset-failed tor 2>/dev/null || true
|
||||
systemctl restart tor 2>/dev/null || true
|
||||
fi
|
||||
fi
|
||||
log "Restarted $unit"
|
||||
|
||||
local i
|
||||
for i in $(seq 1 30); do
|
||||
if timeout 1 bash -c 'echo > /dev/tcp/127.0.0.1/9050' 2>/dev/null; then
|
||||
log "Tor SOCKS answering after ${i}s"
|
||||
return 0
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
log "ERROR: Tor SOCKS not answering 30s after restarting $unit"
|
||||
return 1
|
||||
}
|
||||
|
||||
case "$ACTION_TYPE" in
|
||||
write-torrc-and-restart)
|
||||
if [ ! -f "$TORRC_STAGED" ]; then
|
||||
log "ERROR: No staged torrc at $TORRC_STAGED"
|
||||
write_result '{"ok":false,"error":"No staged torrc"}'
|
||||
exit 1
|
||||
fi
|
||||
cp "$TORRC_STAGED" /etc/tor/torrc
|
||||
chown debian-tor:debian-tor /etc/tor/torrc 2>/dev/null || true
|
||||
log "torrc updated from staged file"
|
||||
|
||||
if restart_tor_daemon; then
|
||||
sync_hostnames
|
||||
write_result '{"ok":true}'
|
||||
else
|
||||
# Never claim success on a dead daemon: the caller surfaces this straight
|
||||
# to the operator, and a false "restarted" is how an outage stays hidden.
|
||||
sync_hostnames
|
||||
write_result '{"ok":false,"error":"Tor restarted but SOCKS never came up on 127.0.0.1:9050 — check journalctl -u tor@default"}'
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
|
||||
restart)
|
||||
if restart_tor_daemon; then
|
||||
sync_hostnames
|
||||
write_result '{"ok":true}'
|
||||
else
|
||||
sync_hostnames
|
||||
write_result '{"ok":false,"error":"Tor restarted but SOCKS never came up on 127.0.0.1:9050 — check journalctl -u tor@default"}'
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
|
||||
delete-service)
|
||||
NAME=$(echo "$ACTION" | python3 -c "import sys,json; print(json.load(sys.stdin).get('name',''))" 2>/dev/null || echo "")
|
||||
if [ -z "$NAME" ]; then
|
||||
write_result '{"ok":false,"error":"Missing service name"}'
|
||||
exit 1
|
||||
fi
|
||||
if ! echo "$NAME" | grep -qE '^[a-zA-Z0-9_-]+$'; then
|
||||
write_result '{"ok":false,"error":"Invalid service name"}'
|
||||
exit 1
|
||||
fi
|
||||
rm -rf "/var/lib/tor/hidden_service_${NAME}" 2>/dev/null || true
|
||||
rm -rf "/var/lib/archipelago/tor/hidden_service_${NAME}" 2>/dev/null || true
|
||||
rm -f "${HOSTNAMES_DIR}/${NAME}" 2>/dev/null || true
|
||||
log "Deleted hidden service: $NAME"
|
||||
write_result '{"ok":true}'
|
||||
;;
|
||||
|
||||
rename-service)
|
||||
NAME=$(echo "$ACTION" | python3 -c "import sys,json; print(json.load(sys.stdin).get('name',''))" 2>/dev/null || echo "")
|
||||
TIMESTAMP=$(echo "$ACTION" | python3 -c "import sys,json; print(json.load(sys.stdin).get('timestamp',''))" 2>/dev/null || echo "")
|
||||
if [ -z "$NAME" ] || [ -z "$TIMESTAMP" ]; then
|
||||
write_result '{"ok":false,"error":"Missing service name or timestamp"}'
|
||||
exit 1
|
||||
fi
|
||||
if ! echo "$NAME" | grep -qE '^[a-zA-Z0-9_-]+$'; then
|
||||
write_result '{"ok":false,"error":"Invalid service name"}'
|
||||
exit 1
|
||||
fi
|
||||
if ! echo "$TIMESTAMP" | grep -qE '^[0-9]+$'; then
|
||||
write_result '{"ok":false,"error":"Invalid timestamp"}'
|
||||
exit 1
|
||||
fi
|
||||
OLD_SUFFIX="${NAME}_old_${TIMESTAMP}"
|
||||
for base in /var/lib/tor /var/lib/archipelago/tor; do
|
||||
SRC="${base}/hidden_service_${NAME}"
|
||||
DST="${base}/hidden_service_${OLD_SUFFIX}"
|
||||
if [ -d "$SRC" ]; then
|
||||
mv "$SRC" "$DST"
|
||||
log "Renamed $SRC -> $DST"
|
||||
fi
|
||||
done
|
||||
rm -f "${HOSTNAMES_DIR}/${NAME}" 2>/dev/null || true
|
||||
write_result '{"ok":true}'
|
||||
;;
|
||||
|
||||
sync-hostnames)
|
||||
sync_hostnames
|
||||
write_result '{"ok":true}'
|
||||
;;
|
||||
|
||||
reboot)
|
||||
write_result '{"ok":true}'
|
||||
log "System reboot initiated"
|
||||
sleep 1
|
||||
systemctl reboot
|
||||
;;
|
||||
|
||||
*)
|
||||
log "Unknown action: $ACTION_TYPE"
|
||||
write_result '{"ok":false,"error":"Unknown action"}'
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
@@ -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.
|
||||
@@ -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
|
||||
Executable
+77
@@ -0,0 +1,77 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Trust the Archipelago server's self-signed certificate on macOS.
|
||||
# Run this to eliminate "Not secure" when accessing https://<node-host>
|
||||
#
|
||||
# Usage: ./scripts/trust-archipelago-cert.sh [host]
|
||||
# Host is required: pass it as $1 or set ARCHY_HOST
|
||||
#
|
||||
# Requires: SSH access to archipelago@host (uses deploy-config.sh password)
|
||||
#
|
||||
|
||||
set -e
|
||||
|
||||
HOST="${1:-${ARCHY_HOST:-}}"
|
||||
if [ -z "$HOST" ]; then
|
||||
echo "usage: $0 <node-host> (or set ARCHY_HOST)" >&2
|
||||
exit 2
|
||||
fi
|
||||
CERT_FILE="/tmp/archipelago-${HOST}.crt"
|
||||
KEYCHAIN="${HOME}/Library/Keychains/login.keychain-db"
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
|
||||
|
||||
# Try to fetch cert from server via SSH (most reliable)
|
||||
SSH_KEY="${ARCHIPELAGO_SSH_KEY:-$HOME/.ssh/archipelago-deploy}"
|
||||
echo "Fetching certificate from server..."
|
||||
if [ -f "$SSH_KEY" ]; then
|
||||
ssh -o StrictHostKeyChecking=no -i "$SSH_KEY" archipelago@${HOST} \
|
||||
'sudo -n cat /etc/archipelago/ssl/archipelago.crt' > "$CERT_FILE" 2>/dev/null || true
|
||||
elif [ -f "$SCRIPT_DIR/deploy-config.sh" ]; then
|
||||
# Last-resort fallback: password auth (leaks credentials to process list)
|
||||
. "$SCRIPT_DIR/deploy-config.sh"
|
||||
echo "WARNING: SSH key not found at $SSH_KEY — falling back to password auth"
|
||||
if command -v sshpass >/dev/null 2>&1; then
|
||||
sshpass -p "$ARCHIPELAGO_PASSWORD" ssh -o StrictHostKeyChecking=no archipelago@${HOST} \
|
||||
'sudo -n cat /etc/archipelago/ssl/archipelago.crt' > "$CERT_FILE" 2>/dev/null || true
|
||||
else
|
||||
echo "WARNING: No SSH key and sshpass not installed — skipping SSH fetch"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Fallback: fetch via openssl (can hang on some systems)
|
||||
if [ ! -s "$CERT_FILE" ]; then
|
||||
echo "Fetching certificate via TLS..."
|
||||
(echo "Q"; sleep 1) | openssl s_client -connect "${HOST}:443" -servername "${HOST}" 2>/dev/null | \
|
||||
openssl x509 -outform PEM > "$CERT_FILE"
|
||||
fi
|
||||
|
||||
if [ ! -s "$CERT_FILE" ]; then
|
||||
echo "Failed to fetch certificate. Ensure deploy-config.sh exists and SSH works, or the server is reachable."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Adding to your login keychain..."
|
||||
|
||||
# Remove old cert if present (by common name)
|
||||
security delete-certificate -c "archipelago.local" "$KEYCHAIN" 2>/dev/null || true
|
||||
|
||||
# Add to user keychain with trust (no sudo needed)
|
||||
if security add-trusted-cert -d -r trustRoot -k "$KEYCHAIN" "$CERT_FILE" 2>/dev/null; then
|
||||
echo " Certificate trusted successfully."
|
||||
elif security add-trusted-cert -d -r trustAsRoot -k "$KEYCHAIN" "$CERT_FILE" 2>/dev/null; then
|
||||
echo " Certificate trusted successfully."
|
||||
else
|
||||
# Fallback: add cert and open Keychain Access for manual trust
|
||||
cp "$CERT_FILE" "$HOME/Desktop/archipelago-${HOST}.crt"
|
||||
echo ""
|
||||
echo " Could not auto-trust. Certificate saved to Desktop."
|
||||
echo " Double-click archipelago-${HOST}.crt to add it, then in Keychain Access"
|
||||
echo " find it, double-click, expand Trust → set to 'Always Trust'."
|
||||
CERT_FILE="" # Don't delete, we copied to Desktop
|
||||
fi
|
||||
|
||||
rm -f "$CERT_FILE"
|
||||
|
||||
echo ""
|
||||
echo "✅ Done. Restart your browser fully (quit Chrome/Safari) and visit https://${HOST}"
|
||||
Executable
+97
@@ -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
|
||||
Executable
+328
@@ -0,0 +1,328 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# validate-app-manifest.sh - validate an Archipelago app manifest.
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/validate-app-manifest.sh [--repo-audit] apps/my-app/manifest.yml
|
||||
#
|
||||
# This intentionally mirrors the public app contract documented in
|
||||
# docs/app-manifest-spec.md: manifests have a top-level `app:` block and are
|
||||
# ultimately validated by the Rust parser in core/container/src/manifest.rs.
|
||||
# This script is the contributor-friendly preflight; the Rust parser remains
|
||||
# canonical.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
REPO_AUDIT=0
|
||||
if [[ "${1:-}" == "--repo-audit" ]]; then
|
||||
REPO_AUDIT=1
|
||||
shift
|
||||
fi
|
||||
|
||||
if [[ $# -ne 1 ]]; then
|
||||
echo "Usage: $0 [--repo-audit] <manifest.yml>"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
MANIFEST="$1"
|
||||
PASS=0
|
||||
FAIL=0
|
||||
WARN=0
|
||||
|
||||
check() {
|
||||
local desc="$1" result="$2"
|
||||
case "$result" in
|
||||
pass)
|
||||
PASS=$((PASS + 1))
|
||||
echo " PASS: $desc"
|
||||
;;
|
||||
warn)
|
||||
WARN=$((WARN + 1))
|
||||
echo " WARN: $desc"
|
||||
;;
|
||||
*)
|
||||
FAIL=$((FAIL + 1))
|
||||
echo " FAIL: $desc"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Preflight the YAML parser BEFORE any check runs. This used to shell out to
|
||||
# ruby with stderr discarded, so a machine without ruby reported "invalid YAML"
|
||||
# and rejected every manifest that was in fact perfectly valid — the first tool
|
||||
# an app developer runs, failing with a message that sent them to fix the wrong
|
||||
# thing. Fail loudly about the real cause instead.
|
||||
if ! command -v python3 >/dev/null 2>&1; then
|
||||
echo "ERROR: python3 is required to validate manifests, but was not found." >&2
|
||||
exit 3
|
||||
fi
|
||||
if ! python3 -c 'import yaml' >/dev/null 2>&1; then
|
||||
echo "ERROR: the PyYAML module is required to validate manifests." >&2
|
||||
echo " Install it with: python3 -m pip install pyyaml" >&2
|
||||
echo " (Debian/Ubuntu: apt-get install python3-yaml)" >&2
|
||||
exit 3
|
||||
fi
|
||||
|
||||
# Evaluate a path expression against the manifest's top-level `app` block.
|
||||
# Missing keys yield an empty string rather than an error, so callers can write
|
||||
# a plain chain like app["container"]["build"]["tag"] without guarding each hop.
|
||||
yaml_eval() {
|
||||
python3 -c '
|
||||
import sys, yaml
|
||||
|
||||
class Nil:
|
||||
"""Absent value: indexes to itself, is falsy, prints as empty."""
|
||||
def __getitem__(self, key): return self
|
||||
def __bool__(self): return False
|
||||
def __str__(self): return ""
|
||||
def __iter__(self): return iter(())
|
||||
|
||||
NIL = Nil()
|
||||
|
||||
class SafeDict(dict):
|
||||
def __missing__(self, key): return NIL
|
||||
|
||||
def wrap(value):
|
||||
if isinstance(value, dict):
|
||||
return SafeDict({k: wrap(v) for k, v in value.items()})
|
||||
if isinstance(value, list):
|
||||
return [wrap(v) for v in value]
|
||||
return value
|
||||
|
||||
path, expr = sys.argv[1], sys.argv[2]
|
||||
with open(path) as fh:
|
||||
data = yaml.safe_load(fh)
|
||||
app = data.get("app") if isinstance(data, dict) else None
|
||||
if not isinstance(app, dict):
|
||||
sys.exit("missing top-level app block")
|
||||
app = wrap(app)
|
||||
|
||||
value = eval(expr, {"__builtins__": {}}, {"app": app})
|
||||
if isinstance(value, list):
|
||||
print("\n".join(str(v) for v in value))
|
||||
elif isinstance(value, dict):
|
||||
print("\n".join(f"{k}={v}" for k, v in value.items()))
|
||||
elif value is None or isinstance(value, Nil):
|
||||
print("")
|
||||
elif isinstance(value, bool):
|
||||
print("true" if value else "false")
|
||||
else:
|
||||
print(value)
|
||||
' "$MANIFEST" "$1"
|
||||
}
|
||||
|
||||
echo "Validating: $MANIFEST"
|
||||
echo ""
|
||||
|
||||
if [[ ! -f "$MANIFEST" ]]; then
|
||||
echo " FAIL: File not found: $MANIFEST"
|
||||
exit 1
|
||||
fi
|
||||
check "File exists" "pass"
|
||||
|
||||
if ! python3 -c '
|
||||
import sys, yaml
|
||||
with open(sys.argv[1]) as fh:
|
||||
data = yaml.safe_load(fh)
|
||||
sys.exit(0 if isinstance(data, dict) and isinstance(data.get("app"), dict) else 1)
|
||||
' "$MANIFEST" 2>/dev/null; then
|
||||
check "Valid YAML with top-level app block" "fail"
|
||||
echo ""
|
||||
echo "Results: $PASS passed, $FAIL failed, $WARN warnings"
|
||||
echo "STATUS: REJECTED - fix failures before resubmitting"
|
||||
exit 1
|
||||
fi
|
||||
check "Valid YAML with top-level app block" "pass"
|
||||
|
||||
APP_ID="$(yaml_eval 'app["id"]')"
|
||||
APP_NAME="$(yaml_eval 'app["name"]')"
|
||||
APP_VERSION="$(yaml_eval 'app["version"]')"
|
||||
APP_DESCRIPTION="$(yaml_eval 'app["description"]')"
|
||||
APP_INTERNAL="$(yaml_eval 'app["internal"]')"
|
||||
IMAGE="$(yaml_eval 'app["container"]["image"]')"
|
||||
BUILD_CONTEXT="$(yaml_eval 'app["container"]["build"]["context"]')"
|
||||
BUILD_TAG="$(yaml_eval 'app["container"]["build"]["tag"]')"
|
||||
|
||||
if [[ "$APP_ID" =~ ^[a-z0-9]+(-[a-z0-9]+)*$ ]]; then
|
||||
check "app.id is lowercase kebab-case ($APP_ID)" "pass"
|
||||
else
|
||||
check "app.id is lowercase kebab-case" "fail"
|
||||
fi
|
||||
|
||||
if [[ -n "$APP_NAME" ]]; then
|
||||
check "app.name present" "pass"
|
||||
else
|
||||
check "app.name present" "fail"
|
||||
fi
|
||||
|
||||
if [[ "$APP_VERSION" =~ [0-9] ]]; then
|
||||
check "app.version present and contains a digit" "pass"
|
||||
else
|
||||
check "app.version present and contains a digit" "fail"
|
||||
fi
|
||||
|
||||
if [[ -n "$APP_DESCRIPTION" ]]; then
|
||||
check "app.description present" "pass"
|
||||
else
|
||||
check "app.description present" "warn"
|
||||
fi
|
||||
|
||||
HAS_IMAGE=0
|
||||
HAS_BUILD=0
|
||||
[[ -n "$IMAGE" ]] && HAS_IMAGE=1
|
||||
[[ -n "$BUILD_CONTEXT" || -n "$BUILD_TAG" ]] && HAS_BUILD=1
|
||||
|
||||
if [[ "$HAS_IMAGE" -eq 1 && "$HAS_BUILD" -eq 0 ]]; then
|
||||
check "container.image specified" "pass"
|
||||
elif [[ "$HAS_IMAGE" -eq 0 && "$HAS_BUILD" -eq 1 ]]; then
|
||||
if [[ -n "$BUILD_CONTEXT" && -n "$BUILD_TAG" ]]; then
|
||||
check "container.build specified with context and tag" "pass"
|
||||
else
|
||||
check "container.build requires context and tag" "fail"
|
||||
fi
|
||||
else
|
||||
check "exactly one of container.image or container.build specified" "fail"
|
||||
fi
|
||||
|
||||
if [[ -n "$IMAGE" ]]; then
|
||||
TRUSTED=false
|
||||
for reg in "docker.io" "ghcr.io" "quay.io" "registry.hub.docker.com" "source.archipelago-foundation.org" "localhost/"; do
|
||||
if [[ "$IMAGE" == *"$reg"* ]]; then
|
||||
TRUSTED=true
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [[ "$TRUSTED" == "true" || "$IMAGE" != */* ]]; then
|
||||
check "image registry is recognized" "pass"
|
||||
else
|
||||
check "image registry is not in the reviewed list ($IMAGE)" "warn"
|
||||
fi
|
||||
|
||||
if [[ "$IMAGE" == *":latest" ]]; then
|
||||
if [[ "$APP_INTERNAL" == "true" || "$IMAGE" == localhost/* ]]; then
|
||||
check "internal/local build uses :latest ($IMAGE)" "warn"
|
||||
elif [[ "$REPO_AUDIT" -eq 1 ]]; then
|
||||
check "existing manifest uses :latest and must be pinned before public app submission ($IMAGE)" "warn"
|
||||
else
|
||||
check "image tag is pinned and not :latest ($IMAGE)" "fail"
|
||||
fi
|
||||
elif [[ "$IMAGE" != *:* ]]; then
|
||||
check "image tag is explicit ($IMAGE)" "warn"
|
||||
else
|
||||
check "image tag is pinned" "pass"
|
||||
fi
|
||||
fi
|
||||
|
||||
MEMORY_LIMIT="$(yaml_eval 'app["resources"]["memory_limit"] or app["resources"]["memory"]')"
|
||||
CPU_LIMIT="$(yaml_eval 'app["resources"]["cpu_limit"] or app["resources"]["cpu"]')"
|
||||
[[ -n "$MEMORY_LIMIT" ]] && check "resources.memory_limit specified ($MEMORY_LIMIT)" "pass" || check "resources.memory_limit specified" "warn"
|
||||
[[ -n "$CPU_LIMIT" ]] && check "resources.cpu_limit specified ($CPU_LIMIT)" "pass" || check "resources.cpu_limit specified" "warn"
|
||||
|
||||
READONLY_ROOT="$(yaml_eval 'app["security"]["readonly_root"]')"
|
||||
NO_NEW_PRIVS="$(yaml_eval 'app["security"]["no_new_privileges"]')"
|
||||
NETWORK_POLICY="$(yaml_eval 'app["security"]["network_policy"]')"
|
||||
CONTAINER_NETWORK="$(yaml_eval 'app["container"]["network"]')"
|
||||
|
||||
if [[ "$READONLY_ROOT" == "true" || -z "$READONLY_ROOT" ]]; then
|
||||
check "security.readonly_root true (explicit or Rust default)" "pass"
|
||||
else
|
||||
check "security.readonly_root true or explicitly justified" "warn"
|
||||
fi
|
||||
|
||||
if [[ "$NO_NEW_PRIVS" == "true" || -z "$NO_NEW_PRIVS" ]]; then
|
||||
check "security.no_new_privileges true (explicit or Rust default)" "pass"
|
||||
elif [[ "$REPO_AUDIT" -eq 1 ]]; then
|
||||
check "existing manifest disables security.no_new_privileges and needs review" "warn"
|
||||
else
|
||||
check "security.no_new_privileges true" "fail"
|
||||
fi
|
||||
|
||||
if [[ "$NETWORK_POLICY" == "isolated" || "$NETWORK_POLICY" == "bridge" || "$NETWORK_POLICY" == "host" || -z "$NETWORK_POLICY" ]]; then
|
||||
check "security.network_policy valid" "pass"
|
||||
else
|
||||
check "security.network_policy valid" "fail"
|
||||
fi
|
||||
|
||||
if [[ "$CONTAINER_NETWORK" == container:* || "$CONTAINER_NETWORK" == ns:* ]]; then
|
||||
check "container.network does not share another namespace" "fail"
|
||||
else
|
||||
check "container.network does not share another namespace" "pass"
|
||||
fi
|
||||
|
||||
SECRET_ENV="$(yaml_eval 'app["environment"]')"
|
||||
if echo "$SECRET_ENV" | grep -iqE '^[A-Z0-9_]*(PASSWORD|PASS|SECRET|TOKEN|API_KEY|PRIVATE_KEY)[A-Z0-9_]*=.+$'; then
|
||||
check "no hardcoded secret-like values in app.environment" "warn"
|
||||
else
|
||||
check "no hardcoded secret-like values in app.environment" "pass"
|
||||
fi
|
||||
|
||||
if [[ -n "$APP_ID" && -n "$MANIFEST" ]]; then
|
||||
EXPECTED_DIR="$(basename "$(dirname "$MANIFEST")")"
|
||||
if [[ "$EXPECTED_DIR" == "$APP_ID" ]]; then
|
||||
check "app.id matches directory name" "pass"
|
||||
elif [[ "$REPO_AUDIT" -eq 1 ]]; then
|
||||
check "existing manifest app.id differs from directory name ($EXPECTED_DIR)" "warn"
|
||||
else
|
||||
check "app.id matches directory name ($EXPECTED_DIR)" "fail"
|
||||
fi
|
||||
fi
|
||||
|
||||
PORT_CHECK="$(python3 -c '
|
||||
import glob, os, sys, yaml
|
||||
|
||||
def load_app(path):
|
||||
try:
|
||||
with open(path) as fh:
|
||||
data = yaml.safe_load(fh)
|
||||
except Exception:
|
||||
return None
|
||||
return data.get("app") if isinstance(data, dict) else None
|
||||
|
||||
current = sys.argv[1]
|
||||
current_id = os.path.basename(os.path.dirname(current))
|
||||
|
||||
def port_keys(app):
|
||||
for entry in (app.get("ports") or []):
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
host = entry.get("host")
|
||||
if not host:
|
||||
continue
|
||||
yield (host, entry.get("protocol") or "tcp", entry.get("bind") or "")
|
||||
|
||||
claimed = {}
|
||||
for path in sorted(glob.glob("apps/*/manifest.yml")):
|
||||
app = load_app(path)
|
||||
if not isinstance(app, dict):
|
||||
continue
|
||||
app_id = app.get("id") or os.path.basename(os.path.dirname(path))
|
||||
if app_id == current_id:
|
||||
continue
|
||||
for key in port_keys(app):
|
||||
claimed[key] = app_id
|
||||
|
||||
app = load_app(current)
|
||||
if isinstance(app, dict):
|
||||
for key in port_keys(app):
|
||||
if key in claimed:
|
||||
host, proto, bind = key
|
||||
shown = bind if bind else "*"
|
||||
print(f"{shown}:{host}/{proto} already used by {claimed[key]}")
|
||||
' "$MANIFEST")"
|
||||
if [[ -n "$PORT_CHECK" ]]; then
|
||||
while IFS= read -r conflict; do
|
||||
check "port conflict: $conflict" "warn"
|
||||
done <<< "$PORT_CHECK"
|
||||
else
|
||||
check "no duplicate host port bindings" "pass"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Results: $PASS passed, $FAIL failed, $WARN warnings"
|
||||
|
||||
if [[ "$FAIL" -gt 0 ]]; then
|
||||
echo "STATUS: REJECTED - fix failures before resubmitting"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "STATUS: APPROVED (with $WARN warnings)"
|
||||
Executable
+91
@@ -0,0 +1,91 @@
|
||||
#!/usr/bin/env bash
|
||||
# verify-aiui-deploy.sh — post-deploy check that a marker string is actually
|
||||
# reachable by a browser loading AIUI, not merely present somewhere on disk.
|
||||
#
|
||||
# The node's /opt/archipelago/web-ui/aiui/assets/ directory is a
|
||||
# never-pruned graveyard: nginx never deletes an old build's chunk files
|
||||
# when a new build lands (only the tar+rsync step below AIUI_DIST replaces
|
||||
# what's there, and even that has left orphaned files in past incidents —
|
||||
# see feedback_node_side_frontend_verify_stale_chunks). A disk grep over
|
||||
# assets/ can therefore report "deployed" before the deploy actually
|
||||
# happened, because a DEAD chunk from an old build still contains the old
|
||||
# string. The only honest check fetches what a browser would actually load:
|
||||
# resolve the LIVE chunk set via the service worker's precache manifest
|
||||
# (sw.js), fetch each live chunk over HTTP, and grep the fetched bytes.
|
||||
#
|
||||
# This script never opens a remote shell session onto the node and never
|
||||
# greps the node's filesystem directly — every check here is an HTTP
|
||||
# fetch, exactly what a browser session would do.
|
||||
#
|
||||
# Usage:
|
||||
# ./verify-aiui-deploy.sh <node-host> "<marker string>"
|
||||
#
|
||||
# Exit 0 = the marker was found in at least one live chunk fetched over HTTP.
|
||||
# Exit 1 = the marker was not found in any live chunk (negative control
|
||||
# should also produce this — a check that always passes is not a
|
||||
# check).
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
HOST="${1:?usage: verify-aiui-deploy.sh <node-host> \"<marker string>\"}"
|
||||
MARKER="${2:?usage: verify-aiui-deploy.sh <node-host> \"<marker string>\"}"
|
||||
|
||||
# Accept a bare host or a host:port; default to plain HTTP on :80, matching
|
||||
# how neode-ui/AIUI are actually served on a node (nginx terminates TLS
|
||||
# elsewhere; the lifecycle gate and other production-quality scripts in
|
||||
# this directory talk to nodes over plain HTTP the same way).
|
||||
BASE="http://${HOST}"
|
||||
SW_URL="${BASE}/aiui/sw.js"
|
||||
|
||||
timestamp() { echo "[$(date +%H:%M:%S)]"; }
|
||||
|
||||
echo "$(timestamp) Fetching service worker manifest: $SW_URL"
|
||||
SW_BODY="$(curl -sf -m 15 "$SW_URL" 2>/dev/null || true)"
|
||||
if [ -z "$SW_BODY" ]; then
|
||||
echo "FATAL: could not fetch $SW_URL — is AIUI deployed and nginx up on $HOST?" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# vite-plugin-pwa's generateSW mode emits sw.js with a
|
||||
# workbox.precacheAndRoute([{url:"...",revision:"..."|null}, ...]) call —
|
||||
# a JS array literal (unquoted keys), not JSON. Extract every url:"..."
|
||||
# value without a full JS parser.
|
||||
mapfile -t LIVE_PATHS < <(
|
||||
grep -oE 'url:"[^"]*"' <<<"$SW_BODY" | sed -E 's/^url:"//; s/"$//'
|
||||
)
|
||||
|
||||
if [ "${#LIVE_PATHS[@]}" -eq 0 ]; then
|
||||
echo "FATAL: $SW_URL fetched but no precache entries found — cannot resolve live chunks." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "$(timestamp) Resolved ${#LIVE_PATHS[@]} live chunk(s) from the precache manifest."
|
||||
|
||||
FOUND=0
|
||||
CHECKED=0
|
||||
# Fetch to a temp file and grep that, NOT `curl | grep -q`: under this
|
||||
# script's pipefail, grep -q's early exit EPIPEs curl (exit 23) whenever
|
||||
# the marker sits before the tail of a >64KB chunk, turning a genuine
|
||||
# match into a nondeterministic FAIL.
|
||||
BODY_TMP="$(mktemp)"
|
||||
trap 'rm -f "$BODY_TMP"' EXIT
|
||||
for path in "${LIVE_PATHS[@]}"; do
|
||||
[ -z "$path" ] && continue
|
||||
CHECKED=$((CHECKED + 1))
|
||||
url="${BASE}/aiui/${path}"
|
||||
if curl -sf -m 15 -o "$BODY_TMP" "$url" 2>/dev/null && grep -q -- "$MARKER" "$BODY_TMP"; then
|
||||
echo "$(timestamp) MATCH: $path"
|
||||
FOUND=1
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
echo "$(timestamp) Checked $CHECKED live chunk(s) fetched over HTTP for marker: $MARKER"
|
||||
|
||||
if [ "$FOUND" -eq 1 ]; then
|
||||
echo "$(timestamp) PASS — marker found in a live, browser-fetchable chunk."
|
||||
exit 0
|
||||
else
|
||||
echo "$(timestamp) FAIL — marker not found in any live chunk (fetched via sw.js manifest, not a disk grep)." >&2
|
||||
exit 1
|
||||
fi
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user