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.168.1.198 \
|
||||
--ssh-key /home/archipelago/.ssh/id_ed25519
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
INSECURE_REGISTRIES = ("146.59.87.168:3000", "23.182.128.160:3000")
|
||||
|
||||
|
||||
def run(cmd: list[str], timeout: int = 120) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(
|
||||
cmd,
|
||||
text=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
|
||||
class Remote:
|
||||
def __init__(self, target: str, ssh_key: str | None, extra: list[str]) -> None:
|
||||
self.base = [
|
||||
"ssh",
|
||||
"-F",
|
||||
"/dev/null",
|
||||
"-o",
|
||||
"ConnectTimeout=8",
|
||||
"-o",
|
||||
"BatchMode=yes",
|
||||
"-o",
|
||||
"PreferredAuthentications=publickey",
|
||||
"-o",
|
||||
"PasswordAuthentication=no",
|
||||
"-o",
|
||||
"StrictHostKeyChecking=no",
|
||||
]
|
||||
if ssh_key:
|
||||
self.base.extend(["-i", ssh_key])
|
||||
self.base.extend(extra)
|
||||
self.target = target
|
||||
|
||||
def sh(self, script: str, timeout: int = 120) -> subprocess.CompletedProcess[str]:
|
||||
return run(self.base + [self.target, script], timeout=timeout)
|
||||
|
||||
|
||||
def load_manifests(apps_dir: Path) -> list[dict]:
|
||||
manifests = []
|
||||
for path in sorted(apps_dir.glob("*/manifest.yml")):
|
||||
with path.open("r", encoding="utf-8") as fh:
|
||||
data = yaml.safe_load(fh)
|
||||
if not isinstance(data, dict):
|
||||
app = None
|
||||
container = None
|
||||
elif isinstance(data.get("app"), dict):
|
||||
app = data["app"]
|
||||
container = app.get("container")
|
||||
else:
|
||||
app = data
|
||||
container = data.get("container") if isinstance(data.get("container"), dict) else data
|
||||
manifests.append({"path": path, "app": app, "container": container})
|
||||
return manifests
|
||||
|
||||
|
||||
def insecure(image: str) -> bool:
|
||||
return image.startswith(INSECURE_REGISTRIES)
|
||||
|
||||
|
||||
def shell_probe_for(app_id: str, command: str) -> str | None:
|
||||
if app_id in {"bitcoin-core", "bitcoin-knots"}:
|
||||
return "command -v bitcoind || find /opt -path '*/bin/bitcoind' -type f 2>/dev/null | sort | tail -n 1"
|
||||
|
||||
match = re.search(r"\bexec\s+([\"']?)([A-Za-z0-9_./-]+)\1", command)
|
||||
if not match:
|
||||
return None
|
||||
|
||||
binary = match.group(2)
|
||||
if binary.startswith("$"):
|
||||
return None
|
||||
if "/" in binary:
|
||||
return f"test -x {shlex.quote(binary)} && echo {shlex.quote(binary)}"
|
||||
return f"command -v {shlex.quote(binary)}"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--target", required=True)
|
||||
parser.add_argument("--ssh-key", default=os.environ.get("ARCHIPELAGO_SSH_KEY"))
|
||||
parser.add_argument("--apps-dir", default="apps")
|
||||
parser.add_argument("--pull", action="store_true", help="pull missing images before probing")
|
||||
parser.add_argument("--ssh-option", action="append", default=[])
|
||||
args = parser.parse_args()
|
||||
|
||||
apps_dir = Path(args.apps_dir)
|
||||
remote = Remote(args.target, args.ssh_key, sum((["-o", x] for x in args.ssh_option), []))
|
||||
|
||||
failures: list[str] = []
|
||||
warnings: list[str] = []
|
||||
passes = 0
|
||||
|
||||
health = remote.sh("curl -fsS --max-time 5 http://127.0.0.1:5678/health", timeout=15)
|
||||
if health.returncode != 0:
|
||||
failures.append(f"target health failed: {health.stderr.strip() or health.stdout.strip()}")
|
||||
print(json.dumps({"passes": passes, "warnings": 0, "failures": len(failures)}, sort_keys=True))
|
||||
for failure in failures:
|
||||
print(f"FAIL {failure}")
|
||||
return 1
|
||||
else:
|
||||
passes += 1
|
||||
print(f"PASS target health {health.stdout.strip()}")
|
||||
|
||||
manifests = load_manifests(apps_dir)
|
||||
print(f"INFO loaded {len(manifests)} manifests from {apps_dir}")
|
||||
|
||||
for item in manifests:
|
||||
path = item["path"]
|
||||
app = item["app"]
|
||||
container = item["container"]
|
||||
if not isinstance(app, dict) or not isinstance(container, dict):
|
||||
failures.append(f"{path}: missing app.container")
|
||||
continue
|
||||
|
||||
app_id = str(app.get("id") or "")
|
||||
image = str(container.get("image") or app.get("image") or "")
|
||||
if not app_id:
|
||||
failures.append(f"{path}: missing app id")
|
||||
continue
|
||||
if not image and container.get("build"):
|
||||
warnings.append(f"{app_id}: skipped locally built image")
|
||||
continue
|
||||
if not image:
|
||||
failures.append(f"{path}: missing container image")
|
||||
continue
|
||||
passes += 1
|
||||
|
||||
if image.startswith("localhost/") or image.startswith("archipelago/"):
|
||||
warnings.append(f"{app_id}: skipped local/unpublished image {image}")
|
||||
continue
|
||||
|
||||
pull_args = ["pull"]
|
||||
if insecure(image):
|
||||
pull_args.append("--tls-verify=false")
|
||||
pull_args.append(image)
|
||||
|
||||
if args.pull:
|
||||
pull_cmd = "timeout 300s podman " + " ".join(shlex.quote(x) for x in pull_args)
|
||||
pulled = remote.sh(pull_cmd, timeout=330)
|
||||
if pulled.returncode != 0:
|
||||
failures.append(f"{app_id}: pull failed for {image}: {(pulled.stderr or pulled.stdout).strip()[-500:]}")
|
||||
continue
|
||||
print(f"PASS {app_id}: pulled {image}")
|
||||
passes += 1
|
||||
else:
|
||||
exists = remote.sh(f"podman image exists {shlex.quote(image)}", timeout=30)
|
||||
if exists.returncode != 0:
|
||||
warnings.append(f"{app_id}: image not present on target, rerun with --pull: {image}")
|
||||
continue
|
||||
|
||||
custom_args = container.get("custom_args") or []
|
||||
entrypoint = container.get("entrypoint") or []
|
||||
if entrypoint == ["sh", "-lc"] and custom_args:
|
||||
command = str(custom_args[0])
|
||||
probe = shell_probe_for(app_id, command)
|
||||
if probe:
|
||||
remote_script = (
|
||||
"timeout 45s podman run --rm "
|
||||
f"--entrypoint sh {shlex.quote(image)} -c {shlex.quote(probe)}"
|
||||
)
|
||||
checked = remote.sh(remote_script, timeout=60)
|
||||
found = checked.stdout.strip().splitlines()[-1:] or [""]
|
||||
if checked.returncode == 0 and found[0]:
|
||||
print(f"PASS {app_id}: command probe found {found[0]}")
|
||||
passes += 1
|
||||
else:
|
||||
failures.append(
|
||||
f"{app_id}: command probe failed in {image}: {(checked.stderr or checked.stdout).strip()[-500:]}"
|
||||
)
|
||||
|
||||
print(json.dumps({"passes": passes, "warnings": len(warnings), "failures": len(failures)}, sort_keys=True))
|
||||
for warning in warnings:
|
||||
print(f"WARN {warning}")
|
||||
for failure in failures:
|
||||
print(f"FAIL {failure}")
|
||||
return 1 if failures else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
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.168.1.228 --ssh-key /path/key
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
TARGET=""
|
||||
SSH_KEY="${ARCHIPELAGO_SSH_KEY:-}"
|
||||
SSH_EXTRA=()
|
||||
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
--target) TARGET="${2:-}"; shift 2 ;;
|
||||
--ssh-key) SSH_KEY="${2:-}"; shift 2 ;;
|
||||
--ssh-option) SSH_EXTRA+=("-o" "${2:-}"); shift 2 ;;
|
||||
-h|--help) sed -n '1,12p' "$0"; exit 0 ;;
|
||||
*) echo "unknown argument: $1" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
[ -n "$TARGET" ] || { echo "--target is required" >&2; exit 2; }
|
||||
|
||||
SSH_OPTS=(-F /dev/null -o BatchMode=yes -o PreferredAuthentications=publickey -o PasswordAuthentication=no)
|
||||
[ -n "$SSH_KEY" ] && SSH_OPTS+=(-i "$SSH_KEY")
|
||||
SSH_OPTS+=("${SSH_EXTRA[@]}")
|
||||
|
||||
ssh_run() {
|
||||
ssh "${SSH_OPTS[@]}" "$TARGET" "$@"
|
||||
}
|
||||
|
||||
ssh_run 'bash -s' <<'REMOTE'
|
||||
set -u
|
||||
|
||||
pass=0
|
||||
fail=0
|
||||
|
||||
ok() { echo " PASS $*"; pass=$((pass + 1)); }
|
||||
bad() { echo " FAIL $*"; fail=$((fail + 1)); }
|
||||
|
||||
container_exists() {
|
||||
podman ps -a --format '{{.Names}}' 2>/dev/null | grep -qx "$1"
|
||||
}
|
||||
|
||||
port_listening() {
|
||||
ss -ltn 2>/dev/null | awk '{print $4}' | grep -Eq "(^|:)$1$"
|
||||
}
|
||||
|
||||
http_code() {
|
||||
local url="$1" code
|
||||
for _ in 1 2 3; do
|
||||
code=$(curl -ksS -o /dev/null -w '%{http_code}' --max-time 12 "$url" 2>/dev/null || true)
|
||||
[ -n "$code" ] || code=000
|
||||
[ "$code" != "000" ] && { echo "$code"; return; }
|
||||
sleep 2
|
||||
done
|
||||
echo "$code"
|
||||
}
|
||||
|
||||
http_post_code() {
|
||||
local url="$1" code
|
||||
for _ in 1 2 3; do
|
||||
code=$(curl -ksS -o /dev/null -w '%{http_code}' --max-time 25 \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"jsonrpc":"2.0","id":1,"method":"getblockchaininfo","params":[]}' \
|
||||
"$url" 2>/dev/null || true)
|
||||
[ -n "$code" ] || code=000
|
||||
[ "$code" != "000" ] && { echo "$code"; return; }
|
||||
sleep 2
|
||||
done
|
||||
echo "$code"
|
||||
}
|
||||
|
||||
assert_http() {
|
||||
local label="$1" url="$2" code
|
||||
code=$(http_code "$url")
|
||||
case "$code" in
|
||||
200|204|301|302|307|308|401|403) ok "$label HTTP $code" ;;
|
||||
*) bad "$label HTTP $code ($url)" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
assert_http_post() {
|
||||
local label="$1" url="$2" code
|
||||
code=$(http_post_code "$url")
|
||||
case "$code" in
|
||||
200|204|401|403) ok "$label HTTP POST $code" ;;
|
||||
*) bad "$label HTTP POST $code ($url)" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
assert_container_ports() {
|
||||
local name="$1" ports port missing=0
|
||||
container_exists "$name" || return 0
|
||||
ports=$(podman inspect "$name" --format '{{range $p,$bindings := .NetworkSettings.Ports}}{{if $bindings}}{{range $bindings}}{{.HostPort}}{{"\n"}}{{end}}{{end}}{{end}}' 2>/dev/null | sort -u)
|
||||
[ -n "$ports" ] || return 0
|
||||
while IFS= read -r port; do
|
||||
[ -n "$port" ] || continue
|
||||
if port_listening "$port"; then
|
||||
ok "$name port $port listening"
|
||||
else
|
||||
bad "$name port $port missing listener"
|
||||
missing=1
|
||||
fi
|
||||
done <<< "$ports"
|
||||
return "$missing"
|
||||
}
|
||||
|
||||
assert_env_contains() {
|
||||
local name="$1" key="$2" needle="$3" val
|
||||
container_exists "$name" || return 0
|
||||
val=$(podman inspect "$name" --format '{{range .Config.Env}}{{println .}}{{end}}' 2>/dev/null | sed -n "s/^${key}=//p" | head -n 1)
|
||||
if [ -n "$val" ] && printf '%s' "$val" | grep -qF "$needle"; then
|
||||
ok "$name env $key"
|
||||
else
|
||||
bad "$name env $key missing $needle"
|
||||
fi
|
||||
}
|
||||
|
||||
echo "[surface] host=$(hostname) ip=$(hostname -I 2>/dev/null | awk '{print $1}')"
|
||||
|
||||
for c in $(podman ps -a --format '{{.Names}}' 2>/dev/null | sort); do
|
||||
assert_container_ports "$c" || true
|
||||
done
|
||||
|
||||
container_exists archy-bitcoin-ui && {
|
||||
assert_http "bitcoin-ui" "http://127.0.0.1/app/bitcoin-ui/"
|
||||
assert_http "bitcoin status" "http://127.0.0.1/app/bitcoin-ui/bitcoin-status"
|
||||
assert_http_post "bitcoin rpc proxy" "http://127.0.0.1/app/bitcoin-ui/bitcoin-rpc/"
|
||||
}
|
||||
|
||||
container_exists archy-electrs-ui && {
|
||||
assert_http "electrumx ui" "http://127.0.0.1/app/electrumx/"
|
||||
assert_http "electrumx status" "http://127.0.0.1/app/electrumx/electrs-status"
|
||||
assert_http "electrs legacy status" "http://127.0.0.1/app/electrs/electrs-status"
|
||||
}
|
||||
|
||||
container_exists mempool && assert_http "mempool ui" "http://127.0.0.1/app/mempool/"
|
||||
container_exists indeedhub && assert_http "indeedhub ui" "http://127.0.0.1:7778/"
|
||||
container_exists uptime-kuma && assert_http "uptime-kuma" "http://127.0.0.1/app/uptime-kuma/"
|
||||
container_exists filebrowser && assert_http "filebrowser" "http://127.0.0.1/app/filebrowser/"
|
||||
container_exists searxng && assert_http "searxng" "http://127.0.0.1/app/searxng/"
|
||||
container_exists grafana && assert_http "grafana" "http://127.0.0.1/app/grafana/"
|
||||
container_exists portainer && assert_http "portainer" "http://127.0.0.1/app/portainer/"
|
||||
container_exists vaultwarden && assert_http "vaultwarden" "http://127.0.0.1/app/vaultwarden/"
|
||||
container_exists nextcloud && assert_http "nextcloud" "http://127.0.0.1/app/nextcloud/"
|
||||
container_exists archy-nbxplorer && assert_env_contains "archy-nbxplorer" "NBXPLORER_POSTGRES" "Database=nbxplorer"
|
||||
container_exists btcpay-server && {
|
||||
assert_env_contains "btcpay-server" "BTCPAY_POSTGRES" "Database=btcpay"
|
||||
assert_http "btcpay" "http://127.0.0.1/app/btcpay/"
|
||||
}
|
||||
|
||||
echo "[surface] summary: pass=$pass fail=$fail"
|
||||
[ "$fail" -eq 0 ]
|
||||
REMOTE
|
||||
@@ -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
+118
@@ -0,0 +1,118 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
# SEC-202: Secrets audit — checks for hardcoded credentials in the codebase.
|
||||
# Scans source files for common secret patterns.
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
PASS=0
|
||||
FAIL=0
|
||||
RESULTS=()
|
||||
|
||||
log() { echo -e "\033[1;34m[AUDIT]\033[0m $*"; }
|
||||
pass() { echo -e "\033[1;32m[PASS]\033[0m $*"; PASS=$((PASS + 1)); RESULTS+=("PASS: $*"); }
|
||||
fail() { echo -e "\033[1;31m[FAIL]\033[0m $*"; FAIL=$((FAIL + 1)); RESULTS+=("FAIL: $*"); }
|
||||
|
||||
# Patterns to search for (case insensitive)
|
||||
PATTERNS=(
|
||||
"password\s*=\s*['\"][^'\"]*['\"]"
|
||||
"api_key\s*=\s*['\"][^'\"]*['\"]"
|
||||
"secret\s*=\s*['\"][^'\"]*['\"]"
|
||||
"private_key\s*=\s*['\"][^'\"]*['\"]"
|
||||
"sk-ant-"
|
||||
"AKIA[A-Z0-9]{16}"
|
||||
"ghp_[a-zA-Z0-9]{36}"
|
||||
"glpat-[a-zA-Z0-9_-]{20}"
|
||||
)
|
||||
|
||||
# Allowed files (config templates, docs, test fixtures)
|
||||
ALLOW_PATTERNS="test|mock|example|template|CLAUDE.md|deploy-config|\.md$|node_modules|dist|target|default\)|grep.*rpc|audit-secrets"
|
||||
|
||||
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 '*.env' '.env*' 2>/dev/null || echo "")
|
||||
if [ -z "$env_files" ]; then
|
||||
pass "No .env files tracked in git"
|
||||
else
|
||||
fail "Found .env files in git: $env_files"
|
||||
fi
|
||||
|
||||
# 2. Check .gitignore includes sensitive patterns
|
||||
log "2. Checking .gitignore coverage..."
|
||||
local gitignore="$REPO_ROOT/.gitignore"
|
||||
if [ -f "$gitignore" ]; then
|
||||
local has_env has_key
|
||||
has_env=$(grep -c '\.env' "$gitignore" || echo 0)
|
||||
has_key=$(grep -c 'credentials\|\.key\|\.pem' "$gitignore" || echo 0)
|
||||
if [ "$has_env" -gt 0 ]; then
|
||||
pass ".gitignore covers .env files"
|
||||
else
|
||||
fail ".gitignore missing .env pattern"
|
||||
fi
|
||||
else
|
||||
fail "No .gitignore found"
|
||||
fi
|
||||
|
||||
# 3. Scan source for hardcoded credentials
|
||||
log "3. Scanning source for hardcoded secrets..."
|
||||
local found_secrets=0
|
||||
for pattern in "${PATTERNS[@]}"; do
|
||||
local matches
|
||||
matches=$(cd "$REPO_ROOT" && grep -rniE "$pattern" \
|
||||
--include='*.rs' --include='*.ts' --include='*.vue' --include='*.js' \
|
||||
--include='*.json' --include='*.sh' --include='*.py' \
|
||||
2>/dev/null | grep -vE "$ALLOW_PATTERNS" || echo "")
|
||||
if [ -n "$matches" ]; then
|
||||
# Filter out false positives (empty strings, variable declarations, etc.)
|
||||
local real_matches
|
||||
real_matches=$(echo "$matches" | grep -vE '""|\x27\x27|None|null|undefined|TODO|placeholder|example|Option<' || echo "")
|
||||
if [ -n "$real_matches" ]; then
|
||||
echo " WARNING: Pattern '$pattern' found:"
|
||||
echo "$real_matches" | head -5 | sed 's/^/ /'
|
||||
found_secrets=$((found_secrets + 1))
|
||||
fi
|
||||
fi
|
||||
done
|
||||
if [ "$found_secrets" -eq 0 ]; then
|
||||
pass "No hardcoded secrets found in source"
|
||||
else
|
||||
fail "Found $found_secrets secret pattern matches (review above)"
|
||||
fi
|
||||
|
||||
# 4. Check deploy-config is gitignored
|
||||
log "4. Checking deploy-config.sh is gitignored..."
|
||||
if cd "$REPO_ROOT" && git check-ignore scripts/deploy-config.sh > /dev/null 2>&1; then
|
||||
pass "scripts/deploy-config.sh is gitignored"
|
||||
elif [ -f "$REPO_ROOT/scripts/deploy-config.sh" ]; then
|
||||
fail "scripts/deploy-config.sh exists but is NOT gitignored"
|
||||
else
|
||||
pass "scripts/deploy-config.sh does not exist (using env vars)"
|
||||
fi
|
||||
|
||||
# 5. Check for credential files in repo
|
||||
log "5. Checking for credential files..."
|
||||
local cred_files
|
||||
cred_files=$(cd "$REPO_ROOT" && git ls-files '*.pem' '*.key' '*macaroon*' 2>/dev/null | grep -v '\.rs$' | grep -v '\.ts$' || echo "")
|
||||
if [ -z "$cred_files" ]; then
|
||||
pass "No credential files tracked in git"
|
||||
else
|
||||
fail "Credential files in git: $cred_files"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
log "=== RESULTS ==="
|
||||
for r in "${RESULTS[@]}"; do
|
||||
echo " $r"
|
||||
done
|
||||
echo ""
|
||||
log "Pass: $PASS | Fail: $FAIL"
|
||||
|
||||
[ $FAIL -gt 0 ] && exit 1
|
||||
exit 0
|
||||
}
|
||||
|
||||
main "$@"
|
||||
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.168.1.228
|
||||
# scripts/bitcoin-stack-lifecycle-test.sh --target archipelago@192.168.1.116 --cycles 5
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
TARGET=""
|
||||
SSH_KEY="${ARCHIPELAGO_SSH_KEY:-}"
|
||||
CYCLES=3
|
||||
SSH_EXTRA=()
|
||||
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
--target)
|
||||
TARGET="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--ssh-key)
|
||||
SSH_KEY="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--cycles)
|
||||
CYCLES="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--ssh-option)
|
||||
SSH_EXTRA+=("-o" "${2:-}")
|
||||
shift 2
|
||||
;;
|
||||
-h|--help)
|
||||
sed -n '1,22p' "$0"
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "unknown argument: $1" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ -z "$TARGET" ]; then
|
||||
echo "--target is required, for example archipelago@192.168.1.228" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
SSH=(ssh -F /dev/null -o BatchMode=yes -o PreferredAuthentications=publickey -o PasswordAuthentication=no -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null)
|
||||
if [ -n "$SSH_KEY" ]; then
|
||||
SSH+=("-i" "$SSH_KEY")
|
||||
fi
|
||||
SSH+=("${SSH_EXTRA[@]}")
|
||||
|
||||
"${SSH[@]}" "$TARGET" "CYCLES='$CYCLES' bash -s" <<'REMOTE'
|
||||
set -euo pipefail
|
||||
|
||||
PODMAN="${PODMAN:-podman}"
|
||||
SCRIPTS_DIR="/opt/archipelago/scripts"
|
||||
if [ ! -x "$SCRIPTS_DIR/reconcile-containers.sh" ]; then
|
||||
SCRIPTS_DIR="$HOME/archy/scripts"
|
||||
fi
|
||||
RECONCILE="$SCRIPTS_DIR/reconcile-containers.sh"
|
||||
|
||||
pass_count=0
|
||||
fail_count=0
|
||||
|
||||
log() { printf '[%s] %s\n' "$(date +%H:%M:%S)" "$*"; }
|
||||
pass() { pass_count=$((pass_count + 1)); printf ' PASS %s\n' "$*"; }
|
||||
fail() { fail_count=$((fail_count + 1)); printf ' FAIL %s\n' "$*" >&2; }
|
||||
|
||||
retry() {
|
||||
local timeout="$1" label="$2"
|
||||
shift 2
|
||||
local end=$((SECONDS + timeout))
|
||||
local out rc
|
||||
while [ "$SECONDS" -lt "$end" ]; do
|
||||
set +e
|
||||
out=$("$@" 2>&1)
|
||||
rc=$?
|
||||
set -e
|
||||
if [ "$rc" -eq 0 ]; then
|
||||
pass "$label"
|
||||
return 0
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
fail "$label: $out"
|
||||
return 1
|
||||
}
|
||||
|
||||
rpc_pass() {
|
||||
cat /var/lib/archipelago/secrets/bitcoin-rpc-password
|
||||
}
|
||||
|
||||
json_rpc_reachable_or_warming() {
|
||||
local url="$1" auth_arg=() body rc
|
||||
if [ "${2:-}" = "auth" ]; then
|
||||
auth_arg=(--user "archipelago:$(rpc_pass)")
|
||||
fi
|
||||
set +e
|
||||
body=$(curl --connect-timeout 3 --max-time 20 -sS "${auth_arg[@]}" \
|
||||
-H "Content-Type: application/json" \
|
||||
--data-binary '{"jsonrpc":"1.0","id":"lifecycle-test","method":"getblockchaininfo","params":[]}' \
|
||||
"$url" 2>&1)
|
||||
rc=$?
|
||||
set -e
|
||||
[ "$rc" -eq 0 ] || {
|
||||
echo "$body"
|
||||
return 1
|
||||
}
|
||||
echo "$body" | grep -q '"result"' && return 0
|
||||
echo "$body" | grep -q '"code":-28' && return 0
|
||||
echo "$body"
|
||||
return 1
|
||||
}
|
||||
|
||||
bitcoin_status_usable() {
|
||||
local url="$1"
|
||||
local body
|
||||
body=$(curl --connect-timeout 3 --max-time 20 -fsS "$url")
|
||||
echo "$body" | grep -q '"ok":\(true\|false\)' || {
|
||||
echo "$body"
|
||||
return 1
|
||||
}
|
||||
echo "$body" | grep -q '"blockchain_info"' || echo "$body" | grep -q '"error"'
|
||||
}
|
||||
|
||||
http_ok() {
|
||||
local url="$1"
|
||||
curl --connect-timeout 3 --max-time 20 -fsS -o /dev/null "$url"
|
||||
}
|
||||
|
||||
electrs_status_ok() {
|
||||
local url="${1:-http://127.0.0.1:50002/electrs-status}"
|
||||
local body
|
||||
body=$(curl --connect-timeout 3 --max-time 20 -fsS "$url")
|
||||
echo "$body" | grep -q '"network_height":[1-9]' || {
|
||||
echo "$body"
|
||||
return 1
|
||||
}
|
||||
echo "$body" | grep -q '"status":"\(indexing\|syncing\|synced\|waiting\)"'
|
||||
}
|
||||
|
||||
container_running() {
|
||||
local name="$1"
|
||||
[ "$($PODMAN inspect "$name" --format '{{.State.Status}}' 2>/dev/null || true)" = "running" ]
|
||||
}
|
||||
|
||||
container_healthy_or_starting() {
|
||||
local name="$1"
|
||||
local health
|
||||
health=$($PODMAN inspect "$name" --format '{{if .State.Health}}{{.State.Health.Status}}{{end}}' 2>/dev/null || true)
|
||||
[ "$health" = "healthy" ] || [ "$health" = "starting" ] || [ -z "$health" ]
|
||||
}
|
||||
|
||||
assert_bitcoin_stack() {
|
||||
retry 90 "bitcoin-knots running" container_running bitcoin-knots
|
||||
retry 90 "bitcoin-knots healthy/starting" container_healthy_or_starting bitcoin-knots
|
||||
retry 90 "host Bitcoin RPC reachable/ready" json_rpc_reachable_or_warming http://127.0.0.1:8332/ auth
|
||||
retry 90 "backend Bitcoin status bridge usable" bitcoin_status_usable http://127.0.0.1:5678/bitcoin-status
|
||||
retry 90 "bitcoin-ui page" http_ok http://127.0.0.1:8334/
|
||||
retry 90 "bitcoin-ui status bridge usable" bitcoin_status_usable http://127.0.0.1:8334/bitcoin-status
|
||||
retry 90 "bitcoin-ui app-session status bridge usable" bitcoin_status_usable http://127.0.0.1/app/bitcoin-ui/bitcoin-status
|
||||
retry 90 "bitcoin-ui RPC proxy reachable/ready" json_rpc_reachable_or_warming http://127.0.0.1:8334/bitcoin-rpc/
|
||||
retry 90 "bitcoin-ui app-session RPC proxy reachable/ready" json_rpc_reachable_or_warming http://127.0.0.1/app/bitcoin-ui/bitcoin-rpc/
|
||||
}
|
||||
|
||||
assert_electrum_stack() {
|
||||
retry 120 "electrumx running" container_running electrumx
|
||||
retry 120 "electrumx healthy/starting" container_healthy_or_starting electrumx
|
||||
retry 90 "electrs-ui page" http_ok http://127.0.0.1:50002/
|
||||
retry 120 "electrs status has network height" electrs_status_ok
|
||||
retry 120 "electrs app-session status has network height" electrs_status_ok http://127.0.0.1/app/electrumx/electrs-status
|
||||
retry 120 "electrs legacy app-session status has network height" electrs_status_ok http://127.0.0.1/app/electrs/electrs-status
|
||||
}
|
||||
|
||||
reconcile_one() {
|
||||
local name="$1"
|
||||
"$RECONCILE" --container="$name" --force --force-recreate --create-missing
|
||||
}
|
||||
|
||||
restart_container() {
|
||||
local name="$1"
|
||||
log "restart $name"
|
||||
$PODMAN restart "$name" >/dev/null || {
|
||||
log "podman restart failed for $name; using stop/start"
|
||||
$PODMAN stop "$name" >/dev/null 2>&1 || true
|
||||
sleep 3
|
||||
$PODMAN start "$name" >/dev/null
|
||||
}
|
||||
}
|
||||
|
||||
remove_and_reconcile() {
|
||||
local name="$1"
|
||||
log "remove/recreate $name"
|
||||
$PODMAN rm -f "$name" >/dev/null 2>&1 || true
|
||||
reconcile_one "$name"
|
||||
}
|
||||
|
||||
log "target $(hostname) cycles=$CYCLES"
|
||||
log "using reconciler: $RECONCILE"
|
||||
|
||||
assert_bitcoin_stack
|
||||
assert_electrum_stack
|
||||
|
||||
for i in $(seq 1 "$CYCLES"); do
|
||||
log "cycle $i/$CYCLES: bitcoin restart"
|
||||
restart_container bitcoin-knots
|
||||
assert_bitcoin_stack
|
||||
assert_electrum_stack
|
||||
|
||||
log "cycle $i/$CYCLES: bitcoin remove/reconcile"
|
||||
remove_and_reconcile bitcoin-knots
|
||||
assert_bitcoin_stack
|
||||
assert_electrum_stack
|
||||
|
||||
log "cycle $i/$CYCLES: bitcoin UI remove/reconcile"
|
||||
remove_and_reconcile archy-bitcoin-ui
|
||||
assert_bitcoin_stack
|
||||
|
||||
log "cycle $i/$CYCLES: electrumx restart"
|
||||
restart_container electrumx
|
||||
assert_electrum_stack
|
||||
|
||||
log "cycle $i/$CYCLES: electrumx remove/reconcile"
|
||||
remove_and_reconcile electrumx
|
||||
assert_electrum_stack
|
||||
|
||||
log "cycle $i/$CYCLES: electrs UI remove/reconcile"
|
||||
remove_and_reconcile archy-electrs-ui
|
||||
assert_electrum_stack
|
||||
done
|
||||
|
||||
log "final container state"
|
||||
$PODMAN ps -a --format 'table {{.Names}}\t{{.State}}\t{{.Status}}' \
|
||||
| grep -E 'bitcoin-knots|electrumx|archy-bitcoin-ui|archy-electrs-ui' || true
|
||||
|
||||
log "summary: pass=$pass_count fail=$fail_count"
|
||||
[ "$fail_count" -eq 0 ]
|
||||
REMOTE
|
||||
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:-146.59.87.168:3000/lfg2025/electrumx:v1.18.0}"
|
||||
fi
|
||||
|
||||
# Mempool API
|
||||
if $DOCKER ps -a --format '{{.Names}}' 2>/dev/null | grep -q '^mempool-api$'; then
|
||||
recreate_container mempool-api \
|
||||
--name mempool-api --restart unless-stopped \
|
||||
--health-cmd="curl -sf http://localhost:8999/api/v1/backend-info || exit 1" \
|
||||
--health-interval=120s --health-timeout=5s --health-retries=3 \
|
||||
--memory=512m --network archy-net --network-alias mempool-api \
|
||||
--cap-drop ALL --cap-add CHOWN --cap-add SETUID --cap-add SETGID \
|
||||
--security-opt no-new-privileges:true \
|
||||
-v /var/lib/archipelago/mempool-data:/backend/cache \
|
||||
-e "MEMPOOL_BACKEND=electrum" \
|
||||
-e "CORE_RPC_HOST=bitcoin-knots" -e "CORE_RPC_PORT=8332" \
|
||||
-e "CORE_RPC_USERNAME=${RPC_USER}" -e "CORE_RPC_PASSWORD=${RPC_PASS}" \
|
||||
-e "ELECTRUM_HOST=electrumx" -e "ELECTRUM_PORT=50001" -e "ELECTRUM_TLS_ENABLED=false" \
|
||||
-e "DATABASE_ENABLED=true" -e "DATABASE_HOST=archy-mempool-db" \
|
||||
-e "DATABASE_DATABASE=mempool" -e "DATABASE_USERNAME=mempool" \
|
||||
-e "DATABASE_PASSWORD=$(cat "$SECRETS_DIR/mempool-db-password" 2>/dev/null || echo mempoolpass)" \
|
||||
"${MEMPOOL_API_IMAGE:-146.59.87.168:3000/lfg2025/mempool-api:v3.2.0}"
|
||||
fi
|
||||
|
||||
# Stop Tor tunnel if it was active
|
||||
if systemctl is-active archipelago-bootstrap-tunnel.service >/dev/null 2>&1; then
|
||||
log "Stopping bootstrap Tor tunnel..."
|
||||
systemctl stop archipelago-bootstrap-tunnel.service 2>/dev/null || true
|
||||
systemctl disable archipelago-bootstrap-tunnel.service 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Done — remove bootstrap flag
|
||||
rm -f "$BOOTSTRAP_FLAG"
|
||||
log "=== Bootstrap switchover complete — all services now using local Bitcoin node ==="
|
||||
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
|
||||
@@ -0,0 +1,182 @@
|
||||
#!/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",
|
||||
}
|
||||
|
||||
LEGACY_STACK_CATALOG_IDS = {
|
||||
"immich",
|
||||
"netbird",
|
||||
"tailscale",
|
||||
}
|
||||
|
||||
|
||||
def load_catalog(path: Path) -> dict[str, dict[str, Any]]:
|
||||
with path.open("r", encoding="utf-8") as fh:
|
||||
data = json.load(fh)
|
||||
apps = data.get("apps", [])
|
||||
if not isinstance(apps, list):
|
||||
raise ValueError(f"{path}: expected .apps to be a list")
|
||||
return {str(app.get("id", "")): app for app in apps if isinstance(app, dict) and app.get("id")}
|
||||
|
||||
|
||||
def load_manifests(apps_dir: Path) -> dict[str, dict[str, Any]]:
|
||||
manifests: dict[str, dict[str, Any]] = {}
|
||||
for path in sorted(apps_dir.glob("*/manifest.yml")):
|
||||
with path.open("r", encoding="utf-8") as fh:
|
||||
data = yaml.safe_load(fh)
|
||||
if not isinstance(data, dict) or not isinstance(data.get("app"), dict):
|
||||
continue
|
||||
app = data["app"]
|
||||
app_id = app.get("id")
|
||||
if app_id:
|
||||
manifests[str(app_id)] = {"path": str(path), "app": app}
|
||||
return manifests
|
||||
|
||||
|
||||
def metadata(app: dict[str, Any]) -> dict[str, Any]:
|
||||
value = app.get("metadata")
|
||||
return value if isinstance(value, dict) else {}
|
||||
|
||||
|
||||
def manifest_value(app: dict[str, Any], field: str) -> Any:
|
||||
meta = metadata(app)
|
||||
container = app.get("container") if isinstance(app.get("container"), dict) else {}
|
||||
match field:
|
||||
case "title":
|
||||
return app.get("name")
|
||||
case "version":
|
||||
return str(app.get("version", ""))
|
||||
case "description":
|
||||
return app.get("description")
|
||||
case "dockerImage":
|
||||
return container.get("image")
|
||||
case "category":
|
||||
return app.get("category") or meta.get("category")
|
||||
case "tier":
|
||||
return meta.get("tier")
|
||||
case "icon":
|
||||
return meta.get("icon")
|
||||
case "repoUrl":
|
||||
return meta.get("repo") or meta.get("repoUrl")
|
||||
case _:
|
||||
return None
|
||||
|
||||
|
||||
def normalize(value: Any) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
return str(value).strip()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--catalog", default="app-catalog/catalog.json")
|
||||
parser.add_argument("--apps-dir", default="apps")
|
||||
parser.add_argument(
|
||||
"--strict",
|
||||
action="store_true",
|
||||
help="exit non-zero when missing entries or metadata drift are found",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--release",
|
||||
action="store_true",
|
||||
help="suppress known internal/legacy-stack entries so output is release-actionable",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
catalog = load_catalog(Path(args.catalog))
|
||||
manifests = load_manifests(Path(args.apps_dir))
|
||||
|
||||
catalog_ids = set(catalog)
|
||||
manifest_ids = set(manifests)
|
||||
missing_manifests = sorted(catalog_ids - manifest_ids)
|
||||
missing_catalog = sorted(manifest_ids - catalog_ids)
|
||||
if args.release:
|
||||
missing_manifests = [app_id for app_id in missing_manifests if app_id not in LEGACY_STACK_CATALOG_IDS]
|
||||
missing_catalog = [app_id for app_id in missing_catalog if app_id not in INTERNAL_MANIFEST_IDS]
|
||||
|
||||
compared_fields = [
|
||||
"title",
|
||||
"version",
|
||||
"description",
|
||||
"dockerImage",
|
||||
"category",
|
||||
"tier",
|
||||
"icon",
|
||||
"repoUrl",
|
||||
]
|
||||
drift: list[str] = []
|
||||
for app_id in sorted(catalog_ids & manifest_ids):
|
||||
catalog_app = catalog[app_id]
|
||||
manifest_app = manifests[app_id]["app"]
|
||||
for field in compared_fields:
|
||||
catalog_val = normalize(catalog_app.get(field))
|
||||
manifest_val = normalize(manifest_value(manifest_app, field))
|
||||
if catalog_val and manifest_val and catalog_val != manifest_val:
|
||||
drift.append(f"{app_id}: {field}: catalog={catalog_val!r} manifest={manifest_val!r}")
|
||||
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"catalog_apps": len(catalog),
|
||||
"manifest_apps": len(manifests),
|
||||
"missing_manifests": len(missing_manifests),
|
||||
"missing_catalog": len(missing_catalog),
|
||||
"metadata_drift": len(drift),
|
||||
},
|
||||
sort_keys=True,
|
||||
)
|
||||
)
|
||||
|
||||
for app_id in missing_manifests:
|
||||
print(f"MISSING_MANIFEST {app_id}")
|
||||
for app_id in missing_catalog:
|
||||
print(f"MISSING_CATALOG {app_id}")
|
||||
for item in drift:
|
||||
print(f"DRIFT {item}")
|
||||
|
||||
if args.strict and (missing_manifests or missing_catalog or drift):
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
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
+675
@@ -0,0 +1,675 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Container Doctor — diagnose and fix common container health issues
|
||||
#
|
||||
# Usage:
|
||||
# sudo ./scripts/container-doctor.sh # Run locally on node
|
||||
# ./scripts/container-doctor.sh user@host # Run remotely via SSH
|
||||
#
|
||||
# Fixes:
|
||||
# 1. Stale podman ps/stats processes (>10 = pileup)
|
||||
# 2. Orphaned conmon/crun processes holding ports
|
||||
# 3. System tor conflicting with container tor
|
||||
# 4. Tor hidden service directory permissions (must be 700)
|
||||
# 5. SearXNG read-only root / cap-drop ALL
|
||||
# 6. Bitcoin Knots prune+txindex conflict
|
||||
# 7. Containers stuck with exit code 127 (binary not found)
|
||||
# 8. Stopped core containers (rootless restart policy workaround)
|
||||
# 9. Missing rootless port listeners while Podman still shows published ports
|
||||
# 10. Nginx Proxy Manager public hosts not mirrored into host nginx
|
||||
# 11. BTCPay stores producing unpayable Lightning invoices (route hints off)
|
||||
# 12. Missing catatonit (Podman init binary) — init-enabled deploys fail
|
||||
#
|
||||
# Safe to run multiple times (idempotent). Never blocks deploy (exit 0 always).
|
||||
#
|
||||
|
||||
set -o pipefail
|
||||
|
||||
# Source pinned image versions (single source of truth)
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
[ -f "$SCRIPT_DIR/image-versions.sh" ] && . "$SCRIPT_DIR/image-versions.sh"
|
||||
|
||||
FIXES_APPLIED=0
|
||||
CHECKS_PASSED=0
|
||||
FIX_NAMES=()
|
||||
|
||||
log() { echo "[$(date +%H:%M:%S)] DOCTOR: $*"; }
|
||||
|
||||
podman_rootless() {
|
||||
if [ "$(id -u)" = "0" ] && id archipelago >/dev/null 2>&1; then
|
||||
local archi_uid
|
||||
archi_uid=$(id -u archipelago)
|
||||
sudo -u archipelago env XDG_RUNTIME_DIR="/run/user/$archi_uid" podman "$@"
|
||||
else
|
||||
podman "$@"
|
||||
fi
|
||||
}
|
||||
|
||||
port_is_listening() {
|
||||
local port="$1"
|
||||
ss -ltn 2>/dev/null | awk '{print $4}' | grep -Eq "(^|:)$port$"
|
||||
}
|
||||
|
||||
run_fix() {
|
||||
local name="$1"
|
||||
shift
|
||||
if "$@"; then
|
||||
FIXES_APPLIED=$((FIXES_APPLIED + 1))
|
||||
FIX_NAMES+=("$name")
|
||||
else
|
||||
CHECKS_PASSED=$((CHECKS_PASSED + 1))
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Fix 1: Stale podman processes ────────────────────────────
|
||||
fix_stale_podman() {
|
||||
local count
|
||||
count=$(pgrep -f "podman (ps|stats)" 2>/dev/null | wc -l)
|
||||
count=${count:-0}
|
||||
if [ "$count" -gt 10 ]; then
|
||||
log "Killing $count stale podman ps/stats processes"
|
||||
pkill -f "podman (ps|stats)" 2>/dev/null || true
|
||||
sleep 2
|
||||
local after
|
||||
after=$(pgrep -f "podman (ps|stats)" 2>/dev/null | wc -l)
|
||||
after=${after:-0}
|
||||
log "Reduced from $count to $after"
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
# ── Fix 2: Orphaned conmon holding ports ─────────────────────
|
||||
fix_orphaned_conmon() {
|
||||
local fixed=false
|
||||
# Find conmon processes whose containers no longer exist
|
||||
local pids
|
||||
pids=$(pgrep -f "conmon.*--exit-command" 2>/dev/null || true)
|
||||
if [ -z "$pids" ]; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Doctor runs as root but containers are rootless under archipelago user.
|
||||
# Must check container existence using the rootless podman database.
|
||||
local PODMANCMD="sudo -u archipelago XDG_RUNTIME_DIR=/run/user/1000 podman"
|
||||
|
||||
for pid in $pids; do
|
||||
# Extract container ID from conmon args
|
||||
local cid
|
||||
cid=$(tr '\0' ' ' < /proc/"$pid"/cmdline 2>/dev/null | grep -oP '(?<=-c )[a-f0-9]{64}' || true)
|
||||
if [ -z "$cid" ]; then
|
||||
continue
|
||||
fi
|
||||
# Check if container still exists in rootless podman
|
||||
if ! $PODMANCMD inspect "$cid" &>/dev/null; then
|
||||
local port_info
|
||||
port_info=$(ss -tlnp 2>/dev/null | grep "pid=$pid" | grep -oP ':\K\d+' | head -3 | tr '\n' ',' | sed 's/,$//')
|
||||
log "Killing orphaned conmon pid=$pid (ports: ${port_info:-none})"
|
||||
kill "$pid" 2>/dev/null || kill -9 "$pid" 2>/dev/null || true
|
||||
fixed=true
|
||||
fi
|
||||
done
|
||||
|
||||
$fixed && return 0 || return 1
|
||||
}
|
||||
|
||||
# ── Fix 3: Ensure system Tor is running (preferred over container) ──
|
||||
fix_system_tor_conflict() {
|
||||
# System Tor is preferred over container Tor.
|
||||
# If archy-tor container exists, remove it and use system Tor instead.
|
||||
if podman ps -a --format '{{.Names}}' 2>/dev/null | grep -qE '^archy-tor$'; then
|
||||
podman stop archy-tor 2>/dev/null || true
|
||||
podman rm -f archy-tor 2>/dev/null || true
|
||||
log "Removed archy-tor container (system Tor is preferred)"
|
||||
fi
|
||||
|
||||
# Ensure system Tor is enabled and running
|
||||
if command -v tor >/dev/null 2>&1; then
|
||||
if ! systemctl is-active tor@default >/dev/null 2>&1; then
|
||||
systemctl enable tor tor@default 2>/dev/null || true
|
||||
systemctl start tor tor@default 2>/dev/null || true
|
||||
log "Started system Tor"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
# ── Fix 4: Tor hidden service permissions ────────────────────
|
||||
fix_tor_permissions() {
|
||||
local fixed=false
|
||||
local tor_dirs=("/var/lib/archipelago/tor" "/var/lib/tor")
|
||||
|
||||
for base in "${tor_dirs[@]}"; do
|
||||
if [ ! -d "$base" ]; then
|
||||
continue
|
||||
fi
|
||||
while IFS= read -r dir; do
|
||||
local perms
|
||||
perms=$(stat -c '%a' "$dir" 2>/dev/null)
|
||||
if [ "$perms" != "700" ]; then
|
||||
chmod 700 "$dir"
|
||||
log "Fixed permissions on $dir ($perms -> 700)"
|
||||
fixed=true
|
||||
fi
|
||||
done < <(find "$base" -maxdepth 1 -name "hidden_service_*" -type d 2>/dev/null)
|
||||
done
|
||||
|
||||
# If we fixed permissions, restart system Tor to pick up the changes
|
||||
if $fixed; then
|
||||
systemctl restart tor@default 2>/dev/null || true
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
# ── Fix 5: SearXNG read-only / cap-drop ─────────────────────
|
||||
fix_searxng() {
|
||||
if ! podman ps -a --format '{{.Names}}' 2>/dev/null | grep -q '^searxng$'; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
local state
|
||||
state=$(podman inspect searxng --format '{{.State.Status}}' 2>/dev/null || true)
|
||||
local readonly_root
|
||||
readonly_root=$(podman inspect searxng --format '{{.HostConfig.ReadonlyRootfs}}' 2>/dev/null || true)
|
||||
local cap_drop
|
||||
cap_drop=$(podman inspect searxng --format '{{.HostConfig.CapDrop}}' 2>/dev/null || true)
|
||||
|
||||
# Fix if: exited, or has read-only root, or has cap-drop ALL
|
||||
local needs_fix=false
|
||||
if [ "$state" = "exited" ]; then
|
||||
needs_fix=true
|
||||
fi
|
||||
if [ "$readonly_root" = "true" ]; then
|
||||
needs_fix=true
|
||||
fi
|
||||
if [[ "$cap_drop" == *"ALL"* ]] || [[ "$cap_drop" == *"all"* ]]; then
|
||||
needs_fix=true
|
||||
fi
|
||||
|
||||
if ! $needs_fix; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
log "Recreating SearXNG (readonly=$readonly_root, cap_drop=$cap_drop, state=$state)"
|
||||
|
||||
# Get current port mapping
|
||||
local port
|
||||
port=$(podman inspect searxng --format '{{range $k,$v := .HostConfig.PortBindings}}{{$k}}={{range $v}}{{.HostPort}}{{end}}{{println}}{{end}}' 2>/dev/null | head -1)
|
||||
local host_port="${port##*=}"
|
||||
host_port="${host_port:-8888}"
|
||||
|
||||
# Kill any stale conmon holding the port
|
||||
local conmon_pid
|
||||
conmon_pid=$(ss -tlnp 2>/dev/null | grep ":${host_port} " | grep -oP 'pid=\K\d+' | head -1)
|
||||
|
||||
podman stop searxng 2>/dev/null || true
|
||||
podman rm -f searxng 2>/dev/null || true
|
||||
|
||||
if [ -n "$conmon_pid" ]; then
|
||||
kill -9 "$conmon_pid" 2>/dev/null || true
|
||||
sleep 2
|
||||
fi
|
||||
|
||||
podman run -d \
|
||||
--name searxng \
|
||||
--restart=unless-stopped \
|
||||
--security-opt=no-new-privileges:true \
|
||||
--tmpfs /tmp:rw,noexec,nosuid,size=256m \
|
||||
-v searxng-config:/etc/searxng:rw \
|
||||
-v searxng-cache:/var/cache/searxng:rw \
|
||||
-p "${host_port}:8080" \
|
||||
--memory=512m \
|
||||
"${SEARXNG_IMAGE}" 2>&1 || true
|
||||
|
||||
log "SearXNG recreated (no readonly, no cap-drop ALL)"
|
||||
return 0
|
||||
}
|
||||
|
||||
# ── Fix 6: Bitcoin Knots prune+txindex conflict ──────────────
|
||||
fix_bitcoin_txindex() {
|
||||
if ! podman ps -a --format '{{.Names}}' 2>/dev/null | grep -q '^bitcoin-knots$'; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Check if bitcoin.conf has prune enabled
|
||||
local conf="/var/lib/archipelago/bitcoin/bitcoin.conf"
|
||||
if [ ! -f "$conf" ] || ! grep -q '^prune=' "$conf"; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Check if container args include txindex
|
||||
local cmd
|
||||
cmd=$(podman inspect bitcoin-knots --format '{{json .Config.Cmd}}' 2>/dev/null || true)
|
||||
if ! echo "$cmd" | grep -q "txindex"; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
log "Bitcoin Knots: prune+txindex conflict detected"
|
||||
|
||||
# Get current config
|
||||
local image
|
||||
image=$(podman inspect bitcoin-knots --format '{{.ImageName}}' 2>/dev/null)
|
||||
local network
|
||||
network=$(podman inspect bitcoin-knots --format '{{.HostConfig.NetworkMode}}' 2>/dev/null)
|
||||
|
||||
# Read per-installation RPC password
|
||||
local SECRETS_DIR="/var/lib/archipelago/secrets"
|
||||
local BTC_RPC_PASS="archipelago"
|
||||
if [ -f "$SECRETS_DIR/bitcoin-rpc-password" ]; then
|
||||
BTC_RPC_PASS=$(cat "$SECRETS_DIR/bitcoin-rpc-password")
|
||||
fi
|
||||
|
||||
# Ensure bitcoin.conf has all RPC settings
|
||||
if ! grep -q 'rpcuser=' "$conf"; then
|
||||
cat > "$conf" <<BCONF
|
||||
server=1
|
||||
prune=550
|
||||
rpcuser=archipelago
|
||||
rpcpassword=$BTC_RPC_PASS
|
||||
rpcallowip=127.0.0.1/32
|
||||
rpcallowip=10.88.0.0/16
|
||||
listen=1
|
||||
printtoconsole=0
|
||||
BCONF
|
||||
log "Updated bitcoin.conf with full RPC settings"
|
||||
fi
|
||||
|
||||
# Remove stale txindex if present
|
||||
if [ -d "/var/lib/archipelago/bitcoin/indexes/txindex" ]; then
|
||||
find /var/lib/archipelago/bitcoin/indexes/txindex -type f -delete 2>/dev/null
|
||||
rmdir /var/lib/archipelago/bitcoin/indexes/txindex 2>/dev/null || true
|
||||
log "Removed stale txindex directory"
|
||||
fi
|
||||
|
||||
# Recreate without txindex
|
||||
podman stop bitcoin-knots 2>/dev/null || true
|
||||
podman rm -f bitcoin-knots 2>/dev/null || true
|
||||
sleep 2
|
||||
|
||||
# Kill stale conmon on port 8332/8333
|
||||
for p in 8332 8333; do
|
||||
local cpid
|
||||
cpid=$(ss -tlnp 2>/dev/null | grep ":${p} " | grep -oP 'pid=\K\d+' | head -1)
|
||||
if [ -n "$cpid" ]; then
|
||||
kill -9 "$cpid" 2>/dev/null || true
|
||||
fi
|
||||
done
|
||||
sleep 1
|
||||
|
||||
local net_arg=""
|
||||
if [ -n "$network" ] && [ "$network" != "bridge" ] && [ "$network" != "host" ]; then
|
||||
net_arg="--network=$network"
|
||||
elif [ "$network" = "host" ]; then
|
||||
net_arg="--network=host"
|
||||
else
|
||||
net_arg="--network=archy-net"
|
||||
fi
|
||||
|
||||
podman run -d \
|
||||
--name bitcoin-knots \
|
||||
--restart=always \
|
||||
$net_arg \
|
||||
-p 8332:8332 \
|
||||
-p 8333:8333 \
|
||||
-v /var/lib/archipelago/bitcoin:/home/bitcoin/.bitcoin \
|
||||
--memory=2g \
|
||||
--cap-drop=ALL \
|
||||
--cap-add=CHOWN \
|
||||
--cap-add=FOWNER \
|
||||
--cap-add=SETUID \
|
||||
--cap-add=SETGID \
|
||||
--cap-add=DAC_OVERRIDE \
|
||||
--security-opt=no-new-privileges:true \
|
||||
--health-cmd="bitcoin-cli -rpcuser=archipelago -rpcpassword=$BTC_RPC_PASS getblockchaininfo || exit 1" \
|
||||
--health-interval=30s \
|
||||
--health-retries=3 \
|
||||
"$image" 2>&1 || true
|
||||
|
||||
log "Bitcoin Knots recreated without txindex (prune mode)"
|
||||
return 0
|
||||
}
|
||||
|
||||
# ── Fix 7: Exit code 127 containers ─────────────────────────
|
||||
fix_exit_127() {
|
||||
local containers
|
||||
containers=$(podman ps -a --format '{{.Names}} {{.Status}}' 2>/dev/null | grep 'Exited (127)' | awk '{print $1}' || true)
|
||||
|
||||
if [ -z "$containers" ]; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
local fixed_names=()
|
||||
for name in $containers; do
|
||||
# Skip containers handled by other fixes
|
||||
if [ "$name" = "searxng" ]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
log "Container $name has exit code 127 — recreating"
|
||||
# Get image and create command for recreation
|
||||
local image
|
||||
image=$(podman inspect "$name" --format '{{.ImageName}}' 2>/dev/null || true)
|
||||
local create_cmd
|
||||
create_cmd=$(podman inspect "$name" --format '{{json .Config.CreateCommand}}' 2>/dev/null || true)
|
||||
|
||||
podman rm -f "$name" 2>/dev/null || true
|
||||
|
||||
if [ -n "$create_cmd" ] && [ "$create_cmd" != "null" ]; then
|
||||
# Re-run the original create command (strip the leading "podman" and "run")
|
||||
local recreate_args
|
||||
recreate_args=$(echo "$create_cmd" | python3 -c "
|
||||
import json, sys
|
||||
args = json.load(sys.stdin)
|
||||
# Skip 'podman' and 'run', output the rest
|
||||
print(' '.join(['\"' + a + '\"' if ' ' in a else a for a in args[2:]]))
|
||||
" 2>/dev/null || true)
|
||||
|
||||
if [ -n "$recreate_args" ]; then
|
||||
eval "podman run $recreate_args" 2>&1 || true
|
||||
fixed_names+=("$name")
|
||||
log "Recreated $name from original args"
|
||||
else
|
||||
fixed_names+=("$name(removed)")
|
||||
log "Removed $name — will be recreated on next deploy"
|
||||
fi
|
||||
else
|
||||
fixed_names+=("$name(removed)")
|
||||
log "Removed $name — will be recreated on next deploy"
|
||||
fi
|
||||
done
|
||||
|
||||
[ ${#fixed_names[@]} -gt 0 ] && return 0 || return 1
|
||||
}
|
||||
|
||||
# ── Fix 8: Rootless netns egress lost ────────────────────────
|
||||
# Rootless podman uses pasta to give containers internet egress. If pasta's
|
||||
# tap vanishes (host link flap, mount churn, pasta dying during a boot-time
|
||||
# restart storm), the rootless-netns keeps inter-container traffic working
|
||||
# but silently loses outbound. Bitcoin IBD stalls at 0 peers; package pulls
|
||||
# fail. The repair must rebuild the netns from scratch: merely cycling the
|
||||
# containers reuses the existing (broken) netns because its holders
|
||||
# (aardvark-dns, podman's pause process) survive — observed on shorty-s
|
||||
# 2026-07-10, where the old stop/start-only cycle bounced all 35 containers
|
||||
# every timer run for ~an hour without ever restoring egress. So: stop the
|
||||
# containers, kill the netns holders, `podman system migrate`, clear the
|
||||
# stale netns state, then start everything back up.
|
||||
#
|
||||
# Destructive-action latch: cycling the whole fleet is a last resort. After
|
||||
# NETNS_CYCLE_MAX consecutive failed repairs we stop cycling (and log loudly)
|
||||
# until a run observes egress healthy again, which resets the counter.
|
||||
NETNS_CYCLE_STATE="/var/lib/archipelago/doctor-netns-cycle-failures"
|
||||
NETNS_CYCLE_MAX=3
|
||||
fix_rootless_netns_egress() {
|
||||
# Needs root for nsenter. When doctor runs as the rootless container owner,
|
||||
# a failed nsenter probe is a permissions artifact, not evidence of broken
|
||||
# egress; do not cycle the fleet from that context.
|
||||
[ "$(id -u)" = "0" ] || return 1
|
||||
|
||||
local archi_uid
|
||||
archi_uid=$(id -u archipelago 2>/dev/null) || return 1
|
||||
|
||||
# Locate the rootless-netns via aardvark-dns (it lives inside it).
|
||||
local aardvark_pid
|
||||
aardvark_pid=$(pgrep -U "$archi_uid" -f '^/usr/lib/podman/aardvark-dns' 2>/dev/null | head -1)
|
||||
[ -z "$aardvark_pid" ] && return 1 # no rootless network active
|
||||
|
||||
# Host precheck: if the host itself can't reach the internet, no point
|
||||
# cycling containers — this is an upstream problem.
|
||||
if ! timeout 3 bash -c '</dev/tcp/1.1.1.1/443' 2>/dev/null; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Probe egress from inside the rootless-netns. One probe is noisy;
|
||||
# require two consecutive failures 10s apart to rule out transients.
|
||||
if timeout 3 nsenter -t "$aardvark_pid" -n bash -c '</dev/tcp/1.1.1.1/443' 2>/dev/null; then
|
||||
rm -f "$NETNS_CYCLE_STATE" # healthy again — re-arm the latch
|
||||
return 1 # first probe succeeded
|
||||
fi
|
||||
sleep 10
|
||||
aardvark_pid=$(pgrep -U "$archi_uid" -f '^/usr/lib/podman/aardvark-dns' 2>/dev/null | head -1)
|
||||
[ -z "$aardvark_pid" ] && return 1
|
||||
if timeout 3 nsenter -t "$aardvark_pid" -n bash -c '</dev/tcp/1.1.1.1/443' 2>/dev/null; then
|
||||
rm -f "$NETNS_CYCLE_STATE"
|
||||
return 1 # recovered on its own
|
||||
fi
|
||||
|
||||
# Latch: don't keep bouncing the fleet when the rebuild demonstrably
|
||||
# isn't fixing it.
|
||||
local failures
|
||||
failures=$(cat "$NETNS_CYCLE_STATE" 2>/dev/null || echo 0)
|
||||
case "$failures" in *[!0-9]*|"") failures=0;; esac
|
||||
if [ "$failures" -ge "$NETNS_CYCLE_MAX" ]; then
|
||||
log "Rootless-netns egress still broken but $failures rebuilds already failed — NOT cycling again (manual intervention needed; rm $NETNS_CYCLE_STATE to re-arm)"
|
||||
return 1
|
||||
fi
|
||||
|
||||
log "Rootless-netns egress is broken (host online, container netns unreachable) — rebuilding netns"
|
||||
|
||||
local PODMANCMD="sudo -u archipelago XDG_RUNTIME_DIR=/run/user/$archi_uid podman"
|
||||
local running
|
||||
running=$($PODMANCMD ps --format '{{.Names}}' 2>/dev/null)
|
||||
if [ -z "$running" ]; then
|
||||
log " No running containers to cycle — skipping"
|
||||
return 1
|
||||
fi
|
||||
|
||||
local count
|
||||
count=$(echo "$running" | wc -l)
|
||||
log " Stopping $count running containers (graceful, 30s)..."
|
||||
$PODMANCMD stop --all --time 30 >/dev/null 2>&1
|
||||
sleep 5
|
||||
|
||||
# Tear the broken netns down for real: kill its holders and drop the
|
||||
# stale state so the first container start rebuilds pasta + aardvark-dns
|
||||
# from scratch. Without this, podman re-enters the old netns and the
|
||||
# missing pasta tap never comes back.
|
||||
log " Rebuilding rootless netns (killing holders, clearing state)..."
|
||||
pkill -U "$archi_uid" -x aardvark-dns 2>/dev/null
|
||||
pkill -U "$archi_uid" -x pasta 2>/dev/null
|
||||
pkill -U "$archi_uid" -x pasta.avx2 2>/dev/null
|
||||
pkill -U "$archi_uid" -x slirp4netns 2>/dev/null
|
||||
sleep 2
|
||||
$PODMANCMD system migrate >/dev/null 2>&1
|
||||
rm -rf "/run/user/$archi_uid/containers/networks"
|
||||
|
||||
log " Starting containers back up..."
|
||||
for c in $running; do
|
||||
$PODMANCMD start "$c" >/dev/null 2>&1 &
|
||||
done
|
||||
wait
|
||||
sleep 5
|
||||
|
||||
aardvark_pid=$(pgrep -U "$archi_uid" -f '^/usr/lib/podman/aardvark-dns' 2>/dev/null | head -1)
|
||||
if [ -n "$aardvark_pid" ] && timeout 3 nsenter -t "$aardvark_pid" -n bash -c '</dev/tcp/1.1.1.1/443' 2>/dev/null; then
|
||||
log " Rootless-netns egress restored ($count containers cycled)"
|
||||
rm -f "$NETNS_CYCLE_STATE"
|
||||
else
|
||||
failures=$((failures + 1))
|
||||
echo "$failures" > "$NETNS_CYCLE_STATE"
|
||||
log " WARN: egress still broken after rebuild (failure $failures/$NETNS_CYCLE_MAX) — may need manual intervention"
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
# ── Fix 9: Restart stopped core containers ──────────────────
|
||||
# Rootless Podman 4.x restart policies don't auto-restart on crash.
|
||||
# This check restarts any exited core containers (tiers 0-2).
|
||||
fix_stopped_core_containers() {
|
||||
local core_containers="bitcoin-knots lnd electrumx mempool-api archy-mempool-web archy-mempool-db archy-btcpay-db archy-nbxplorer btcpay-server"
|
||||
local restarted=()
|
||||
# Doctor runs as root but containers are rootless under archipelago user
|
||||
local PODMANCMD="sudo -u archipelago XDG_RUNTIME_DIR=/run/user/1000 podman"
|
||||
|
||||
for name in $core_containers; do
|
||||
local state
|
||||
state=$($PODMANCMD inspect "$name" --format '{{.State.Status}}' 2>/dev/null || echo "missing")
|
||||
if [ "$state" = "exited" ] || [ "$state" = "stopped" ]; then
|
||||
log "Restarting stopped container: $name"
|
||||
$PODMANCMD start "$name" 2>/dev/null && restarted+=("$name") || true
|
||||
fi
|
||||
done
|
||||
|
||||
[ ${#restarted[@]} -gt 0 ] && return 0 || return 1
|
||||
}
|
||||
|
||||
# ── Fix 10: Missing rootless port listeners ─────────────────
|
||||
# Rootless Podman can leave a container running with PortBindings still present
|
||||
# while the host-side rootlessport process has disappeared. Nginx then returns
|
||||
# 502 and direct app ports refuse connections even though `podman ps` looks OK.
|
||||
fix_missing_rootless_ports() {
|
||||
local containers
|
||||
containers=$(podman_rootless ps --format '{{.Names}}' 2>/dev/null || true)
|
||||
[ -n "$containers" ] || return 1
|
||||
|
||||
local fixed=false
|
||||
local name
|
||||
for name in $containers; do
|
||||
local ports
|
||||
ports=$(podman_rootless inspect "$name" --format '{{range $p,$bindings := .NetworkSettings.Ports}}{{if $bindings}}{{range $bindings}}{{.HostPort}}{{"\n"}}{{end}}{{end}}{{end}}' 2>/dev/null | sort -u)
|
||||
[ -n "$ports" ] || continue
|
||||
|
||||
local missing=()
|
||||
local port
|
||||
for port in $ports; do
|
||||
[ -n "$port" ] || continue
|
||||
if ! port_is_listening "$port"; then
|
||||
missing+=("$port")
|
||||
fi
|
||||
done
|
||||
|
||||
if [ ${#missing[@]} -gt 0 ]; then
|
||||
log "Restarting $name: missing rootlessport listener(s): ${missing[*]}"
|
||||
if podman_rootless restart "$name" >/dev/null 2>&1; then
|
||||
fixed=true
|
||||
else
|
||||
log "WARN: failed to restart $name for missing rootlessport listener(s)"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
$fixed && return 0 || return 1
|
||||
}
|
||||
|
||||
# ── Fix 11: Nginx Proxy Manager public host bridge ───────────
|
||||
# Host nginx owns public 80/443 on Archipelago. Mirror NPM proxy hosts into
|
||||
# host nginx so issued certs and public traffic reach the intended upstreams.
|
||||
fix_npm_public_hosts() {
|
||||
local script="/opt/archipelago/scripts/sync-npm-public-hosts.sh"
|
||||
[ -x "$script" ] || script="$SCRIPT_DIR/sync-npm-public-hosts.sh"
|
||||
[ -x "$script" ] || return 1
|
||||
[ -f /var/lib/archipelago/nginx-proxy-manager/data/database.sqlite ] || return 1
|
||||
|
||||
if "$script" >/dev/null 2>&1; then
|
||||
log "Synced Nginx Proxy Manager public hosts into host nginx"
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
# ── Fix 12: BTCPay Lightning route hints ─────────────────────
|
||||
# A BTCPay store whose LND node has only private (unannounced) channels
|
||||
# produces BOLT11 invoices that external wallets cannot route to unless the
|
||||
# store's lightningPrivateRouteHints flag is on — payers see "no way to pay
|
||||
# this invoice" (observed on shorty-s 2026-07-10 with a Blink payer). Route
|
||||
# hints are a no-op with public channels and essential with private ones, so
|
||||
# the doctor enforces the flag on every store. BTCPay reads store blobs from
|
||||
# Postgres per request; no restart needed.
|
||||
fix_btcpay_route_hints() {
|
||||
local state
|
||||
state=$(podman_rootless inspect archy-btcpay-db --format '{{.State.Status}}' 2>/dev/null || echo "missing")
|
||||
[ "$state" = "running" ] || return 1
|
||||
|
||||
local count
|
||||
count=$(podman_rootless exec archy-btcpay-db psql -U btcpay -d btcpay -t -A -c \
|
||||
"SELECT count(*) FROM \"Stores\" WHERE (\"StoreBlob\"->>'lightningPrivateRouteHints') = 'false';" 2>/dev/null)
|
||||
[ -n "$count" ] && [ "$count" -gt 0 ] 2>/dev/null || return 1
|
||||
|
||||
if podman_rootless exec archy-btcpay-db psql -U btcpay -d btcpay -q -c \
|
||||
"UPDATE \"Stores\" SET \"StoreBlob\" = jsonb_set(\"StoreBlob\", '{lightningPrivateRouteHints}', 'true'::jsonb) WHERE (\"StoreBlob\"->>'lightningPrivateRouteHints') = 'false';" >/dev/null 2>&1; then
|
||||
log "Enabled Lightning route hints on $count BTCPay store(s) (private-channel invoices were unpayable)"
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
# ── Fix 13: Missing catatonit (container init binary) ────────
|
||||
# Podman resolves `--init` (and any Portainer/compose deploy with
|
||||
# "init: true") through catatonit; Debian's podman package only
|
||||
# Recommends it, so a node installed or upgraded without it fails those
|
||||
# deploys with a missing-init error (observed on shorty-s 2026-07-10
|
||||
# deploying sites via Portainer). install-podman.sh covers fresh ISO
|
||||
# installs; this heals nodes that predate it.
|
||||
fix_missing_catatonit() {
|
||||
command -v catatonit >/dev/null 2>&1 && return 1
|
||||
command -v apt-get >/dev/null 2>&1 || return 1
|
||||
|
||||
if DEBIAN_FRONTEND=noninteractive apt-get install -y catatonit >/dev/null 2>&1; then
|
||||
log "Installed catatonit (init-enabled container deploys were failing)"
|
||||
return 0
|
||||
fi
|
||||
log "WARNING: catatonit missing and apt-get install failed — init-enabled deploys will fail"
|
||||
return 1
|
||||
}
|
||||
|
||||
# ── Fix 14: archipelago user missing dialout (mesh radios) ───
|
||||
# The image used to create the archipelago user with only `sudo`, so the
|
||||
# backend couldn't open /dev/ttyUSB*/ttyACM* serial LoRa radios — Mesh
|
||||
# never detected a plugged-in device (observed on the 1.7.99 RC install
|
||||
# 2026-07-13). Group change takes effect on the next service restart; we
|
||||
# restart archipelago only if a serial device is actually present.
|
||||
fix_archipelago_dialout() {
|
||||
id -nG archipelago 2>/dev/null | grep -qw dialout && return 1
|
||||
usermod -aG dialout archipelago 2>/dev/null || return 1
|
||||
log "Added archipelago to dialout (serial mesh radios were unreadable)"
|
||||
if ls /dev/ttyUSB* /dev/ttyACM* >/dev/null 2>&1; then
|
||||
systemctl try-restart archipelago 2>/dev/null || true
|
||||
log "Restarted archipelago to pick up dialout (radio present)"
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
# ── Main ─────────────────────────────────────────────────────
|
||||
|
||||
# If remote host provided, run via SSH
|
||||
if [ -n "$1" ] && [ "$1" != "--local" ]; then
|
||||
REMOTE_HOST="$1"
|
||||
SSH_KEY="${ARCHIPELAGO_SSH_KEY:-$HOME/.ssh/archipelago-deploy}"
|
||||
SSH_OPTS="-o StrictHostKeyChecking=no -o ServerAliveInterval=15 -o ServerAliveCountMax=4 -i $SSH_KEY"
|
||||
|
||||
log "Running container doctor on $REMOTE_HOST"
|
||||
|
||||
# Copy script to remote and execute
|
||||
scp $SSH_OPTS "$0" "$REMOTE_HOST:/tmp/container-doctor.sh" 2>/dev/null
|
||||
ssh $SSH_OPTS "$REMOTE_HOST" "sudo bash /tmp/container-doctor.sh --local" 2>&1
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Running locally (on the node itself)
|
||||
log "Starting container health check"
|
||||
|
||||
run_fix "stale-podman" fix_stale_podman
|
||||
run_fix "orphaned-conmon" fix_orphaned_conmon
|
||||
run_fix "system-tor" fix_system_tor_conflict
|
||||
run_fix "tor-permissions" fix_tor_permissions
|
||||
run_fix "searxng" fix_searxng
|
||||
run_fix "bitcoin-txindex" fix_bitcoin_txindex
|
||||
run_fix "exit-127" fix_exit_127
|
||||
run_fix "netns-egress" fix_rootless_netns_egress
|
||||
run_fix "stopped-core" fix_stopped_core_containers
|
||||
run_fix "rootless-ports" fix_missing_rootless_ports
|
||||
run_fix "npm-public-hosts" fix_npm_public_hosts
|
||||
run_fix "btcpay-route-hints" fix_btcpay_route_hints
|
||||
run_fix "catatonit" fix_missing_catatonit
|
||||
run_fix "dialout" fix_archipelago_dialout
|
||||
|
||||
echo ""
|
||||
if [ $FIXES_APPLIED -gt 0 ]; then
|
||||
log "Done: $FIXES_APPLIED fixes applied (${FIX_NAMES[*]}), $CHECKS_PASSED checks passed"
|
||||
else
|
||||
log "Done: all $CHECKS_PASSED checks passed — no fixes needed"
|
||||
fi
|
||||
|
||||
exit 0
|
||||
Executable
+640
@@ -0,0 +1,640 @@
|
||||
#!/bin/bash
|
||||
# Container specification registry — SINGLE SOURCE OF TRUTH
|
||||
# Every container's exact creation spec lives here.
|
||||
# Sourced by reconcile-containers.sh, first-boot-containers.sh, deploy scripts.
|
||||
#
|
||||
# Usage:
|
||||
# source container-specs.sh
|
||||
# load_spec "bitcoin-knots" # Sets SPEC_* variables
|
||||
# all_specs # Returns ordered list of all containers
|
||||
|
||||
[ -n "${_CONTAINER_SPECS_LOADED:-}" ] && return 0
|
||||
_CONTAINER_SPECS_LOADED=1
|
||||
|
||||
# Source image versions
|
||||
for f in /opt/archipelago/image-versions.sh \
|
||||
"$(dirname "${BASH_SOURCE[0]}")/image-versions.sh" \
|
||||
"$(dirname "${BASH_SOURCE[0]}")/../image-versions.sh"; do
|
||||
[ -f "$f" ] && { source "$f"; break; }
|
||||
done
|
||||
|
||||
# Source common utilities (mem_limit)
|
||||
for f in "$(dirname "${BASH_SOURCE[0]}")/lib/common.sh" \
|
||||
/opt/archipelago/scripts/lib/common.sh; do
|
||||
[ -f "$f" ] && { source "$f"; break; }
|
||||
done
|
||||
|
||||
# ── Environment detection ─────────────────────────────────────────────
|
||||
detect_environment() {
|
||||
# Measure disk where container data actually lives, not the OS partition.
|
||||
# Archipelago installs mount a separate (usually-encrypted) data volume at
|
||||
# /var/lib/archipelago on any host with meaningful storage, so checking /
|
||||
# would always report the ~30 GB OS partition and wrongly trip prune mode
|
||||
# on 2 TB boxes. Fall back to / only for first-boot before the data
|
||||
# partition is mounted.
|
||||
local disk_target="/var/lib/archipelago"
|
||||
[ -d "$disk_target" ] || disk_target="/"
|
||||
DISK_GB=$(df --output=size -BG "$disk_target" 2>/dev/null | tail -1 | tr -dc '0-9')
|
||||
DISK_GB=${DISK_GB:-500}
|
||||
TOTAL_MEM_MB=$(($(awk '/MemTotal/{print $2}' /proc/meminfo 2>/dev/null || echo 16000000) / 1024))
|
||||
LOW_MEM=false
|
||||
[ "$TOTAL_MEM_MB" -lt 12000 ] && LOW_MEM=true
|
||||
# Bitcoin UTXO cache (dbcache) sized to host RAM, NOT a fixed value.
|
||||
# A large dbcache on a small box pushes total memory (bitcoind + the ~20 app
|
||||
# containers) past physical RAM and forces system-wide swap thrash: the disk
|
||||
# saturates, bitcoind can't answer its own RPC, and the dashboard backend's
|
||||
# sqlite reads stall — surfacing as fleet-wide /rpc/v1 502s and a blank
|
||||
# Bitcoin UI. The old binary LOW_MEM->2048 toggle still over-committed 8 GB
|
||||
# nodes. Budget ~1/16 of RAM for the cache, leaving the bulk for the OS +
|
||||
# containers; floor 300 MB (bitcoind default is 450), cap 4096 MB.
|
||||
BTC_DBCACHE=$(( TOTAL_MEM_MB / 16 ))
|
||||
[ "$BTC_DBCACHE" -lt 300 ] && BTC_DBCACHE=300
|
||||
[ "$BTC_DBCACHE" -gt 4096 ] && BTC_DBCACHE=4096
|
||||
HOST_IP=$(hostname -I 2>/dev/null | awk '{print $1}')
|
||||
HOST_IP=${HOST_IP:-127.0.0.1}
|
||||
# Stable mDNS hostname for URLs that get baked into federation/consensus data.
|
||||
# Survives DHCP churn and reinstalls-on-different-IP (which $HOST_IP does not).
|
||||
# Requires avahi-daemon (shipped on all Archipelago nodes).
|
||||
HOST_MDNS="$(hostname 2>/dev/null).local"
|
||||
HOST_MDNS="${HOST_MDNS:-archipelago.local}"
|
||||
|
||||
# Secrets
|
||||
SECRETS_DIR="/var/lib/archipelago/secrets"
|
||||
BITCOIN_RPC_USER="archipelago"
|
||||
BITCOIN_RPC_PASS=$(cat "$SECRETS_DIR/bitcoin-rpc-password" 2>/dev/null || echo "")
|
||||
MEMPOOL_DB_PASS=$(cat "$SECRETS_DIR/mempool-db-password" 2>/dev/null || echo "")
|
||||
BTCPAY_DB_PASS=$(cat "$SECRETS_DIR/btcpay-db-password" 2>/dev/null || echo "")
|
||||
MYSQL_ROOT_PASS=$(cat "$SECRETS_DIR/mysql-root-db-password" 2>/dev/null || echo "")
|
||||
FEDI_HASH=$(cat "$SECRETS_DIR/fedimint-gateway-hash" 2>/dev/null || echo "")
|
||||
# Escape $ so SPEC_ENTRYPOINT survives eval in reconcile-containers.sh:build_run_cmd.
|
||||
# bcrypt hashes have the form $2y$10$... and get mangled if $2 and $10 are
|
||||
# interpolated as positional args at eval time.
|
||||
FEDI_HASH="${FEDI_HASH//\$/\\\$}"
|
||||
}
|
||||
|
||||
# ── Spec variables ────────────────────────────────────────────────────
|
||||
# Each load_spec_* function sets these variables:
|
||||
# SPEC_NAME Container name
|
||||
# SPEC_IMAGE Full image reference (pinned)
|
||||
# SPEC_NETWORK Network mode (archy-net, bridge, host)
|
||||
# SPEC_PORTS Space-separated host:container port pairs
|
||||
# SPEC_VOLUMES Space-separated host:container volume mappings
|
||||
# SPEC_MEMORY Memory limit (e.g. 2g, 512m)
|
||||
# SPEC_CAPS Space-separated capabilities to add
|
||||
# SPEC_SECURITY Security options
|
||||
# SPEC_RESTART Restart policy
|
||||
# SPEC_HEALTH_CMD Health check command
|
||||
# SPEC_ENV Space-separated KEY=VALUE environment variables
|
||||
# SPEC_CUSTOM_ARGS Extra args appended to podman run
|
||||
# SPEC_READONLY true/false for --read-only
|
||||
# SPEC_TMPFS Space-separated tmpfs mounts
|
||||
# SPEC_TIER 0=DB, 1=Core, 2=Service, 3=App, 4=UI
|
||||
# SPEC_DATA_DIR Host data directory (for ownership fix)
|
||||
# SPEC_DATA_UID Host UID:GID for data dir (rootless mapped)
|
||||
# SPEC_DEPENDS Space-separated container dependencies
|
||||
# SPEC_LOCAL_IMAGE true if image is built locally (don't pull)
|
||||
# SPEC_OPTIONAL true if container should be skipped when image missing
|
||||
|
||||
reset_spec() {
|
||||
SPEC_NAME="" SPEC_IMAGE="" SPEC_NETWORK="bridge" SPEC_PORTS=""
|
||||
SPEC_VOLUMES="" SPEC_MEMORY="512m" SPEC_CAPS="CHOWN FOWNER SETUID SETGID DAC_OVERRIDE"
|
||||
SPEC_SECURITY="no-new-privileges:true" SPEC_RESTART="unless-stopped"
|
||||
SPEC_HEALTH_CMD="" SPEC_ENV="" SPEC_CUSTOM_ARGS="" SPEC_READONLY="false"
|
||||
SPEC_TMPFS="" SPEC_TIER="3" SPEC_DATA_DIR="" SPEC_DATA_UID="100000:100000"
|
||||
# SPEC_OPTIONAL defaults true: reconcile-containers.sh only REPAIRS existing
|
||||
# containers — it never creates missing ones. Baseline (filebrowser) is
|
||||
# bootstrapped by first-boot-containers.sh; all other apps come from the
|
||||
# install RPC. Per-spec `SPEC_OPTIONAL="true"` lines below are now redundant
|
||||
# but kept for readability.
|
||||
SPEC_DEPENDS="" SPEC_LOCAL_IMAGE="false" SPEC_OPTIONAL="true"
|
||||
SPEC_ENTRYPOINT=""
|
||||
}
|
||||
|
||||
if ! declare -F alloc_port >/dev/null 2>&1; then
|
||||
alloc_port() { printf '%s' "$2"; }
|
||||
fi
|
||||
|
||||
# ── Tier 0: Databases ────────────────────────────────────────────────
|
||||
|
||||
load_spec_archy-mempool-db() {
|
||||
reset_spec
|
||||
SPEC_NAME="archy-mempool-db"
|
||||
SPEC_IMAGE="${MARIADB_IMAGE}"
|
||||
SPEC_NETWORK="archy-net"
|
||||
SPEC_MEMORY="$(mem_limit archy-mempool-db)"
|
||||
SPEC_VOLUMES="/var/lib/archipelago/mysql-mempool:/var/lib/mysql"
|
||||
SPEC_HEALTH_CMD="mariadb -uroot -e 'SELECT 1' || exit 1"
|
||||
SPEC_ENV="MYSQL_DATABASE=mempool MYSQL_USER=mempool MYSQL_PASSWORD=$MEMPOOL_DB_PASS MYSQL_ROOT_PASSWORD=$MYSQL_ROOT_PASS"
|
||||
SPEC_TIER="0"
|
||||
SPEC_DATA_DIR="/var/lib/archipelago/mysql-mempool"
|
||||
SPEC_DATA_UID="100999:100999"
|
||||
SPEC_CAPS="CHOWN FOWNER SETUID SETGID DAC_OVERRIDE"
|
||||
}
|
||||
|
||||
load_spec_archy-btcpay-db() {
|
||||
reset_spec
|
||||
SPEC_NAME="archy-btcpay-db"
|
||||
SPEC_IMAGE="${BTCPAY_POSTGRES_IMAGE}"
|
||||
SPEC_NETWORK="archy-net"
|
||||
SPEC_MEMORY="$(mem_limit archy-btcpay-db)"
|
||||
SPEC_VOLUMES="/var/lib/archipelago/postgres-btcpay:/var/lib/postgresql/data"
|
||||
SPEC_HEALTH_CMD="pg_isready -U postgres || exit 1"
|
||||
SPEC_ENV="POSTGRES_DB=btcpay POSTGRES_USER=btcpay POSTGRES_PASSWORD=$BTCPAY_DB_PASS"
|
||||
SPEC_TIER="0"
|
||||
SPEC_DATA_DIR="/var/lib/archipelago/postgres-btcpay"
|
||||
SPEC_DATA_UID="100070:100070"
|
||||
SPEC_CAPS="CHOWN FOWNER SETUID SETGID DAC_OVERRIDE"
|
||||
}
|
||||
|
||||
load_spec_immich_postgres() {
|
||||
reset_spec
|
||||
SPEC_NAME="immich_postgres"
|
||||
SPEC_IMAGE="${IMMICH_POSTGRES_IMAGE}"
|
||||
SPEC_NETWORK="bridge"
|
||||
SPEC_MEMORY="$(mem_limit immich_postgres)"
|
||||
SPEC_VOLUMES="/var/lib/archipelago/immich-db:/var/lib/postgresql/data"
|
||||
SPEC_ENV="POSTGRES_USER=postgres POSTGRES_DB=immich POSTGRES_PASSWORD=$BTCPAY_DB_PASS"
|
||||
SPEC_TIER="0"
|
||||
SPEC_DATA_DIR="/var/lib/archipelago/immich-db"
|
||||
SPEC_DATA_UID="100070:100070"
|
||||
SPEC_CAPS="CHOWN FOWNER SETUID SETGID DAC_OVERRIDE"
|
||||
SPEC_OPTIONAL="true"
|
||||
}
|
||||
|
||||
load_spec_immich_redis() {
|
||||
reset_spec
|
||||
SPEC_NAME="immich_redis"
|
||||
SPEC_IMAGE="${VALKEY_IMAGE}"
|
||||
SPEC_NETWORK="bridge"
|
||||
SPEC_MEMORY="$(mem_limit immich_redis)"
|
||||
SPEC_TIER="0"
|
||||
SPEC_CAPS="CHOWN SETUID SETGID"
|
||||
SPEC_OPTIONAL="true"
|
||||
}
|
||||
|
||||
# ── Tier 1: Core Infrastructure ──────────────────────────────────────
|
||||
|
||||
load_spec_bitcoin-knots() {
|
||||
reset_spec
|
||||
SPEC_NAME="bitcoin-knots"
|
||||
SPEC_IMAGE="${BITCOIN_KNOTS_IMAGE}"
|
||||
SPEC_NETWORK="archy-net"
|
||||
SPEC_PORTS="8332:8332 8333:8333"
|
||||
SPEC_VOLUMES="/var/lib/archipelago/bitcoin:/home/bitcoin/.bitcoin"
|
||||
SPEC_MEMORY="$(mem_limit bitcoin-knots)"
|
||||
SPEC_HEALTH_CMD="bitcoin-cli -rpcuser=\$BITCOIN_RPC_USER -rpcpassword=\$BITCOIN_RPC_PASS getblockchaininfo || exit 1"
|
||||
SPEC_TIER="1"
|
||||
SPEC_DATA_DIR="/var/lib/archipelago/bitcoin"
|
||||
SPEC_DATA_UID="100101:100101"
|
||||
local btc_rpc_headroom="-rpcthreads=16 -rpcworkqueue=256"
|
||||
local btc_txrelay_flags="-rpcwhitelistdefault=0"
|
||||
if [ -f "$SECRETS_DIR/bitcoin-rpc-txrelay-rpcauth" ]; then
|
||||
btc_txrelay_flags="$btc_txrelay_flags -rpcauth=$(cat "$SECRETS_DIR/bitcoin-rpc-txrelay-rpcauth") -rpcwhitelist=txrelay:sendrawtransaction,submitpackage,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblock,getblockheader,getrawtransaction,gettxout,gettxspendingprevout,decoderawtransaction,decodescript,estimatesmartfee,uptime,ping,getconnectioncount,getpeerinfo,getindexinfo,getdeploymentinfo,getchaintips"
|
||||
fi
|
||||
# Dynamic: prune on small disk
|
||||
if [ "${DISK_GB:-0}" -lt 1000 ]; then
|
||||
SPEC_CUSTOM_ARGS="-server=1 -prune=550 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=${BTC_DBCACHE} -par=0 -maxconnections=125 ${btc_rpc_headroom} ${btc_txrelay_flags}"
|
||||
else
|
||||
SPEC_CUSTOM_ARGS="-server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=${BTC_DBCACHE} -par=0 -maxconnections=125 ${btc_rpc_headroom} ${btc_txrelay_flags}"
|
||||
fi
|
||||
}
|
||||
|
||||
load_spec_electrumx() {
|
||||
reset_spec
|
||||
SPEC_NAME="electrumx"
|
||||
SPEC_IMAGE="${ELECTRUMX_IMAGE}"
|
||||
SPEC_NETWORK="archy-net"
|
||||
SPEC_PORTS="50001:50001"
|
||||
SPEC_VOLUMES="/var/lib/archipelago/electrumx:/data"
|
||||
SPEC_MEMORY="$(mem_limit electrumx)"
|
||||
SPEC_HEALTH_CMD="python3 -c 'import socket; socket.create_connection((\\\"localhost\\\",8000),2).close()' || exit 1"
|
||||
SPEC_ENV="DAEMON_URL=http://$BITCOIN_RPC_USER:$BITCOIN_RPC_PASS@bitcoin-knots:8332/ COIN=Bitcoin DB_DIRECTORY=/data SERVICES=tcp://:50001,rpc://0.0.0.0:8000"
|
||||
SPEC_TIER="1"
|
||||
SPEC_DATA_DIR="/var/lib/archipelago/electrumx"
|
||||
SPEC_DEPENDS="bitcoin-knots"
|
||||
SPEC_CAPS="DAC_OVERRIDE"
|
||||
}
|
||||
|
||||
# ── Tier 2: Services ─────────────────────────────────────────────────
|
||||
|
||||
load_spec_lnd() {
|
||||
reset_spec
|
||||
SPEC_NAME="lnd"
|
||||
SPEC_IMAGE="${LND_IMAGE}"
|
||||
SPEC_NETWORK="archy-net"
|
||||
SPEC_PORTS="9735:9735 10009:10009 18080:8080"
|
||||
SPEC_VOLUMES="/var/lib/archipelago/lnd:/root/.lnd"
|
||||
SPEC_MEMORY="$(mem_limit lnd)"
|
||||
SPEC_CAPS="CHOWN FOWNER SETUID SETGID DAC_OVERRIDE NET_RAW"
|
||||
SPEC_HEALTH_CMD="lncli --tlscertpath /root/.lnd/tls.cert --macaroonpath /root/.lnd/data/chain/bitcoin/mainnet/readonly.macaroon --rpcserver localhost:10009 getinfo > /dev/null 2>&1 || exit 1"
|
||||
SPEC_TIER="2"
|
||||
SPEC_DATA_DIR="/var/lib/archipelago/lnd"
|
||||
SPEC_DEPENDS="bitcoin-knots"
|
||||
}
|
||||
|
||||
load_spec_mempool-api() {
|
||||
reset_spec
|
||||
SPEC_NAME="mempool-api"
|
||||
SPEC_IMAGE="${MEMPOOL_BACKEND_IMAGE}"
|
||||
SPEC_NETWORK="archy-net"
|
||||
SPEC_PORTS="8999:8999"
|
||||
SPEC_VOLUMES="/var/lib/archipelago/mempool:/data"
|
||||
SPEC_MEMORY="$(mem_limit mempool-api)"
|
||||
SPEC_HEALTH_CMD="curl -sf http://localhost:8999/ || exit 1"
|
||||
local MYSQL_CNT="archy-mempool-db"
|
||||
SPEC_ENV="MEMPOOL_BACKEND=electrum ELECTRUM_HOST=electrumx ELECTRUM_PORT=50001 ELECTRUM_TLS_ENABLED=false CORE_RPC_HOST=bitcoin-knots CORE_RPC_PORT=8332 CORE_RPC_USERNAME=$BITCOIN_RPC_USER CORE_RPC_PASSWORD=$BITCOIN_RPC_PASS DATABASE_ENABLED=true DATABASE_HOST=$MYSQL_CNT DATABASE_DATABASE=mempool DATABASE_USERNAME=mempool DATABASE_PASSWORD=$MEMPOOL_DB_PASS"
|
||||
SPEC_TIER="2"
|
||||
SPEC_DATA_DIR="/var/lib/archipelago/mempool"
|
||||
SPEC_DEPENDS="bitcoin-knots electrumx archy-mempool-db"
|
||||
SPEC_CAPS=""
|
||||
}
|
||||
|
||||
load_spec_archy-mempool-web() {
|
||||
reset_spec
|
||||
SPEC_NAME="archy-mempool-web"
|
||||
SPEC_IMAGE="${MEMPOOL_WEB_IMAGE}"
|
||||
SPEC_NETWORK="archy-net"
|
||||
SPEC_PORTS="4080:8080"
|
||||
SPEC_MEMORY="$(mem_limit archy-mempool-web)"
|
||||
SPEC_HEALTH_CMD="curl -sf http://localhost:8080/ || exit 1"
|
||||
SPEC_ENV="FRONTEND_HTTP_PORT=8080 BACKEND_MAINNET_HTTP_HOST=mempool-api"
|
||||
SPEC_TIER="2"
|
||||
SPEC_DEPENDS="mempool-api"
|
||||
SPEC_CAPS=""
|
||||
}
|
||||
|
||||
load_spec_archy-nbxplorer() {
|
||||
reset_spec
|
||||
SPEC_NAME="archy-nbxplorer"
|
||||
SPEC_IMAGE="${NBXPLORER_IMAGE}"
|
||||
SPEC_NETWORK="archy-net"
|
||||
SPEC_PORTS="32838:32838"
|
||||
SPEC_VOLUMES="/var/lib/archipelago/nbxplorer:/data"
|
||||
SPEC_MEMORY="$(mem_limit archy-nbxplorer)"
|
||||
SPEC_HEALTH_CMD="curl -sf http://localhost:32838/ || exit 1"
|
||||
SPEC_ENV="NBXPLORER_DATADIR=/data NBXPLORER_NETWORK=mainnet NBXPLORER_CHAINS=btc NBXPLORER_BIND=0.0.0.0:32838 NBXPLORER_BTCRPCURL=http://bitcoin-knots:8332 NBXPLORER_BTCRPCUSER=$BITCOIN_RPC_USER NBXPLORER_BTCRPCPASSWORD=$BITCOIN_RPC_PASS NBXPLORER_POSTGRES=Username=btcpay;Password=$BTCPAY_DB_PASS;Host=archy-btcpay-db;Port=5432;Database=nbxplorer"
|
||||
SPEC_TIER="2"
|
||||
SPEC_DATA_DIR="/var/lib/archipelago/nbxplorer"
|
||||
SPEC_DEPENDS="bitcoin-knots archy-btcpay-db"
|
||||
SPEC_CAPS=""
|
||||
}
|
||||
|
||||
load_spec_btcpay-server() {
|
||||
reset_spec
|
||||
SPEC_NAME="btcpay-server"
|
||||
SPEC_IMAGE="${BTCPAY_IMAGE}"
|
||||
SPEC_NETWORK="archy-net"
|
||||
SPEC_PORTS="23000:49392"
|
||||
SPEC_VOLUMES="/var/lib/archipelago/btcpay:/datadir"
|
||||
SPEC_MEMORY="$(mem_limit btcpay-server)"
|
||||
SPEC_HEALTH_CMD="bash -ec '</dev/tcp/127.0.0.1/49392'"
|
||||
SPEC_ENV="ASPNETCORE_URLS=http://0.0.0.0:49392 BTCPAY_PROTOCOL=http BTCPAY_HOST=$HOST_IP:23000 BTCPAY_CHAINS=btc BTCPAY_BTCEXPLORERURL=http://archy-nbxplorer:32838 BTCPAY_BTCRPCURL=http://bitcoin-knots:8332 BTCPAY_BTCRPCUSER=$BITCOIN_RPC_USER BTCPAY_BTCRPCPASSWORD=$BITCOIN_RPC_PASS BTCPAY_POSTGRES=Username=btcpay;Password=$BTCPAY_DB_PASS;Host=archy-btcpay-db;Port=5432;Database=btcpay"
|
||||
SPEC_TIER="2"
|
||||
SPEC_DATA_DIR="/var/lib/archipelago/btcpay"
|
||||
SPEC_DEPENDS="archy-nbxplorer archy-btcpay-db"
|
||||
}
|
||||
|
||||
load_spec_fedimint() {
|
||||
reset_spec
|
||||
SPEC_NAME="fedimint"
|
||||
SPEC_IMAGE="${FEDIMINT_IMAGE}"
|
||||
SPEC_NETWORK="archy-net"
|
||||
SPEC_PORTS="8173:8173 8174:8174 8175:8175"
|
||||
SPEC_VOLUMES="/var/lib/archipelago/fedimint:/data"
|
||||
SPEC_MEMORY="$(mem_limit fedimint)"
|
||||
SPEC_HEALTH_CMD="curl -sf http://localhost:8175/ || exit 1"
|
||||
SPEC_ENV="FM_DATA_DIR=/data FM_BITCOIND_USERNAME=$BITCOIN_RPC_USER FM_BITCOIND_PASSWORD=$BITCOIN_RPC_PASS FM_BITCOIN_NETWORK=bitcoin FM_BIND_P2P=0.0.0.0:8173 FM_BIND_API=0.0.0.0:8174 FM_BIND_UI=0.0.0.0:8175 FM_P2P_URL=fedimint://$HOST_MDNS:8173 FM_API_URL=ws://$HOST_MDNS:8174 FM_BITCOIND_URL=http://bitcoin-knots:8332"
|
||||
SPEC_TIER="2"
|
||||
SPEC_DATA_DIR="/var/lib/archipelago/fedimint"
|
||||
SPEC_DEPENDS="bitcoin-knots"
|
||||
SPEC_OPTIONAL="true"
|
||||
}
|
||||
|
||||
load_spec_fedimint-gateway() {
|
||||
reset_spec
|
||||
SPEC_NAME="fedimint-gateway"
|
||||
SPEC_IMAGE="${FEDIMINT_GATEWAY_IMAGE}"
|
||||
SPEC_NETWORK="archy-net"
|
||||
SPEC_PORTS="8176:8176"
|
||||
SPEC_VOLUMES="/var/lib/archipelago/fedimint-gateway:/data"
|
||||
SPEC_MEMORY="$(mem_limit fedimint-gateway)"
|
||||
SPEC_HEALTH_CMD="curl -sf http://localhost:8176/ || exit 1"
|
||||
SPEC_TIER="2"
|
||||
SPEC_DATA_DIR="/var/lib/archipelago/fedimint-gateway"
|
||||
SPEC_DEPENDS="bitcoin-knots fedimint"
|
||||
SPEC_OPTIONAL="true"
|
||||
# Custom entrypoint depends on whether LND is available
|
||||
local LND_CERT=/var/lib/archipelago/lnd/tls.cert
|
||||
local LND_MAC=/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/admin.macaroon
|
||||
if [ -f "$LND_CERT" ] && [ -f "$LND_MAC" ]; then
|
||||
SPEC_VOLUMES="$SPEC_VOLUMES $LND_CERT:/lnd/tls.cert:ro $LND_MAC:/lnd/admin.macaroon:ro"
|
||||
SPEC_ENTRYPOINT="gatewayd --data-dir /data --listen 0.0.0.0:8176 --bcrypt-password-hash $FEDI_HASH --network bitcoin --bitcoind-url http://bitcoin-knots:8332 --bitcoind-username $BITCOIN_RPC_USER --bitcoind-password $BITCOIN_RPC_PASS lnd --lnd-rpc-host lnd:10009 --lnd-tls-cert /lnd/tls.cert --lnd-macaroon /lnd/admin.macaroon"
|
||||
else
|
||||
SPEC_PORTS="8176:8176 9737:9737"
|
||||
SPEC_ENTRYPOINT="gatewayd --data-dir /data --listen 0.0.0.0:8176 --bcrypt-password-hash $FEDI_HASH --network bitcoin --bitcoind-url http://bitcoin-knots:8332 --bitcoind-username $BITCOIN_RPC_USER --bitcoind-password $BITCOIN_RPC_PASS ldk --ldk-lightning-port 9737 --ldk-alias archipelago-gateway"
|
||||
fi
|
||||
}
|
||||
|
||||
load_spec_immich_server() {
|
||||
reset_spec
|
||||
SPEC_NAME="immich_server"
|
||||
SPEC_IMAGE="${IMMICH_SERVER_IMAGE}"
|
||||
SPEC_NETWORK="bridge"
|
||||
SPEC_PORTS="2283:2283"
|
||||
SPEC_VOLUMES="/var/lib/archipelago/immich:/usr/src/app/upload"
|
||||
SPEC_MEMORY="$(mem_limit immich_server)"
|
||||
SPEC_ENV="DB_HOSTNAME=immich_postgres DB_DATABASE_NAME=immich DB_USERNAME=postgres DB_PASSWORD=$BTCPAY_DB_PASS REDIS_HOSTNAME=immich_redis UPLOAD_LOCATION=/usr/src/app/upload"
|
||||
SPEC_TIER="2"
|
||||
SPEC_DATA_DIR="/var/lib/archipelago/immich"
|
||||
SPEC_DEPENDS="immich_postgres immich_redis"
|
||||
SPEC_CAPS=""
|
||||
SPEC_OPTIONAL="true"
|
||||
}
|
||||
|
||||
# ── Tier 3: Applications ─────────────────────────────────────────────
|
||||
|
||||
load_spec_homeassistant() {
|
||||
reset_spec
|
||||
SPEC_NAME="homeassistant"
|
||||
SPEC_IMAGE="${HOMEASSISTANT_IMAGE}"
|
||||
SPEC_PORTS="8123:8123"
|
||||
SPEC_VOLUMES="/var/lib/archipelago/home-assistant:/config"
|
||||
SPEC_MEMORY="$(mem_limit homeassistant)"
|
||||
SPEC_HEALTH_CMD="curl -sf http://localhost:8123/ || exit 1"
|
||||
SPEC_ENV="TZ=UTC"
|
||||
SPEC_TIER="3"
|
||||
SPEC_DATA_DIR="/var/lib/archipelago/home-assistant"
|
||||
SPEC_CAPS="CHOWN SETUID SETGID DAC_OVERRIDE NET_BIND_SERVICE"
|
||||
SPEC_OPTIONAL="true"
|
||||
}
|
||||
|
||||
load_spec_grafana() {
|
||||
reset_spec
|
||||
SPEC_NAME="grafana"
|
||||
SPEC_IMAGE="${GRAFANA_IMAGE}"
|
||||
SPEC_PORTS="3000:3000"
|
||||
SPEC_VOLUMES="/var/lib/archipelago/grafana:/var/lib/grafana"
|
||||
SPEC_MEMORY="$(mem_limit grafana)"
|
||||
SPEC_HEALTH_CMD="curl -sf http://localhost:3000/api/health || exit 1"
|
||||
SPEC_ENV="GF_PATHS_DATA=/var/lib/grafana GF_USERS_ALLOW_SIGN_UP=false"
|
||||
SPEC_READONLY="true"
|
||||
SPEC_TMPFS="/tmp:rw,noexec,nosuid,size=256m /run:rw,noexec,nosuid,size=64m"
|
||||
SPEC_TIER="3"
|
||||
SPEC_DATA_DIR="/var/lib/archipelago/grafana"
|
||||
SPEC_DATA_UID="100472:100472"
|
||||
SPEC_CAPS="CHOWN SETUID SETGID DAC_OVERRIDE NET_BIND_SERVICE"
|
||||
SPEC_OPTIONAL="true"
|
||||
}
|
||||
|
||||
load_spec_uptime-kuma() {
|
||||
reset_spec
|
||||
SPEC_NAME="uptime-kuma"
|
||||
SPEC_IMAGE="${UPTIME_KUMA_IMAGE}"
|
||||
SPEC_PORTS="3002:3001"
|
||||
SPEC_VOLUMES="/var/lib/archipelago/uptime-kuma:/app/data"
|
||||
SPEC_MEMORY="$(mem_limit uptime-kuma)"
|
||||
SPEC_HEALTH_CMD="curl -sf http://localhost:3001/ || exit 1"
|
||||
SPEC_ENV="TZ=UTC"
|
||||
SPEC_TIER="3"
|
||||
SPEC_DATA_DIR="/var/lib/archipelago/uptime-kuma"
|
||||
SPEC_CAPS="CHOWN FOWNER SETUID SETGID"
|
||||
SPEC_OPTIONAL="true"
|
||||
}
|
||||
|
||||
load_spec_jellyfin() {
|
||||
reset_spec
|
||||
SPEC_NAME="jellyfin"
|
||||
SPEC_IMAGE="${JELLYFIN_IMAGE}"
|
||||
SPEC_PORTS="8096:8096"
|
||||
SPEC_VOLUMES="/var/lib/archipelago/jellyfin/config:/config /var/lib/archipelago/jellyfin/cache:/cache"
|
||||
SPEC_MEMORY="$(mem_limit jellyfin)"
|
||||
SPEC_HEALTH_CMD="curl -sf http://localhost:8096/ || exit 1"
|
||||
SPEC_TIER="3"
|
||||
SPEC_DATA_DIR="/var/lib/archipelago/jellyfin"
|
||||
SPEC_CAPS=""
|
||||
SPEC_OPTIONAL="true"
|
||||
}
|
||||
|
||||
load_spec_photoprism() {
|
||||
reset_spec
|
||||
SPEC_NAME="photoprism"
|
||||
SPEC_IMAGE="${PHOTOPRISM_IMAGE}"
|
||||
SPEC_PORTS="2342:2342"
|
||||
SPEC_VOLUMES="/var/lib/archipelago/photoprism:/photoprism/storage"
|
||||
SPEC_MEMORY="$(mem_limit photoprism)"
|
||||
SPEC_HEALTH_CMD="curl -sf http://localhost:2342/ || exit 1"
|
||||
SPEC_ENV="PHOTOPRISM_ADMIN_PASSWORD=archipelago PHOTOPRISM_DEFAULT_LOCALE=en"
|
||||
SPEC_TIER="3"
|
||||
SPEC_DATA_DIR="/var/lib/archipelago/photoprism"
|
||||
SPEC_CAPS="CHOWN SETUID SETGID"
|
||||
SPEC_OPTIONAL="true"
|
||||
}
|
||||
|
||||
load_spec_vaultwarden() {
|
||||
reset_spec
|
||||
SPEC_NAME="vaultwarden"
|
||||
SPEC_IMAGE="${VAULTWARDEN_IMAGE}"
|
||||
SPEC_PORTS="8082:80"
|
||||
SPEC_VOLUMES="/var/lib/archipelago/vaultwarden:/data"
|
||||
SPEC_MEMORY="$(mem_limit vaultwarden)"
|
||||
SPEC_HEALTH_CMD="curl -sf http://localhost:80/ || exit 1"
|
||||
SPEC_TIER="3"
|
||||
SPEC_DATA_DIR="/var/lib/archipelago/vaultwarden"
|
||||
SPEC_CAPS="CHOWN SETUID SETGID NET_BIND_SERVICE"
|
||||
SPEC_OPTIONAL="true"
|
||||
}
|
||||
|
||||
load_spec_nextcloud() {
|
||||
reset_spec
|
||||
SPEC_NAME="nextcloud"
|
||||
SPEC_IMAGE="${NEXTCLOUD_IMAGE}"
|
||||
SPEC_PORTS="8085:80"
|
||||
SPEC_VOLUMES="/var/lib/archipelago/nextcloud:/var/www/html"
|
||||
SPEC_MEMORY="$(mem_limit nextcloud)"
|
||||
SPEC_HEALTH_CMD="curl -sf http://localhost:80/ || exit 1"
|
||||
SPEC_TIER="3"
|
||||
SPEC_DATA_DIR="/var/lib/archipelago/nextcloud"
|
||||
SPEC_CAPS="CHOWN SETUID SETGID DAC_OVERRIDE NET_BIND_SERVICE"
|
||||
SPEC_OPTIONAL="true"
|
||||
}
|
||||
|
||||
load_spec_searxng() {
|
||||
reset_spec
|
||||
SPEC_NAME="searxng"
|
||||
SPEC_IMAGE="${SEARXNG_IMAGE}"
|
||||
SPEC_PORTS="8888:8080"
|
||||
SPEC_MEMORY="$(mem_limit searxng)"
|
||||
SPEC_VOLUMES="/var/lib/archipelago/searxng:/etc/searxng"
|
||||
SPEC_HEALTH_CMD="curl -sf http://localhost:8080/ || exit 1"
|
||||
SPEC_READONLY="true"
|
||||
SPEC_TMPFS="/tmp:rw,noexec,nosuid,size=256m /run:rw,noexec,nosuid,size=64m"
|
||||
SPEC_TIER="3"
|
||||
SPEC_CAPS=""
|
||||
SPEC_DATA_DIR="/var/lib/archipelago/searxng"
|
||||
SPEC_OPTIONAL="true"
|
||||
}
|
||||
|
||||
load_spec_filebrowser() {
|
||||
reset_spec
|
||||
SPEC_NAME="filebrowser"
|
||||
SPEC_IMAGE="${FILEBROWSER_IMAGE}"
|
||||
SPEC_NETWORK="archy-net"
|
||||
SPEC_PORTS="8083:80"
|
||||
SPEC_VOLUMES="/var/lib/archipelago/filebrowser:/srv /var/lib/archipelago/filebrowser-data:/data"
|
||||
SPEC_MEMORY="$(mem_limit filebrowser)"
|
||||
SPEC_HEALTH_CMD="wget -q --spider http://localhost:80/health || exit 1"
|
||||
SPEC_TIER="3"
|
||||
SPEC_DATA_DIR="/var/lib/archipelago/filebrowser"
|
||||
SPEC_DATA_UID="100000:100000"
|
||||
# first-boot-containers.sh writes /data/.filebrowser.json (see filebrowser
|
||||
# creation block at ~line 1128). Config path is required or filebrowser
|
||||
# opens /database.db in CWD and fails with permission denied.
|
||||
SPEC_CUSTOM_ARGS="--config /data/.filebrowser.json"
|
||||
# Needs default caps (CHOWN FOWNER SETUID SETGID DAC_OVERRIDE) from reset_spec
|
||||
# for rootless userns-root to write /data/filebrowser.db, plus NET_BIND_SERVICE
|
||||
# to listen on port 80.
|
||||
SPEC_CAPS="CHOWN FOWNER SETUID SETGID DAC_OVERRIDE NET_BIND_SERVICE"
|
||||
SPEC_OPTIONAL="true"
|
||||
}
|
||||
|
||||
load_spec_nginx-proxy-manager() {
|
||||
reset_spec
|
||||
SPEC_NAME="nginx-proxy-manager"
|
||||
SPEC_IMAGE="${NPM_IMAGE}"
|
||||
local admin_port http_port https_port
|
||||
admin_port=$(alloc_port nginx-proxy-manager 8081 81)
|
||||
http_port=$(alloc_port nginx-proxy-manager-http 8084 80)
|
||||
https_port=$(alloc_port nginx-proxy-manager-https 8444 443)
|
||||
SPEC_PORTS="$admin_port:81 $http_port:80 $https_port:443"
|
||||
SPEC_VOLUMES="/var/lib/archipelago/nginx-proxy-manager/data:/data /var/lib/archipelago/nginx-proxy-manager/letsencrypt:/etc/letsencrypt"
|
||||
SPEC_MEMORY="$(mem_limit nginx-proxy-manager)"
|
||||
SPEC_HEALTH_CMD="curl -sf http://localhost:81/ || exit 1"
|
||||
SPEC_TIER="3"
|
||||
SPEC_DATA_DIR="/var/lib/archipelago/nginx-proxy-manager"
|
||||
SPEC_CAPS="CHOWN FOWNER SETUID SETGID DAC_OVERRIDE NET_BIND_SERVICE"
|
||||
SPEC_OPTIONAL="true"
|
||||
}
|
||||
|
||||
load_spec_portainer() {
|
||||
reset_spec
|
||||
SPEC_NAME="portainer"
|
||||
SPEC_IMAGE="${PORTAINER_IMAGE}"
|
||||
SPEC_PORTS="9000:9000"
|
||||
SPEC_VOLUMES="/var/lib/archipelago/portainer:/data /run/user/1000/podman/podman.sock:/var/run/docker.sock /var/lib/archipelago/portainer/compose:/data/compose"
|
||||
SPEC_MEMORY="$(mem_limit portainer)"
|
||||
SPEC_HEALTH_CMD="curl -sf http://localhost:9000/ || exit 1"
|
||||
SPEC_TIER="3"
|
||||
SPEC_DATA_DIR="/var/lib/archipelago/portainer"
|
||||
SPEC_DATA_UID="1000:1000"
|
||||
SPEC_OPTIONAL="true"
|
||||
}
|
||||
|
||||
load_spec_ollama() {
|
||||
reset_spec
|
||||
SPEC_NAME="ollama"
|
||||
SPEC_IMAGE="${OLLAMA_IMAGE}"
|
||||
SPEC_PORTS="11434:11434"
|
||||
SPEC_VOLUMES="/var/lib/archipelago/ollama:/root/.ollama"
|
||||
SPEC_MEMORY="$(mem_limit ollama)"
|
||||
SPEC_HEALTH_CMD="curl -sf http://localhost:11434/ || exit 1"
|
||||
SPEC_READONLY="true"
|
||||
SPEC_TMPFS="/tmp:rw,noexec,nosuid,size=256m /run:rw,noexec,nosuid,size=64m"
|
||||
SPEC_TIER="3"
|
||||
SPEC_DATA_DIR="/var/lib/archipelago/ollama"
|
||||
SPEC_CAPS=""
|
||||
SPEC_OPTIONAL="true"
|
||||
}
|
||||
|
||||
# ── Tier 4: Frontend UIs ─────────────────────────────────────────────
|
||||
|
||||
load_spec_archy-bitcoin-ui() {
|
||||
reset_spec
|
||||
SPEC_NAME="archy-bitcoin-ui"
|
||||
SPEC_IMAGE="localhost/bitcoin-ui:local"
|
||||
SPEC_NETWORK="host"
|
||||
SPEC_VOLUMES="/var/lib/archipelago/bitcoin-ui/nginx.conf:/etc/nginx/conf.d/default.conf:ro"
|
||||
SPEC_MEMORY="$(mem_limit archy-bitcoin-ui)"
|
||||
SPEC_TIER="4"
|
||||
SPEC_LOCAL_IMAGE="true"
|
||||
SPEC_CAPS="CHOWN SETUID SETGID"
|
||||
SPEC_SECURITY="no-new-privileges:true"
|
||||
}
|
||||
|
||||
load_spec_archy-lnd-ui() {
|
||||
reset_spec
|
||||
SPEC_NAME="archy-lnd-ui"
|
||||
SPEC_IMAGE="localhost/lnd-ui:local"
|
||||
SPEC_PORTS="18083:80"
|
||||
SPEC_MEMORY="$(mem_limit archy-lnd-ui)"
|
||||
SPEC_TIER="4"
|
||||
SPEC_LOCAL_IMAGE="true"
|
||||
SPEC_CAPS="CHOWN SETUID SETGID NET_BIND_SERVICE"
|
||||
SPEC_SECURITY="no-new-privileges:true"
|
||||
}
|
||||
|
||||
load_spec_archy-electrs-ui() {
|
||||
reset_spec
|
||||
SPEC_NAME="archy-electrs-ui"
|
||||
SPEC_IMAGE="localhost/electrs-ui:local"
|
||||
SPEC_NETWORK="host"
|
||||
SPEC_MEMORY="$(mem_limit archy-electrs-ui)"
|
||||
SPEC_TIER="4"
|
||||
SPEC_LOCAL_IMAGE="true"
|
||||
SPEC_CAPS="CHOWN SETUID SETGID"
|
||||
SPEC_SECURITY="no-new-privileges:true"
|
||||
}
|
||||
|
||||
# ── Registry ─────────────────────────────────────────────────────────
|
||||
# Ordered by tier, then dependency order within tier
|
||||
|
||||
ALL_CONTAINER_SPECS=(
|
||||
# Tier 0: Databases
|
||||
archy-mempool-db
|
||||
archy-btcpay-db
|
||||
immich_postgres
|
||||
immich_redis
|
||||
# Tier 1: Core
|
||||
bitcoin-knots
|
||||
electrumx
|
||||
# Tier 2: Services
|
||||
lnd
|
||||
mempool-api
|
||||
archy-mempool-web
|
||||
archy-nbxplorer
|
||||
btcpay-server
|
||||
fedimint
|
||||
fedimint-gateway
|
||||
immich_server
|
||||
# Tier 3: Apps
|
||||
homeassistant
|
||||
grafana
|
||||
uptime-kuma
|
||||
jellyfin
|
||||
photoprism
|
||||
vaultwarden
|
||||
nextcloud
|
||||
searxng
|
||||
filebrowser
|
||||
nginx-proxy-manager
|
||||
portainer
|
||||
ollama
|
||||
# Tier 4: UIs
|
||||
archy-bitcoin-ui
|
||||
archy-lnd-ui
|
||||
archy-electrs-ui
|
||||
)
|
||||
|
||||
# Load a spec by name. Usage: load_spec "bitcoin-knots"
|
||||
load_spec() {
|
||||
local fn="load_spec_${1}"
|
||||
if declare -f "$fn" >/dev/null 2>&1; then
|
||||
"$fn"
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
# Return all spec names
|
||||
all_specs() {
|
||||
echo "${ALL_CONTAINER_SPECS[@]}"
|
||||
}
|
||||
Executable
+284
@@ -0,0 +1,284 @@
|
||||
#!/usr/bin/env bash
|
||||
# create-release-manifest.sh — Build a release manifest for the Archipelago update system.
|
||||
#
|
||||
# Generates a JSON manifest with version info, changelog, and SHA256 hashes for
|
||||
# each component, matching the format expected by core/archipelago/src/update.rs.
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/create-release-manifest.sh --version 0.2.0 --date 2026-04-01
|
||||
#
|
||||
# The script reads built artifacts from the build output directories and produces
|
||||
# a manifest.json file suitable for hosting at the UPDATE_MANIFEST_URL.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Defaults
|
||||
VERSION=""
|
||||
RELEASE_DATE=""
|
||||
OUTPUT_FILE="manifest.json"
|
||||
BACKEND_BINARY=""
|
||||
FRONTEND_ARCHIVE=""
|
||||
BASE_URL="http://146.59.87.168:3000/lfg2025/archy/releases/download"
|
||||
|
||||
usage() {
|
||||
echo "Usage: $0 --version VERSION [--date DATE] [--output FILE]"
|
||||
echo ""
|
||||
echo "Options:"
|
||||
echo " --version VERSION Release version (e.g., 0.2.0) [required]"
|
||||
echo " --date DATE Release date (YYYY-MM-DD) [default: today]"
|
||||
echo " --output FILE Output manifest path [default: manifest.json]"
|
||||
echo " --backend PATH Path to backend binary [default: auto-detect]"
|
||||
echo " --frontend PATH Path to frontend archive [default: auto-detect]"
|
||||
echo " --base-url URL Base download URL [default: Gitea release attachments]"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Parse arguments
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--version) VERSION="$2"; shift 2 ;;
|
||||
--date) RELEASE_DATE="$2"; shift 2 ;;
|
||||
--output) OUTPUT_FILE="$2"; shift 2 ;;
|
||||
--backend) BACKEND_BINARY="$2"; shift 2 ;;
|
||||
--frontend) FRONTEND_ARCHIVE="$2"; shift 2 ;;
|
||||
--base-url) BASE_URL="$2"; shift 2 ;;
|
||||
-h|--help) usage ;;
|
||||
*) echo "Unknown option: $1"; usage ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ -z "$VERSION" ]; then
|
||||
echo "Error: --version is required"
|
||||
usage
|
||||
fi
|
||||
|
||||
if [ -z "$RELEASE_DATE" ]; then
|
||||
RELEASE_DATE=$(date +%Y-%m-%d)
|
||||
fi
|
||||
|
||||
# Find project root
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
|
||||
# Auto-detect backend binary
|
||||
if [ -z "$BACKEND_BINARY" ]; then
|
||||
BACKEND_BINARY="$PROJECT_ROOT/core/target/release/archipelago"
|
||||
fi
|
||||
|
||||
# Auto-detect frontend archive.
|
||||
# Layout: flat tarball (`./index.html`, `./assets/…`, `./aiui/…`) so the
|
||||
# Rust updater can unpack it directly into /opt/archipelago/web-ui/.
|
||||
# Using `-C web/dist neode-ui` would produce a `neode-ui/` prefix which
|
||||
# breaks the installer and returns 403 on every fleet UI — see
|
||||
# feedback_release_tarball_layout.md.
|
||||
if [ -z "$FRONTEND_ARCHIVE" ]; then
|
||||
FRONTEND_DIST="$PROJECT_ROOT/web/dist/neode-ui"
|
||||
if [ -d "$FRONTEND_DIST" ]; then
|
||||
FRONTEND_ARCHIVE="/tmp/archipelago-frontend-${VERSION}.tar.gz"
|
||||
STAGING_DIR=$(mktemp -d -t archipelago-frontend.XXXXXX)
|
||||
echo "Staging frontend archive in $STAGING_DIR..."
|
||||
cp -r "$FRONTEND_DIST/." "$STAGING_DIR/"
|
||||
# Bake AIUI in so fresh installs pick it up. OTA already
|
||||
# carries-forward the existing aiui/ if the tarball lacks one
|
||||
# (update.rs:922), but including it here makes the tarball
|
||||
# the single source of truth instead of relying on a side-
|
||||
# effect of the in-place swap.
|
||||
if [ -d "$PROJECT_ROOT/demo/aiui" ] && [ -f "$PROJECT_ROOT/demo/aiui/index.html" ]; then
|
||||
echo " Including AIUI from demo/aiui/"
|
||||
cp -r "$PROJECT_ROOT/demo/aiui" "$STAGING_DIR/aiui"
|
||||
fi
|
||||
# OTA bridge for nodes running older updaters: they only know how to
|
||||
# apply the backend binary and frontend archive. Carry host runtime
|
||||
# assets inside the frontend tarball; the new backend promotes them
|
||||
# from /opt/archipelago/web-ui/archipelago-runtime on first startup.
|
||||
RUNTIME_DIR="$STAGING_DIR/archipelago-runtime"
|
||||
mkdir -p "$RUNTIME_DIR"
|
||||
for runtime_path in apps scripts docker; do
|
||||
if [ -d "$PROJECT_ROOT/$runtime_path" ]; then
|
||||
echo " Including runtime $runtime_path/"
|
||||
cp -r "$PROJECT_ROOT/$runtime_path" "$RUNTIME_DIR/$runtime_path"
|
||||
fi
|
||||
done
|
||||
if [ -f "$PROJECT_ROOT/image-recipe/configs/archipelago-doctor.service" ] || \
|
||||
[ -f "$PROJECT_ROOT/image-recipe/configs/archipelago-doctor.timer" ]; then
|
||||
mkdir -p "$RUNTIME_DIR/image-recipe/configs"
|
||||
for unit in archipelago-doctor.service archipelago-doctor.timer; do
|
||||
if [ -f "$PROJECT_ROOT/image-recipe/configs/$unit" ]; then
|
||||
echo " Including runtime unit $unit"
|
||||
cp "$PROJECT_ROOT/image-recipe/configs/$unit" "$RUNTIME_DIR/image-recipe/configs/$unit"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
if [ -f "$PROJECT_ROOT/image-recipe/configs/nginx-archipelago.conf" ]; then
|
||||
mkdir -p "$RUNTIME_DIR/image-recipe/configs"
|
||||
echo " Including runtime nginx-archipelago.conf"
|
||||
cp "$PROJECT_ROOT/image-recipe/configs/nginx-archipelago.conf" \
|
||||
"$RUNTIME_DIR/image-recipe/configs/nginx-archipelago.conf"
|
||||
fi
|
||||
rm -rf "$RUNTIME_DIR/scripts/resilience/reports"
|
||||
find "$RUNTIME_DIR" -type d -name '__pycache__' -prune -exec rm -rf {} +
|
||||
find "$RUNTIME_DIR" -type f \( -name '*.bak' -o -name '*.bak-*' -o -name '._*' -o -name '*.log' -o -name '*.pyc' \) -delete
|
||||
# Force world-readable perms on every entry BEFORE tar, so the
|
||||
# archive's internal mode bits are 755/644 regardless of what
|
||||
# the staging dir's umask gave us. Without this, mktemp -d
|
||||
# creates the staging dir at 700, that 700 gets baked into the
|
||||
# tarball's root `./` entry, and every node that extracts the
|
||||
# archive ends up with /opt/archipelago/web-ui at 700 — which
|
||||
# causes nginx (www-data) to return 500 "permission denied" on
|
||||
# every page. Bit us on the v1.7.38 + v1.7.39 rollouts.
|
||||
chmod 755 "$STAGING_DIR"
|
||||
find "$STAGING_DIR" -type d -exec chmod 755 {} +
|
||||
find "$STAGING_DIR" -type f -exec chmod 644 {} +
|
||||
echo "Creating frontend archive $FRONTEND_ARCHIVE..."
|
||||
# --mode is a belt-and-braces in case a file's on-disk perms
|
||||
# drift again; forces 755 dir / 644 file in the archive too.
|
||||
tar --owner=0 --group=0 \
|
||||
--mode='u=rwX,go=rX' \
|
||||
-czf "$FRONTEND_ARCHIVE" \
|
||||
-C "$STAGING_DIR" .
|
||||
# Verify the archive root entry is world-readable before we
|
||||
# declare success — catches regressions in tar-flag handling
|
||||
# (BSD tar, busybox tar) that might silently drop --mode.
|
||||
# SIGPIPE-safe: use awk to read only the first line and exit,
|
||||
# then terminate the tar pipeline explicitly so `pipefail`+SIGPIPE
|
||||
# don't kill the whole `set -euo pipefail` script.
|
||||
root_mode=$(tar tvzf "$FRONTEND_ARCHIVE" 2>/dev/null | 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
+250
@@ -0,0 +1,250 @@
|
||||
#!/usr/bin/env bash
|
||||
# create-release.sh — Full release automation for Archipelago
|
||||
#
|
||||
# Bumps version in Cargo.toml and package.json, generates changelog from git log,
|
||||
# creates release manifest, and creates git tag.
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/create-release.sh 1.0.0 # Release v1.0.0
|
||||
# ./scripts/create-release.sh 1.0.0 --dry-run # Preview without changes
|
||||
#
|
||||
# Releases are tarball-only. ISO builds are archived under
|
||||
# image-recipe/_archived/. Nodes OTA-update from releases/manifest.json.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
DRY_RUN=false
|
||||
VERSION=""
|
||||
|
||||
# Parse args
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--dry-run) DRY_RUN=true ;;
|
||||
--help|-h)
|
||||
echo "Usage: $0 VERSION [--dry-run]"
|
||||
echo ""
|
||||
echo "Steps performed:"
|
||||
echo " 1. Validate version format (SemVer)"
|
||||
echo " 2. Bump version in Cargo.toml and package.json"
|
||||
echo " 3. Build backend"
|
||||
echo " 4. Build frontend"
|
||||
echo " 5. Generate changelog from git log"
|
||||
echo " 6. Create release manifest"
|
||||
echo " 7. Commit version bump"
|
||||
echo " 8. Create git tag v{VERSION}"
|
||||
echo ""
|
||||
echo "Options:"
|
||||
echo " --dry-run Show what would be done without making changes"
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
if [ -z "$VERSION" ]; then
|
||||
VERSION="$arg"
|
||||
else
|
||||
echo "Error: Unknown argument: $arg"
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ -z "$VERSION" ]; then
|
||||
echo "Error: VERSION argument required"
|
||||
echo "Usage: $0 VERSION [--dry-run]"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Validate SemVer format
|
||||
if ! echo "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?$'; then
|
||||
echo "Error: Version '$VERSION' is not valid SemVer (expected: X.Y.Z or X.Y.Z-suffix)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check we're on main branch
|
||||
BRANCH=$(git -C "$PROJECT_ROOT" branch --show-current)
|
||||
if [ "$BRANCH" != "main" ]; then
|
||||
echo "Error: Must be on 'main' branch (currently on '$BRANCH')"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check for uncommitted changes
|
||||
if ! git -C "$PROJECT_ROOT" diff --quiet HEAD; then
|
||||
echo "Error: Uncommitted changes detected. Commit or stash first."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ── Pre-flight test gate ──────────────────────────────────────────────
|
||||
# A release must not ship if the static/frontend/backend checks fail. This
|
||||
# runs the release gate harness (cargo fmt/check, catalog drift, vitest, and
|
||||
# the focused cargo suites — incl. the receive/port-drift/secret regressions).
|
||||
# Skipped on --dry-run, or set SKIP_RELEASE_TESTS=1 to bypass in an emergency.
|
||||
# The lifecycle bats harness (tests/lifecycle/run-gate.sh) still runs separately
|
||||
# against live nodes — see tests/lifecycle/TESTING.md.
|
||||
if ! $DRY_RUN; then
|
||||
if [ "${SKIP_RELEASE_TESTS:-0}" = "1" ]; then
|
||||
echo "WARNING: SKIP_RELEASE_TESTS=1 — bypassing the pre-flight test gate"
|
||||
elif [ -x "$PROJECT_ROOT/tests/release/run.sh" ]; then
|
||||
echo "[0/7] Running release gate (tests/release/run.sh)..."
|
||||
if ! "$PROJECT_ROOT/tests/release/run.sh"; then
|
||||
echo "Error: release gate failed — aborting release. Fix the failing"
|
||||
echo " stage, or re-run with SKIP_RELEASE_TESTS=1 to override."
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "WARNING: tests/release/run.sh not found/executable — skipping test gate"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Check tag doesn't already exist
|
||||
if git -C "$PROJECT_ROOT" tag -l "v$VERSION" | grep -q "v$VERSION"; then
|
||||
echo "Error: Tag v$VERSION already exists"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Get current version
|
||||
CURRENT_CARGO_VERSION=$(grep '^version' "$PROJECT_ROOT/core/archipelago/Cargo.toml" | head -1 | sed 's/.*"\(.*\)".*/\1/')
|
||||
CURRENT_NPM_VERSION=$(node -p "require('$PROJECT_ROOT/neode-ui/package.json').version")
|
||||
|
||||
echo "=== Archipelago Release v${VERSION} ==="
|
||||
echo " Current Cargo version: ${CURRENT_CARGO_VERSION}"
|
||||
echo " Current npm version: ${CURRENT_NPM_VERSION}"
|
||||
echo " Target version: ${VERSION}"
|
||||
echo " Dry run: ${DRY_RUN}"
|
||||
echo ""
|
||||
|
||||
if $DRY_RUN; then
|
||||
echo "[DRY RUN] Would perform the following:"
|
||||
echo " 0. Run pre-flight test gate (tests/release/run.sh) — aborts on failure"
|
||||
echo " 1. Update core/archipelago/Cargo.toml version to $VERSION"
|
||||
echo " 2. Update neode-ui/package.json version to $VERSION"
|
||||
echo " 3. Build backend (cargo build --release -p archipelago)"
|
||||
echo " 4. Build frontend (npm run build)"
|
||||
echo " 5. Generate changelog from git log since v${CURRENT_CARGO_VERSION}"
|
||||
echo " 6. Create release manifest"
|
||||
echo " 7. Commit: 'chore: release v${VERSION}'"
|
||||
echo " 8. Tag: v${VERSION}"
|
||||
echo ""
|
||||
echo "After this script, you would:"
|
||||
echo " - Push: git push && git push --tags"
|
||||
echo " - Build ISOs on server: ssh archipelago@192.168.1.228"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "[1/7] Bumping version in Cargo.toml..."
|
||||
# Update archipelago Cargo.toml
|
||||
sed -i.bak "s/^version = \".*\"/version = \"$VERSION\"/" "$PROJECT_ROOT/core/archipelago/Cargo.toml"
|
||||
rm -f "$PROJECT_ROOT/core/archipelago/Cargo.toml.bak"
|
||||
|
||||
# Also update workspace Cargo.lock if it exists
|
||||
if [ -f "$PROJECT_ROOT/core/Cargo.lock" ]; then
|
||||
# Cargo will update the lock file on next build; touch the toml to trigger
|
||||
true
|
||||
fi
|
||||
|
||||
echo "[2/7] Bumping version in package.json..."
|
||||
cd "$PROJECT_ROOT/neode-ui"
|
||||
npm version "$VERSION" --no-git-tag-version --allow-same-version 2>/dev/null || true
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
echo "[3/8] Building backend..."
|
||||
cd "$PROJECT_ROOT/core"
|
||||
cargo build --release -p archipelago
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
echo "[4/8] Building frontend..."
|
||||
cd "$PROJECT_ROOT/neode-ui"
|
||||
npm run build 2>&1 | tail -3
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
# npm run build can silently no-op (vue-tsc EACCES burned us before) — a stale
|
||||
# dist would ship with a perfectly valid sha256. Require the freshly built
|
||||
# bundle to embed the version we just bumped to before it gets packaged.
|
||||
if ! grep -rqo "${VERSION}" "$PROJECT_ROOT"/web/dist/neode-ui/assets/*.js; then
|
||||
echo "Error: web/dist/neode-ui does not contain v${VERSION} — the frontend" >&2
|
||||
echo " build no-opped or its output is stale. Aborting release." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[5/8] Validating curated changelog..."
|
||||
|
||||
CHANGELOG_FILE="$PROJECT_ROOT/CHANGELOG.md"
|
||||
RELEASE_DATE=$(date +%Y-%m-%d)
|
||||
if [ ! -f "$CHANGELOG_FILE" ] || ! grep -q "^## v${VERSION} (" "$CHANGELOG_FILE"; then
|
||||
echo "Error: CHANGELOG.md must already contain curated notes for v${VERSION}."
|
||||
echo "Add a section like:"
|
||||
echo ""
|
||||
echo "## v${VERSION} (${RELEASE_DATE})"
|
||||
echo ""
|
||||
echo "- User/operator-facing change ..."
|
||||
echo "- Another concrete change ..."
|
||||
echo "- Validation or operational note ..."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[6/8] Creating release manifest..."
|
||||
mkdir -p "$PROJECT_ROOT/releases"
|
||||
"$SCRIPT_DIR/create-release-manifest.sh" --version "$VERSION" --date "$RELEASE_DATE" --output "$PROJECT_ROOT/releases/manifest.json" 2>&1 | grep -v "^$"
|
||||
|
||||
# §A supply-chain: the OTA manifest must carry the release-root signature.
|
||||
# Nodes refuse to AUTO-apply unsigned manifests, and publish-release-assets.sh
|
||||
# hard-refuses to ship one. The mnemonic is read interactively (or from
|
||||
# RELEASE_MASTER_MNEMONIC) — it must never land in files or shell history.
|
||||
SIGNER="$PROJECT_ROOT/core/target/release/archipelago"
|
||||
if [ ! -x "$SIGNER" ]; then
|
||||
echo "Error: release binary not found at $SIGNER — cannot sign manifest" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -n "${RELEASE_MASTER_MNEMONIC:-}" ] || [ -t 0 ]; then
|
||||
echo "[6b/8] Signing release manifest (paste the release master mnemonic when prompted)..."
|
||||
"$SIGNER" ceremony sign "$PROJECT_ROOT/releases/manifest.json"
|
||||
"$SIGNER" ceremony verify "$PROJECT_ROOT/releases/manifest.json"
|
||||
else
|
||||
echo "⚠ WARNING: no TTY and RELEASE_MASTER_MNEMONIC unset — manifest left UNSIGNED."
|
||||
echo " Sign it before publishing: bash scripts/sign-manifest.sh"
|
||||
echo " (publish-release-assets.sh refuses to ship an unsigned manifest)"
|
||||
fi
|
||||
cp "$PROJECT_ROOT/releases/manifest.json" "$PROJECT_ROOT/release-manifest.json"
|
||||
|
||||
echo "[6c/8] Staging release artifacts for validation..."
|
||||
VERSION_DIR="$PROJECT_ROOT/releases/v${VERSION}"
|
||||
FRONTEND_ARCHIVE="/tmp/archipelago-frontend-${VERSION}.tar.gz"
|
||||
mkdir -p "$VERSION_DIR"
|
||||
install -m 0755 "$PROJECT_ROOT/core/target/release/archipelago" "$VERSION_DIR/archipelago"
|
||||
install -m 0644 "$FRONTEND_ARCHIVE" "$VERSION_DIR/archipelago-frontend-${VERSION}.tar.gz"
|
||||
"$SCRIPT_DIR/check-release-manifest.sh"
|
||||
|
||||
echo "[7/8] Committing version bump..."
|
||||
git -C "$PROJECT_ROOT" add \
|
||||
core/archipelago/Cargo.toml \
|
||||
neode-ui/package.json \
|
||||
neode-ui/package-lock.json \
|
||||
CHANGELOG.md \
|
||||
releases/manifest.json \
|
||||
release-manifest.json \
|
||||
2>/dev/null || true
|
||||
|
||||
git -C "$PROJECT_ROOT" commit -m "chore: release v${VERSION}"
|
||||
|
||||
echo "[8/8] Creating git tag..."
|
||||
git -C "$PROJECT_ROOT" tag -a "v${VERSION}" -m "Release v${VERSION}"
|
||||
|
||||
echo ""
|
||||
echo "=== Release v${VERSION} Ready ==="
|
||||
echo ""
|
||||
echo "Artifacts:"
|
||||
echo " - Version bumped in Cargo.toml and package.json"
|
||||
echo " - Changelog updated in CHANGELOG.md"
|
||||
echo " - Release manifest: releases/manifest.json"
|
||||
echo " - Release manifest copy: release-manifest.json"
|
||||
echo " - Staged artifacts: releases/v${VERSION}/"
|
||||
echo " - Git tag: v${VERSION}"
|
||||
echo ""
|
||||
echo "Next steps:"
|
||||
echo " 1. Review: git log --oneline -5"
|
||||
echo " 2. Publish commits, tag, artifacts, and verify download URLs:"
|
||||
echo " scripts/publish-release-assets.sh ${VERSION} gitea-vps2"
|
||||
echo " 3. Verify manifest is live on both mirrors:"
|
||||
echo " curl -fsS http://localhost:3000/lfg2025/archy/raw/branch/main/releases/manifest.json"
|
||||
echo " curl -fsS http://146.59.87.168:3000/lfg2025/archy/raw/branch/main/releases/manifest.json"
|
||||
Executable
+31
@@ -0,0 +1,31 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
# Check what's actually in the deployed frontend
|
||||
|
||||
TARGET_HOST="${ARCHIPELAGO_TARGET:-archipelago@192.168.1.228}"
|
||||
|
||||
echo "Checking deployed frontend content..."
|
||||
echo ""
|
||||
|
||||
echo "1. Search for 'bundledApps' variable in JS:"
|
||||
ssh "$TARGET_HOST" "grep -o 'bundledApps' /opt/archipelago/web-ui/assets/*.js | wc -l"
|
||||
|
||||
echo ""
|
||||
echo "2. Search for 'Bitcoin Knots' string:"
|
||||
ssh "$TARGET_HOST" "grep -o 'Bitcoin Knots' /opt/archipelago/web-ui/assets/*.js | head -1"
|
||||
|
||||
echo ""
|
||||
echo "3. Search for the v-for loop pattern:"
|
||||
ssh "$TARGET_HOST" "grep -o 'v-for.*bundled' /opt/archipelago/web-ui/assets/*.js | head -1"
|
||||
|
||||
echo ""
|
||||
echo "4. List all JS assets (to see if they updated):"
|
||||
ssh "$TARGET_HOST" "ls -lh /opt/archipelago/web-ui/assets/*.js | head -10"
|
||||
|
||||
echo ""
|
||||
echo "5. Check index.html timestamp:"
|
||||
ssh "$TARGET_HOST" "stat /opt/archipelago/web-ui/index.html | grep Modify"
|
||||
|
||||
echo ""
|
||||
echo "6. Try accessing the API from target:"
|
||||
ssh "$TARGET_HOST" 'curl -s http://localhost:80/ | head -20'
|
||||
@@ -0,0 +1,136 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Complete Bitcoin Knots Deployment for Archipelago
|
||||
# This script deploys Bitcoin Knots with a working web UI
|
||||
#
|
||||
# For production/beta releases, this needs to be captured in the auto-installer
|
||||
# or provided as a one-click install in the App Store
|
||||
#
|
||||
|
||||
set -e
|
||||
|
||||
# Source pinned image versions (single source of truth)
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
[ -f "$SCRIPT_DIR/image-versions.sh" ] && . "$SCRIPT_DIR/image-versions.sh"
|
||||
|
||||
# Read per-installation Bitcoin RPC credentials
|
||||
SECRETS_DIR="/var/lib/archipelago/secrets"
|
||||
sudo mkdir -p "$SECRETS_DIR" && sudo chmod 700 "$SECRETS_DIR"
|
||||
if [ ! -f "$SECRETS_DIR/bitcoin-rpc-password" ]; then
|
||||
openssl rand -base64 24 | sudo tee "$SECRETS_DIR/bitcoin-rpc-password" > /dev/null
|
||||
sudo chmod 600 "$SECRETS_DIR/bitcoin-rpc-password"
|
||||
fi
|
||||
BITCOIN_RPC_USER="archipelago"
|
||||
BITCOIN_RPC_PASS=$(sudo cat "$SECRETS_DIR/bitcoin-rpc-password")
|
||||
|
||||
echo "╔════════════════════════════════════════════════════════════════╗"
|
||||
echo "║ Deploying Bitcoin Knots with Web UI ║"
|
||||
echo "╚════════════════════════════════════════════════════════════════╝"
|
||||
echo ""
|
||||
|
||||
# Step 1: Create data directory
|
||||
echo "📁 Creating Bitcoin data directory..."
|
||||
sudo mkdir -p /var/lib/archipelago/bitcoin
|
||||
echo " ✅ Directory created"
|
||||
|
||||
# Step 2: Deploy Bitcoin Knots node
|
||||
echo ""
|
||||
echo "₿ Deploying Bitcoin Knots node..."
|
||||
podman run -d \
|
||||
--name bitcoin-knots \
|
||||
--restart unless-stopped \
|
||||
-p 8332:8332 \
|
||||
-p 8333:8333 \
|
||||
-v /var/lib/archipelago/bitcoin:/home/bitcoin/.bitcoin \
|
||||
--label "com.archipelago.app=bitcoin-knots" \
|
||||
--label "com.archipelago.title=Bitcoin Knots" \
|
||||
--label "com.archipelago.version=28.1" \
|
||||
--label "com.archipelago.category=bitcoin" \
|
||||
--label "com.archipelago.description.short=Full Bitcoin node implementation" \
|
||||
--label "com.archipelago.description.long=Bitcoin Knots is a derivative of Bitcoin Core with additional features and bug fixes. Maintain the full blockchain and validate all transactions." \
|
||||
--label "com.archipelago.license=MIT" \
|
||||
--label "com.archipelago.icon=/assets/img/app-icons/bitcoin-knots.webp" \
|
||||
--label "com.archipelago.port=8332" \
|
||||
--label "com.archipelago.repo=https://github.com/bitcoinknots/bitcoin" \
|
||||
"${BITCOIN_KNOTS_IMAGE}" \
|
||||
-server=1 \
|
||||
-txindex=1 \
|
||||
-rpcallowip=127.0.0.1/32 -rpcallowip=10.88.0.0/16 \
|
||||
-rpcbind=0.0.0.0:8332 \
|
||||
-rpcuser=archipelago \
|
||||
-rpcpassword=$BITCOIN_RPC_PASS \
|
||||
-dbcache=4096
|
||||
|
||||
echo " ✅ Bitcoin Knots node starting"
|
||||
|
||||
# Step 3: Build and deploy web UI
|
||||
echo ""
|
||||
echo "🌐 Building Bitcoin Knots web UI..."
|
||||
|
||||
# Create temporary build directory
|
||||
BUILD_DIR="/tmp/bitcoin-ui-build"
|
||||
rm -rf "$BUILD_DIR"
|
||||
mkdir -p "$BUILD_DIR"
|
||||
|
||||
# Create Dockerfile
|
||||
cat > "$BUILD_DIR/Dockerfile" << 'EOF'
|
||||
FROM ${NGINX_ALPINE_IMAGE:-146.59.87.168:3000/lfg2025/nginx:1.29.6-alpine}
|
||||
|
||||
# Copy the static UI
|
||||
COPY index.html /usr/share/nginx/html/
|
||||
|
||||
# Create assets directories
|
||||
RUN mkdir -p /usr/share/nginx/html/assets/img/app-icons && \
|
||||
mkdir -p /usr/share/nginx/html/assets/img
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
EOF
|
||||
|
||||
# Copy UI file from the project
|
||||
# For beta: this needs to be included in the ISO or downloadable
|
||||
cp /home/archipelago/archy/docker/bitcoin-ui/index.html "$BUILD_DIR/"
|
||||
|
||||
# Build the image
|
||||
podman build -t localhost/bitcoin-ui:local "$BUILD_DIR"
|
||||
|
||||
# Deploy UI container
|
||||
podman run -d \
|
||||
--name bitcoin-ui \
|
||||
--restart unless-stopped \
|
||||
-p 8334:80 \
|
||||
--label "com.archipelago.app=bitcoin-ui" \
|
||||
--label "com.archipelago.parent=bitcoin-knots" \
|
||||
localhost/bitcoin-ui:local
|
||||
|
||||
echo " ✅ Bitcoin UI deployed on port 8334"
|
||||
|
||||
# Cleanup
|
||||
rm -rf "$BUILD_DIR"
|
||||
|
||||
# Step 4: Wait for backend to detect
|
||||
echo ""
|
||||
echo "⏳ Waiting for backend to detect containers..."
|
||||
sleep 5
|
||||
|
||||
echo ""
|
||||
echo "╔════════════════════════════════════════════════════════════════╗"
|
||||
echo "║ ✅ BITCOIN KNOTS DEPLOYED! ║"
|
||||
echo "╚════════════════════════════════════════════════════════════════╝"
|
||||
echo ""
|
||||
echo "📊 Status:"
|
||||
podman ps | grep bitcoin
|
||||
echo ""
|
||||
echo "🌐 Access:"
|
||||
echo " • Web UI: http://YOUR-SERVER-IP:8334"
|
||||
echo " • RPC: http://localhost:8332"
|
||||
echo " • Network: Port 8333 (Bitcoin P2P)"
|
||||
echo ""
|
||||
echo "📝 RPC Credentials:"
|
||||
echo " • User: archipelago"
|
||||
echo " • Pass: (stored in /var/lib/archipelago/secrets/bitcoin-rpc-password)"
|
||||
echo ""
|
||||
echo "⏰ Blockchain sync will take several hours to days."
|
||||
echo " Check progress: podman logs -f bitcoin-knots"
|
||||
echo ""
|
||||
Executable
+7
@@ -0,0 +1,7 @@
|
||||
#!/bin/bash
|
||||
# Default deployment targets — override in deploy-config.sh (gitignored)
|
||||
DEFAULT_PRIMARY="192.168.1.228"
|
||||
DEFAULT_SECONDARY="192.168.1.198"
|
||||
TAILSCALE_ARCH1="100.82.97.63"
|
||||
TAILSCALE_ARCH2="100.122.84.60"
|
||||
TAILSCALE_ARCH3="100.124.105.113"
|
||||
@@ -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
+1246
File diff suppressed because it is too large
Load Diff
Executable
+2066
File diff suppressed because it is too large
Load Diff
Executable
+259
@@ -0,0 +1,259 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Container Orchestration Dev Loop
|
||||
# Fast edit-build-test cycle against real containers on .228
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/dev-container-test.sh # Interactive loop
|
||||
# ./scripts/dev-container-test.sh --once # Single run (for CI)
|
||||
#
|
||||
# Workflow: edit locally → rsync → build on server → restart → smoke test
|
||||
#
|
||||
|
||||
set -o pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
|
||||
SSH_KEY="${ARCHIPELAGO_SSH_KEY:-$HOME/.ssh/archipelago-deploy}"
|
||||
SSH_HOST="${ARCHIPELAGO_SSH_HOST:-archipelago@192.168.1.228}"
|
||||
SSH_OPTS="-o StrictHostKeyChecking=no -o ServerAliveInterval=15 -i $SSH_KEY"
|
||||
REMOTE_DIR="/home/archipelago/archy"
|
||||
RPC_URL="http://192.168.1.228/rpc/v1"
|
||||
COOKIE=""
|
||||
ONCE=false
|
||||
[ "$1" = "--once" ] && ONCE=true
|
||||
|
||||
# ── Colors ──────────────────────────────────────────────────────────────
|
||||
RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[0;33m'
|
||||
CYAN='\033[0;36m' BOLD='\033[1m' NC='\033[0m'
|
||||
|
||||
pass() { echo -e " ${GREEN}✓${NC} $*"; }
|
||||
fail() { echo -e " ${RED}✗${NC} $*"; FAILURES=$((FAILURES + 1)); }
|
||||
info() { echo -e " ${CYAN}→${NC} $*"; }
|
||||
header() { echo -e "\n${BOLD}$*${NC}"; }
|
||||
|
||||
TESTS=0
|
||||
FAILURES=0
|
||||
|
||||
# ── Helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
rpc() {
|
||||
local method="$1"
|
||||
local params="${2:-{}}"
|
||||
local result
|
||||
result=$(curl -sf -b "$COOKIE" -X POST "$RPC_URL" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"jsonrpc\":\"2.0\",\"method\":\"$method\",\"params\":$params,\"id\":1}" \
|
||||
--connect-timeout 10 --max-time 30 2>/dev/null)
|
||||
echo "$result"
|
||||
}
|
||||
|
||||
login() {
|
||||
# Get session cookie
|
||||
COOKIE=$(mktemp)
|
||||
local resp
|
||||
resp=$(curl -sf -c "$COOKIE" -X POST "$RPC_URL" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"jsonrpc":"2.0","method":"auth.login","params":{"password":"password123"},"id":1}' \
|
||||
--connect-timeout 10 2>/dev/null)
|
||||
if echo "$resp" | grep -q '"result"'; then
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
wait_for_health() {
|
||||
local timeout=${1:-30}
|
||||
for i in $(seq 1 "$timeout"); do
|
||||
if curl -sf "http://192.168.1.228/health" >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# ── Sync & Build ────────────────────────────────────────────────────────
|
||||
|
||||
sync_and_build() {
|
||||
header "Step 1: Sync code to .228"
|
||||
rsync -az --delete \
|
||||
--exclude='.git' --exclude='target' --exclude='node_modules' \
|
||||
--exclude='dist' --exclude='*.iso' --exclude='.claude' \
|
||||
-e "ssh $SSH_OPTS" \
|
||||
"$PROJECT_ROOT/" "$SSH_HOST:$REMOTE_DIR/" 2>&1
|
||||
pass "Code synced"
|
||||
|
||||
header "Step 2: Build backend (incremental)"
|
||||
local build_start=$(date +%s)
|
||||
if ssh $SSH_OPTS "$SSH_HOST" "cd $REMOTE_DIR/core && cargo build --release -p archipelago 2>&1 | tail -3"; then
|
||||
local elapsed=$(( $(date +%s) - build_start ))
|
||||
pass "Built in ${elapsed}s"
|
||||
else
|
||||
fail "Build failed"
|
||||
return 1
|
||||
fi
|
||||
|
||||
header "Step 3: Restart service"
|
||||
ssh $SSH_OPTS "$SSH_HOST" "sudo systemctl restart archipelago"
|
||||
info "Waiting for health..."
|
||||
if wait_for_health 30; then
|
||||
pass "Backend healthy"
|
||||
else
|
||||
fail "Backend failed to start (30s timeout)"
|
||||
ssh $SSH_OPTS "$SSH_HOST" "journalctl -u archipelago --since '30 sec ago' --no-pager | tail -20"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Smoke Tests ─────────────────────────────────────────────────────────
|
||||
|
||||
run_smoke_tests() {
|
||||
header "Step 4: Container Orchestration Smoke Tests"
|
||||
TESTS=0
|
||||
FAILURES=0
|
||||
|
||||
# Login
|
||||
if login; then
|
||||
pass "Authenticated"
|
||||
else
|
||||
fail "Login failed"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Test 1: Container list
|
||||
TESTS=$((TESTS + 1))
|
||||
local list
|
||||
list=$(rpc "container.list")
|
||||
if echo "$list" | grep -q '"result"'; then
|
||||
local count
|
||||
count=$(echo "$list" | python3 -c "import sys,json; print(len(json.load(sys.stdin).get('result',{}).get('containers',[])))" 2>/dev/null || echo "?")
|
||||
pass "container.list: $count containers"
|
||||
else
|
||||
fail "container.list failed"
|
||||
fi
|
||||
|
||||
# Test 2: Health status
|
||||
TESTS=$((TESTS + 1))
|
||||
local health
|
||||
health=$(rpc "container.health")
|
||||
if echo "$health" | grep -q '"result"'; then
|
||||
pass "container.health: OK"
|
||||
else
|
||||
fail "container.health failed"
|
||||
fi
|
||||
|
||||
# Test 3: Install a lightweight container (filebrowser — small, fast, no deps)
|
||||
TESTS=$((TESTS + 1))
|
||||
local install_img="146.59.87.168:3000/lfg2025/filebrowser:v2.27.0"
|
||||
# Check if already installed
|
||||
local fb_state
|
||||
fb_state=$(ssh $SSH_OPTS "$SSH_HOST" "podman inspect filebrowser --format '{{.State.Status}}' 2>/dev/null || echo 'none'")
|
||||
if [ "$fb_state" = "none" ]; then
|
||||
info "Installing filebrowser..."
|
||||
local install_result
|
||||
install_result=$(rpc "package.install" "{\"id\":\"filebrowser\",\"dockerImage\":\"$install_img\"}")
|
||||
if echo "$install_result" | grep -q '"success"'; then
|
||||
pass "package.install filebrowser: success"
|
||||
else
|
||||
fail "package.install filebrowser: $(echo "$install_result" | python3 -c 'import sys,json; print(json.load(sys.stdin).get("error",{}).get("message","unknown"))' 2>/dev/null)"
|
||||
fi
|
||||
else
|
||||
pass "filebrowser already installed ($fb_state)"
|
||||
fi
|
||||
|
||||
# Test 4: Stop with grace period
|
||||
TESTS=$((TESTS + 1))
|
||||
local stop_result
|
||||
stop_result=$(rpc "package.stop" '{"id":"filebrowser"}')
|
||||
sleep 2
|
||||
fb_state=$(ssh $SSH_OPTS "$SSH_HOST" "podman inspect filebrowser --format '{{.State.Status}}' 2>/dev/null || echo 'unknown'")
|
||||
if [ "$fb_state" = "exited" ] || [ "$fb_state" = "stopped" ]; then
|
||||
pass "package.stop: filebrowser → $fb_state"
|
||||
else
|
||||
fail "package.stop: expected stopped, got $fb_state"
|
||||
fi
|
||||
|
||||
# Test 5: Start
|
||||
TESTS=$((TESTS + 1))
|
||||
rpc "package.start" '{"id":"filebrowser"}' >/dev/null
|
||||
sleep 3
|
||||
fb_state=$(ssh $SSH_OPTS "$SSH_HOST" "podman inspect filebrowser --format '{{.State.Status}}' 2>/dev/null || echo 'unknown'")
|
||||
if [ "$fb_state" = "running" ]; then
|
||||
pass "package.start: filebrowser → running"
|
||||
else
|
||||
fail "package.start: expected running, got $fb_state"
|
||||
fi
|
||||
|
||||
# Test 6: Restart tracker persisted
|
||||
TESTS=$((TESTS + 1))
|
||||
local tracker
|
||||
tracker=$(ssh $SSH_OPTS "$SSH_HOST" "cat /var/lib/archipelago/restart-tracker.json 2>/dev/null")
|
||||
if [ -n "$tracker" ] && echo "$tracker" | python3 -c "import sys,json; json.load(sys.stdin)" 2>/dev/null; then
|
||||
pass "restart-tracker.json: valid JSON"
|
||||
else
|
||||
pass "restart-tracker.json: empty (no failures — healthy)"
|
||||
fi
|
||||
|
||||
# Test 7: Systemd timers active
|
||||
TESTS=$((TESTS + 1))
|
||||
local timers
|
||||
timers=$(ssh $SSH_OPTS "$SSH_HOST" "systemctl list-timers --no-pager 2>/dev/null | grep -c archipelago")
|
||||
if [ "${timers:-0}" -ge 2 ]; then
|
||||
pass "Systemd timers: $timers active (doctor + reconcile)"
|
||||
else
|
||||
fail "Systemd timers: expected ≥2, got ${timers:-0}"
|
||||
fi
|
||||
|
||||
# Test 8: Container doctor runs cleanly
|
||||
TESTS=$((TESTS + 1))
|
||||
local doctor_exit
|
||||
ssh $SSH_OPTS "$SSH_HOST" "sudo /home/archipelago/archy/scripts/container-doctor.sh --local 2>&1 | tail -1"
|
||||
doctor_exit=$?
|
||||
if [ $doctor_exit -eq 0 ]; then
|
||||
pass "container-doctor.sh: clean exit"
|
||||
else
|
||||
fail "container-doctor.sh: exit code $doctor_exit"
|
||||
fi
|
||||
|
||||
# Summary
|
||||
header "Results"
|
||||
local passed=$((TESTS - FAILURES))
|
||||
echo -e " ${GREEN}$passed passed${NC} / ${RED}$FAILURES failed${NC} / $TESTS total"
|
||||
|
||||
# Cleanup temp cookie
|
||||
rm -f "$COOKIE" 2>/dev/null
|
||||
|
||||
return $FAILURES
|
||||
}
|
||||
|
||||
# ── Main ────────────────────────────────────────────────────────────────
|
||||
|
||||
echo ""
|
||||
echo "╔════════════════════════════════════════════════════════════════╗"
|
||||
echo "║ Container Orchestration Dev Loop ║"
|
||||
echo "╚════════════════════════════════════════════════════════════════╝"
|
||||
echo ""
|
||||
info "Target: $SSH_HOST"
|
||||
info "Mode: $($ONCE && echo 'single run' || echo 'interactive loop')"
|
||||
echo ""
|
||||
|
||||
# Check SSH
|
||||
if ! ssh $SSH_OPTS "$SSH_HOST" "echo ok" >/dev/null 2>&1; then
|
||||
fail "Cannot SSH to $SSH_HOST"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if $ONCE; then
|
||||
sync_and_build && run_smoke_tests
|
||||
exit $?
|
||||
fi
|
||||
|
||||
# Interactive loop
|
||||
while true; do
|
||||
sync_and_build && run_smoke_tests
|
||||
echo ""
|
||||
echo -e "${YELLOW}Press Enter to re-sync + re-test, Ctrl+C to stop${NC}"
|
||||
read -r
|
||||
done
|
||||
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.168.1.228:8083)"
|
||||
echo " then drop it on your Desktop and re-run this option."
|
||||
echo ""
|
||||
echo " Files you can edit:"
|
||||
echo " image-recipe/branding/grub-theme/background.png — GRUB boot background"
|
||||
echo " image-recipe/branding/grub-theme/theme.txt — GRUB menu colors/layout"
|
||||
echo " image-recipe/branding/plymouth-theme/logo.png — Plymouth boot logo"
|
||||
echo " image-recipe/branding/plymouth-theme/*.script — Plymouth animation"
|
||||
echo ""
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo " ISO: $ISO"
|
||||
echo " Edit these files, then this script patches and boots in QEMU:"
|
||||
echo " branding/grub-theme/background.png — GRUB background"
|
||||
echo " branding/grub-theme/theme.txt — GRUB menu theme"
|
||||
echo " branding/plymouth-theme/logo.png — Plymouth logo"
|
||||
echo ""
|
||||
|
||||
if [ -f "$DEV_BRANDING" ]; then
|
||||
exec bash "$DEV_BRANDING" "$ISO"
|
||||
else
|
||||
echo " dev-branding.sh not found at: $DEV_BRANDING"
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
1)
|
||||
echo ""
|
||||
echo "Starting frontend with mock backend..."
|
||||
cleanup_ports
|
||||
ensure_deps
|
||||
exec npm run dev:mock
|
||||
;;
|
||||
2)
|
||||
echo ""
|
||||
echo "Starting full stack (Rust backend + frontend)..."
|
||||
cleanup_ports
|
||||
|
||||
if [ ! -d "$BACKEND_DIR" ]; then
|
||||
echo "Backend directory not found: $BACKEND_DIR"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cd "$BACKEND_DIR"
|
||||
if ! cargo check --bin archipelago > /tmp/archipelago-backend-check.log 2>&1; then
|
||||
echo "Backend build check failed. See /tmp/archipelago-backend-check.log"
|
||||
echo "Falling back to mock backend."
|
||||
ensure_deps
|
||||
exec npm run dev:mock
|
||||
fi
|
||||
|
||||
echo " Starting Rust backend..."
|
||||
export ARCHIPELAGO_DATA_DIR=/tmp/archipelago-dev
|
||||
export ARCHIPELAGO_DEV_DATA_DIR=/tmp/archipelago-dev
|
||||
export ARCHIPELAGO_DEV_MODE=true
|
||||
export ARCHIPELAGO_BIND=127.0.0.1:5959
|
||||
export ARCHIPELAGO_LOG_LEVEL=debug
|
||||
export ARCHIPELAGO_BITCOIN_SIMULATION=mock
|
||||
|
||||
cargo run --bin archipelago > /tmp/archipelago-backend.log 2>&1 &
|
||||
BACKEND_PID=$!
|
||||
echo " Backend PID: $BACKEND_PID (logs: /tmp/archipelago-backend.log)"
|
||||
|
||||
echo " Waiting for backend on port 5959..."
|
||||
for i in $(seq 1 60); do
|
||||
if lsof -ti:5959 >/dev/null 2>&1; then break; fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
if ! lsof -ti:5959 >/dev/null 2>&1; then
|
||||
echo "Backend did not start. Falling back to mock."
|
||||
kill "$BACKEND_PID" 2>/dev/null || true
|
||||
ensure_deps
|
||||
exec npm run dev:mock
|
||||
fi
|
||||
|
||||
echo " Backend ready."
|
||||
trap "kill $BACKEND_PID 2>/dev/null" EXIT
|
||||
|
||||
ensure_deps
|
||||
exec npm run dev
|
||||
;;
|
||||
3)
|
||||
echo ""
|
||||
echo "Starting setup mode..."
|
||||
cleanup_ports
|
||||
ensure_deps
|
||||
VITE_DEV_MODE=setup exec npm run dev:mock
|
||||
;;
|
||||
4)
|
||||
echo ""
|
||||
echo "Starting onboarding mode..."
|
||||
cleanup_ports
|
||||
ensure_deps
|
||||
VITE_DEV_MODE=onboarding exec npm run dev:mock
|
||||
;;
|
||||
5)
|
||||
echo ""
|
||||
echo "Starting existing user mode..."
|
||||
cleanup_ports
|
||||
ensure_deps
|
||||
VITE_DEV_MODE=existing exec npm run dev:mock
|
||||
;;
|
||||
6)
|
||||
echo ""
|
||||
echo "Starting boot mode (25s simulated startup)..."
|
||||
cleanup_ports
|
||||
ensure_deps
|
||||
VITE_DEV_MODE=boot exec npm run dev:mock
|
||||
;;
|
||||
7)
|
||||
echo ""
|
||||
echo "Starting testnet stack (signet) via Podman/Docker..."
|
||||
|
||||
# Check for a working container runtime (binary exists AND daemon responds)
|
||||
RUNTIME=""
|
||||
COMPOSE=""
|
||||
if command -v docker &>/dev/null && docker ps &>/dev/null; then
|
||||
RUNTIME="docker"
|
||||
COMPOSE="docker compose"
|
||||
elif command -v podman &>/dev/null && podman ps &>/dev/null; then
|
||||
if command -v podman-compose &>/dev/null; then
|
||||
RUNTIME="podman"
|
||||
COMPOSE="podman-compose"
|
||||
else
|
||||
RUNTIME="podman"
|
||||
COMPOSE="podman compose"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -z "$RUNTIME" ]; then
|
||||
if command -v podman &>/dev/null; then
|
||||
echo " Podman machine not running — starting it..."
|
||||
if ! podman machine ls --format '{{.Name}}' 2>/dev/null | grep -q .; then
|
||||
echo " No Podman machine found — initializing..."
|
||||
podman machine init
|
||||
fi
|
||||
podman machine start
|
||||
if podman ps &>/dev/null; then
|
||||
if command -v podman-compose &>/dev/null; then
|
||||
RUNTIME="podman"
|
||||
COMPOSE="podman-compose"
|
||||
else
|
||||
RUNTIME="podman"
|
||||
COMPOSE="podman compose"
|
||||
fi
|
||||
else
|
||||
echo " Failed to start Podman machine."
|
||||
exit 1
|
||||
fi
|
||||
elif command -v docker &>/dev/null; then
|
||||
echo ""
|
||||
echo "Docker is installed but the daemon isn't running."
|
||||
echo "Start Docker Desktop and try again."
|
||||
exit 1
|
||||
else
|
||||
echo ""
|
||||
echo "No container runtime found. Install one:"
|
||||
echo " brew install podman podman-compose"
|
||||
echo " # or"
|
||||
echo " brew install --cask docker"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
echo " Using: $RUNTIME"
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
echo " Starting signet Bitcoin + LND + ThunderHub + Fedimint..."
|
||||
$COMPOSE -f docker-compose.testnet.yml up -d
|
||||
|
||||
echo ""
|
||||
echo " Testnet stack starting. Services:"
|
||||
echo " ThunderHub: http://localhost:3010 (password: thunderhub)"
|
||||
echo " Fedimint Guardian: http://localhost:18175"
|
||||
echo " LND REST: http://localhost:8080"
|
||||
echo " Bitcoin RPC: localhost:38332"
|
||||
echo ""
|
||||
echo " Get signet coins: https://signetfaucet.com"
|
||||
echo ""
|
||||
echo " Also starting mock frontend..."
|
||||
cleanup_ports
|
||||
ensure_deps
|
||||
exec npm run dev:mock
|
||||
;;
|
||||
8)
|
||||
echo ""
|
||||
echo "Manual Instructions"
|
||||
echo ""
|
||||
echo "UI development (mock backend, no Docker):"
|
||||
echo " cd $FRONTEND_DIR"
|
||||
echo " npm install && npm run dev:mock"
|
||||
echo ""
|
||||
echo "Dev modes (prepend to command):"
|
||||
echo " VITE_DEV_MODE=setup First-time setup flow"
|
||||
echo " VITE_DEV_MODE=onboarding Onboarding flow"
|
||||
echo " VITE_DEV_MODE=existing Login screen"
|
||||
echo " VITE_DEV_MODE=boot Boot sequence"
|
||||
echo ""
|
||||
echo "Testnet stack (requires Podman or Docker):"
|
||||
echo " podman compose -f docker-compose.testnet.yml up -d"
|
||||
echo ""
|
||||
echo "Full stack (requires Rust toolchain):"
|
||||
echo " Terminal 1: cd $BACKEND_DIR && cargo run --bin archipelago"
|
||||
echo " Terminal 2: cd $FRONTEND_DIR && npm run dev"
|
||||
echo ""
|
||||
echo "Access: http://localhost:8100 (password: password123)"
|
||||
;;
|
||||
9)
|
||||
echo ""
|
||||
echo "Container Orchestration Dev (live testing on .228)"
|
||||
echo "Syncs code, builds on server, runs orchestration smoke tests."
|
||||
echo ""
|
||||
exec "$SCRIPT_DIR/dev-container-test.sh"
|
||||
;;
|
||||
10)
|
||||
if [ "$IS_LINUX" != true ]; then
|
||||
echo "Production build is only available on Linux dev machines."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Production Build — mirrors ISO install exactly"
|
||||
echo ""
|
||||
|
||||
FAILED=0
|
||||
|
||||
# Step 1: Build backend
|
||||
echo "[1/5] Building Rust backend (release)..."
|
||||
cd "$BACKEND_DIR/archipelago"
|
||||
if cargo build --release 2>&1 | tail -3; then
|
||||
RELEASE_BIN="$BACKEND_DIR/target/release/archipelago"
|
||||
sudo cp "$RELEASE_BIN" /usr/local/bin/archipelago
|
||||
sudo chmod +x /usr/local/bin/archipelago
|
||||
echo " Backend installed: $(ls -lh /usr/local/bin/archipelago | awk '{print $5}')"
|
||||
else
|
||||
echo " FAILED: cargo build --release"
|
||||
FAILED=1
|
||||
fi
|
||||
|
||||
# Step 2: Type-check + build frontend
|
||||
echo "[2/5] Building frontend..."
|
||||
cd "$FRONTEND_DIR"
|
||||
if [ ! -d "node_modules" ]; then
|
||||
npm install
|
||||
fi
|
||||
if npx vue-tsc -b --noEmit 2>&1 | tail -3; then
|
||||
npm run build 2>&1 | tail -3
|
||||
sudo cp -r "$PROJECT_ROOT/web/dist/neode-ui/"* /opt/archipelago/web-ui/
|
||||
# Deploy AIUI (pre-built demo or source build)
|
||||
if [ -d "$PROJECT_ROOT/../AIUI/packages/app/dist" ]; then
|
||||
sudo cp -r "$PROJECT_ROOT/../AIUI/packages/app/dist/"* /opt/archipelago/web-ui/aiui/
|
||||
echo " AIUI deployed from source build"
|
||||
elif [ -d "$PROJECT_ROOT/demo/aiui" ]; then
|
||||
sudo mkdir -p /opt/archipelago/web-ui/aiui/
|
||||
sudo cp -r "$PROJECT_ROOT/demo/aiui/"* /opt/archipelago/web-ui/aiui/
|
||||
echo " AIUI deployed from demo/"
|
||||
fi
|
||||
echo " Frontend deployed to /opt/archipelago/web-ui/"
|
||||
else
|
||||
echo " FAILED: vue-tsc type check"
|
||||
FAILED=1
|
||||
fi
|
||||
|
||||
# Step 3: Sync configs from repo
|
||||
echo "[3/5] Syncing configs..."
|
||||
sudo cp "$PROJECT_ROOT/image-recipe/configs/archipelago.service" /etc/systemd/system/archipelago.service
|
||||
sudo cp "$PROJECT_ROOT/image-recipe/configs/nginx-archipelago.conf" /etc/nginx/sites-available/archipelago
|
||||
sudo cp "$PROJECT_ROOT/image-recipe/configs/snippets/"*.conf /etc/nginx/snippets/ 2>/dev/null
|
||||
for unit in archipelago-tor-helper.service archipelago-tor-helper.path archipelago-wg.service archipelago-wg-address.service nostr-relay.service nostr-vpn.service; do
|
||||
sudo cp "$PROJECT_ROOT/image-recipe/configs/$unit" "/etc/systemd/system/$unit"
|
||||
done
|
||||
sudo cp "$PROJECT_ROOT/scripts/tor-helper.sh" /opt/archipelago/scripts/tor-helper.sh
|
||||
sudo chmod +x /opt/archipelago/scripts/tor-helper.sh
|
||||
sudo cp "$PROJECT_ROOT/scripts/archipelago-wg" /usr/local/bin/archipelago-wg
|
||||
sudo chmod +x /usr/local/bin/archipelago-wg
|
||||
echo " Configs synced"
|
||||
|
||||
# Step 4: Sync Tor hostnames
|
||||
echo "[4/5] Syncing Tor hostnames..."
|
||||
for svc in archipelago bitcoin electrumx lnd btcpay mempool fedimint; do
|
||||
dir="/var/lib/archipelago/tor/hidden_service_$svc"
|
||||
if [ -f "$dir/hostname" ]; then
|
||||
sudo cp "$dir/hostname" "/var/lib/archipelago/tor-hostnames/$svc"
|
||||
fi
|
||||
done
|
||||
sudo chown -R "$(whoami)":"$(whoami)" /var/lib/archipelago/tor-hostnames 2>/dev/null
|
||||
|
||||
# Step 5: Reload and restart all services
|
||||
echo "[5/5] Restarting services..."
|
||||
sudo systemctl daemon-reload
|
||||
sudo nginx -t 2>&1 && sudo systemctl reload nginx
|
||||
sudo systemctl restart archipelago
|
||||
|
||||
# Verify
|
||||
echo ""
|
||||
echo "Service Status:"
|
||||
for svc in tor@default archipelago-wg archipelago-wg-address nostr-relay nostr-vpn archipelago-tor-helper.path archipelago nginx; do
|
||||
STATUS=$(systemctl is-active "$svc" 2>/dev/null)
|
||||
if [ "$STATUS" = "active" ]; then
|
||||
printf " %-30s active\n" "$svc"
|
||||
else
|
||||
printf " %-30s FAILED\n" "$svc"
|
||||
FAILED=1
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
if [ "$FAILED" -eq 0 ]; then
|
||||
HOST_IP=$(hostname -I 2>/dev/null | awk '{print $1}')
|
||||
ONION=$(cat /var/lib/archipelago/tor-hostnames/archipelago 2>/dev/null || echo "generating...")
|
||||
echo "All services running. Access:"
|
||||
echo " LAN: http://$HOST_IP"
|
||||
echo " Tor: http://$ONION"
|
||||
echo " WG: 10.44.0.1"
|
||||
echo " RPC: http://127.0.0.1:5678/rpc/v1"
|
||||
else
|
||||
echo "Some services failed. Check: journalctl -u <service> --no-pager -n 20"
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
echo "Invalid choice"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
Executable
+1470
File diff suppressed because it is too large
Load Diff
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 ==="
|
||||
Executable
+176
@@ -0,0 +1,176 @@
|
||||
#!/bin/bash
|
||||
# LAN fast-path pairing for our 4 dev fleet nodes.
|
||||
#
|
||||
# ── Is this needed for every archipelago install? No. ────────────────
|
||||
# For nodes deployed anywhere in the world, FIPS-to-FIPS routing by
|
||||
# npub works via the anchor peer network (fips.v0l.io ships by default
|
||||
# in /etc/fips/fips.yaml on every install — that anchor bootstraps DHT
|
||||
# routing for any npub the node has ever heard about). The peer's
|
||||
# fips_npub is advertised in our federation invite codes (since v1.4),
|
||||
# so accepting an invite is enough for `dial::peer_base_url(npub)` to
|
||||
# reach the peer through the anchor mesh.
|
||||
#
|
||||
# ── Why this script exists ───────────────────────────────────────────
|
||||
# Our 4 fleet nodes are all on 192.168.1.0/24. Hopping through the
|
||||
# fips.v0l.io anchor for intra-LAN traffic is wasteful when the peers
|
||||
# are on the same wire. This script writes per-node fips.yaml with:
|
||||
# 1. The public anchor (fips.v0l.io) so internet peers still route.
|
||||
# 2. The other 3 fleet nodes as static LAN peers (UDP 2121 / TCP
|
||||
# 8443) so LAN traffic stays on LAN.
|
||||
# 3. `persistent: true` so the npub is stable across restarts —
|
||||
# without this the daemon rolls a new keypair on every restart
|
||||
# and any federation invite we advertised goes stale.
|
||||
#
|
||||
# Idempotent: re-running picks up any newly-added or removed nodes.
|
||||
#
|
||||
# For a production install on an unknown LAN, this script isn't the
|
||||
# mechanism — the ISO install writes the anchor-only fips.yaml and
|
||||
# identity comes from the archipelago seed; peer discovery is purely
|
||||
# through the DHT + federation invites.
|
||||
#
|
||||
# Usage:
|
||||
# scripts/fleet-fips-pair.sh # apply to all nodes
|
||||
# scripts/fleet-fips-pair.sh --verify # just print the peer state
|
||||
|
||||
set -eo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
. "$SCRIPT_DIR/lib/common.sh"
|
||||
|
||||
# Fleet roster: "<ip-last-octet> <nic-name> <fips-npub>"
|
||||
NODES=(
|
||||
"116 enp0s25 npub1mxavs6scfgl056k6lm4mk73ddnrhjewg78zlyzfn2lmr0rfyrs5qhcr03g"
|
||||
"198 enp2s0 npub13cy4lml94cj4rdu8runrr945z2muszuvr5tql8mr9m063d7xzpqqu3k8se"
|
||||
"228 enp2s0 npub1a0xxcqce2tsv8ulwastep23jtf3h4wvvry8r8nklnl36jtrdnefqh5qn6h"
|
||||
"253 enx9cbf0d0129f9 npub1dl0m0yfzfw6467c3z6q63s7ggzd77yg97j90ptfrheprxeypt3msj0mq4g"
|
||||
)
|
||||
|
||||
LAN_PREFIX="192.168.1"
|
||||
UDP_PORT=2121
|
||||
TCP_PORT=8443
|
||||
|
||||
if [ "${1:-}" = "--verify" ]; then
|
||||
for row in "${NODES[@]}"; do
|
||||
read -r node _nic _npub <<< "$row"
|
||||
echo "=== .$node ==="
|
||||
ssh_cmd "$LAN_PREFIX.$node" "sudo fipsctl show peers 2>/dev/null | python3 -c 'import sys,json; d=json.load(sys.stdin); print(f\"{len(d[\"peers\"])} authenticated peers\"); [print(\" npub=\", p.get(\"npub\",\"?\"), \"alias=\", p.get(\"alias\",\"?\")) for p in d[\"peers\"]]' || echo ' fipsctl show peers failed'"
|
||||
done
|
||||
exit 0
|
||||
fi
|
||||
|
||||
TMP_ROOT=$(mktemp -d)
|
||||
trap 'rm -rf "$TMP_ROOT"' EXIT
|
||||
|
||||
generate_yaml() {
|
||||
# $1 = self node octet, $2 = self nic
|
||||
local self_node="$1"
|
||||
local self_nic="$2"
|
||||
local out="$TMP_ROOT/fips.yaml.$self_node"
|
||||
|
||||
cat > "$out" <<YAML
|
||||
# FIPS Node Configuration — managed by scripts/fleet-fips-pair.sh
|
||||
# DO NOT hand-edit: re-run the script to regenerate.
|
||||
|
||||
node:
|
||||
identity:
|
||||
# Persistent identity so the npub stays stable across restarts.
|
||||
# Without this, every restart rolls a new keypair and federation
|
||||
# peer lists go stale.
|
||||
persistent: true
|
||||
|
||||
tun:
|
||||
enabled: true
|
||||
name: fips0
|
||||
mtu: 1280
|
||||
|
||||
dns:
|
||||
enabled: true
|
||||
bind_addr: "127.0.0.1"
|
||||
port: 5354
|
||||
|
||||
transports:
|
||||
udp:
|
||||
bind_addr: "0.0.0.0:$UDP_PORT"
|
||||
tcp:
|
||||
bind_addr: "0.0.0.0:$TCP_PORT"
|
||||
|
||||
ethernet:
|
||||
interface: "$self_nic"
|
||||
discovery: true
|
||||
announce: true
|
||||
auto_connect: true
|
||||
accept_connections: true
|
||||
|
||||
peers:
|
||||
# Public anchor — bootstraps DHT routing for any npub heard via
|
||||
# federation invites. Every archipelago install ships this peer.
|
||||
- npub: "npub1zv58cn7v83mxvttl70w5fwjwuclfmntv9cnmv5wmz2nzz88u5urqvdx96n"
|
||||
alias: "fips.v0l.io"
|
||||
addresses:
|
||||
- transport: tcp
|
||||
addr: "fips.v0l.io:8443"
|
||||
- transport: udp
|
||||
addr: "fips.v0l.io:2121"
|
||||
connect_policy: auto_connect
|
||||
|
||||
# Fleet LAN fast-path — other archipelago nodes on this subnet.
|
||||
YAML
|
||||
|
||||
for other_row in "${NODES[@]}"; do
|
||||
read -r o_node _o_nic o_npub <<< "$other_row"
|
||||
[ "$o_node" = "$self_node" ] && continue
|
||||
cat >> "$out" <<YAML
|
||||
- npub: "$o_npub"
|
||||
alias: "archi-$o_node"
|
||||
addresses:
|
||||
- transport: udp
|
||||
addr: "$LAN_PREFIX.$o_node:$UDP_PORT"
|
||||
- transport: tcp
|
||||
addr: "$LAN_PREFIX.$o_node:$TCP_PORT"
|
||||
connect_policy: auto_connect
|
||||
YAML
|
||||
done
|
||||
echo "$out"
|
||||
}
|
||||
|
||||
deploy_to() {
|
||||
local node="$1"
|
||||
local nic="$2"
|
||||
local ip="$LAN_PREFIX.$node"
|
||||
local yaml
|
||||
yaml=$(generate_yaml "$node" "$nic")
|
||||
|
||||
log_info "[.${node}] uploading fips.yaml"
|
||||
scp_cmd "$yaml" "archipelago@${ip}:/tmp/fips.yaml.new"
|
||||
|
||||
log_info "[.${node}] installing + restarting fips.service"
|
||||
ssh_cmd "$ip" '
|
||||
set -e
|
||||
sudo install -o root -g root -m 0600 /tmp/fips.yaml.new /etc/fips/fips.yaml
|
||||
rm -f /tmp/fips.yaml.new
|
||||
sudo systemctl restart fips.service
|
||||
# Give the daemon a beat to come up before we ask about peers
|
||||
for i in $(seq 1 10); do
|
||||
if sudo systemctl is-active fips.service >/dev/null 2>&1; then break; fi
|
||||
sleep 0.5
|
||||
done
|
||||
sudo systemctl is-active fips.service
|
||||
'
|
||||
}
|
||||
|
||||
for row in "${NODES[@]}"; do
|
||||
read -r node nic _npub <<< "$row"
|
||||
deploy_to "$node" "$nic"
|
||||
done
|
||||
|
||||
echo
|
||||
log_info "Waiting 10s for peer handshakes to settle…"
|
||||
sleep 10
|
||||
|
||||
echo
|
||||
log_info "Post-pair peer state:"
|
||||
for row in "${NODES[@]}"; do
|
||||
read -r node _nic _npub <<< "$row"
|
||||
count=$(ssh_cmd "$LAN_PREFIX.$node" "sudo fipsctl show peers 2>/dev/null | grep -c '\"npub\"' || echo 0")
|
||||
log_info " .$node: $count authenticated peers"
|
||||
done
|
||||
Executable
+135
@@ -0,0 +1,135 @@
|
||||
#!/bin/bash
|
||||
# Strip the LAN fast-path peers from all 4 fleet nodes' fips.yaml,
|
||||
# leaving only the public anchor (fips.v0l.io). Restart fips.service
|
||||
# on each node.
|
||||
#
|
||||
# Purpose: verify that the general-case deployment (nodes anywhere in
|
||||
# the world, no LAN between them) actually works — i.e. that two
|
||||
# paired archipelago peers can reach each other purely through the
|
||||
# FIPS DHT bootstrapped from the anchor.
|
||||
#
|
||||
# After running this, test with:
|
||||
# scripts/fleet-fips-pair.sh --verify (peer state per node)
|
||||
# for ip in 116 198 228 253; do
|
||||
# ssh archipelago@192.168.1.$ip "dig @127.0.0.1 -p 5354 +short \
|
||||
# <other-node-npub>.fips AAAA"
|
||||
# done
|
||||
#
|
||||
# To restore the LAN fast-path: re-run scripts/fleet-fips-pair.sh.
|
||||
#
|
||||
# Usage: scripts/fleet-fips-unpair.sh
|
||||
|
||||
set -eo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
. "$SCRIPT_DIR/lib/common.sh"
|
||||
|
||||
# Roster — only need NIC names to preserve them in the yaml.
|
||||
NODES=(
|
||||
"116 enp0s25"
|
||||
"198 enp2s0"
|
||||
"228 enp2s0"
|
||||
"253 enx9cbf0d0129f9"
|
||||
)
|
||||
|
||||
TMP_ROOT=$(mktemp -d)
|
||||
trap 'rm -rf "$TMP_ROOT"' EXIT
|
||||
|
||||
for row in "${NODES[@]}"; do
|
||||
read -r node nic <<< "$row"
|
||||
out="$TMP_ROOT/fips.yaml.$node"
|
||||
cat > "$out" <<YAML
|
||||
# FIPS Node Configuration — anchor-only (managed by fleet-fips-unpair.sh)
|
||||
# This is the shape a general archipelago install ships with: fleet
|
||||
# nodes are NOT pre-paired; discovery happens via the anchor DHT.
|
||||
|
||||
node:
|
||||
identity:
|
||||
persistent: true
|
||||
|
||||
tun:
|
||||
enabled: true
|
||||
name: fips0
|
||||
mtu: 1280
|
||||
|
||||
dns:
|
||||
enabled: true
|
||||
bind_addr: "127.0.0.1"
|
||||
port: 5354
|
||||
|
||||
transports:
|
||||
udp:
|
||||
bind_addr: "0.0.0.0:2121"
|
||||
tcp:
|
||||
bind_addr: "0.0.0.0:8443"
|
||||
|
||||
ethernet:
|
||||
interface: "$nic"
|
||||
discovery: true
|
||||
announce: true
|
||||
auto_connect: true
|
||||
accept_connections: true
|
||||
|
||||
peers:
|
||||
- npub: "npub1zv58cn7v83mxvttl70w5fwjwuclfmntv9cnmv5wmz2nzz88u5urqvdx96n"
|
||||
alias: "fips.v0l.io"
|
||||
addresses:
|
||||
- transport: tcp
|
||||
addr: "fips.v0l.io:8443"
|
||||
- transport: udp
|
||||
addr: "fips.v0l.io:2121"
|
||||
connect_policy: auto_connect
|
||||
YAML
|
||||
|
||||
ip="192.168.1.$node"
|
||||
log_info "[.${node}] uploading anchor-only fips.yaml"
|
||||
scp_cmd "$out" "archipelago@${ip}:/tmp/fips.yaml.new"
|
||||
log_info "[.${node}] installing + restarting fips.service"
|
||||
ssh_cmd "$ip" '
|
||||
set -e
|
||||
sudo install -o root -g root -m 0600 /tmp/fips.yaml.new /etc/fips/fips.yaml
|
||||
rm -f /tmp/fips.yaml.new
|
||||
sudo systemctl restart fips.service
|
||||
for i in $(seq 1 10); do
|
||||
if sudo systemctl is-active fips.service >/dev/null 2>&1; then break; fi
|
||||
sleep 0.5
|
||||
done
|
||||
sudo systemctl is-active fips.service
|
||||
'
|
||||
done
|
||||
|
||||
echo
|
||||
log_info "Waiting 20s for anchor handshake + DHT propagation…"
|
||||
sleep 20
|
||||
|
||||
echo
|
||||
log_info "Post-unpair state (should show only fips.v0l.io as an authenticated peer):"
|
||||
for row in "${NODES[@]}"; do
|
||||
read -r node _nic <<< "$row"
|
||||
ip="192.168.1.$node"
|
||||
count=$(ssh_cmd "$ip" "sudo fipsctl show peers 2>/dev/null | grep -c '\"npub\"' || echo 0")
|
||||
log_info " .$node: $count authenticated peers"
|
||||
done
|
||||
|
||||
echo
|
||||
log_info "DHT resolution test — each node resolves the other 3 by npub:"
|
||||
declare -A NPUBS=(
|
||||
[116]="npub1mxavs6scfgl056k6lm4mk73ddnrhjewg78zlyzfn2lmr0rfyrs5qhcr03g"
|
||||
[198]="npub13cy4lml94cj4rdu8runrr945z2muszuvr5tql8mr9m063d7xzpqqu3k8se"
|
||||
[228]="npub1a0xxcqce2tsv8ulwastep23jtf3h4wvvry8r8nklnl36jtrdnefqh5qn6h"
|
||||
[253]="npub1dl0m0yfzfw6467c3z6q63s7ggzd77yg97j90ptfrheprxeypt3msj0mq4g"
|
||||
)
|
||||
for row in "${NODES[@]}"; do
|
||||
read -r self_node _ <<< "$row"
|
||||
ip="192.168.1.$self_node"
|
||||
echo ".${self_node}:"
|
||||
for other in 116 198 228 253; do
|
||||
[ "$other" = "$self_node" ] && continue
|
||||
r=$(ssh_cmd "$ip" "dig @127.0.0.1 -p 5354 +short +time=3 +tries=1 ${NPUBS[$other]}.fips AAAA" 2>&1)
|
||||
if [ -z "$r" ]; then
|
||||
echo " .${other} → unresolved (DHT route not found)"
|
||||
else
|
||||
echo " .${other} → $r"
|
||||
fi
|
||||
done
|
||||
done
|
||||
@@ -0,0 +1,209 @@
|
||||
#!/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 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(
|
||||
"--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")
|
||||
print(f"total_updated={total}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Executable
+242
@@ -0,0 +1,242 @@
|
||||
#!/usr/bin/env bash
|
||||
# Generate releases/app-catalog.json — the REMOTE per-app version catalog that
|
||||
# decouples app updates from the binary OTA (see
|
||||
# core/.../container/app_catalog.rs and docs/dht-distribution-design.md).
|
||||
#
|
||||
# Nodes fetch this file over HTTP from the OVH origin (same host as the OTA
|
||||
# manifest), compare each app's catalog version against the running container
|
||||
# tag, and light up the per-app "Update" button — no node release required.
|
||||
#
|
||||
# The app_id -> image-variable mapping below MIRRORS
|
||||
# core/archipelago/src/container/image_versions.rs (image_var_for_app +
|
||||
# containers_for_stack). image_versions.rs is the canonical mapping; keep this in
|
||||
# sync when you add an app there.
|
||||
#
|
||||
# Usage:
|
||||
# scripts/generate-app-catalog.sh [output-path]
|
||||
# EMBED_MANIFESTS=0 scripts/generate-app-catalog.sh # version/image only (legacy)
|
||||
# # then publish: push releases/app-catalog.json to the OVH gitea (raw URL).
|
||||
#
|
||||
# EMBED_MANIFESTS (default ON, 2026-06-23): embed each app's full
|
||||
# apps/<id>/manifest.yml into its catalog entry's `manifest` field, so nodes
|
||||
# install from the signed registry alone (no OTA-shipped disk manifest). Consumed
|
||||
# by container::app_catalog + the orchestrator's load_manifests overlay
|
||||
# (origin-wins, disk = fallback). See docs/registry-manifest-design.md. The
|
||||
# migration window is over — every regen now embeds; set EMBED_MANIFESTS=0 only
|
||||
# to reproduce the old version/image-only catalog.
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
OUT="${1:-$ROOT/releases/app-catalog.json}"
|
||||
|
||||
# Export every *_IMAGE var (and ARCHY_REGISTRY) so python can read them.
|
||||
set -a
|
||||
# shellcheck disable=SC1091
|
||||
source "$ROOT/scripts/image-versions.sh"
|
||||
set +a
|
||||
|
||||
UPDATED="$(date -u +%Y-%m-%d)" OUT="$OUT" APPS_DIR="$ROOT/apps" \
|
||||
EMBED_MANIFESTS="${EMBED_MANIFESTS:-1}" python3 - <<'PY'
|
||||
import glob
|
||||
import json, os
|
||||
|
||||
try:
|
||||
import yaml
|
||||
except ImportError:
|
||||
yaml = None
|
||||
|
||||
def img(var):
|
||||
v = os.environ.get(var)
|
||||
return v if v else None
|
||||
|
||||
def tag(image):
|
||||
# version = tag after the LAST colon that follows the last slash
|
||||
if not image:
|
||||
return None
|
||||
tail = image.rsplit('/', 1)[-1]
|
||||
return tail.rsplit(':', 1)[1] if ':' in tail else 'latest'
|
||||
|
||||
# Single-container apps: app_id -> primary image variable.
|
||||
SINGLE = {
|
||||
"bitcoin-knots": "BITCOIN_KNOTS_IMAGE",
|
||||
"lnd": "LND_IMAGE",
|
||||
"electrumx": "ELECTRUMX_IMAGE",
|
||||
"bitcoin-ui": "BITCOIN_UI_IMAGE",
|
||||
"lnd-ui": "LND_UI_IMAGE",
|
||||
"electrs-ui": "ELECTRS_UI_IMAGE",
|
||||
"homeassistant": "HOMEASSISTANT_IMAGE",
|
||||
"grafana": "GRAFANA_IMAGE",
|
||||
"uptime-kuma": "UPTIME_KUMA_IMAGE",
|
||||
"jellyfin": "JELLYFIN_IMAGE",
|
||||
"photoprism": "PHOTOPRISM_IMAGE",
|
||||
"ollama": "OLLAMA_IMAGE",
|
||||
"vaultwarden": "VAULTWARDEN_IMAGE",
|
||||
"nextcloud": "NEXTCLOUD_IMAGE",
|
||||
"searxng": "SEARXNG_IMAGE",
|
||||
"cryptpad": "CRYPTPAD_IMAGE",
|
||||
"filebrowser": "FILEBROWSER_IMAGE",
|
||||
"nginx-proxy-manager": "NPM_IMAGE",
|
||||
"portainer": "PORTAINER_IMAGE",
|
||||
"tailscale": "TAILSCALE_IMAGE",
|
||||
"fedimint": "FEDIMINT_IMAGE",
|
||||
"fedimint-gateway": "FEDIMINT_GATEWAY_IMAGE",
|
||||
"nostr-rs-relay": "NOSTR_RS_RELAY_IMAGE",
|
||||
"nostr-vpn": "NOSTR_VPN_IMAGE",
|
||||
"fips": "FIPS_IMAGE",
|
||||
"routstr": "ROUTSTR_IMAGE",
|
||||
"adguardhome": "ADGUARDHOME_IMAGE",
|
||||
}
|
||||
|
||||
# Stack apps: app_id -> {container_name: image variable}. The FIRST entry is the
|
||||
# primary (its version drives the badge); it is also emitted as `image`.
|
||||
STACK = {
|
||||
"indeedhub": {
|
||||
"indeedhub": "INDEEDHUB_IMAGE",
|
||||
"indeedhub-api": "INDEEDHUB_API_IMAGE",
|
||||
"indeedhub-ffmpeg": "INDEEDHUB_FFMPEG_IMAGE",
|
||||
},
|
||||
"immich": {
|
||||
"immich_server": "IMMICH_SERVER_IMAGE",
|
||||
"immich_postgres": "IMMICH_POSTGRES_IMAGE",
|
||||
"immich_redis": "REDIS_IMAGE",
|
||||
},
|
||||
"penpot": {
|
||||
"penpot-frontend": "PENPOT_FRONTEND_IMAGE",
|
||||
"penpot-backend": "PENPOT_BACKEND_IMAGE",
|
||||
"penpot-exporter": "PENPOT_EXPORTER_IMAGE",
|
||||
"penpot-postgres": "PENPOT_POSTGRES_IMAGE",
|
||||
"penpot-valkey": "PENPOT_VALKEY_IMAGE",
|
||||
},
|
||||
"mempool": {
|
||||
"archy-mempool-web": "MEMPOOL_WEB_IMAGE",
|
||||
"mempool-api": "MEMPOOL_BACKEND_IMAGE",
|
||||
"archy-mempool-db": "MARIADB_IMAGE",
|
||||
},
|
||||
"btcpay": {
|
||||
"btcpay-server": "BTCPAY_IMAGE",
|
||||
"archy-nbxplorer": "NBXPLORER_IMAGE",
|
||||
"archy-btcpay-db": "BTCPAY_POSTGRES_IMAGE",
|
||||
},
|
||||
}
|
||||
|
||||
apps = {}
|
||||
for app_id, var in SINGLE.items():
|
||||
image = img(var)
|
||||
if image:
|
||||
apps[app_id] = {"version": tag(image), "image": image}
|
||||
|
||||
for app_id, comps in STACK.items():
|
||||
images = {name: img(var) for name, var in comps.items() if img(var)}
|
||||
if not images:
|
||||
continue
|
||||
primary_name = next(iter(comps)) # first listed = primary
|
||||
primary_image = img(comps[primary_name])
|
||||
entry = {"version": tag(primary_image)}
|
||||
if primary_image:
|
||||
entry["image"] = primary_image
|
||||
entry["images"] = images
|
||||
apps[app_id] = entry
|
||||
|
||||
# Opt-in (EMBED_MANIFESTS): embed each app's full manifest so nodes install from
|
||||
# the registry alone. The whole manifest document is embedded under `manifest`
|
||||
# (top-level `app:` preserved) — that is exactly what the Rust side deserializes
|
||||
# into an AppManifest. Apps not already in SINGLE/STACK get a new entry whose
|
||||
# version comes from the manifest. A bad embed is harmless: the node validates and
|
||||
# falls back to its disk manifest.
|
||||
embedded = 0
|
||||
apps_dir = os.environ.get("APPS_DIR")
|
||||
if os.environ.get("EMBED_MANIFESTS") and apps_dir:
|
||||
if yaml is None:
|
||||
raise SystemExit("EMBED_MANIFESTS set but PyYAML is not available")
|
||||
for path in sorted(glob.glob(os.path.join(apps_dir, "*", "manifest.yml"))):
|
||||
with open(path) as fh:
|
||||
data = yaml.safe_load(fh)
|
||||
if not isinstance(data, dict) or not isinstance(data.get("app"), dict):
|
||||
continue
|
||||
app = data["app"]
|
||||
app_id = app.get("id")
|
||||
if not app_id:
|
||||
continue
|
||||
entry = apps.setdefault(str(app_id), {})
|
||||
entry.setdefault("version", str(app.get("version", "")) or "0")
|
||||
entry["manifest"] = data
|
||||
embedded += 1
|
||||
|
||||
# Multi-version support (docs/bitcoin-multi-version-design.md §3 Phase 1):
|
||||
# curated, bounded `versions[]` a runner may install or switch to. The entry
|
||||
# marked default:true MUST equal the app's top-level catalog `version` (the
|
||||
# manifest version for embedded apps) so selecting it un-pins / tracks latest.
|
||||
#
|
||||
# ONLY list versions whose tagged image is actually published to the registry —
|
||||
# an unbuilt tag 404s on install. Extend each list as scripts/build-bitcoin-
|
||||
# image.sh (Phase 0) publishes more tagged images, e.g.:
|
||||
# {"version": "30.0", "image": f"{REGISTRY}/bitcoin:30.0"},
|
||||
# {"version": "27.2", "image": f"{REGISTRY}/bitcoin:27.2", "deprecated": True, "eol": "2026-12-31"},
|
||||
REGISTRY = os.environ.get("ARCHY_REGISTRY", "146.59.87.168:3000/lfg2025")
|
||||
VERSIONS = {
|
||||
# Curated Core set (latest patch per major, current → 25). Images built +
|
||||
# verified (SHA-256 + OpenPGP, fail-closed) and pushed by
|
||||
# scripts/build-bitcoin-image.sh. `28.4.0` is the default (== the manifest's
|
||||
# top-level version) so existing/new installs are undisturbed; runners switch
|
||||
# up to 31.0 (e.g. for BIP-110 signalling) or down to 25.2 from the app's
|
||||
# "Version & Updates" card. Add the next release by building its image then
|
||||
# prepending it here.
|
||||
"bitcoin-core": [
|
||||
{"version": "latest", "image": f"{REGISTRY}/bitcoin:latest", "default": True},
|
||||
{"version": "31.0", "image": f"{REGISTRY}/bitcoin:31.0"},
|
||||
{"version": "30.2", "image": f"{REGISTRY}/bitcoin:30.2"},
|
||||
{"version": "29.3", "image": f"{REGISTRY}/bitcoin:29.3"},
|
||||
{"version": "29.2", "image": f"{REGISTRY}/bitcoin:29.2"},
|
||||
{"version": "28.4.0", "image": f"{REGISTRY}/bitcoin:28.4"},
|
||||
{"version": "27.2", "image": f"{REGISTRY}/bitcoin:27.2"},
|
||||
{"version": "26.2", "image": f"{REGISTRY}/bitcoin:26.2", "deprecated": True},
|
||||
{"version": "25.2", "image": f"{REGISTRY}/bitcoin:25.2", "deprecated": True},
|
||||
],
|
||||
# Knots: a real tagged build is now published, so it's selectable + pinnable
|
||||
# in the Knots app interface. `latest` is the default (== the manifest's
|
||||
# floating tag) so selecting it un-pins / tracks latest — this MUST match the
|
||||
# top-level catalog version (L167-168) or the card can't reach "latest" and
|
||||
# selecting the highlighted default would instead pin+recreate. Pinning
|
||||
# 29.3.knots20260508 moves a runner off the floating tag.
|
||||
# `latest` is the default and points at the NEWEST published dated image
|
||||
# (not the bare :latest tag) so "Always use the latest version" installs the
|
||||
# newest build on fixed-binary nodes, while UNPINNED nodes still resolve via
|
||||
# the manifest's floating :latest tag (kept on the legacy image until the
|
||||
# entrypoint-render fix is fleet-deployed — see
|
||||
# docs/bitcoin-version-bulletproof-rollout.md).
|
||||
"bitcoin-knots": [
|
||||
{"version": "latest",
|
||||
"image": f"{REGISTRY}/bitcoin-knots:29.3.knots20260508", "default": True},
|
||||
{"version": "29.3.knots20260508",
|
||||
"image": f"{REGISTRY}/bitcoin-knots:29.3.knots20260508"},
|
||||
{"version": "29.3.knots20260507",
|
||||
"image": f"{REGISTRY}/bitcoin-knots:29.3.knots20260507"},
|
||||
{"version": "29.3.knots20260210",
|
||||
"image": f"{REGISTRY}/bitcoin-knots:29.3.knots20260210"},
|
||||
{"version": "29.2.knots20251110",
|
||||
"image": f"{REGISTRY}/bitcoin-knots:29.2.knots20251110"},
|
||||
],
|
||||
}
|
||||
for app_id, versions in VERSIONS.items():
|
||||
if app_id in apps and versions:
|
||||
apps[app_id]["versions"] = versions
|
||||
# The default/latest entry MUST equal the app's top-level catalog
|
||||
# `version` (commit 169ff2e2) so selecting the highlighted default
|
||||
# un-pins / tracks latest instead of pinning+recreating. Enforce it here
|
||||
# rather than relying on the manifest version matching.
|
||||
default_entry = next((v for v in versions if v.get("default")), None)
|
||||
if default_entry:
|
||||
apps[app_id]["version"] = default_entry["version"]
|
||||
|
||||
catalog = {
|
||||
"schema": 1,
|
||||
"updated": os.environ["UPDATED"],
|
||||
"apps": dict(sorted(apps.items())),
|
||||
}
|
||||
|
||||
with open(os.environ["OUT"], "w") as f:
|
||||
json.dump(catalog, f, indent=2)
|
||||
f.write("\n")
|
||||
suffix = f" (embedded {embedded} manifests)" if embedded else ""
|
||||
print(f"Wrote {os.environ['OUT']} with {len(apps)} apps{suffix}")
|
||||
PY
|
||||
@@ -0,0 +1,124 @@
|
||||
#!/bin/bash
|
||||
# Container image versions — single source of truth
|
||||
# Source this file from all scripts that create containers
|
||||
#
|
||||
# Usage: source /opt/archipelago/image-versions.sh 2>/dev/null || true
|
||||
# source "$(dirname "$0")/image-versions.sh" 2>/dev/null || true
|
||||
#
|
||||
# Tags MUST match what's actually in the registry at 146.59.87.168:3000/lfg2025/
|
||||
# Run: podman images --format '{{.Repository}}:{{.Tag}}' | grep '146.59.87.168:3000' | sort
|
||||
# to verify against the registry.
|
||||
|
||||
# Archipelago app registries (primary + fallback)
|
||||
ARCHY_REGISTRY="146.59.87.168:3000/lfg2025"
|
||||
# No fallback registry: the old tx1138 registry host was retired (2026-06-13); empty disables the fallback path.
|
||||
ARCHY_REGISTRY_FALLBACK=""
|
||||
|
||||
# Bitcoin stack
|
||||
BITCOIN_KNOTS_IMAGE="$ARCHY_REGISTRY/bitcoin-knots:latest"
|
||||
LND_IMAGE="$ARCHY_REGISTRY/lnd:v0.18.4-beta"
|
||||
ELECTRUMX_IMAGE="$ARCHY_REGISTRY/electrumx:v1.18.0"
|
||||
|
||||
# Mempool stack
|
||||
MEMPOOL_BACKEND_IMAGE="$ARCHY_REGISTRY/mempool-backend:v3.0.0"
|
||||
MEMPOOL_WEB_IMAGE="$ARCHY_REGISTRY/mempool-frontend:v3.0.1"
|
||||
MARIADB_IMAGE="$ARCHY_REGISTRY/mariadb:11.4.10"
|
||||
|
||||
# BTCPay
|
||||
BTCPAY_IMAGE="docker.io/btcpayserver/btcpayserver:2.3.9"
|
||||
NBXPLORER_IMAGE="$ARCHY_REGISTRY/nbxplorer:2.6.0"
|
||||
POSTGRES_IMAGE="$ARCHY_REGISTRY/postgres:15.17"
|
||||
BTCPAY_POSTGRES_IMAGE="$ARCHY_REGISTRY/postgres:15.17"
|
||||
|
||||
# Apps
|
||||
HOMEASSISTANT_IMAGE="$ARCHY_REGISTRY/home-assistant:2024.1"
|
||||
GRAFANA_IMAGE="$ARCHY_REGISTRY/grafana:10.2.0"
|
||||
UPTIME_KUMA_IMAGE="$ARCHY_REGISTRY/uptime-kuma:1"
|
||||
JELLYFIN_IMAGE="$ARCHY_REGISTRY/jellyfin:10.8.13"
|
||||
PHOTOPRISM_IMAGE="$ARCHY_REGISTRY/photoprism:240915"
|
||||
OLLAMA_IMAGE="$ARCHY_REGISTRY/ollama:latest"
|
||||
VAULTWARDEN_IMAGE="$ARCHY_REGISTRY/vaultwarden:1.30.0-alpine"
|
||||
NEXTCLOUD_IMAGE="$ARCHY_REGISTRY/nextcloud:29"
|
||||
SEARXNG_IMAGE="$ARCHY_REGISTRY/searxng:latest"
|
||||
# OnlyOffice removed — incompatible with rootless Podman (internal postgres/rabbitmq fail)
|
||||
# Replaced by CryptPad (single Node.js process, e2e encrypted)
|
||||
CRYPTPAD_IMAGE="$ARCHY_REGISTRY/cryptpad:2024.12.0"
|
||||
FILEBROWSER_IMAGE="$ARCHY_REGISTRY/filebrowser:v2.27.0"
|
||||
NPM_IMAGE="$ARCHY_REGISTRY/nginx-proxy-manager:latest"
|
||||
PORTAINER_IMAGE="$ARCHY_REGISTRY/portainer:2.19.4"
|
||||
|
||||
# Networking
|
||||
TAILSCALE_IMAGE="$ARCHY_REGISTRY/tailscale:stable"
|
||||
NETBIRD_DASHBOARD_IMAGE="docker.io/netbirdio/dashboard:v2.38.0"
|
||||
NETBIRD_SERVER_IMAGE="docker.io/netbirdio/netbird-server:0.71.2"
|
||||
NETBIRD_PROXY_IMAGE="docker.io/library/nginx:1.27-alpine"
|
||||
ALPINE_TOR_IMAGE="$ARCHY_REGISTRY/alpine-tor:0.4.8.13"
|
||||
ADGUARDHOME_IMAGE="$ARCHY_REGISTRY/adguardhome:v0.107.55"
|
||||
|
||||
# Fedimint
|
||||
FEDIMINT_IMAGE="$ARCHY_REGISTRY/fedimintd:v0.10.0"
|
||||
FEDIMINT_GATEWAY_IMAGE="$ARCHY_REGISTRY/gatewayd:v0.10.0"
|
||||
# fmcd = Fedimint client daemon (iroh-capable, fedimint-client 0.8.2). Built
|
||||
# from minmoto/fmcd. NOT yet added to the bundled CONTAINER_IMAGES list / first-
|
||||
# boot auto-create: bundling fleet-wide needs a fleet-reachable default
|
||||
# federation first (the interim default is node-local). See docs/dual-ecash-design.md.
|
||||
FMCD_IMAGE="$ARCHY_REGISTRY/fmcd:0.8.1"
|
||||
|
||||
# Ark (bark)
|
||||
# barkd = Ark wallet daemon, packaged from the pinned upstream release binary
|
||||
# (apps/barkd/Dockerfile). Signet-only default config; keep the tag in
|
||||
# lockstep with core/archipelago/src/wallet/ark_client.rs REST shapes. Not in
|
||||
# the bundled CONTAINER_IMAGES list — install via the barkd app manifest.
|
||||
BARKD_IMAGE="$ARCHY_REGISTRY/barkd:0.3.0"
|
||||
|
||||
# Media
|
||||
REDIS_IMAGE="$ARCHY_REGISTRY/redis:7.4.8"
|
||||
|
||||
# Valkey (general purpose)
|
||||
VALKEY_IMAGE="$ARCHY_REGISTRY/valkey:8.1.6"
|
||||
|
||||
# Nostr
|
||||
NOSTR_RS_RELAY_IMAGE="$ARCHY_REGISTRY/nostr-rs-relay:0.9.0"
|
||||
STRFRY_IMAGE="$ARCHY_REGISTRY/strfry:1.0.4"
|
||||
NOSTR_VPN_IMAGE="$ARCHY_REGISTRY/nostr-vpn:v0.3.7"
|
||||
NOSTR_VPN_UI_IMAGE="$ARCHY_REGISTRY/nostr-vpn-ui:latest"
|
||||
FIPS_IMAGE="$ARCHY_REGISTRY/fips:v0.1.0"
|
||||
FIPS_UI_IMAGE="$ARCHY_REGISTRY/fips-ui:latest"
|
||||
|
||||
# AI / Routing
|
||||
ROUTSTR_IMAGE="$ARCHY_REGISTRY/routstr:v0.4.3"
|
||||
|
||||
# Community / Gaming
|
||||
BOTFIGHTS_IMAGE="$ARCHY_REGISTRY/botfights:1.1.0"
|
||||
|
||||
# IndeedHub stack
|
||||
INDEEDHUB_IMAGE="$ARCHY_REGISTRY/indeedhub:1.0.0"
|
||||
INDEEDHUB_API_IMAGE="$ARCHY_REGISTRY/indeedhub-api:1.0.0"
|
||||
INDEEDHUB_FFMPEG_IMAGE="$ARCHY_REGISTRY/indeedhub-ffmpeg:1.0.0"
|
||||
MINIO_IMAGE="$ARCHY_REGISTRY/minio:RELEASE.2024-11-07T00-52-20Z"
|
||||
INDEEDHUB_POSTGRES_IMAGE="$ARCHY_REGISTRY/postgres:16.13-alpine"
|
||||
INDEEDHUB_REDIS_IMAGE="$ARCHY_REGISTRY/redis:7.4.8-alpine"
|
||||
|
||||
# Gitea (Git + Container Registry)
|
||||
GITEA_IMAGE="docker.io/gitea/gitea:1.23"
|
||||
|
||||
# DWN (Decentralized Web Node)
|
||||
|
||||
# Immich stack
|
||||
IMMICH_POSTGRES_IMAGE="$ARCHY_REGISTRY/immich-postgres:14-vectorchord0.4.3-pgvectors0.2.0"
|
||||
IMMICH_SERVER_IMAGE="$ARCHY_REGISTRY/immich-server:release"
|
||||
|
||||
# Penpot stack
|
||||
PENPOT_POSTGRES_IMAGE="$ARCHY_REGISTRY/postgres:15"
|
||||
PENPOT_VALKEY_IMAGE="$ARCHY_REGISTRY/valkey:8.1"
|
||||
PENPOT_BACKEND_IMAGE="$ARCHY_REGISTRY/penpot-backend:2.4"
|
||||
PENPOT_EXPORTER_IMAGE="$ARCHY_REGISTRY/penpot-exporter:2.4"
|
||||
PENPOT_FRONTEND_IMAGE="$ARCHY_REGISTRY/penpot-frontend:2.4"
|
||||
|
||||
# Custom UI containers (built from docker/ dirs, pushed to registry)
|
||||
BITCOIN_UI_IMAGE="$ARCHY_REGISTRY/bitcoin-ui:1.7.84-alpha"
|
||||
LND_UI_IMAGE="$ARCHY_REGISTRY/lnd-ui:latest"
|
||||
ELECTRS_UI_IMAGE="$ARCHY_REGISTRY/electrs-ui:latest"
|
||||
|
||||
# Base images
|
||||
NGINX_ALPINE_IMAGE="$ARCHY_REGISTRY/nginx:1.27.4-alpine"
|
||||
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.168.1.198${NC}"
|
||||
row=$((row + 2))
|
||||
|
||||
# Credentials — white, NOT orange (user request)
|
||||
goto $row 1
|
||||
p " ${WHITE}SSH ssh archipelago@192.168.1.198${NC}"
|
||||
row=$((row + 1))
|
||||
goto $row 1
|
||||
p " ${WHITE}Password archipelago${NC}"
|
||||
row=$((row + 1))
|
||||
goto $row 1
|
||||
p " ${WHITE}Web Login password123${NC}"
|
||||
row=$((row + 2))
|
||||
|
||||
goto $row 1; hrule; row=$((row + 2))
|
||||
|
||||
for _ in $(seq 1 5); do
|
||||
goto $row 1; clear_line
|
||||
p "${ORANGE}${BOLD} >>> REMOVE THE USB DRIVE NOW <<<${NC}"
|
||||
sleep 0.5
|
||||
goto $row 1; clear_line
|
||||
p "${ORANGE_DIM} >>> REMOVE THE USB DRIVE NOW <<<${NC}"
|
||||
sleep 0.5
|
||||
done
|
||||
goto $row 1; clear_line
|
||||
p "${ORANGE}${BOLD} >>> REMOVE THE USB DRIVE NOW <<<${NC}"
|
||||
|
||||
goto $((row + 2)) 1
|
||||
p "${ORANGE_DIM} Press Enter to reboot${NC}"
|
||||
sleep 3
|
||||
}
|
||||
|
||||
# ── Main ───────────────────────────────────────────────────────
|
||||
main() {
|
||||
echo ""
|
||||
echo " ${ORANGE}▌${NC} ${ORANGE}Archipelago Install TUI Demo${NC}"
|
||||
echo " ${ORANGE_DIM} Each screen auto-advances. Ctrl+C to exit.${NC}"
|
||||
echo ""
|
||||
sleep 2
|
||||
|
||||
screen_welcome
|
||||
sleep 0.3
|
||||
screen_disk_detect
|
||||
sleep 0.3
|
||||
screen_install
|
||||
sleep 0.3
|
||||
screen_complete
|
||||
|
||||
show_cursor
|
||||
echo ""
|
||||
p "${ORANGE_DIM}** Demo complete **${NC}"
|
||||
echo ""
|
||||
}
|
||||
|
||||
main "$@"
|
||||
Executable
+183
@@ -0,0 +1,183 @@
|
||||
#!/bin/bash
|
||||
# Shared utility functions for Archipelago scripts
|
||||
#
|
||||
# Source this from any script:
|
||||
# source "$(dirname "$0")/lib/common.sh"
|
||||
#
|
||||
# Provides: logging, SSH helpers, health checks, disk checks, memory limits
|
||||
|
||||
# Guard against double-sourcing
|
||||
[ -n "${_ARCHY_COMMON_LOADED:-}" ] && return 0
|
||||
_ARCHY_COMMON_LOADED=1
|
||||
|
||||
# ── Colored logging ─────────────────────────────────────────────────────
|
||||
|
||||
log_info() { echo -e "\033[0;32m[INFO]\033[0m $(date '+%H:%M:%S') $*"; }
|
||||
log_warn() { echo -e "\033[0;33m[WARN]\033[0m $(date '+%H:%M:%S') $*"; }
|
||||
log_error() { echo -e "\033[0;31m[ERROR]\033[0m $(date '+%H:%M:%S') $*"; }
|
||||
|
||||
# ── SSH wrapper with deploy key ─────────────────────────────────────────
|
||||
|
||||
# Usage: ssh_cmd <host> <command...>
|
||||
# Uses the standard deploy key and safe defaults.
|
||||
ssh_cmd() {
|
||||
local host="$1"; shift
|
||||
local key="${ARCHIPELAGO_SSH_KEY:-$HOME/.ssh/archipelago-deploy}"
|
||||
ssh -i "$key" \
|
||||
-o StrictHostKeyChecking=no \
|
||||
-o ConnectTimeout=10 \
|
||||
-o ServerAliveInterval=15 \
|
||||
-o ServerAliveCountMax=4 \
|
||||
"archipelago@${host}" "$@"
|
||||
}
|
||||
|
||||
# Usage: scp_cmd <src> <dest>
|
||||
# Wraps scp with the same deploy key and options.
|
||||
scp_cmd() {
|
||||
local key="${ARCHIPELAGO_SSH_KEY:-$HOME/.ssh/archipelago-deploy}"
|
||||
scp -i "$key" \
|
||||
-o StrictHostKeyChecking=no \
|
||||
-o ConnectTimeout=10 \
|
||||
"$@"
|
||||
}
|
||||
|
||||
# ── Health check ────────────────────────────────────────────────────────
|
||||
|
||||
# Wait for an HTTP health endpoint to respond successfully.
|
||||
# Usage: wait_for_health <host> [max_wait_seconds] [path]
|
||||
wait_for_health() {
|
||||
local host="$1" max_wait="${2:-60}" path="${3:-/health}"
|
||||
local waited=0
|
||||
while [ $waited -lt $max_wait ]; do
|
||||
if curl -sf "http://${host}${path}" >/dev/null 2>&1; then
|
||||
log_info "Health check passed for ${host}"
|
||||
return 0
|
||||
fi
|
||||
sleep 2
|
||||
waited=$((waited + 2))
|
||||
done
|
||||
log_error "Health check failed for ${host} after ${max_wait}s"
|
||||
return 1
|
||||
}
|
||||
|
||||
# ── Disk space check ───────────────────────────────────────────────────
|
||||
|
||||
# Check that disk usage on a remote host is below a threshold.
|
||||
# Usage: check_disk_space <host> [max_percent]
|
||||
check_disk_space() {
|
||||
local host="$1" max_pct="${2:-85}"
|
||||
local pct
|
||||
pct=$(ssh_cmd "$host" "df / | tail -1 | awk '{print \$(NF-1)}' | tr -d '%'" 2>/dev/null)
|
||||
if [ -n "$pct" ] && [ "$pct" -gt "$max_pct" ] 2>/dev/null; then
|
||||
log_error "Disk at ${pct}% on ${host} (max ${max_pct}%)"
|
||||
return 1
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
# ── Memory limit calculator ────────────────────────────────────────────
|
||||
|
||||
# Returns the memory limit for a container by name.
|
||||
# Checks /etc/archipelago/memory-limits.conf first (override), then falls
|
||||
# back to built-in defaults. Mirrors the pattern in first-boot-containers.sh.
|
||||
#
|
||||
# Low-memory mode: set LOW_MEM=true before calling to get reduced limits
|
||||
# on certain heavy containers.
|
||||
#
|
||||
# Usage: mem_limit <container-name>
|
||||
mem_limit() {
|
||||
local name="$1"
|
||||
|
||||
# Allow per-host overrides via config file
|
||||
local limit
|
||||
limit=$(grep "^${name}=" /etc/archipelago/memory-limits.conf 2>/dev/null | cut -d= -f2)
|
||||
if [ -n "$limit" ]; then
|
||||
echo "$limit"
|
||||
return
|
||||
fi
|
||||
|
||||
# Built-in defaults (keep in sync with first-boot-containers.sh and Rust package config)
|
||||
local low="${LOW_MEM:-false}"
|
||||
case "$name" in
|
||||
bitcoin|bitcoin-core|bitcoin-knots) $low && echo "4g" || echo "8g" ;;
|
||||
ollama) $low && echo "1g" || echo "4g" ;;
|
||||
lnd) echo "512m" ;;
|
||||
electrumx|mempool-electrs|electrs) echo "4g" ;;
|
||||
nextcloud) echo "1g" ;;
|
||||
immich_server) echo "1g" ;;
|
||||
btcpay-server|btcpayserver) echo "1g" ;;
|
||||
homeassistant) echo "512m" ;;
|
||||
fedimint) echo "512m" ;;
|
||||
fedimint-gateway) echo "512m" ;;
|
||||
photoprism) $low && echo "512m" || echo "1g" ;;
|
||||
mempool-api) echo "512m" ;;
|
||||
jellyfin) echo "1g" ;;
|
||||
searxng) echo "512m" ;;
|
||||
archy-btcpay-db) echo "512m" ;;
|
||||
archy-nbxplorer) echo "512m" ;;
|
||||
archy-mempool-db) echo "512m" ;;
|
||||
archy-mempool-web) echo "256m" ;;
|
||||
grafana) echo "256m" ;;
|
||||
vaultwarden) echo "256m" ;;
|
||||
uptime-kuma) echo "256m" ;;
|
||||
filebrowser) echo "256m" ;;
|
||||
portainer) echo "256m" ;;
|
||||
nginx-proxy-manager) echo "256m" ;;
|
||||
immich_postgres) echo "256m" ;;
|
||||
immich_redis) echo "128m" ;;
|
||||
tailscale) echo "256m" ;;
|
||||
penpot-postgres) echo "256m" ;;
|
||||
penpot-valkey) echo "128m" ;;
|
||||
penpot-backend) echo "512m" ;;
|
||||
penpot-exporter) echo "256m" ;;
|
||||
penpot-frontend) echo "256m" ;;
|
||||
nostr-rs-relay) echo "256m" ;;
|
||||
strfry) echo "256m" ;;
|
||||
indeedhub|archy-bitcoin-ui|archy-lnd-ui|archy-electrs-ui) echo "128m" ;;
|
||||
*) echo "512m" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# ── Wait for container readiness ───────────────────────────────────────
|
||||
|
||||
# Wait for a container health check command to succeed.
|
||||
# Usage: wait_for_container <name> <check_cmd> [max_wait_seconds]
|
||||
wait_for_container() {
|
||||
local name="$1" check_cmd="$2" max_wait="${3:-30}"
|
||||
local waited=0
|
||||
while [ $waited -lt $max_wait ]; do
|
||||
if eval "$check_cmd" 2>/dev/null; then
|
||||
log_info "$name is ready (${waited}s)"
|
||||
return 0
|
||||
fi
|
||||
sleep 2
|
||||
waited=$((waited + 2))
|
||||
done
|
||||
log_warn "$name not ready after ${max_wait}s"
|
||||
return 1
|
||||
}
|
||||
|
||||
# ── Section timing ─────────────────────────────────────────────────────
|
||||
|
||||
# Track elapsed time for deploy sections.
|
||||
# Usage:
|
||||
# section_start "Building frontend"
|
||||
# ... do work ...
|
||||
# section_end
|
||||
_SECTION_START=0
|
||||
_SECTION_NAME=""
|
||||
|
||||
section_start() {
|
||||
_SECTION_NAME="${1:-}"
|
||||
_SECTION_START=$(date +%s)
|
||||
[ -n "$_SECTION_NAME" ] && log_info "$_SECTION_NAME"
|
||||
}
|
||||
|
||||
section_end() {
|
||||
local elapsed=$(( $(date +%s) - _SECTION_START ))
|
||||
if [ -n "$_SECTION_NAME" ]; then
|
||||
log_info "$_SECTION_NAME done (${elapsed}s)"
|
||||
else
|
||||
echo " (${elapsed}s)"
|
||||
fi
|
||||
}
|
||||
@@ -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,205 @@
|
||||
# App proxies for HTTPS - avoids mixed content when embedding apps from HTTPS page
|
||||
# Complete list for all apps that may be launched from the UI
|
||||
location /app/grafana/ {
|
||||
proxy_pass http://127.0.0.1:3000/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_hide_header X-Frame-Options;
|
||||
proxy_hide_header Content-Security-Policy;
|
||||
}
|
||||
location = /app/uptime-kuma/ {
|
||||
return 302 /app/uptime-kuma/dashboard;
|
||||
}
|
||||
location /app/uptime-kuma/ {
|
||||
proxy_pass http://127.0.0.1:3002/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-Prefix /app/uptime-kuma;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_redirect / /app/uptime-kuma/;
|
||||
proxy_hide_header X-Frame-Options;
|
||||
proxy_hide_header Content-Security-Policy;
|
||||
}
|
||||
location /app/gitea/ {
|
||||
proxy_pass http://127.0.0.1:3001/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_hide_header X-Frame-Options;
|
||||
proxy_hide_header Content-Security-Policy;
|
||||
}
|
||||
location /app/searxng/ {
|
||||
proxy_pass http://127.0.0.1:8888/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_hide_header X-Frame-Options;
|
||||
proxy_hide_header Content-Security-Policy;
|
||||
}
|
||||
location /app/portainer/ {
|
||||
proxy_pass http://127.0.0.1:9000/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_hide_header X-Frame-Options;
|
||||
proxy_hide_header Content-Security-Policy;
|
||||
}
|
||||
location /app/filebrowser/ {
|
||||
client_max_body_size 10G;
|
||||
proxy_pass http://127.0.0.1:8083/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_hide_header X-Frame-Options;
|
||||
proxy_hide_header Content-Security-Policy;
|
||||
proxy_request_buffering off;
|
||||
}
|
||||
location /app/endurain/ {
|
||||
proxy_pass http://127.0.0.1:8080/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_hide_header X-Frame-Options;
|
||||
proxy_hide_header Content-Security-Policy;
|
||||
}
|
||||
location /app/lnd/ {
|
||||
proxy_pass http://127.0.0.1:18083/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_hide_header X-Frame-Options;
|
||||
proxy_hide_header Content-Security-Policy;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
}
|
||||
location /app/jellyfin/ {
|
||||
proxy_pass http://127.0.0.1:8096/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_hide_header X-Frame-Options;
|
||||
proxy_hide_header Content-Security-Policy;
|
||||
}
|
||||
location /app/photoprism/ {
|
||||
proxy_pass http://127.0.0.1:2342/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_hide_header X-Frame-Options;
|
||||
proxy_hide_header Content-Security-Policy;
|
||||
}
|
||||
location /app/mempool/ {
|
||||
proxy_pass http://127.0.0.1:4080/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_hide_header X-Frame-Options;
|
||||
proxy_hide_header Content-Security-Policy;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
}
|
||||
location /app/fedimint/ {
|
||||
proxy_pass http://127.0.0.1:8175/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_hide_header X-Frame-Options;
|
||||
proxy_hide_header Content-Security-Policy;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
}
|
||||
location /app/tailscale/ {
|
||||
proxy_pass http://127.0.0.1:8240/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_hide_header X-Frame-Options;
|
||||
proxy_hide_header Content-Security-Policy;
|
||||
}
|
||||
location /app/ollama/ {
|
||||
proxy_pass http://127.0.0.1:11434/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_hide_header X-Frame-Options;
|
||||
proxy_hide_header Content-Security-Policy;
|
||||
}
|
||||
location /app/bitcoin-ui/ {
|
||||
proxy_pass http://127.0.0.1:8334/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_hide_header X-Frame-Options;
|
||||
proxy_hide_header Content-Security-Policy;
|
||||
}
|
||||
location /app/electrs/ {
|
||||
proxy_pass http://127.0.0.1:50002/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_hide_header X-Frame-Options;
|
||||
proxy_hide_header Content-Security-Policy;
|
||||
}
|
||||
location /app/electrumx/ {
|
||||
proxy_pass http://127.0.0.1:50002/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_hide_header X-Frame-Options;
|
||||
proxy_hide_header Content-Security-Policy;
|
||||
}
|
||||
location /app/electrs-ui/ {
|
||||
proxy_pass http://127.0.0.1:50002/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_hide_header X-Frame-Options;
|
||||
proxy_hide_header Content-Security-Policy;
|
||||
}
|
||||
location /app/nginx-proxy-manager/ {
|
||||
proxy_pass http://127.0.0.1:8081/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_hide_header X-Frame-Options;
|
||||
proxy_hide_header Content-Security-Policy;
|
||||
}
|
||||
@@ -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
+252
@@ -0,0 +1,252 @@
|
||||
#!/bin/bash
|
||||
# node-profile.sh — CPU/memory/container profiling across all Archipelago nodes
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/node-profile.sh # All reachable nodes
|
||||
# ./scripts/node-profile.sh 192.168.1.228 # Single node
|
||||
# ./scripts/node-profile.sh --watch # Repeat every 30s
|
||||
#
|
||||
# Requires: SSH key at ~/.ssh/archipelago-deploy (or ARCHIPELAGO_SSH_KEY)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
source "$SCRIPT_DIR/lib/common.sh"
|
||||
source "$SCRIPT_DIR/deploy-config-defaults.sh"
|
||||
[ -f "$SCRIPT_DIR/deploy-config.sh" ] && source "$SCRIPT_DIR/deploy-config.sh"
|
||||
|
||||
ALL_NODES=(
|
||||
"$DEFAULT_PRIMARY"
|
||||
"$DEFAULT_SECONDARY"
|
||||
"$TAILSCALE_ARCH1"
|
||||
"$TAILSCALE_ARCH2"
|
||||
"$TAILSCALE_ARCH3"
|
||||
)
|
||||
|
||||
NODE_LABELS=(
|
||||
"primary (.228)"
|
||||
"secondary (.198)"
|
||||
"tailscale-1"
|
||||
"tailscale-2"
|
||||
"tailscale-3"
|
||||
)
|
||||
|
||||
WATCH_MODE=false
|
||||
WATCH_INTERVAL=30
|
||||
TARGET_NODES=()
|
||||
|
||||
# ── Parse args ─────────────────────────────────────────────────────────
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--watch)
|
||||
WATCH_MODE=true
|
||||
shift
|
||||
;;
|
||||
--interval)
|
||||
WATCH_INTERVAL="$2"
|
||||
shift 2
|
||||
;;
|
||||
*)
|
||||
TARGET_NODES+=("$1")
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# If specific nodes given, use those; otherwise use all
|
||||
if [ ${#TARGET_NODES[@]} -eq 0 ]; then
|
||||
TARGET_NODES=("${ALL_NODES[@]}")
|
||||
fi
|
||||
|
||||
# ── Remote profiling command ───────────────────────────────────────────
|
||||
|
||||
PROFILE_CMD='
|
||||
hostname_val=$(hostname 2>/dev/null || echo "unknown")
|
||||
uptime_val=$(uptime -p 2>/dev/null || uptime | sed "s/.*up/up/;s/,.*//")
|
||||
|
||||
# CPU info
|
||||
cpu_cores=$(nproc 2>/dev/null || echo "?")
|
||||
load_avg=$(cat /proc/loadavg 2>/dev/null | awk "{print \$1, \$2, \$3}")
|
||||
|
||||
# Memory
|
||||
mem_info=$(free -h 2>/dev/null | awk "/^Mem:/{printf \"%s / %s (%s free)\", \$3, \$2, \$4}")
|
||||
swap_info=$(free -h 2>/dev/null | awk "/^Swap:/{if(\$2 != \"0B\" && \$2 != \"0\") printf \"%s / %s\", \$3, \$2; else print \"none\"}")
|
||||
|
||||
# Disk
|
||||
disk_info=$(df -h / 2>/dev/null | awk "NR==2{printf \"%s / %s (%s)\", \$3, \$2, \$5}")
|
||||
|
||||
# CPU temperature (if available)
|
||||
temp="n/a"
|
||||
if [ -f /sys/class/thermal/thermal_zone0/temp ]; then
|
||||
raw=$(cat /sys/class/thermal/thermal_zone0/temp)
|
||||
temp="$((raw / 1000))°C"
|
||||
fi
|
||||
|
||||
echo "HEADER|${hostname_val}|${uptime_val}|${cpu_cores} cores|load ${load_avg}|${temp}"
|
||||
echo "MEM|${mem_info}"
|
||||
echo "SWAP|${swap_info}"
|
||||
echo "DISK|${disk_info}"
|
||||
|
||||
# Top 10 processes by CPU
|
||||
echo "PROCS_START"
|
||||
ps aux --sort=-%cpu 2>/dev/null | head -11 | awk "NR>1{printf \"%-6s %-5s %-5s %s\n\", \$2, \$3, \$4, \$11}" 2>/dev/null
|
||||
echo "PROCS_END"
|
||||
|
||||
# Container status
|
||||
echo "CONTAINERS_START"
|
||||
if command -v podman >/dev/null 2>&1; then
|
||||
podman ps -a --format "{{.Names}}|{{.Status}}|{{.Size}}" 2>/dev/null || \
|
||||
podman ps -a --format "{{.Names}}|{{.Status}}" 2>/dev/null || \
|
||||
echo "podman error"
|
||||
elif command -v docker >/dev/null 2>&1; then
|
||||
docker ps -a --format "{{.Names}}|{{.Status}}" 2>/dev/null || echo "docker error"
|
||||
else
|
||||
echo "no container runtime"
|
||||
fi
|
||||
echo "CONTAINERS_END"
|
||||
'
|
||||
|
||||
# ── Formatting ─────────────────────────────────────────────────────────
|
||||
|
||||
BOLD="\033[1m"
|
||||
DIM="\033[2m"
|
||||
GREEN="\033[0;32m"
|
||||
YELLOW="\033[0;33m"
|
||||
RED="\033[0;31m"
|
||||
CYAN="\033[0;36m"
|
||||
RESET="\033[0m"
|
||||
|
||||
SEP="━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
|
||||
print_node_report() {
|
||||
local ip="$1"
|
||||
local label="$2"
|
||||
local output="$3"
|
||||
|
||||
echo -e "\n${BOLD}${CYAN}${SEP}${RESET}"
|
||||
echo -e "${BOLD}${CYAN} ${label} ${DIM}(${ip})${RESET}"
|
||||
echo -e "${BOLD}${CYAN}${SEP}${RESET}"
|
||||
|
||||
# Parse HEADER line
|
||||
local header
|
||||
header=$(echo "$output" | grep "^HEADER|" | head -1)
|
||||
if [ -n "$header" ]; then
|
||||
IFS='|' read -r _ hostname uptime cores load temp <<< "$header"
|
||||
echo -e " ${BOLD}Host:${RESET} ${hostname} ${DIM}${uptime}${RESET}"
|
||||
echo -e " ${BOLD}CPU:${RESET} ${cores} ${load} ${temp}"
|
||||
fi
|
||||
|
||||
# Memory
|
||||
local mem
|
||||
mem=$(echo "$output" | grep "^MEM|" | cut -d'|' -f2)
|
||||
[ -n "$mem" ] && echo -e " ${BOLD}Mem:${RESET} ${mem}"
|
||||
|
||||
local swap
|
||||
swap=$(echo "$output" | grep "^SWAP|" | cut -d'|' -f2)
|
||||
[ -n "$swap" ] && echo -e " ${BOLD}Swap:${RESET} ${swap}"
|
||||
|
||||
local disk
|
||||
disk=$(echo "$output" | grep "^DISK|" | cut -d'|' -f2)
|
||||
[ -n "$disk" ] && echo -e " ${BOLD}Disk:${RESET} ${disk}"
|
||||
|
||||
# Top processes
|
||||
echo ""
|
||||
echo -e " ${BOLD}Top processes by CPU:${RESET}"
|
||||
echo -e " ${DIM}PID CPU% MEM% Command${RESET}"
|
||||
local procs
|
||||
procs=$(echo "$output" | sed -n '/^PROCS_START$/,/^PROCS_END$/p' | grep -v "^PROCS_")
|
||||
if [ -n "$procs" ]; then
|
||||
while IFS= read -r line; do
|
||||
local cpu_pct
|
||||
cpu_pct=$(echo "$line" | awk '{print $2}' | tr -d '.')
|
||||
if [ "${cpu_pct:-0}" -gt 500 ] 2>/dev/null; then
|
||||
echo -e " ${RED}${line}${RESET}"
|
||||
elif [ "${cpu_pct:-0}" -gt 100 ] 2>/dev/null; then
|
||||
echo -e " ${YELLOW}${line}${RESET}"
|
||||
else
|
||||
echo -e " ${line}"
|
||||
fi
|
||||
done <<< "$procs"
|
||||
else
|
||||
echo -e " ${DIM}(no process data)${RESET}"
|
||||
fi
|
||||
|
||||
# Containers
|
||||
echo ""
|
||||
echo -e " ${BOLD}Containers:${RESET}"
|
||||
local containers
|
||||
containers=$(echo "$output" | sed -n '/^CONTAINERS_START$/,/^CONTAINERS_END$/p' | grep -v "^CONTAINERS_")
|
||||
if [ -n "$containers" ] && [ "$containers" != "no container runtime" ] && [ "$containers" != "podman error" ]; then
|
||||
while IFS='|' read -r name status size; do
|
||||
local icon
|
||||
if echo "$status" | grep -qi "up"; then
|
||||
icon="${GREEN}●${RESET}"
|
||||
else
|
||||
icon="${RED}○${RESET}"
|
||||
fi
|
||||
echo -e " ${icon} ${BOLD}${name}${RESET} ${DIM}${status}${RESET}"
|
||||
done <<< "$containers"
|
||||
else
|
||||
echo -e " ${DIM}${containers:-none}${RESET}"
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Main profiling loop ───────────────────────────────────────────────
|
||||
|
||||
profile_all() {
|
||||
echo -e "\n${BOLD}Archipelago Node Profile${RESET} ${DIM}$(date '+%Y-%m-%d %H:%M:%S')${RESET}"
|
||||
|
||||
local tmpdir
|
||||
tmpdir=$(mktemp -d)
|
||||
|
||||
# Probe all nodes in parallel
|
||||
local pids=()
|
||||
for i in "${!TARGET_NODES[@]}"; do
|
||||
local ip="${TARGET_NODES[$i]}"
|
||||
local label="${NODE_LABELS[$i]:-$ip}"
|
||||
(
|
||||
result=$(ssh_cmd "$ip" "$PROFILE_CMD" 2>/dev/null) && \
|
||||
echo "$result" > "$tmpdir/$i.out" || \
|
||||
echo "UNREACHABLE" > "$tmpdir/$i.out"
|
||||
) &
|
||||
pids+=($!)
|
||||
done
|
||||
|
||||
# Wait for all probes
|
||||
for pid in "${pids[@]}"; do
|
||||
wait "$pid" 2>/dev/null || true
|
||||
done
|
||||
|
||||
# Print reports
|
||||
local reachable=0 unreachable=0
|
||||
for i in "${!TARGET_NODES[@]}"; do
|
||||
local ip="${TARGET_NODES[$i]}"
|
||||
local label="${NODE_LABELS[$i]:-$ip}"
|
||||
local outfile="$tmpdir/$i.out"
|
||||
|
||||
if [ -f "$outfile" ] && [ "$(cat "$outfile")" != "UNREACHABLE" ]; then
|
||||
print_node_report "$ip" "$label" "$(cat "$outfile")"
|
||||
reachable=$((reachable + 1))
|
||||
else
|
||||
echo -e "\n${DIM}${SEP}${RESET}"
|
||||
echo -e "${RED} ${label} (${ip}) — unreachable${RESET}"
|
||||
echo -e "${DIM}${SEP}${RESET}"
|
||||
unreachable=$((unreachable + 1))
|
||||
fi
|
||||
done
|
||||
|
||||
echo -e "\n${DIM}${reachable} reachable, ${unreachable} unreachable${RESET}\n"
|
||||
rm -rf "$tmpdir"
|
||||
}
|
||||
|
||||
if $WATCH_MODE; then
|
||||
while true; do
|
||||
clear
|
||||
profile_all
|
||||
echo -e "${DIM}Refreshing every ${WATCH_INTERVAL}s — Ctrl+C to stop${RESET}"
|
||||
sleep "$WATCH_INTERVAL"
|
||||
done
|
||||
else
|
||||
profile_all
|
||||
fi
|
||||
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
+94
@@ -0,0 +1,94 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build the Archipelago companion debug APK and stage it as the served download
|
||||
# at neode-ui/public/packages/archipelago-companion.apk (a plain APK, so a phone
|
||||
# can install it straight from the link — no unzip step).
|
||||
#
|
||||
# Run manually, or automatically via the pre-push hook (.githooks/pre-push).
|
||||
#
|
||||
# Hardened (2026-06-26) so a broken APK can never ship again:
|
||||
# 1. Aborts on stray resource dirs whose names contain spaces (these break a
|
||||
# clean build with "Invalid resource directory name"). Empty ones — junk
|
||||
# left by some icon-export tools — are auto-removed; non-empty ones error.
|
||||
# 2. Always a CLEAN build (incremental builds masked the bad resource dirs).
|
||||
# 3. Forces v1 + v2 + v3 signing with zipalign + apksigner. AGP's
|
||||
# `enableV1Signing = true` flag is silently ignored for minSdk>=24, which
|
||||
# shipped a v2-only APK that some OEM installers reject ("App not installed").
|
||||
# 4. VERIFIES all three schemes and ABORTS if any is missing — no silent ship.
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(git rev-parse --show-toplevel)"
|
||||
cd "$ROOT"
|
||||
|
||||
JAVA="${JAVA_HOME:-/opt/homebrew/opt/openjdk@17}"
|
||||
SDK="${ANDROID_HOME:-$HOME/Library/Android/sdk}"
|
||||
|
||||
if [ ! -x "$JAVA/bin/java" ] || [ ! -d "$SDK" ]; then
|
||||
echo "publish-companion-apk: JDK or Android SDK not found — skipping." >&2
|
||||
echo " (set JAVA_HOME and ANDROID_HOME to build the companion APK)" >&2
|
||||
exit 0
|
||||
fi
|
||||
export JAVA_HOME="$JAVA"
|
||||
export PATH="$JAVA/bin:$PATH"
|
||||
|
||||
RES="Android/app/src/main/res"
|
||||
APK="Android/app/build/outputs/apk/debug/app-debug.apk"
|
||||
SIGNED="Android/app/build/outputs/apk/debug/app-debug-signed.apk"
|
||||
DEST="neode-ui/public/packages/archipelago-companion.apk"
|
||||
OLD_ZIP="neode-ui/public/packages/archipelago-companion.apk.zip"
|
||||
KS="Android/app/debug.keystore"
|
||||
|
||||
# 1. Guard against resource dirs with spaces (Android forbids them; a clean
|
||||
# build aborts on them). Empty ones are removed; non-empty ones are fatal.
|
||||
while IFS= read -r d; do
|
||||
[ -n "$d" ] || continue
|
||||
if [ -n "$(ls -A "$d" 2>/dev/null)" ]; then
|
||||
echo "publish-companion-apk: ERROR — resource dir with a space is not empty:" >&2
|
||||
echo " $d" >&2
|
||||
echo " Rename it (Android resource dir names cannot contain spaces)." >&2
|
||||
exit 1
|
||||
fi
|
||||
rmdir "$d" && echo "publish-companion-apk: removed stray empty resource dir: $d" >&2
|
||||
done < <(find "$RES" -type d -name '* *' 2>/dev/null)
|
||||
|
||||
# 2. Clean build.
|
||||
echo "publish-companion-apk: clean build of debug APK…" >&2
|
||||
( cd Android && ./gradlew -q --console=plain :app:clean :app:assembleDebug )
|
||||
[ -f "$APK" ] || { echo "publish-companion-apk: ERROR — APK not produced at $APK" >&2; exit 1; }
|
||||
|
||||
# 3. Force v1 + v2 + v3 signing (AGP's enableV1Signing flag is ignored here).
|
||||
BT="$(ls -d "$SDK"/build-tools/*/ | sort -V | tail -1)"
|
||||
ZIPALIGN="${BT}zipalign"; APKSIGNER="${BT}apksigner"
|
||||
[ -x "$ZIPALIGN" ] && [ -x "$APKSIGNER" ] || {
|
||||
echo "publish-companion-apk: ERROR — zipalign/apksigner not found under $BT" >&2; exit 1; }
|
||||
[ -f "$KS" ] || { echo "publish-companion-apk: ERROR — keystore missing at $KS" >&2; exit 1; }
|
||||
|
||||
echo "publish-companion-apk: zipalign + sign (v1+v2+v3)…" >&2
|
||||
"$ZIPALIGN" -p -f 4 "$APK" "$SIGNED"
|
||||
"$APKSIGNER" sign \
|
||||
--ks "$KS" --ks-pass pass:android \
|
||||
--ks-key-alias androiddebugkey --key-pass pass:android \
|
||||
--v1-signing-enabled true --v2-signing-enabled true --v3-signing-enabled true \
|
||||
"$SIGNED"
|
||||
|
||||
# 4. Verify all three schemes (min-sdk 21 forces the v1 path to be exercised).
|
||||
VERIFY="$("$APKSIGNER" verify -v --min-sdk-version 21 "$SIGNED" 2>&1)"
|
||||
for scheme in "v1 scheme" "v2 scheme" "v3 scheme"; do
|
||||
if ! printf '%s\n' "$VERIFY" | grep -iq "$scheme.*: true"; then
|
||||
echo "publish-companion-apk: ERROR — $scheme NOT present after signing. Aborting." >&2
|
||||
printf '%s\n' "$VERIFY" | grep -iE "scheme" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
echo "publish-companion-apk: verified v1 + v2 + v3 signatures." >&2
|
||||
|
||||
# 5. Publish.
|
||||
mkdir -p "$(dirname "$DEST")"
|
||||
cp "$SIGNED" "$DEST"
|
||||
|
||||
# Drop the legacy zipped artifact so the served download is the raw APK only.
|
||||
if [ -f "$OLD_ZIP" ]; then
|
||||
git rm -q --ignore-unmatch "$OLD_ZIP" 2>/dev/null || rm -f "$OLD_ZIP"
|
||||
fi
|
||||
|
||||
git add "$DEST"
|
||||
echo "publish-companion-apk: staged $DEST" >&2
|
||||
Executable
+121
@@ -0,0 +1,121 @@
|
||||
#!/usr/bin/env bash
|
||||
# Publish an Archipelago OTA release to a Gitea remote and verify downloads.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
VERSION="${1:-}"
|
||||
REMOTE="${2:-gitea-vps2}"
|
||||
|
||||
if [ -z "$VERSION" ]; then
|
||||
echo "Usage: $0 VERSION [remote]"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
VERSION_DIR="$PROJECT_ROOT/releases/v${VERSION}"
|
||||
BACKEND="$VERSION_DIR/archipelago"
|
||||
FRONTEND="$VERSION_DIR/archipelago-frontend-${VERSION}.tar.gz"
|
||||
|
||||
fail() { echo "Error: $*" >&2; exit 1; }
|
||||
|
||||
[ -f "$PROJECT_ROOT/releases/manifest.json" ] || fail "releases/manifest.json missing"
|
||||
[ -f "$BACKEND" ] || fail "backend artifact missing: $BACKEND"
|
||||
[ -f "$FRONTEND" ] || fail "frontend artifact missing: $FRONTEND"
|
||||
|
||||
"$SCRIPT_DIR/check-release-manifest.sh"
|
||||
|
||||
# §A supply-chain gate: never publish an unsigned OTA manifest. Fleet nodes
|
||||
# with the pinned release-root anchor refuse to auto-apply unsigned manifests,
|
||||
# and enforcement will tighten to hard-reject — an unsigned publish would
|
||||
# strand them. Grep proves presence; ceremony verify proves the crypto.
|
||||
EXPECTED_DID="did:key:z6MkkidEnEpo6qHMCNSZoNKWtvQvxq3whnaME9wGgEFhq7ur"
|
||||
grep -q '"signature":' "$PROJECT_ROOT/releases/manifest.json" \
|
||||
&& grep -q "\"signed_by\": \"$EXPECTED_DID\"" "$PROJECT_ROOT/releases/manifest.json" \
|
||||
|| fail "releases/manifest.json is not signed by the release root — run: bash scripts/sign-manifest.sh"
|
||||
if [ -x "$PROJECT_ROOT/core/target/release/archipelago" ]; then
|
||||
"$PROJECT_ROOT/core/target/release/archipelago" ceremony verify "$PROJECT_ROOT/releases/manifest.json" \
|
||||
|| fail "manifest signature failed cryptographic verification"
|
||||
fi
|
||||
|
||||
remote_url=$(git -C "$PROJECT_ROOT" remote get-url "$REMOTE")
|
||||
case "$remote_url" in
|
||||
http://*@*) ;;
|
||||
*) fail "$REMOTE must be an authenticated http:// Gitea remote URL for API uploads" ;;
|
||||
esac
|
||||
|
||||
auth=${remote_url#http://}
|
||||
auth=${auth%@*}
|
||||
host_path=${remote_url#http://$auth@}
|
||||
host=${host_path%%/*}
|
||||
repo_path=${host_path#*/}
|
||||
repo_path=${repo_path%.git}
|
||||
api="http://$host/api/v1/repos/$repo_path"
|
||||
release_url="$api/releases/tags/v${VERSION}"
|
||||
|
||||
echo "Pushing main and v${VERSION} to $REMOTE..."
|
||||
git -C "$PROJECT_ROOT" push "$REMOTE" main "refs/tags/v${VERSION}"
|
||||
|
||||
release_json=$(curl -fsS -u "$auth" "$release_url" || true)
|
||||
if [ -z "$release_json" ]; then
|
||||
echo "Creating Gitea release v${VERSION}..."
|
||||
release_body=$(python3 - "$VERSION" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
|
||||
version = sys.argv[1]
|
||||
print(json.dumps({
|
||||
"tag_name": f"v{version}",
|
||||
"target_commitish": "main",
|
||||
"name": f"v{version}",
|
||||
"body": f"Archipelago v{version} release artifacts for OTA updates.",
|
||||
"draft": False,
|
||||
"prerelease": True,
|
||||
}))
|
||||
PY
|
||||
)
|
||||
release_json=$(curl -fsS -u "$auth" -H 'Content-Type: application/json' -d "$release_body" "$api/releases")
|
||||
fi
|
||||
|
||||
release_id=$(printf '%s' "$release_json" | python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])')
|
||||
|
||||
asset_names=$(curl -fsS -u "$auth" "$api/releases/$release_id/assets" | python3 -c 'import json,sys; print("\n".join(a["name"] for a in json.load(sys.stdin)))')
|
||||
upload_asset() {
|
||||
local path="$1"
|
||||
local name="$2"
|
||||
if printf '%s\n' "$asset_names" | grep -Fxq "$name"; then
|
||||
echo "Asset $name already exists; leaving it in place."
|
||||
return
|
||||
fi
|
||||
echo "Uploading $name..."
|
||||
curl --fail --show-error --silent --http1.1 --connect-timeout 20 --max-time 900 \
|
||||
-u "$auth" \
|
||||
-F "attachment=@$path" \
|
||||
"$api/releases/$release_id/assets?name=$name" >/dev/null
|
||||
asset_names=$(printf '%s\n%s\n' "$asset_names" "$name")
|
||||
}
|
||||
|
||||
upload_asset "$BACKEND" "archipelago"
|
||||
upload_asset "$FRONTEND" "archipelago-frontend-${VERSION}.tar.gz"
|
||||
|
||||
echo "Verifying public download URLs from manifest (size + sha256)..."
|
||||
python3 - "$PROJECT_ROOT/releases/manifest.json" <<'PY' | while read -r url size sha; do
|
||||
import json
|
||||
import sys
|
||||
|
||||
manifest = json.load(open(sys.argv[1]))
|
||||
for component in manifest["components"]:
|
||||
print(component["download_url"], component["size_bytes"], component["sha256"])
|
||||
PY
|
||||
# Full GET, not HEAD: a size-correct/content-wrong mirror asset must fail
|
||||
# the publish gate, so compare the actual bytes against the manifest sha256.
|
||||
tmp=$(mktemp)
|
||||
curl -fsSL --max-time 900 -o "$tmp" "$url" || { rm -f "$tmp"; fail "download URL failed: $url"; }
|
||||
actual_size=$(stat -c %s "$tmp")
|
||||
actual_sha=$(sha256sum "$tmp" | awk '{print $1}')
|
||||
rm -f "$tmp"
|
||||
[ "$actual_size" = "$size" ] || fail "download size mismatch for $url (expected $size, got $actual_size)"
|
||||
[ "$actual_sha" = "$sha" ] || fail "download sha256 mismatch for $url (expected $sha, got $actual_sha)"
|
||||
done
|
||||
|
||||
echo "Release v${VERSION} published and verified on $REMOTE."
|
||||
Executable
+900
@@ -0,0 +1,900 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Archipelago Container Reconciler
|
||||
# Ensures every container matches the canonical spec from container-specs.sh.
|
||||
# Safe to run repeatedly (idempotent). Run on any node.
|
||||
#
|
||||
# Usage:
|
||||
# sudo ./reconcile-containers.sh # Fix everything
|
||||
# sudo ./reconcile-containers.sh --check-only # Audit only, no changes
|
||||
# sudo ./reconcile-containers.sh --force # Override user-stopped
|
||||
# sudo ./reconcile-containers.sh --force-recreate # Recreate matched containers
|
||||
# sudo ./reconcile-containers.sh --tier=2 # Only reconcile tier 2
|
||||
# sudo ./reconcile-containers.sh --container=lnd # Only reconcile lnd
|
||||
#
|
||||
set -o pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
|
||||
# ── Parse arguments ──────────────────────────────────────────────────
|
||||
CHECK_ONLY=false
|
||||
FORCE=false
|
||||
FORCE_RECREATE=false
|
||||
CREATE_MISSING=false
|
||||
FILTER_TIER=""
|
||||
FILTER_CONTAINER=""
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--check-only) CHECK_ONLY=true ;;
|
||||
--force) FORCE=true ;;
|
||||
--force-recreate) FORCE_RECREATE=true ;;
|
||||
--create-missing) CREATE_MISSING=true ;;
|
||||
--tier=*) FILTER_TIER="${arg#*=}" ;;
|
||||
--container=*) FILTER_CONTAINER="${arg#*=}" ;;
|
||||
-h|--help)
|
||||
echo "Usage: $0 [--check-only] [--force] [--force-recreate] [--create-missing] [--tier=N] [--container=NAME]"
|
||||
echo ""
|
||||
echo " --check-only Audit only, no changes."
|
||||
echo " --force Override user-stopped state."
|
||||
echo " --force-recreate Recreate matched existing containers even if they"
|
||||
echo " otherwise match the spec. Use with --container or"
|
||||
echo " --tier for scoped image/config refreshes."
|
||||
echo " --create-missing Override SPEC_OPTIONAL for containers that have on-disk"
|
||||
echo " data but no live container (recovery from failed updates)."
|
||||
echo " --tier=N Only reconcile containers in tier N."
|
||||
echo " --container=NAME Only reconcile the named container (spec key)."
|
||||
exit 0 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# ── Colors ───────────────────────────────────────────────────────────
|
||||
RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[0;33m'
|
||||
BLUE='\033[0;34m' CYAN='\033[0;36m' BOLD='\033[1m'
|
||||
NC='\033[0m'
|
||||
|
||||
ok() { echo -e " ${GREEN}[OK]${NC} $*"; }
|
||||
fixed() { echo -e " ${CYAN}[FIXED]${NC} $*"; }
|
||||
skip() { echo -e " ${YELLOW}[SKIP]${NC} $*"; }
|
||||
fail() { echo -e " ${RED}[FAIL]${NC} $*"; }
|
||||
info() { echo -e " ${BLUE}[INFO]${NC} $*"; }
|
||||
header(){ echo -e "\n${BOLD}$*${NC}"; }
|
||||
|
||||
# ── Source specs ─────────────────────────────────────────────────────
|
||||
source "$SCRIPT_DIR/container-specs.sh" || { echo "Cannot source container-specs.sh"; exit 1; }
|
||||
detect_environment
|
||||
|
||||
PORT_ALLOC_FILE="/var/lib/archipelago/port-allocations.env"
|
||||
[ -f "$PORT_ALLOC_FILE" ] && . "$PORT_ALLOC_FILE"
|
||||
|
||||
port_available() {
|
||||
local port="$1"
|
||||
ss -ltn 2>/dev/null | awk -v p=":$port" '$4 == p || $4 ~ p "$" { found=1 } END { exit found ? 1 : 0 }'
|
||||
}
|
||||
|
||||
alloc_port() {
|
||||
local key="$1" preferred="$2" var="PORT_${key//[^A-Za-z0-9]/_}" cur=""
|
||||
eval "cur=\${$var:-}"
|
||||
if [ -n "$cur" ] && port_available "$cur"; then
|
||||
printf '%s' "$cur"
|
||||
return
|
||||
fi
|
||||
if port_available "$preferred"; then
|
||||
cur="$preferred"
|
||||
else
|
||||
cur=""
|
||||
for p in $(seq 8085 9999); do
|
||||
if port_available "$p"; then cur="$p"; break; fi
|
||||
done
|
||||
fi
|
||||
[ -n "$cur" ] || cur="$preferred"
|
||||
sudo mkdir -p "$(dirname "$PORT_ALLOC_FILE")" 2>/dev/null || true
|
||||
if ! grep -q "^$var=" "$PORT_ALLOC_FILE" 2>/dev/null; then
|
||||
printf '%s=%s\n' "$var" "$cur" | sudo tee -a "$PORT_ALLOC_FILE" >/dev/null
|
||||
fi
|
||||
printf '%s' "$cur"
|
||||
}
|
||||
|
||||
# ── Podman command ───────────────────────────────────────────────────
|
||||
# Run as archipelago user — podman sees rootless containers directly.
|
||||
# Use sudo only for chown/mkdir operations.
|
||||
PODMAN="podman"
|
||||
PODMAN_IMAGE_CHECK_TIMEOUT="${PODMAN_IMAGE_CHECK_TIMEOUT:-10}"
|
||||
|
||||
podman_bounded() {
|
||||
timeout --kill-after=2s "${PODMAN_IMAGE_CHECK_TIMEOUT}s" "$PODMAN" "$@"
|
||||
}
|
||||
|
||||
# ── Pre-flight ───────────────────────────────────────────────────────
|
||||
header "╔══════════════════════════════════════════════════╗"
|
||||
header "║ ARCHIPELAGO CONTAINER RECONCILER ║"
|
||||
header "╚══════════════════════════════════════════════════╝"
|
||||
echo ""
|
||||
info "Host: $(hostname) ($HOST_IP)"
|
||||
info "Disk: ${DISK_GB}GB | RAM: ${TOTAL_MEM_MB}MB | Low-mem: $LOW_MEM"
|
||||
info "Mode: $($CHECK_ONLY && echo 'CHECK ONLY (no changes)' || echo 'APPLY FIXES')"
|
||||
echo ""
|
||||
|
||||
# Ensure archy-net exists
|
||||
if ! $PODMAN network exists archy-net 2>/dev/null; then
|
||||
if $CHECK_ONLY; then
|
||||
info "archy-net missing (would create)"
|
||||
else
|
||||
$PODMAN network create archy-net 2>/dev/null && info "Created archy-net" || fail "Cannot create archy-net"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Load user-stopped list
|
||||
USER_STOPPED_FILE="/var/lib/archipelago/user-stopped.json"
|
||||
USER_STOPPED=""
|
||||
if [ -f "$USER_STOPPED_FILE" ]; then
|
||||
USER_STOPPED=$(cat "$USER_STOPPED_FILE" 2>/dev/null)
|
||||
fi
|
||||
is_user_stopped() {
|
||||
[ "$FORCE" = "true" ] && return 1
|
||||
echo "$USER_STOPPED" | grep -q "\"$1\"" 2>/dev/null
|
||||
}
|
||||
|
||||
# ── Inspection helpers ───────────────────────────────────────────────
|
||||
container_exists() {
|
||||
# Avoid SIGPIPE-from-grep-q failing under `set -o pipefail`.
|
||||
local names
|
||||
names=$($PODMAN ps -a --format '{{.Names}}' 2>/dev/null)
|
||||
echo "$names" | grep -qx "$1"
|
||||
}
|
||||
|
||||
container_running() {
|
||||
local names
|
||||
names=$($PODMAN ps --format '{{.Names}}' 2>/dev/null)
|
||||
echo "$names" | grep -qx "$1"
|
||||
}
|
||||
|
||||
container_image() {
|
||||
$PODMAN inspect "$1" --format '{{.ImageName}}' 2>/dev/null
|
||||
}
|
||||
|
||||
container_image_id() {
|
||||
$PODMAN inspect "$1" --format '{{.Image}}' 2>/dev/null
|
||||
}
|
||||
|
||||
spec_image_id() {
|
||||
podman_bounded image inspect "$SPEC_IMAGE" --format '{{.Id}}' 2>/dev/null
|
||||
}
|
||||
|
||||
container_network() {
|
||||
# Use actual Networks map — NetworkMode is unreliable (always shows 'bridge' in rootless)
|
||||
local nets
|
||||
nets=$($PODMAN inspect "$1" --format '{{range $k,$v := .NetworkSettings.Networks}}{{$k}} {{end}}' 2>/dev/null)
|
||||
# Return first network name, trimmed
|
||||
echo "$nets" | awk '{print $1}'
|
||||
}
|
||||
|
||||
container_memory() {
|
||||
$PODMAN inspect "$1" --format '{{.HostConfig.Memory}}' 2>/dev/null
|
||||
}
|
||||
|
||||
container_health_cmd() {
|
||||
$PODMAN inspect "$1" --format '{{with .Config.Healthcheck}}{{range .Test}}{{println .}}{{end}}{{end}}' 2>/dev/null \
|
||||
| awk 'NR > 1 { print }' \
|
||||
| paste -sd ' ' -
|
||||
}
|
||||
|
||||
normalize_health_cmd() {
|
||||
printf '%s' "$1" | sed 's/\\"/"/g; s/[[:space:]][[:space:]]*/ /g; s/^ //; s/ $//'
|
||||
}
|
||||
|
||||
host_port_listening() {
|
||||
local port="$1"
|
||||
ss -ltn 2>/dev/null | awk -v p=":$port" '
|
||||
$4 == p || $4 ~ p "$" { found=1 }
|
||||
END { exit found ? 0 : 1 }
|
||||
'
|
||||
}
|
||||
|
||||
prepare_bind_source() {
|
||||
local source="$1"
|
||||
[ -n "$source" ] || return 0
|
||||
|
||||
case "$source" in
|
||||
/run/user/*/podman/podman.sock)
|
||||
if [ ! -S "$source" ]; then
|
||||
local runtime_dir="${source%/podman/podman.sock}"
|
||||
XDG_RUNTIME_DIR="$runtime_dir" systemctl --user start podman.socket 2>/dev/null || true
|
||||
for _ in 1 2 3 4 5 6 7 8 9 10; do
|
||||
[ -S "$source" ] && return 0
|
||||
sleep 0.25
|
||||
done
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
case "$source" in
|
||||
/var/lib/archipelago/*)
|
||||
sudo mkdir -p "$source" 2>/dev/null
|
||||
;;
|
||||
*)
|
||||
# Non-data bind mounts can be files/sockets/devices. Creating the full
|
||||
# path would turn e.g. podman.sock into a directory and break Portainer.
|
||||
if [ -e "$source" ]; then
|
||||
return 0
|
||||
fi
|
||||
fail "bind source missing: $source"
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
ensure_catatonit() {
|
||||
command -v catatonit >/dev/null 2>&1 && return 0
|
||||
$CHECK_ONLY && { info "catatonit missing (would install)"; return 0; }
|
||||
|
||||
if command -v apt-get >/dev/null 2>&1; then
|
||||
sudo apt-get update >/dev/null 2>&1 || true
|
||||
sudo apt-get install -y catatonit >/dev/null 2>&1 || true
|
||||
elif command -v dnf >/dev/null 2>&1; then
|
||||
sudo dnf install -y catatonit >/dev/null 2>&1 || true
|
||||
elif command -v apk >/dev/null 2>&1; then
|
||||
sudo apk add catatonit >/dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
command -v catatonit >/dev/null 2>&1 || { fail "catatonit missing; Portainer compose builds may fail"; return 1; }
|
||||
}
|
||||
|
||||
ensure_portainer_host_paths() {
|
||||
ensure_catatonit
|
||||
if $CHECK_ONLY; then
|
||||
[ -d /var/lib/archipelago/portainer/compose ] || info "Portainer compose dir missing (would create)"
|
||||
[ -e /data ] || info "/data host path missing (would link to /var/lib/archipelago/portainer)"
|
||||
return 0
|
||||
fi
|
||||
|
||||
sudo mkdir -p /var/lib/archipelago/portainer/compose 2>/dev/null || true
|
||||
sudo chown -R 1000:1000 /var/lib/archipelago/portainer 2>/dev/null || true
|
||||
if [ ! -e /data ]; then
|
||||
sudo ln -s /var/lib/archipelago/portainer /data 2>/dev/null || true
|
||||
elif [ -d /data ] && [ ! -L /data ] && [ ! -e /data/compose ]; then
|
||||
sudo ln -s /var/lib/archipelago/portainer/compose /data/compose 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
|
||||
container_has_mount() {
|
||||
local name="$1" source="$2" target="$3"
|
||||
$PODMAN inspect "$name" --format '{{range .Mounts}}{{println .Source "|" .Destination}}{{end}}' 2>/dev/null \
|
||||
| awk -F'|' -v src="$source" -v dst="$target" '
|
||||
{ gsub(/[[:space:]]+$/, "", $1); gsub(/^[[:space:]]+/, "", $2); }
|
||||
$1 == src && $2 == dst { found=1 }
|
||||
END { exit found ? 0 : 1 }
|
||||
'
|
||||
}
|
||||
|
||||
# Read one environment variable's current value from a running/stopped container.
|
||||
# Returns empty string if the var is not set.
|
||||
container_env_val() {
|
||||
local name="$1" key="$2"
|
||||
$PODMAN inspect "$name" --format '{{range .Config.Env}}{{println .}}{{end}}' 2>/dev/null \
|
||||
| awk -F= -v k="$key" '$1==k { sub(/^[^=]+=/, ""); print; exit }'
|
||||
}
|
||||
|
||||
# Env keys whose values bake network topology into the container. If the spec's
|
||||
# value for one of these keys ever differs from the running container's value
|
||||
# (host IP changed, DHCP lease rotated, LAN re-subnetted, container dependency
|
||||
# moved between archy-net and bridge), the container MUST be recreated.
|
||||
# This is the systemic fix for the fedimint April-11 stale-IP class of bug
|
||||
# where a container's URL env was never reconciled after network changes.
|
||||
#
|
||||
# Match by suffix to keep the list small. Covers:
|
||||
# *_URL (FM_P2P_URL, FM_API_URL, FM_BITCOIND_URL, NBXPLORER_BTCRPCURL, ...)
|
||||
# *_HOST (BTCPAY_HOST, CORE_RPC_HOST, ...)
|
||||
# *_ENDPOINT (NBXPLORER_BTCNODEENDPOINT, ...)
|
||||
URL_ENV_SUFFIXES="_URL _HOST _ENDPOINT"
|
||||
|
||||
image_exists() {
|
||||
podman_bounded image exists "$1" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
resolve_spec_image() {
|
||||
image_exists "$SPEC_IMAGE" && return
|
||||
|
||||
local image_path image_name image_tag candidate repo
|
||||
image_path="${SPEC_IMAGE#*/}"
|
||||
image_name="${SPEC_IMAGE##*/}"
|
||||
image_tag="${image_name#*:}"
|
||||
image_name="${image_name%%:*}"
|
||||
|
||||
for candidate in \
|
||||
"${ARCHY_REGISTRY_FALLBACK:-}/${image_path}" \
|
||||
"80.71.235.15:3000/archipelago/${image_name}:${image_tag}" \
|
||||
"80.71.235.15:3000/lfg2025/${image_name}:${image_tag}"; do
|
||||
case "$candidate" in /*) continue;; esac
|
||||
if image_exists "$candidate"; then
|
||||
info "$SPEC_NAME — using local image alias $candidate"
|
||||
SPEC_IMAGE="$candidate"
|
||||
return
|
||||
fi
|
||||
done
|
||||
|
||||
repo=$(podman_bounded images --format '{{.Repository}}:{{.Tag}}' 2>/dev/null \
|
||||
| grep -E "/${image_name}:${image_tag}$" \
|
||||
| head -1 || true)
|
||||
if [ -n "$repo" ]; then
|
||||
info "$SPEC_NAME — using local image alias $repo"
|
||||
SPEC_IMAGE="$repo"
|
||||
fi
|
||||
}
|
||||
|
||||
# Convert memory string to bytes for comparison
|
||||
mem_to_bytes() {
|
||||
local m="$1"
|
||||
case "$m" in
|
||||
*g|*G) echo $(( ${m%[gG]} * 1073741824 )) ;;
|
||||
*m|*M) echo $(( ${m%[mM]} * 1048576 )) ;;
|
||||
*) echo "$m" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# ── Build podman run command from spec ───────────────────────────────
|
||||
build_run_cmd() {
|
||||
local cmd="$PODMAN run -d --name $SPEC_NAME"
|
||||
cmd+=" --restart $SPEC_RESTART"
|
||||
|
||||
# Network
|
||||
if [ "$SPEC_NETWORK" = "host" ]; then
|
||||
cmd+=" --network=host"
|
||||
elif [ "$SPEC_NETWORK" = "archy-net" ]; then
|
||||
cmd+=" --network archy-net"
|
||||
fi
|
||||
|
||||
# Memory
|
||||
[ -n "$SPEC_MEMORY" ] && cmd+=" --memory=$SPEC_MEMORY"
|
||||
|
||||
# Capabilities
|
||||
cmd+=" --cap-drop ALL"
|
||||
for cap in $SPEC_CAPS; do
|
||||
cmd+=" --cap-add $cap"
|
||||
done
|
||||
|
||||
# Security
|
||||
[ -n "$SPEC_SECURITY" ] && cmd+=" --security-opt $SPEC_SECURITY"
|
||||
|
||||
# Read-only
|
||||
[ "$SPEC_READONLY" = "true" ] && cmd+=" --read-only"
|
||||
|
||||
# Tmpfs
|
||||
for t in $SPEC_TMPFS; do
|
||||
cmd+=" --tmpfs $t"
|
||||
done
|
||||
|
||||
# Health check
|
||||
if [ -n "$SPEC_HEALTH_CMD" ]; then
|
||||
cmd+=" --health-cmd=\"$SPEC_HEALTH_CMD\" --health-interval=120s --health-timeout=10s --health-retries=3"
|
||||
fi
|
||||
|
||||
# Ports
|
||||
for p in $SPEC_PORTS; do
|
||||
cmd+=" -p $p"
|
||||
done
|
||||
|
||||
# Volumes
|
||||
for v in $SPEC_VOLUMES; do
|
||||
cmd+=" -v $v"
|
||||
done
|
||||
|
||||
# Environment
|
||||
for e in $SPEC_ENV; do
|
||||
cmd+=" -e \"$e\""
|
||||
done
|
||||
|
||||
# Image
|
||||
cmd+=" $SPEC_IMAGE"
|
||||
|
||||
# Custom args
|
||||
[ -n "$SPEC_CUSTOM_ARGS" ] && cmd+=" $SPEC_CUSTOM_ARGS"
|
||||
|
||||
# Entrypoint override
|
||||
[ -n "$SPEC_ENTRYPOINT" ] && cmd+=" $SPEC_ENTRYPOINT"
|
||||
|
||||
echo "$cmd"
|
||||
}
|
||||
|
||||
# ── Counters ─────────────────────────────────────────────────────────
|
||||
COUNT_OK=0 COUNT_FIXED=0 COUNT_CREATED=0 COUNT_SKIPPED=0 COUNT_FAILED=0
|
||||
FAILED_LIST=""
|
||||
|
||||
# ── Reconcile one container ──────────────────────────────────────────
|
||||
reconcile() {
|
||||
local name="$1"
|
||||
|
||||
if ! load_spec "$name"; then
|
||||
skip "$name — no spec defined"
|
||||
COUNT_SKIPPED=$((COUNT_SKIPPED + 1))
|
||||
return
|
||||
fi
|
||||
|
||||
[ "$name" = "portainer" ] && ensure_portainer_host_paths
|
||||
|
||||
# Filter by tier
|
||||
[ -n "$FILTER_TIER" ] && [ "$SPEC_TIER" != "$FILTER_TIER" ] && return
|
||||
|
||||
# User-stopped
|
||||
if is_user_stopped "$name"; then
|
||||
skip "$name — user-stopped"
|
||||
COUNT_SKIPPED=$((COUNT_SKIPPED + 1))
|
||||
fix_ownership "$name"
|
||||
return
|
||||
fi
|
||||
|
||||
# Optional apps: only reconcile if already installed (container exists).
|
||||
# The install RPC creates the container; the reconciler just keeps it running.
|
||||
# --create-missing overrides this so we can recover from failed-update rollbacks
|
||||
# that deleted a container without restoring it (on-disk data still present).
|
||||
if [ "$SPEC_OPTIONAL" = "true" ] && ! container_exists "$name" && ! $CREATE_MISSING; then
|
||||
skip "$name — not installed"
|
||||
COUNT_SKIPPED=$((COUNT_SKIPPED + 1))
|
||||
return
|
||||
fi
|
||||
|
||||
# Resolve registry aliases before create/recreate. ISOs and older installers
|
||||
# may seed the same image under a fallback registry tag.
|
||||
resolve_spec_image
|
||||
|
||||
# Local images: skip if image doesn't exist and container doesn't exist
|
||||
if [ "$SPEC_LOCAL_IMAGE" = "true" ]; then
|
||||
if ! image_exists "$SPEC_IMAGE" && ! container_exists "$name"; then
|
||||
skip "$name — image not available"
|
||||
COUNT_SKIPPED=$((COUNT_SKIPPED + 1))
|
||||
return
|
||||
fi
|
||||
fi
|
||||
|
||||
# Check dependencies
|
||||
for dep in $SPEC_DEPENDS; do
|
||||
if ! container_running "$dep"; then
|
||||
skip "$name — dependency $dep not running"
|
||||
COUNT_SKIPPED=$((COUNT_SKIPPED + 1))
|
||||
return
|
||||
fi
|
||||
done
|
||||
|
||||
local action="OK"
|
||||
local reasons=""
|
||||
|
||||
if container_exists "$name"; then
|
||||
local cur_image cur_image_id want_image_id cur_network cur_memory
|
||||
cur_image=$(container_image "$name")
|
||||
cur_image_id=$(container_image_id "$name")
|
||||
want_image_id=$(spec_image_id)
|
||||
cur_network=$(container_network "$name")
|
||||
cur_memory=$(container_memory "$name")
|
||||
local spec_memory_bytes expected_network
|
||||
|
||||
spec_memory_bytes=$(mem_to_bytes "$SPEC_MEMORY")
|
||||
|
||||
if [ "$FORCE_RECREATE" = "true" ]; then
|
||||
action="RECREATE"
|
||||
reasons+="force-recreate "
|
||||
fi
|
||||
|
||||
# Same-tag local rebuilds leave running containers on the old image ID.
|
||||
# Recreate when the currently tagged spec image points at a different ID.
|
||||
if [ "$action" = "OK" ] && [ -n "$want_image_id" ] && [ -n "$cur_image_id" ] && [ "$cur_image_id" != "$want_image_id" ]; then
|
||||
action="RECREATE"
|
||||
reasons+="image-id "
|
||||
fi
|
||||
|
||||
# Check network mismatch
|
||||
# For archy-net and host: exact match required
|
||||
# For bridge/default: accept any non-archy-net, non-host network
|
||||
if [ "$SPEC_NETWORK" = "archy-net" ]; then
|
||||
if [ "$cur_network" != "archy-net" ]; then
|
||||
action="RECREATE"
|
||||
reasons+="network($cur_network→archy-net) "
|
||||
fi
|
||||
elif [ "$SPEC_NETWORK" = "host" ]; then
|
||||
if [ "$cur_network" != "host" ]; then
|
||||
action="RECREATE"
|
||||
reasons+="network($cur_network→host) "
|
||||
fi
|
||||
else
|
||||
# Default/bridge: anything that isn't archy-net or host is fine
|
||||
if [ "$cur_network" = "archy-net" ] || [ "$cur_network" = "host" ]; then
|
||||
action="RECREATE"
|
||||
reasons+="network($cur_network→bridge) "
|
||||
fi
|
||||
fi
|
||||
|
||||
# Check memory limit (0 = no limit)
|
||||
if [ "${cur_memory:-0}" = "0" ] && [ "${spec_memory_bytes:-0}" != "0" ]; then
|
||||
action="RECREATE"
|
||||
reasons+="memory(none→$SPEC_MEMORY) "
|
||||
fi
|
||||
|
||||
# Healthcheck drift matters: a stale check can leave an otherwise working
|
||||
# service permanently unhealthy (for example ElectrumX images do not ship
|
||||
# curl, so the healthcheck must use python's socket module).
|
||||
if [ "$action" = "OK" ] && [ -n "$SPEC_HEALTH_CMD" ]; then
|
||||
local cur_health spec_health
|
||||
cur_health=$(normalize_health_cmd "$(container_health_cmd "$name")")
|
||||
spec_health=$(normalize_health_cmd "$SPEC_HEALTH_CMD")
|
||||
if [ "$cur_health" != "$spec_health" ]; then
|
||||
action="RECREATE"
|
||||
reasons+="healthcheck "
|
||||
fi
|
||||
fi
|
||||
|
||||
# Check URL/HOST env drift — catches stale network topology baked into
|
||||
# container env (fedimint April-11 bug: FM_P2P_URL pointed at old IP).
|
||||
# Only checks URL-shaped keys; other env drift (passwords rotated, etc.)
|
||||
# is intentionally ignored to avoid thrashing.
|
||||
if [ "$action" = "OK" ] && [ -n "$SPEC_ENV" ]; then
|
||||
for kv in $SPEC_ENV; do
|
||||
local env_key="${kv%%=*}"
|
||||
local env_val_spec="${kv#*=}"
|
||||
local is_url_key=false
|
||||
for suffix in $URL_ENV_SUFFIXES; do
|
||||
case "$env_key" in *"$suffix") is_url_key=true; break ;; esac
|
||||
done
|
||||
[ "$is_url_key" = "true" ] || continue
|
||||
local env_val_cur
|
||||
env_val_cur=$(container_env_val "$name" "$env_key")
|
||||
if [ "$env_val_cur" != "$env_val_spec" ]; then
|
||||
action="RECREATE"
|
||||
reasons+="env($env_key:$env_val_cur→$env_val_spec) "
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
# Check bind mounts. This catches companion UIs recreated from older specs,
|
||||
# especially bitcoin-ui: its image intentionally does not bake nginx.conf,
|
||||
# so the rendered RPC proxy config must be mounted from the host.
|
||||
if [ "$action" = "OK" ] && [ -n "$SPEC_VOLUMES" ]; then
|
||||
for v in $SPEC_VOLUMES; do
|
||||
local mount_source mount_rest mount_target
|
||||
mount_source="${v%%:*}"
|
||||
mount_rest="${v#*:}"
|
||||
mount_target="${mount_rest%%:*}"
|
||||
[ -n "$mount_source" ] && [ -n "$mount_target" ] || continue
|
||||
if ! container_has_mount "$name" "$mount_source" "$mount_target"; then
|
||||
action="RECREATE"
|
||||
reasons+="mount($mount_target) "
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
# Rootless Podman can occasionally leave a container running while its
|
||||
# rootlessport listener is gone. The container still looks healthy in
|
||||
# `podman ps`, but host-network UIs and backend status probes fail against
|
||||
# 127.0.0.1. Treat missing host listeners as spec drift.
|
||||
if [ "$action" = "OK" ] && [ -n "$SPEC_PORTS" ]; then
|
||||
for p in $SPEC_PORTS; do
|
||||
local host_port="${p%%:*}"
|
||||
[ -n "$host_port" ] || continue
|
||||
if ! host_port_listening "$host_port"; then
|
||||
action="RECREATE"
|
||||
reasons+="port($host_port-not-listening) "
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
# Check if running
|
||||
if ! container_running "$name" && [ "$action" = "OK" ]; then
|
||||
action="START"
|
||||
reasons+="not-running "
|
||||
fi
|
||||
else
|
||||
action="CREATE"
|
||||
reasons+="missing "
|
||||
fi
|
||||
|
||||
# Fix ownership regardless
|
||||
fix_ownership "$name"
|
||||
|
||||
case "$action" in
|
||||
OK)
|
||||
ok "$name"
|
||||
COUNT_OK=$((COUNT_OK + 1))
|
||||
;;
|
||||
START)
|
||||
if $CHECK_ONLY; then
|
||||
info "$name — would start ($reasons)"
|
||||
else
|
||||
if $PODMAN start "$name" >/dev/null 2>&1; then
|
||||
fixed "$name — started ($reasons)"
|
||||
else
|
||||
fail "$name — start failed"
|
||||
COUNT_FAILED=$((COUNT_FAILED + 1))
|
||||
FAILED_LIST+=" $name"
|
||||
return
|
||||
fi
|
||||
fi
|
||||
COUNT_FIXED=$((COUNT_FIXED + 1))
|
||||
;;
|
||||
RECREATE)
|
||||
if $CHECK_ONLY; then
|
||||
info "$name — would recreate ($reasons)"
|
||||
else
|
||||
info "$name — recreating ($reasons)"
|
||||
$PODMAN stop "$name" >/dev/null 2>&1
|
||||
$PODMAN rm "$name" >/dev/null 2>&1
|
||||
if eval "$(build_run_cmd)" >/dev/null 2>&1; then
|
||||
fixed "$name — recreated ($reasons)"
|
||||
else
|
||||
fail "$name — recreate failed: $(eval "$(build_run_cmd)" 2>&1 | tail -1)"
|
||||
COUNT_FAILED=$((COUNT_FAILED + 1))
|
||||
FAILED_LIST+=" $name"
|
||||
return
|
||||
fi
|
||||
fi
|
||||
COUNT_FIXED=$((COUNT_FIXED + 1))
|
||||
;;
|
||||
CREATE)
|
||||
if $CHECK_ONLY; then
|
||||
info "$name — would create ($reasons)"
|
||||
else
|
||||
for v in $SPEC_VOLUMES; do
|
||||
local host_dir="${v%%:*}"
|
||||
prepare_bind_source "$host_dir" || {
|
||||
COUNT_FAILED=$((COUNT_FAILED + 1))
|
||||
FAILED_LIST+=" $name"
|
||||
return
|
||||
}
|
||||
done
|
||||
if eval "$(build_run_cmd)" >/dev/null 2>&1; then
|
||||
fixed "$name — created"
|
||||
else
|
||||
fail "$name — create failed"
|
||||
COUNT_FAILED=$((COUNT_FAILED + 1))
|
||||
FAILED_LIST+=" $name"
|
||||
return
|
||||
fi
|
||||
fi
|
||||
COUNT_CREATED=$((COUNT_CREATED + 1))
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# ── Fix ownership ────────────────────────────────────────────────────
|
||||
fix_ownership() {
|
||||
local name="$1"
|
||||
[ -z "$SPEC_DATA_DIR" ] && return
|
||||
[ ! -d "$SPEC_DATA_DIR" ] && return
|
||||
[ "$SPEC_DATA_UID" = "100000:100000" ] && return
|
||||
|
||||
local expected_uid="${SPEC_DATA_UID%%:*}"
|
||||
local current_uid
|
||||
current_uid=$(stat -c '%u' "$SPEC_DATA_DIR" 2>/dev/null)
|
||||
|
||||
if [ "$current_uid" != "$expected_uid" ]; then
|
||||
if $CHECK_ONLY; then
|
||||
info "$name — ownership: $current_uid → $SPEC_DATA_UID"
|
||||
else
|
||||
sudo chown -R "$SPEC_DATA_UID" "$SPEC_DATA_DIR" 2>/dev/null
|
||||
info "$name — fixed ownership → $SPEC_DATA_UID"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Ensure secrets exist ─────────────────────────────────────────────
|
||||
ensure_secrets() {
|
||||
local SECRETS_DIR="/var/lib/archipelago/secrets"
|
||||
sudo mkdir -p "$SECRETS_DIR" 2>/dev/null
|
||||
sudo chmod 700 "$SECRETS_DIR" 2>/dev/null
|
||||
|
||||
for svc in bitcoin-rpc-password mempool-db-password btcpay-db-password mysql-root-db-password; do
|
||||
if [ ! -f "$SECRETS_DIR/$svc" ]; then
|
||||
if $CHECK_ONLY; then
|
||||
info "Would generate secret: $svc"
|
||||
else
|
||||
openssl rand -hex 16 | sudo tee "$SECRETS_DIR/$svc" >/dev/null
|
||||
sudo chmod 600 "$SECRETS_DIR/$svc"
|
||||
info "Generated secret: $svc"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
if [ ! -f "$SECRETS_DIR/fedimint-gateway-password" ]; then
|
||||
if ! $CHECK_ONLY; then
|
||||
local fpass
|
||||
fpass=$(openssl rand -base64 16)
|
||||
echo "$fpass" | sudo tee "$SECRETS_DIR/fedimint-gateway-password" >/dev/null
|
||||
sudo chmod 600 "$SECRETS_DIR/fedimint-gateway-password"
|
||||
if command -v htpasswd >/dev/null 2>&1; then
|
||||
htpasswd -bnBC 10 "" "$fpass" | tr -d ':\n' | sudo tee "$SECRETS_DIR/fedimint-gateway-hash" >/dev/null
|
||||
sudo chmod 600 "$SECRETS_DIR/fedimint-gateway-hash"
|
||||
fi
|
||||
info "Generated fedimint gateway secret"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Reload after generation
|
||||
detect_environment
|
||||
}
|
||||
|
||||
# ── Ensure bitcoin.conf ─────────────────────────────────────────────
|
||||
ensure_bitcoin_conf() {
|
||||
local BITCOIN_CONF="/var/lib/archipelago/bitcoin/bitcoin.conf"
|
||||
sudo mkdir -p /var/lib/archipelago/bitcoin 2>/dev/null
|
||||
if [ ! -f "$BITCOIN_CONF" ] || ! sudo grep -q "^rpcauth=" "$BITCOIN_CONF" 2>/dev/null; then
|
||||
if ! $CHECK_ONLY && [ -n "$BITCOIN_RPC_PASS" ]; then
|
||||
local salt hash rpcauth
|
||||
salt=$(openssl rand -hex 16)
|
||||
hash=$(echo -n "$BITCOIN_RPC_PASS" | openssl dgst -sha256 -hmac "$salt" -hex 2>/dev/null | awk '{print $NF}')
|
||||
rpcauth="${BITCOIN_RPC_USER}:${salt}\$${hash}"
|
||||
# Only rpcauth + printtoconsole here — all other options are in SPEC_CUSTOM_ARGS
|
||||
# to avoid duplicate bind conflicts. printtoconsole=0: datadir debug.log
|
||||
# already has everything; console duplication spammed journald during IBD.
|
||||
sudo tee "$BITCOIN_CONF" >/dev/null << BTCEOF
|
||||
rpcauth=${rpcauth}
|
||||
printtoconsole=0
|
||||
BTCEOF
|
||||
info "Generated bitcoin.conf"
|
||||
fi
|
||||
fi
|
||||
# Strip duplicate server/rpc/listen lines from existing conf files to avoid
|
||||
# conflicts with custom args. Knots can persist runtime args in
|
||||
# bitcoin_rw.conf, so clean both files.
|
||||
for conf in "$BITCOIN_CONF" "/var/lib/archipelago/bitcoin/bitcoin_rw.conf"; do
|
||||
if [ -f "$conf" ]; then
|
||||
sudo sed -i '/^server=/d; /^txindex=/d; /^rpcbind=/d; /^rpcallowip=/d; /^rpcport=/d; /^listen=/d; /^bind=/d; /^dbcache=/d; /^rpcthreads=/d; /^rpcworkqueue=/d' "$conf" 2>/dev/null
|
||||
fi
|
||||
done
|
||||
sudo chown -R 100101:100101 /var/lib/archipelago/bitcoin 2>/dev/null
|
||||
}
|
||||
|
||||
# ── Ensure lnd.conf ─────────────────────────────────────────────────
|
||||
ensure_lnd_conf() {
|
||||
local LND_CONF="/var/lib/archipelago/lnd/lnd.conf"
|
||||
sudo mkdir -p /var/lib/archipelago/lnd 2>/dev/null
|
||||
if [ ! -f "$LND_CONF" ] && [ -n "$BITCOIN_RPC_PASS" ]; then
|
||||
if ! $CHECK_ONLY; then
|
||||
sudo tee "$LND_CONF" >/dev/null << LNDEOF
|
||||
[Application Options]
|
||||
listen=0.0.0.0:9735
|
||||
rpclisten=0.0.0.0:10009
|
||||
restlisten=0.0.0.0:8080
|
||||
debuglevel=info
|
||||
noseedbackup=true
|
||||
|
||||
[Bitcoin]
|
||||
bitcoin.mainnet=true
|
||||
bitcoin.node=bitcoind
|
||||
|
||||
[Bitcoind]
|
||||
bitcoind.rpchost=bitcoin-knots:8332
|
||||
bitcoind.rpcuser=$BITCOIN_RPC_USER
|
||||
bitcoind.rpcpass=$BITCOIN_RPC_PASS
|
||||
bitcoind.rpcpolling=true
|
||||
bitcoind.estimatemode=ECONOMICAL
|
||||
|
||||
[autopilot]
|
||||
autopilot.active=false
|
||||
LNDEOF
|
||||
info "Generated lnd.conf"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Ensure bitcoin-ui nginx.conf ────────────────────────────────────
|
||||
ensure_bitcoin_ui_nginx_conf() {
|
||||
local CONF_DIR="/var/lib/archipelago/bitcoin-ui"
|
||||
local CONF_PATH="$CONF_DIR/nginx.conf"
|
||||
[ -n "$BITCOIN_RPC_PASS" ] || return
|
||||
if $CHECK_ONLY; then
|
||||
[ -f "$CONF_PATH" ] || info "Would generate bitcoin-ui nginx.conf"
|
||||
return
|
||||
fi
|
||||
|
||||
local auth_b64 tmp
|
||||
auth_b64=$(printf '%s' "${BITCOIN_RPC_USER}:${BITCOIN_RPC_PASS}" | base64 | tr -d '\n')
|
||||
sudo mkdir -p "$CONF_DIR" 2>/dev/null
|
||||
tmp="${CONF_PATH}.tmp.$$"
|
||||
sudo tee "$tmp" >/dev/null << EOF
|
||||
server {
|
||||
listen 8334;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location /bitcoin-rpc/ {
|
||||
proxy_pass http://127.0.0.1:8332/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host \$host;
|
||||
proxy_set_header X-Real-IP \$remote_addr;
|
||||
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||||
proxy_set_header Authorization "Basic ${auth_b64}";
|
||||
add_header Access-Control-Allow-Origin *;
|
||||
add_header Access-Control-Allow-Methods "POST, GET, OPTIONS";
|
||||
add_header Access-Control-Allow-Headers "Content-Type, Authorization";
|
||||
if (\$request_method = OPTIONS) { return 204; }
|
||||
}
|
||||
|
||||
location /bitcoin-status {
|
||||
proxy_pass http://127.0.0.1:5678/bitcoin-status;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host \$host;
|
||||
proxy_set_header X-Real-IP \$remote_addr;
|
||||
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||||
add_header Cache-Control "no-store";
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files \$uri \$uri/ /index.html;
|
||||
}
|
||||
}
|
||||
EOF
|
||||
if ! sudo cmp -s "$tmp" "$CONF_PATH" 2>/dev/null; then
|
||||
sudo mv "$tmp" "$CONF_PATH"
|
||||
sudo chmod 644 "$CONF_PATH"
|
||||
info "Generated bitcoin-ui nginx.conf"
|
||||
else
|
||||
sudo rm -f "$tmp"
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Ensure BTCPay databases ─────────────────────────────────────────
|
||||
ensure_btcpay_db() {
|
||||
if container_running "archy-btcpay-db"; then
|
||||
$PODMAN exec archy-btcpay-db psql -U postgres -tc \
|
||||
"SELECT 1 FROM pg_database WHERE datname='nbxplorer'" 2>/dev/null | grep -q 1 || \
|
||||
$PODMAN exec archy-btcpay-db psql -U postgres -c \
|
||||
"CREATE DATABASE nbxplorer;" 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════
|
||||
# MAIN
|
||||
# ══════════════════════════════════════════════════════════════════════
|
||||
START_TIME=$(date +%s)
|
||||
|
||||
header "Phase 0: Prerequisites"
|
||||
ensure_secrets
|
||||
detect_environment
|
||||
ensure_bitcoin_conf
|
||||
ensure_lnd_conf
|
||||
ensure_bitcoin_ui_nginx_conf
|
||||
|
||||
TIER_NAMES=("Databases" "Core Infrastructure" "Services" "Applications" "Frontend UIs")
|
||||
|
||||
for tier in 0 1 2 3 4; do
|
||||
[ -n "$FILTER_TIER" ] && [ "$FILTER_TIER" != "$tier" ] && continue
|
||||
|
||||
header "Tier $tier: ${TIER_NAMES[$tier]}"
|
||||
|
||||
for name in "${ALL_CONTAINER_SPECS[@]}"; do
|
||||
[ -n "$FILTER_CONTAINER" ] && [ "$name" != "$FILTER_CONTAINER" ] && continue
|
||||
|
||||
# Load spec to check tier before reconciling
|
||||
if load_spec "$name" && [ "$SPEC_TIER" = "$tier" ]; then
|
||||
reconcile "$name"
|
||||
fi
|
||||
done
|
||||
|
||||
# After databases, ensure BTCPay DB schemas exist
|
||||
[ "$tier" = "0" ] && ensure_btcpay_db
|
||||
|
||||
# Brief pause between tiers
|
||||
[ "$tier" -lt 4 ] && ! $CHECK_ONLY && sleep 2
|
||||
done
|
||||
|
||||
# ── Summary ──────────────────────────────────────────────────────────
|
||||
ELAPSED=$(( $(date +%s) - START_TIME ))
|
||||
TOTAL=$((COUNT_OK + COUNT_FIXED + COUNT_CREATED + COUNT_SKIPPED + COUNT_FAILED))
|
||||
|
||||
echo ""
|
||||
header "╔══════════════════════════════════════════════════╗"
|
||||
header "║ RECONCILIATION REPORT ║"
|
||||
header "╚══════════════════════════════════════════════════╝"
|
||||
echo ""
|
||||
echo -e " Total: ${BOLD}$TOTAL${NC}"
|
||||
echo -e " OK: ${GREEN}$COUNT_OK${NC}"
|
||||
echo -e " Fixed: ${CYAN}$COUNT_FIXED${NC}"
|
||||
echo -e " Created: ${CYAN}$COUNT_CREATED${NC}"
|
||||
echo -e " Skipped: ${YELLOW}$COUNT_SKIPPED${NC}"
|
||||
echo -e " Failed: ${RED}$COUNT_FAILED${NC}"
|
||||
[ -n "$FAILED_LIST" ] && echo -e " Failed: ${RED}$FAILED_LIST${NC}"
|
||||
echo -e " Duration: ${ELAPSED}s"
|
||||
echo ""
|
||||
|
||||
[ "$COUNT_FAILED" -gt 0 ] && exit 1
|
||||
exit 0
|
||||
@@ -0,0 +1,109 @@
|
||||
# Resilience Harness
|
||||
|
||||
Black-box state-machine tester for archipelago app containers.
|
||||
|
||||
Drives the live RPC against a real archipelago + podman runtime on a target
|
||||
host. For each app in `app-catalog/catalog.json`, runs every state transition
|
||||
a user could trigger and asserts the system stays in the expected state.
|
||||
|
||||
## Why this exists
|
||||
|
||||
We shipped v1.7.43-alpha on .228 with three independent bugs that no unit test
|
||||
caught:
|
||||
|
||||
1. `indeedhub-api` crashlooped 8500+ times because `stacks.rs` was missing 5
|
||||
env vars (`QUEUE_HOST`/`QUEUE_PORT`/`DATABASE_PORT`/`S3_PRIVATE_BUCKET_NAME`/
|
||||
`AES_MASTER_SECRET`) — the install "succeeded" (containers running) but the
|
||||
API never became healthy.
|
||||
2. `bitcoin-ui` shipped with a stale baked-in `Authorization: Basic …` header
|
||||
from the registry image, so every `/bitcoin-rpc/` call returned 401.
|
||||
3. The container-absence scanner evicted apps from the UI 14 seconds into
|
||||
install (before image pull finished).
|
||||
|
||||
All three were exactly the kind of bug a "did the user-visible flow actually
|
||||
work end to end?" test would catch — and the kind a single-file unit test
|
||||
will never catch. This harness is the gate.
|
||||
|
||||
## Running
|
||||
|
||||
Against the .228 test node:
|
||||
|
||||
scripts/resilience/resilience.sh archipelago@192.168.1.228
|
||||
|
||||
Or non-interactive (CI):
|
||||
|
||||
RESILIENCE_SSH_PASS=… RESILIENCE_UI_PASS=… \
|
||||
scripts/resilience/resilience.sh archipelago@192.168.1.228
|
||||
|
||||
Filters:
|
||||
|
||||
# Smoke test (3 apps, no reboot, ~15min)
|
||||
scripts/resilience/resilience.sh archipelago@192.168.1.228 smoke
|
||||
|
||||
# Single app
|
||||
scripts/resilience/resilience.sh archipelago@192.168.1.228 bitcoin-knots
|
||||
|
||||
# Subset
|
||||
scripts/resilience/resilience.sh archipelago@192.168.1.228 bitcoin-knots,lnd
|
||||
|
||||
Without a filter, the harness sweeps **every** app in the catalog
|
||||
(~24 apps × 7 per-app transitions + 2 batch transitions) and runs the
|
||||
batch transitions (archipelago.service restart, host reboot) at the end.
|
||||
Full sweep is ~3-4 hours and **reboots the target host** as part of the
|
||||
run — only point it at a dedicated test node.
|
||||
|
||||
## What it tests
|
||||
|
||||
Per-app transitions:
|
||||
|
||||
| # | Transition | Pass criteria |
|
||||
|---|----------------------|------------------------------------------------|
|
||||
| 1 | install | All containers reach `running` within 10 min |
|
||||
| 2 | ui_probe | HTTP 2xx/3xx via `https://<host>/app/<id>/` |
|
||||
| 3 | auth_probe | (bitcoin-rpc only) returns 200 not 401 |
|
||||
| 4 | stop | All containers reach `exited` state |
|
||||
| 5 | start | All containers reach `running` state |
|
||||
| 6 | restart | All containers `running` after restart |
|
||||
| 7 | uninstall | All containers absent, no residue |
|
||||
|
||||
Batch transitions (full sweep only):
|
||||
|
||||
| # | Transition | Pass criteria |
|
||||
|---|-------------------------------|-------------------------------------|
|
||||
| 8 | archipelago.service restart | Container set unchanged across |
|
||||
| 9 | host reboot | Container set unchanged across |
|
||||
|
||||
Coverage by design — discovery rather than encoded metadata. The harness
|
||||
snapshots `podman ps -a` before install, again after install stabilizes,
|
||||
and the difference IS this app's container set. Works equally well for
|
||||
single-container apps and 7-container stacks (indeedhub) without per-app
|
||||
configuration.
|
||||
|
||||
## Output
|
||||
|
||||
JSON-lines results at `scripts/resilience/reports/<run_ts>/results.jsonl`:
|
||||
|
||||
{"ts":"…","app":"bitcoin-knots","transition":"install","status":"PASS","detail":"bitcoin-knots,archy-bitcoin-ui"}
|
||||
{"ts":"…","app":"bitcoin-knots","transition":"auth_probe","status":"PASS","detail":"bitcoin-rpc HTTP 200"}
|
||||
|
||||
Exit code: `0` if every cell green, `1` if any red, `2` if setup failed
|
||||
before tests began. Use as a release gate — refuse to tag if any cell red.
|
||||
|
||||
## Auth flow
|
||||
|
||||
The harness uses the same `auth.login` RPC that the UI uses, then carries
|
||||
`session=…` and `csrf_token=…` cookies plus the `X-CSRF-Token` header on
|
||||
every subsequent call. Re-logs in after archipelago.service restart and
|
||||
host reboot.
|
||||
|
||||
## Caveats / known gaps
|
||||
|
||||
- App proxy probe (`/app/<id>/`) only validates the proxy responds — for
|
||||
apps with deeper protocol behavior (lnd, fedimint, mempool) this only
|
||||
catches "container alive, proxy reachable", not "the protocol is healthy".
|
||||
- Multi-container stack assertions: the harness checks **every** new
|
||||
container is `running`, so it would catch the indeedhub-api restart loop
|
||||
while postgres/redis/minio looked fine.
|
||||
- Host reboot test is destructive and slow — runs once at end of full sweep.
|
||||
- `package.start`/`stop`/`restart` RPC methods may not exist for all apps;
|
||||
failures are recorded and the harness continues.
|
||||
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.168.1.228
|
||||
# RPC_URL — http://<host>:5678/rpc/v1
|
||||
# COOKIE_JAR — path for curl cookie store
|
||||
# SSH_PASS — sshpass password
|
||||
# UI_PASS — archipelago UI password
|
||||
# OUT_DIR — report output dir
|
||||
|
||||
# ── ssh ─────────────────────────────────────────────────────────
|
||||
ssh_run() {
|
||||
# -n: redirect stdin from /dev/null so ssh doesn't gobble up our parent's
|
||||
# stdin. Without this, ssh inside a `while read … done <<< "$LIST"`
|
||||
# consumes the heredoc on the first call, ending the loop after one
|
||||
# iteration. Cost us a smoke run that only tested filebrowser instead
|
||||
# of all three smoke apps.
|
||||
sshpass -p "$SSH_PASS" ssh -n -o StrictHostKeyChecking=accept-new \
|
||||
-o ConnectTimeout=10 -o LogLevel=ERROR "$TARGET" "$@"
|
||||
}
|
||||
|
||||
# Run a command and tolerate ssh failure (host rebooting, etc.).
|
||||
ssh_try() {
|
||||
sshpass -p "$SSH_PASS" ssh -n -o StrictHostKeyChecking=accept-new \
|
||||
-o ConnectTimeout=5 -o LogLevel=ERROR "$TARGET" "$@" 2>/dev/null || echo "__SSH_FAIL__"
|
||||
}
|
||||
|
||||
ssh_wait_ready() {
|
||||
local deadline=$(($(date +%s) + ${1:-180}))
|
||||
while [ "$(date +%s)" -lt "$deadline" ]; do
|
||||
if [ "$(ssh_try 'echo OK')" = "OK" ]; then return 0; fi
|
||||
sleep 3
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# ── rpc ─────────────────────────────────────────────────────────
|
||||
rpc_login() {
|
||||
local resp
|
||||
resp=$(curl -ksS -c "$COOKIE_JAR" -H "Content-Type: application/json" \
|
||||
-d "{\"jsonrpc\":\"2.0\",\"method\":\"auth.login\",\"params\":{\"password\":\"$UI_PASS\"},\"id\":1}" \
|
||||
"$RPC_URL")
|
||||
if echo "$resp" | jq -e '.error' >/dev/null 2>&1; then
|
||||
echo "ERROR: login failed: $(echo "$resp" | jq -c .)" >&2
|
||||
return 1
|
||||
fi
|
||||
CSRF_TOKEN=$(awk '/csrf_token/ {print $7}' "$COOKIE_JAR" | head -1)
|
||||
[ -n "$CSRF_TOKEN" ] || { echo "ERROR: no CSRF token after login" >&2; return 1; }
|
||||
export CSRF_TOKEN
|
||||
}
|
||||
|
||||
# Make an RPC call. Args: method, json_params, timeout_secs (optional, default 90).
|
||||
# Prints raw JSON response. Caller asserts success via jq.
|
||||
#
|
||||
# CSRF rotates per-response: the server may issue a new csrf_token on every
|
||||
# state-changing call, so we re-read it from the cookie jar before each call
|
||||
# rather than caching the value from login. Also retries once on nginx-served
|
||||
# BACKEND_UNAVAILABLE (5xx fallback) for transient stalls.
|
||||
rpc_call() {
|
||||
local method="$1"
|
||||
# NOTE: don't use ${2:-{}} — bash matches the first unescaped `}` as the
|
||||
# end of the expansion, so the trailing `}` becomes a literal char and
|
||||
# corrupts every params value into invalid JSON. Use an if-check instead.
|
||||
local params="${2-}"
|
||||
[ -z "$params" ] && params='{}'
|
||||
local timeout="${3:-90}"
|
||||
local attempt
|
||||
for attempt in 1 2 3 4; do
|
||||
local csrf
|
||||
csrf=$(awk '/^[^#]/ && /csrf_token/ {print $7; exit}' "$COOKIE_JAR")
|
||||
local resp
|
||||
resp=$(curl -ksS -b "$COOKIE_JAR" -c "$COOKIE_JAR" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-CSRF-Token: $csrf" \
|
||||
-d "{\"jsonrpc\":\"2.0\",\"method\":\"$method\",\"params\":$params,\"id\":1}" \
|
||||
--max-time "$timeout" \
|
||||
"$RPC_URL")
|
||||
# Retry on transient errors:
|
||||
# BACKEND_UNAVAILABLE — nginx 5xx fallback (archipelago briefly stalled)
|
||||
# 429 — nginx rate limiter exceeded (burst=40 in /etc/nginx/sites-enabled/*)
|
||||
if echo "$resp" | jq -e '.error.code == "BACKEND_UNAVAILABLE" or .error.code == 429' >/dev/null 2>&1; then
|
||||
[ "$attempt" -eq 4 ] && { echo "$resp"; return; }
|
||||
# Exponential-ish backoff: 5s, 15s, 30s. Plenty of time for the
|
||||
# nginx rate window (1s) and any archipelago restart to clear.
|
||||
sleep $((attempt * 10))
|
||||
continue
|
||||
fi
|
||||
echo "$resp"
|
||||
return
|
||||
done
|
||||
}
|
||||
|
||||
# After a service restart the session may need re-establishing.
|
||||
rpc_relogin_if_needed() {
|
||||
local probe
|
||||
probe=$(rpc_call "package.list" '{}' 2>/dev/null)
|
||||
if echo "$probe" | jq -e '.error.code == -32001' >/dev/null 2>&1; then
|
||||
rpc_login || return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# ── per-app metadata ────────────────────────────────────────────
|
||||
# Mappings the harness needs that aren't expressible from catalog.json alone:
|
||||
# multi-container stack rosters, alias/variant container names (bitcoin-knots
|
||||
# vs bitcoin-core install the same slots), and the actual nginx UI proxy path
|
||||
# (which often differs from /app/<id>/, e.g. `bitcoin-knots` → `/app/bitcoin-ui/`).
|
||||
#
|
||||
# Keep these tables in sync with the install code in package/stacks.rs and
|
||||
# the `*_IMAGE` companion handling in install.rs (the `archy-<x>-ui` set).
|
||||
|
||||
# Containers an app installs. Used for app_already_installed detection AND
|
||||
# for state assertions when the snapshot-diff falls back (variant apps don't
|
||||
# create new containers when their alternate is already present).
|
||||
expected_containers_for() {
|
||||
case "$1" in
|
||||
bitcoin-knots) echo "bitcoin-knots archy-bitcoin-ui" ;;
|
||||
bitcoin-core) echo "bitcoin-core archy-bitcoin-ui" ;;
|
||||
lnd) echo "lnd archy-lnd-ui" ;;
|
||||
electrumx|electrs|mempool-electrs)
|
||||
echo "electrs archy-electrs-ui" ;;
|
||||
btcpay-server) echo "archy-btcpay-server archy-btcpay-db archy-nbxplorer archy-btcpay-ui" ;;
|
||||
mempool) echo "mempool archy-mempool-web archy-mempool-db" ;;
|
||||
immich) echo "immich_server immich_machine_learning immich_postgres immich_redis" ;;
|
||||
penpot|penpot-frontend)
|
||||
echo "penpot-frontend penpot-backend penpot-exporter penpot-postgres penpot-redis" ;;
|
||||
indeedhub) echo "indeedhub indeedhub-api indeedhub-ffmpeg indeedhub-postgres indeedhub-redis indeedhub-minio indeedhub-relay" ;;
|
||||
*) echo "$1" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# UI proxy URL path on the HTTPS frontend. Most apps live at /app/<id>/ but
|
||||
# Bitcoin/LND/Electrs proxy through their UI companion containers, and BTCPay
|
||||
# uses its own short path.
|
||||
ui_proxy_path_for() {
|
||||
case "$1" in
|
||||
bitcoin-knots|bitcoin-core) echo "/app/bitcoin-ui/" ;;
|
||||
electrumx|electrs) echo "/app/electrumx/" ;;
|
||||
lnd) echo "/app/lnd-ui/" ;;
|
||||
btcpay-server) echo "/app/btcpay/" ;;
|
||||
*) echo "/app/$1/" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Authenticated probe for credentialed UIs. Echoes the HTTP status code if
|
||||
# defined, otherwise returns 1 (caller records SKIP). PASS = code in
|
||||
# {200,401,403} for endpoints that prove the proxy reaches the backend
|
||||
# (401/403 from app's own auth ≠ 502 from broken proxy).
|
||||
auth_probe_for() {
|
||||
local app="$1"
|
||||
local host; host="$(echo "$TARGET" | cut -d@ -f2)"
|
||||
case "$app" in
|
||||
bitcoin-knots|bitcoin-core)
|
||||
# Direct bitcoin-rpc proxy on :8334 inside .228 — credential
|
||||
# plumbing is the .228 bug we just shipped, must return 200.
|
||||
ssh_run 'curl -s -o /dev/null -w "%{http_code}" --max-time 5 -X POST http://127.0.0.1:8334/bitcoin-rpc/ -H "Content-Type: application/json" -d "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"getblockchaininfo\",\"params\":[]}"'
|
||||
;;
|
||||
btcpay-server)
|
||||
# BTCPay's own auth returns 401 for unauthenticated API calls;
|
||||
# 502 means proxy broken / backend down.
|
||||
curl -ks -o /dev/null -w "%{http_code}" --max-time 5 \
|
||||
"https://$host/app/btcpay/api/v1/server/info"
|
||||
;;
|
||||
lnd)
|
||||
# LND has a /lnd-connect-info passthrough on archipelago itself —
|
||||
# returns lndconnect URI when LND is up. 200 = backend reachable.
|
||||
curl -ks -o /dev/null -w "%{http_code}" --max-time 5 \
|
||||
"https://$host/lnd-connect-info"
|
||||
;;
|
||||
electrumx|electrs)
|
||||
# ElectrumX is plain TCP (electrum protocol) — no HTTPS auth path.
|
||||
# archipelago exposes /electrs-status which queries the daemon.
|
||||
curl -ks -o /dev/null -w "%{http_code}" --max-time 5 \
|
||||
"https://$host/electrs-status"
|
||||
;;
|
||||
*)
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Whether an auth_probe HTTP code counts as a pass.
|
||||
auth_probe_pass_codes() {
|
||||
case "$1" in
|
||||
bitcoin-knots|bitcoin-core) echo "200" ;;
|
||||
btcpay-server) echo "200 401 403" ;;
|
||||
lnd|electrumx|electrs) echo "200" ;;
|
||||
*) echo "200" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# ── probes (state assertions) ───────────────────────────────────
|
||||
# Returns container Status string ("running","exited","absent",…).
|
||||
probe_container_state() {
|
||||
local name="$1"
|
||||
ssh_run "podman inspect '$name' --format '{{.State.Status}}' 2>/dev/null || echo absent"
|
||||
}
|
||||
|
||||
# Returns RestartCount as integer.
|
||||
probe_container_restart_count() {
|
||||
local name="$1"
|
||||
ssh_run "podman inspect '$name' --format '{{.RestartCount}}' 2>/dev/null || echo -1"
|
||||
}
|
||||
|
||||
# Probe the app's UI proxy on the HTTPS frontend. Returns HTTP code.
|
||||
# Uses ui_proxy_path_for so apps with non-default proxy paths (bitcoin-ui,
|
||||
# lnd-ui, electrs-ui, btcpay) get probed at the right URL.
|
||||
probe_app_proxy() {
|
||||
local app_id="$1"
|
||||
local host
|
||||
host="$(echo "$TARGET" | cut -d@ -f2)"
|
||||
local path
|
||||
path=$(ui_proxy_path_for "$app_id")
|
||||
curl -ks -o /dev/null -w "%{http_code}" --max-time 5 "https://$host$path" || echo "000"
|
||||
}
|
||||
|
||||
# Check that ZERO containers are leftover for this app — catches uninstall residue.
|
||||
probe_no_residue() {
|
||||
local prefix="$1"
|
||||
ssh_run "podman ps -a --format '{{.Names}}' | grep -E '^${prefix}(-|$)' | wc -l"
|
||||
}
|
||||
|
||||
# ── waiters ─────────────────────────────────────────────────────
|
||||
# Wait for the package's state in the RPC list to match expected, with timeout.
|
||||
wait_for_package_state() {
|
||||
local pkg="$1"; local want="$2"; local timeout="${3:-300}"
|
||||
local deadline=$(($(date +%s) + timeout))
|
||||
while [ "$(date +%s)" -lt "$deadline" ]; do
|
||||
local got
|
||||
got=$(rpc_call "package.list" '{}' \
|
||||
| jq -r ".result.package_data[\"$pkg\"].state // \"absent\"")
|
||||
case "$want" in
|
||||
Running) [ "$got" = "Running" ] && return 0 ;;
|
||||
Stopped) [ "$got" = "Stopped" ] && return 0 ;;
|
||||
absent) [ "$got" = "absent" ] && return 0 ;;
|
||||
esac
|
||||
sleep 4
|
||||
done
|
||||
echo "TIMEOUT waiting for $pkg → $want (last seen: $got)" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
# Wait for podman state of a specific container.
|
||||
wait_for_container_state() {
|
||||
local name="$1"; local want="$2"; local timeout="${3:-180}"
|
||||
local deadline=$(($(date +%s) + timeout))
|
||||
while [ "$(date +%s)" -lt "$deadline" ]; do
|
||||
local got
|
||||
got=$(probe_container_state "$name")
|
||||
[ "$got" = "$want" ] && return 0
|
||||
sleep 3
|
||||
done
|
||||
echo "TIMEOUT waiting for container $name → $want (last seen: $got)" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
# Wait until restart count is stable for `stable_secs` seconds — proxy for "no crashloop".
|
||||
wait_restart_count_stable() {
|
||||
local name="$1"; local stable_secs="${2:-30}"; local timeout="${3:-180}"
|
||||
local deadline=$(($(date +%s) + timeout))
|
||||
local last; local last_change_ts
|
||||
last=$(probe_container_restart_count "$name")
|
||||
last_change_ts=$(date +%s)
|
||||
while [ "$(date +%s)" -lt "$deadline" ]; do
|
||||
sleep 5
|
||||
local now
|
||||
now=$(probe_container_restart_count "$name")
|
||||
if [ "$now" != "$last" ]; then
|
||||
last="$now"
|
||||
last_change_ts=$(date +%s)
|
||||
elif [ $(( $(date +%s) - last_change_ts )) -ge "$stable_secs" ]; then
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
echo "TIMEOUT waiting for $name restart-count stable (last=$last)" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
# ── result recording ────────────────────────────────────────────
|
||||
# Append a result row to the JSON-lines report.
|
||||
# Args: app_id, transition, status (PASS/FAIL/SKIP), detail
|
||||
record() {
|
||||
local app="$1"; local transition="$2"; local status="$3"; local detail="${4:-}"
|
||||
local ts
|
||||
ts=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||
jq -nc --arg ts "$ts" --arg app "$app" --arg t "$transition" --arg s "$status" --arg d "$detail" \
|
||||
'{ts:$ts, app:$app, transition:$t, status:$s, detail:$d}' >> "$OUT_DIR/results.jsonl"
|
||||
local marker
|
||||
case "$status" in
|
||||
PASS) marker="✅" ;;
|
||||
FAIL) marker="❌" ;;
|
||||
SKIP) marker="⏭" ;;
|
||||
*) marker="•" ;;
|
||||
esac
|
||||
printf '%s [%-15s] %-30s %s%s\n' "$marker" "$app" "$transition" "$status" "${detail:+ — $detail}"
|
||||
}
|
||||
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.168.1.228 [filter]
|
||||
#
|
||||
# `filter` is a comma-separated list of app IDs (or "smoke" for the curated
|
||||
# fast subset). Default: every app in app-catalog/catalog.json.
|
||||
#
|
||||
# Exit codes:
|
||||
# 0 every cell green
|
||||
# 1 any cell red — release should not ship
|
||||
# 2 setup/auth error before tests began
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
# ── args ─────────────────────────────────────────────────────────
|
||||
TARGET="${1:?usage: $0 <user@host> [filter]}"
|
||||
FILTER="${2:-}"
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||
HERE="$ROOT/scripts/resilience"
|
||||
RUN_TS="$(date -u +%Y%m%dT%H%M%SZ)"
|
||||
OUT_DIR="$HERE/reports/$RUN_TS"
|
||||
mkdir -p "$OUT_DIR"
|
||||
COOKIE_JAR="$OUT_DIR/cookies.txt"
|
||||
|
||||
HOST="$(echo "$TARGET" | cut -d@ -f2)"
|
||||
# RPC reaches archipelago through nginx on 443 (which proxies to localhost:5678).
|
||||
# Direct :5678 is bound to 127.0.0.1 on the target so we can't curl it from here.
|
||||
RPC_URL="https://$HOST/rpc/v1"
|
||||
|
||||
export TARGET RPC_URL COOKIE_JAR OUT_DIR
|
||||
|
||||
# shellcheck source=lib.sh
|
||||
. "$HERE/lib.sh"
|
||||
|
||||
# ── credentials ──────────────────────────────────────────────────
|
||||
# Pull from env first (so this script can be called from CI). Fall back to
|
||||
# interactive prompts.
|
||||
SSH_PASS="${RESILIENCE_SSH_PASS:-}"
|
||||
UI_PASS="${RESILIENCE_UI_PASS:-}"
|
||||
if [ -z "$SSH_PASS" ]; then
|
||||
read -rsp "SSH password for $TARGET: " SSH_PASS; echo
|
||||
fi
|
||||
if [ -z "$UI_PASS" ]; then
|
||||
read -rsp "Archipelago UI password: " UI_PASS; echo
|
||||
fi
|
||||
export SSH_PASS UI_PASS
|
||||
|
||||
command -v sshpass >/dev/null || { echo "sshpass required"; exit 2; }
|
||||
command -v jq >/dev/null || { echo "jq required"; exit 2; }
|
||||
|
||||
ssh_run 'echo ok' >/dev/null || { echo "ssh to $TARGET failed"; exit 2; }
|
||||
rpc_login || exit 2
|
||||
|
||||
echo "Resilience harness — target $TARGET, run $RUN_TS"
|
||||
echo "Output: $OUT_DIR/results.jsonl"
|
||||
echo "─────────────────────────────────────────────────────────────"
|
||||
|
||||
# ── catalog & filter ─────────────────────────────────────────────
|
||||
CATALOG="$ROOT/app-catalog/catalog.json"
|
||||
ALL_APPS=$(jq -r '.apps[].id' "$CATALOG")
|
||||
|
||||
# Topo-sort the catalog by `requires`. Outputs app IDs in install order
|
||||
# (deps first, then dependents). Kahn's algorithm via python — keeps the
|
||||
# bash side simple and the deps logic obvious for next-time-readers.
|
||||
topo_order() {
|
||||
python3 -c "
|
||||
import json
|
||||
with open('$CATALOG') as f: c = json.load(f)
|
||||
deps = {a['id']: list(a.get('requires', [])) for a in c['apps']}
|
||||
order = []
|
||||
remaining = set(deps)
|
||||
while remaining:
|
||||
ready = sorted(a for a in remaining if all(d not in remaining for d in deps[a]))
|
||||
if not ready: # cycle (shouldn't happen) — emit whatever's left
|
||||
order.extend(sorted(remaining)); break
|
||||
order.extend(ready); remaining.difference_update(ready)
|
||||
print('\n'.join(order))
|
||||
"
|
||||
}
|
||||
|
||||
apps_to_test() {
|
||||
local order; order=$(topo_order)
|
||||
if [ -z "$FILTER" ]; then
|
||||
# Full sweep — but skip bitcoin-core since it shares container slots
|
||||
# with bitcoin-knots; testing both back-to-back would just churn the
|
||||
# same containers. bitcoin-knots is the canonical entry.
|
||||
echo "$order" | grep -v '^bitcoin-core$'
|
||||
elif [ "$FILTER" = "smoke" ]; then
|
||||
# Fast subset exercising the bug classes we just fixed:
|
||||
# single-container, multi-container stack, credentialed UI.
|
||||
echo -e "filebrowser\nbitcoin-knots\nindeedhub"
|
||||
else
|
||||
echo "$order" | grep -E "^($(echo "$FILTER" | tr ',' '|'))$"
|
||||
fi
|
||||
}
|
||||
|
||||
# Resolve `requires` chain for $1 in install-order (deps first).
|
||||
deps_for_app() {
|
||||
local app="$1"
|
||||
python3 -c "
|
||||
import json
|
||||
with open('$CATALOG') as f: c = json.load(f)
|
||||
deps_map = {a['id']: list(a.get('requires', [])) for a in c['apps']}
|
||||
visited, order = set(), []
|
||||
def visit(x):
|
||||
if x in visited or x not in deps_map: return
|
||||
visited.add(x)
|
||||
for d in deps_map.get(x, []): visit(d)
|
||||
order.append(x)
|
||||
for d in deps_map.get('$app', []): visit(d)
|
||||
print('\n'.join(order))
|
||||
"
|
||||
}
|
||||
|
||||
# ── per-app transitions ──────────────────────────────────────────
|
||||
# Diff helper: capture container names matching a sane prefix for $app_id.
|
||||
# Approach: snapshot before install, snapshot after, take the difference =
|
||||
# this app's containers.
|
||||
snapshot_containers() {
|
||||
ssh_run "podman ps -a --format '{{.Names}}' | sort"
|
||||
}
|
||||
|
||||
# Whether $app currently has ALL of its expected containers running. Uses
|
||||
# the per-app metadata table in lib.sh (expected_containers_for) so variant
|
||||
# apps (bitcoin-knots/bitcoin-core sharing slots) and stacks are detected
|
||||
# correctly. Falls back to name-prefix match for apps the table doesn't know.
|
||||
#
|
||||
# Returns true only when every expected container is present. Earlier
|
||||
# versions returned true on ANY match — that caused dep installs (e.g.
|
||||
# bitcoin-knots required by btcpay) to be declared "installed" as soon as
|
||||
# the backend container appeared, before the UI companion (archy-bitcoin-ui)
|
||||
# was up. The before-snapshot then missed the companion, the after-snapshot
|
||||
# caught it, and it leaked into the dependent app's "new containers" set,
|
||||
# false-positive-FAILing stop/uninstall when the companion (correctly) did
|
||||
# not respond to the dependent app's package.stop.
|
||||
app_already_installed() {
|
||||
local app="$1"
|
||||
local snap; snap=$(snapshot_containers)
|
||||
local expected
|
||||
expected=$(expected_containers_for "$app")
|
||||
if [ -n "$expected" ] && [ "$expected" != "$app" ]; then
|
||||
local c missing=0
|
||||
for c in $expected; do
|
||||
echo "$snap" | grep -qxF "$c" || missing=1
|
||||
done
|
||||
[ "$missing" -eq 0 ] && return 0
|
||||
# Fall through to prefix match if the expected_containers list has
|
||||
# gaps; a partial install still counts as "installed enough" for
|
||||
# preclean purposes.
|
||||
fi
|
||||
# Generic prefix fallback for apps not in the expected_containers_for table.
|
||||
echo "$snap" | grep -qE "^(${app}|${app}-|archy-${app}|archy-${app}-)"
|
||||
}
|
||||
|
||||
# Install missing deps for $app via the regular install path. Idempotent —
|
||||
# already-installed deps are skipped. Records dep_install per dep so we can
|
||||
# tell from the report whether the bitcoin pre-req was actually green by the
|
||||
# time lnd's matrix started.
|
||||
ensure_deps_installed() {
|
||||
local app="$1"
|
||||
local dep
|
||||
for dep in $(deps_for_app "$app"); do
|
||||
if app_already_installed "$dep"; then
|
||||
continue
|
||||
fi
|
||||
echo " · dep install: $dep (required by $app)"
|
||||
local img ver resp
|
||||
img=$(jq -r --arg id "$dep" '.apps[] | select(.id==$id) | .dockerImage // ""' "$CATALOG")
|
||||
ver=$(jq -r --arg id "$dep" '.apps[] | select(.id==$id) | .version // ""' "$CATALOG")
|
||||
if [ -z "$img" ]; then
|
||||
record "$app" "dep_$dep" FAIL "no dockerImage in catalog for dep $dep"
|
||||
return 1
|
||||
fi
|
||||
resp=$(rpc_call "package.install" "$(jq -nc \
|
||||
--arg id "$dep" --arg img "$img" --arg ver "$ver" \
|
||||
'{id:$id, dockerImage:$img, version:$ver}')")
|
||||
if echo "$resp" | jq -e '.error' >/dev/null 2>&1; then
|
||||
record "$app" "dep_$dep" FAIL "rpc error: $(echo "$resp" | jq -c '.error')"
|
||||
return 1
|
||||
fi
|
||||
# Wait for at least one expected container to appear running.
|
||||
local deadline=$(($(date +%s) + 600))
|
||||
while [ "$(date +%s)" -lt "$deadline" ]; do
|
||||
if app_already_installed "$dep"; then
|
||||
record "$app" "dep_$dep" PASS "installed"
|
||||
break
|
||||
fi
|
||||
sleep 5
|
||||
done
|
||||
if ! app_already_installed "$dep"; then
|
||||
record "$app" "dep_$dep" FAIL "containers did not appear within 10min"
|
||||
return 1
|
||||
fi
|
||||
done
|
||||
return 0
|
||||
}
|
||||
|
||||
# Pre-clean: if the app is currently installed, uninstall it and wait for
|
||||
# all containers to disappear. We can't measure install correctness without
|
||||
# starting from a clean slate. Fail-soft — if the uninstall RPC errors we
|
||||
# log but proceed; the install step will catch any residual state.
|
||||
preclean_app() {
|
||||
local app="$1"
|
||||
if ! app_already_installed "$app"; then
|
||||
return 0
|
||||
fi
|
||||
echo " · pre-clean: $app already installed, uninstalling first"
|
||||
local resp; resp=$(rpc_call "package.uninstall" "{\"id\":\"$app\"}")
|
||||
if echo "$resp" | jq -e '.error' >/dev/null 2>&1; then
|
||||
echo " pre-clean uninstall RPC error: $(echo "$resp" | jq -c '.error')"
|
||||
fi
|
||||
# Multi-container stacks (indeedhub: 7, immich: 5, mempool: 3, btcpay: 6)
|
||||
# take noticeably longer to tear down than single-container apps. 240s was
|
||||
# too tight for indeedhub's 7-container teardown — bump to 10 min for
|
||||
# safety; per-container timeout is still bounded inside archipelago itself.
|
||||
local deadline=$(($(date +%s) + 600))
|
||||
while [ "$(date +%s)" -lt "$deadline" ]; do
|
||||
if ! app_already_installed "$app"; then return 0; fi
|
||||
sleep 5
|
||||
done
|
||||
echo " pre-clean: timeout waiting for $app to uninstall"
|
||||
return 1
|
||||
}
|
||||
|
||||
# Run the full per-app matrix. Records a row per transition.
|
||||
run_app_matrix() {
|
||||
local app="$1"
|
||||
echo
|
||||
echo "═══ $app ═══"
|
||||
|
||||
if ! ensure_deps_installed "$app"; then
|
||||
record "$app" install FAIL "dep install failed; skipping rest of matrix"
|
||||
return
|
||||
fi
|
||||
preclean_app "$app" || record "$app" preclean FAIL "uninstall before test did not complete"
|
||||
|
||||
# ── 01 install ───────────────────────────────────────────────
|
||||
local before after new_containers
|
||||
before=$(snapshot_containers)
|
||||
# The install handler requires `id` + `dockerImage` from the catalog
|
||||
# entry. Match what the UI passes (Discover.vue / MarketplaceAppDetails.vue).
|
||||
local docker_image version
|
||||
docker_image=$(jq -r --arg id "$app" '.apps[] | select(.id==$id) | .dockerImage // ""' "$CATALOG")
|
||||
version=$(jq -r --arg id "$app" '.apps[] | select(.id==$id) | .version // ""' "$CATALOG")
|
||||
if [ -z "$docker_image" ]; then
|
||||
record "$app" install FAIL "no dockerImage in catalog for $app"
|
||||
return
|
||||
fi
|
||||
local install_resp
|
||||
install_resp=$(rpc_call "package.install" "$(jq -nc \
|
||||
--arg id "$app" --arg img "$docker_image" --arg ver "$version" \
|
||||
'{id:$id, dockerImage:$img, version:$ver}')")
|
||||
if echo "$install_resp" | jq -e '.error' >/dev/null 2>&1; then
|
||||
record "$app" install FAIL "rpc error: $(echo "$install_resp" | jq -c '.error')"
|
||||
return # cannot continue this app
|
||||
fi
|
||||
|
||||
# Wait for the EXPECTED containers (per expected_containers_for) to all
|
||||
# appear. The old "snapshot stable for 10s + count > before" heuristic
|
||||
# terminated early on apps with deps: e.g. mempool's wait would break
|
||||
# when archy-electrs-ui (electrumx dep companion) appeared, long before
|
||||
# mempool's own containers were created (those take ~10min to pull and
|
||||
# start). Waiting on the expected-set is exact, not heuristic.
|
||||
#
|
||||
# Cap at 15 minutes — mempool stack with cold image cache needs ~12 min.
|
||||
local expected; expected=$(expected_containers_for "$app")
|
||||
local deadline=$(($(date +%s) + 900))
|
||||
while [ "$(date +%s)" -lt "$deadline" ]; do
|
||||
after=$(snapshot_containers)
|
||||
local missing=0
|
||||
for c in $expected; do
|
||||
echo "$after" | grep -qxF "$c" || missing=1
|
||||
done
|
||||
[ "$missing" -eq 0 ] && break
|
||||
sleep 5
|
||||
done
|
||||
new_containers=$(comm -13 <(echo "$before") <(echo "$after"))
|
||||
if [ -z "$new_containers" ]; then
|
||||
record "$app" install FAIL "no containers created within 10min"
|
||||
return
|
||||
fi
|
||||
# Assert each new container is in 'running' state.
|
||||
local install_ok=1; local detail=""
|
||||
while read -r c; do
|
||||
[ -z "$c" ] && continue
|
||||
local s
|
||||
s=$(probe_container_state "$c")
|
||||
if [ "$s" != "running" ]; then
|
||||
install_ok=0
|
||||
detail="$detail $c=$s"
|
||||
fi
|
||||
done <<< "$new_containers"
|
||||
if [ "$install_ok" -eq 1 ]; then
|
||||
record "$app" install PASS "$(echo "$new_containers" | tr '\n' ',' | sed 's/,$//')"
|
||||
else
|
||||
record "$app" install FAIL "containers not running:$detail"
|
||||
fi
|
||||
|
||||
# ── 02 ui_probe ──────────────────────────────────────────────
|
||||
# Retry with backoff — install just finished, but the app's backend
|
||||
# (fedimint, immich, mempool stack) may take 30+s to be ready to serve
|
||||
# HTTP. Probing immediately false-positive-FAILed those apps; pass on
|
||||
# first 2xx/3xx within 60s.
|
||||
local code
|
||||
local ui_deadline=$(($(date +%s) + 60))
|
||||
while :; do
|
||||
code=$(probe_app_proxy "$app")
|
||||
[[ "$code" =~ ^(2[0-9][0-9]|3[0-9][0-9])$ ]] && break
|
||||
[ "$(date +%s)" -ge "$ui_deadline" ] && break
|
||||
sleep 5
|
||||
done
|
||||
# Accept all 2xx/3xx — proxy reaches backend, app may redirect to login,
|
||||
# serve OAuth flow (307), or use 308 permanent. 401/403 still fail because
|
||||
# those mean "backend reached, app rejected request" which is the
|
||||
# credential-plumbing failure mode we DO want to catch.
|
||||
if [[ "$code" =~ ^(2[0-9][0-9]|3[0-9][0-9])$ ]]; then
|
||||
record "$app" ui_probe PASS "HTTP $code"
|
||||
else
|
||||
record "$app" ui_probe FAIL "HTTP $code (expected 2xx/3xx, retried 60s)"
|
||||
fi
|
||||
|
||||
# ── 03 auth_probe (only for apps with a credentialed/data endpoint) ──
|
||||
# Same backoff treatment: bitcoin-ui's nginx config bind-mount is
|
||||
# picked up at start, but the bitcoin-core backend may not have
|
||||
# accepted RPC connections yet on a fresh install.
|
||||
local probe_code; local pass_codes
|
||||
pass_codes=$(auth_probe_pass_codes "$app")
|
||||
if probe_code=$(auth_probe_for "$app" 2>/dev/null) && [ -n "$probe_code" ]; then
|
||||
local auth_deadline=$(($(date +%s) + 60))
|
||||
while :; do
|
||||
echo " $pass_codes " | grep -qF " $probe_code " && break
|
||||
[ "$(date +%s)" -ge "$auth_deadline" ] && break
|
||||
sleep 5
|
||||
probe_code=$(auth_probe_for "$app" 2>/dev/null) || break
|
||||
done
|
||||
if echo " $pass_codes " | grep -qF " $probe_code "; then
|
||||
record "$app" auth_probe PASS "HTTP $probe_code"
|
||||
else
|
||||
record "$app" auth_probe FAIL "HTTP $probe_code (expected one of: $pass_codes; retried 60s — credential plumbing broken)"
|
||||
fi
|
||||
else
|
||||
record "$app" auth_probe SKIP "no authenticated probe defined"
|
||||
fi
|
||||
|
||||
# ── 04 stop ──────────────────────────────────────────────────
|
||||
local stop_resp
|
||||
stop_resp=$(rpc_call "package.stop" "{\"id\":\"$app\"}")
|
||||
if echo "$stop_resp" | jq -e '.error' >/dev/null 2>&1; then
|
||||
record "$app" stop FAIL "rpc error: $(echo "$stop_resp" | jq -c '.error')"
|
||||
else
|
||||
local all_stopped=1
|
||||
while read -r c; do
|
||||
[ -z "$c" ] && continue
|
||||
wait_for_container_state "$c" "exited" 60 || all_stopped=0
|
||||
done <<< "$new_containers"
|
||||
if [ "$all_stopped" -eq 1 ]; then
|
||||
record "$app" stop PASS
|
||||
else
|
||||
record "$app" stop FAIL "not all containers reached exited state"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── 05 start ─────────────────────────────────────────────────
|
||||
local start_resp
|
||||
start_resp=$(rpc_call "package.start" "{\"id\":\"$app\"}")
|
||||
if echo "$start_resp" | jq -e '.error' >/dev/null 2>&1; then
|
||||
record "$app" start FAIL "rpc error: $(echo "$start_resp" | jq -c '.error')"
|
||||
else
|
||||
local all_started=1
|
||||
while read -r c; do
|
||||
[ -z "$c" ] && continue
|
||||
wait_for_container_state "$c" "running" 90 || all_started=0
|
||||
done <<< "$new_containers"
|
||||
if [ "$all_started" -eq 1 ]; then
|
||||
record "$app" start PASS
|
||||
else
|
||||
record "$app" start FAIL "not all containers reached running state"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── 06 restart_container ─────────────────────────────────────
|
||||
# `package.restart` returns immediately and spawns the actual restart.
|
||||
# `podman restart -t <stop_timeout>` blocks for up to stop_timeout
|
||||
# seconds (e.g. 600s for bitcoin-core). Polling once after sleep 5
|
||||
# races on slow-stopping apps and false-positive-FAILs them. Poll
|
||||
# each container up to 90s for "running" instead.
|
||||
local restart_resp
|
||||
restart_resp=$(rpc_call "package.restart" "{\"id\":\"$app\"}")
|
||||
if echo "$restart_resp" | jq -e '.error' >/dev/null 2>&1; then
|
||||
record "$app" restart FAIL "rpc error: $(echo "$restart_resp" | jq -c '.error')"
|
||||
else
|
||||
local all_running=1
|
||||
while read -r c; do
|
||||
[ -z "$c" ] && continue
|
||||
wait_for_container_state "$c" "running" 90 || all_running=0
|
||||
done <<< "$new_containers"
|
||||
if [ "$all_running" -eq 1 ]; then
|
||||
record "$app" restart PASS
|
||||
else
|
||||
record "$app" restart FAIL "container not running 90s after restart"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── 09 uninstall (skip 07 archipelago-restart and 08 host-reboot
|
||||
# here — those are batch tests run once across all installed apps) ─
|
||||
local uninst_resp
|
||||
uninst_resp=$(rpc_call "package.uninstall" "{\"id\":\"$app\"}")
|
||||
if echo "$uninst_resp" | jq -e '.error' >/dev/null 2>&1; then
|
||||
record "$app" uninstall FAIL "rpc error: $(echo "$uninst_resp" | jq -c '.error')"
|
||||
else
|
||||
# Wait for all this-app containers to be absent.
|
||||
local all_gone=1
|
||||
while read -r c; do
|
||||
[ -z "$c" ] && continue
|
||||
wait_for_container_state "$c" "absent" 120 || all_gone=0
|
||||
done <<< "$new_containers"
|
||||
if [ "$all_gone" -eq 1 ]; then
|
||||
record "$app" uninstall PASS
|
||||
else
|
||||
record "$app" uninstall FAIL "not all containers removed"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# ── batch transitions (run after per-app loop) ───────────────────
|
||||
batch_archipelago_service_restart() {
|
||||
echo
|
||||
echo "═══ batch: archipelago.service restart ═══"
|
||||
local before; before=$(snapshot_containers)
|
||||
if ! ssh_run 'sudo systemctl restart archipelago'; then
|
||||
record "_batch" archipelago_restart FAIL "systemctl restart errored"
|
||||
return
|
||||
fi
|
||||
ssh_wait_ready 60 || { record "_batch" archipelago_restart FAIL "ssh did not return"; return; }
|
||||
sleep 30 # let containers re-stabilize
|
||||
rpc_login || { record "_batch" archipelago_restart FAIL "rpc relogin failed"; return; }
|
||||
local after; after=$(snapshot_containers)
|
||||
if [ "$before" = "$after" ]; then
|
||||
record "_batch" archipelago_restart PASS "container set unchanged"
|
||||
else
|
||||
record "_batch" archipelago_restart FAIL "container set drifted across restart"
|
||||
fi
|
||||
}
|
||||
|
||||
batch_host_reboot() {
|
||||
echo
|
||||
echo "═══ batch: host reboot ═══"
|
||||
local before; before=$(snapshot_containers)
|
||||
ssh_run 'sudo systemctl reboot' || true # ssh disconnects immediately
|
||||
sleep 30
|
||||
# 5 min was too short — .228 took ~9min for full BIOS+kernel+systemd+
|
||||
# rootless-podman boot. 12 min gives margin for slower hardware.
|
||||
ssh_wait_ready 720 || { record "_batch" host_reboot FAIL "host did not come back in 12min"; return; }
|
||||
sleep 60 # let containers auto-restart
|
||||
rpc_login || { record "_batch" host_reboot FAIL "rpc unreachable after reboot"; return; }
|
||||
local after; after=$(snapshot_containers)
|
||||
if [ "$before" = "$after" ]; then
|
||||
record "_batch" host_reboot PASS "all containers came back"
|
||||
else
|
||||
local missing
|
||||
missing=$(comm -23 <(echo "$before") <(echo "$after") | tr '\n' ',' | sed 's/,$//')
|
||||
record "_batch" host_reboot FAIL "missing: $missing"
|
||||
fi
|
||||
|
||||
# ── L3 per-boot health gate ──────────────────────────────────
|
||||
# Container-set equality proves the right containers exist; os-audit proves
|
||||
# the node is actually *healthy* after the reboot: RPC up, OTA not wedged
|
||||
# (FM12), every app reachable with valid launch metadata, FM-guards green.
|
||||
# This is the per-boot building block os-audit.sh was written to be.
|
||||
if [ -x "$ROOT/tests/lifecycle/os-audit.sh" ]; then
|
||||
echo "── per-boot os-audit gate ──"
|
||||
if ARCHY_HOST="$HOST" ARCHY_SCHEME=https ARCHY_PASSWORD="$UI_PASS" ARCHY_LOCAL=0 \
|
||||
"$ROOT/tests/lifecycle/os-audit.sh" >"$OUT_DIR/os-audit-postboot.log" 2>&1; then
|
||||
record "_batch" host_reboot_osaudit PASS "os-audit green after reboot"
|
||||
else
|
||||
record "_batch" host_reboot_osaudit FAIL "os-audit not green after reboot (see $OUT_DIR/os-audit-postboot.log)"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# ── main ─────────────────────────────────────────────────────────
|
||||
APPS_LIST=$(apps_to_test)
|
||||
if [ -z "$APPS_LIST" ]; then
|
||||
echo "no apps match filter '$FILTER'" >&2; exit 2
|
||||
fi
|
||||
|
||||
while read -r app; do
|
||||
[ -z "$app" ] && continue
|
||||
run_app_matrix "$app"
|
||||
done <<< "$APPS_LIST"
|
||||
|
||||
# Batch transitions only run on full sweep (skip in filtered/smoke mode).
|
||||
if [ -z "$FILTER" ]; then
|
||||
batch_archipelago_service_restart
|
||||
batch_host_reboot
|
||||
fi
|
||||
|
||||
# ── summary ──────────────────────────────────────────────────────
|
||||
echo
|
||||
echo "═══ summary ═══"
|
||||
count_status() {
|
||||
local pat="$1"
|
||||
[ -s "$OUT_DIR/results.jsonl" ] || { echo 0; return; }
|
||||
awk -v pat="$pat" '$0 ~ pat { n++ } END { print n+0 }' "$OUT_DIR/results.jsonl"
|
||||
}
|
||||
PASS=$(count_status '"status":"PASS"')
|
||||
FAIL=$(count_status '"status":"FAIL"')
|
||||
SKIP=$(count_status '"status":"SKIP"')
|
||||
TOTAL=$((PASS + FAIL + SKIP))
|
||||
echo "PASS: $PASS / FAIL: $FAIL / SKIP: $SKIP / TOTAL: $TOTAL"
|
||||
echo "Report: $OUT_DIR/results.jsonl"
|
||||
|
||||
[ "$FAIL" -eq 0 ] || exit 1
|
||||
exit 0
|
||||
@@ -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
+496
@@ -0,0 +1,496 @@
|
||||
#!/usr/bin/env bash
|
||||
# Post-install + onboarding + container lifecycle E2E tests.
|
||||
# Run on an installed Archipelago node (SSH or local).
|
||||
#
|
||||
# Usage: bash run-post-install-tests.sh [password]
|
||||
# bash run-post-install-tests.sh --phase1-only # Install checks only (no auth)
|
||||
#
|
||||
# Tests:
|
||||
# Phase 1: Install verification (services, files, logs) — safe, no side effects
|
||||
# Phase 2: Onboarding (password setup, auth flow) — creates user account
|
||||
# Phase 3: Container lifecycle (install 3 apps, start/stop/health) — needs auth
|
||||
set -u
|
||||
|
||||
PHASE1_ONLY=false
|
||||
PASSWORD="testpass123!"
|
||||
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--phase1-only) PHASE1_ONLY=true ;;
|
||||
*) PASSWORD="$arg" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
BASE="http://127.0.0.1:5678"
|
||||
JAR="/tmp/e2e-cookies.txt"
|
||||
rm -f "$JAR"
|
||||
PC=0; FC=0; SC=0
|
||||
|
||||
pass() { PC=$((PC + 1)); printf "\033[32m ✓ %s\033[0m\n" "$1"; }
|
||||
fail() { FC=$((FC + 1)); printf "\033[31m ✗ %s — %s\033[0m\n" "$1" "${2:-}"; }
|
||||
skip() { SC=$((SC + 1)); printf "\033[33m ⊘ %s\033[0m\n" "$1"; }
|
||||
section() { printf "\n\033[1m━━━ %s ━━━\033[0m\n" "$1"; }
|
||||
|
||||
# Extract CSRF token from cookie jar
|
||||
get_csrf() {
|
||||
grep 'csrf_token' "$JAR" 2>/dev/null | awk '{print $NF}'
|
||||
}
|
||||
|
||||
rpc() {
|
||||
local method="$1"
|
||||
local params="${2:-"{}"}"
|
||||
local csrf
|
||||
csrf=$(get_csrf)
|
||||
local csrf_header=""
|
||||
if [ -n "$csrf" ]; then
|
||||
csrf_header="-H X-CSRF-Token:${csrf}"
|
||||
fi
|
||||
curl -s -b "$JAR" -c "$JAR" \
|
||||
-H "Content-Type: application/json" \
|
||||
$csrf_header \
|
||||
-d "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"$method\",\"params\":$params}" \
|
||||
"${BASE}/rpc/v1" 2>/dev/null
|
||||
}
|
||||
|
||||
rpc_ok() {
|
||||
local resp="$1"
|
||||
[ -z "$resp" ] && return 1
|
||||
echo "$resp" | grep -q '"error":null' && return 0
|
||||
echo "$resp" | grep -q '"error"' && return 1
|
||||
return 0
|
||||
}
|
||||
|
||||
rpc_result() {
|
||||
echo "$1" | python3 -c "import sys,json; d=json.load(sys.stdin); print(json.dumps(d.get('result','')))" 2>/dev/null
|
||||
}
|
||||
|
||||
wait_for_server() {
|
||||
local max_wait=60
|
||||
local waited=0
|
||||
while [ $waited -lt $max_wait ]; do
|
||||
if curl -sf "${BASE}/health" >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
sleep 2
|
||||
waited=$((waited + 2))
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
echo ""
|
||||
echo "╔══════════════════════════════════════════════╗"
|
||||
echo "║ Archipelago Post-Install E2E Test Suite ║"
|
||||
echo "╚══════════════════════════════════════════════╝"
|
||||
echo ""
|
||||
echo "Target: ${BASE}"
|
||||
echo "Date: $(date -u '+%Y-%m-%d %H:%M:%S UTC')"
|
||||
|
||||
# ═══════════════════════════════════════════
|
||||
# PHASE 1: Install Verification
|
||||
# ═══════════════════════════════════════════
|
||||
section "Phase 1: Install Verification"
|
||||
|
||||
# 1.1 — Critical files exist
|
||||
for f in /usr/local/bin/archipelago \
|
||||
/opt/archipelago/web-ui/index.html \
|
||||
/etc/nginx/sites-available/archipelago \
|
||||
/etc/archipelago/ssl/archipelago.crt \
|
||||
/opt/archipelago/scripts/image-versions.sh; do
|
||||
if [ -f "$f" ]; then
|
||||
pass "File exists: $f"
|
||||
else
|
||||
fail "File missing" "$f"
|
||||
fi
|
||||
done
|
||||
|
||||
# 1.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
+87
@@ -0,0 +1,87 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Run all Archipelago tests: frontend (local) + backend (dev server via SSH).
|
||||
# Exit 0 only if both pass.
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
SSH_KEY="${ARCHIPELAGO_SSH_KEY:-$HOME/.ssh/archipelago-deploy}"
|
||||
SSH_HOST="${ARCHIPELAGO_SSH_HOST:-archipelago@192.168.1.228}"
|
||||
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 (on dev server) ---
|
||||
echo "--- Backend Tests (dev server) ---"
|
||||
|
||||
# Sync source to server
|
||||
echo "Syncing source to dev server..."
|
||||
rsync -az --exclude 'target' --exclude 'node_modules' --exclude '.git' \
|
||||
-e "ssh -i $SSH_KEY" \
|
||||
"$PROJECT_DIR/core/" "$SSH_HOST:~/archy/core/" 2>&1
|
||||
|
||||
# Run tests on server
|
||||
if ssh -i "$SSH_KEY" "$SSH_HOST" \
|
||||
"source ~/.cargo/env && cd ~/archy/core && cargo test -p archipelago 2>&1"; then
|
||||
echo "✅ Backend unit tests PASSED"
|
||||
BACKEND_OK=1
|
||||
else
|
||||
echo "❌ Backend unit tests FAILED"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
# --- Integration Tests ---
|
||||
echo "--- Integration Tests (dev server) ---"
|
||||
if ssh -i "$SSH_KEY" "$SSH_HOST" \
|
||||
"source ~/.cargo/env && cd ~/archy/core && cargo test --test rpc_integration 2>&1"; then
|
||||
echo "✅ Integration tests PASSED"
|
||||
else
|
||||
echo "❌ Integration tests FAILED"
|
||||
BACKEND_OK=0
|
||||
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
+410
@@ -0,0 +1,410 @@
|
||||
#!/bin/bash
|
||||
# Self-update: pull latest code from the OVH Gitea (146.59.87.168:3000) and apply
|
||||
# Designed to run on installed Archipelago nodes (as archipelago user)
|
||||
#
|
||||
# Usage:
|
||||
# ./self-update.sh # Check + apply if available
|
||||
# ./self-update.sh --check # Check only, don't apply
|
||||
# ./self-update.sh --force # Apply even if already up to date
|
||||
#
|
||||
# The script:
|
||||
# 1. Pulls latest code from origin (146.59.87.168:3000)
|
||||
# 2. Builds the Rust backend (release mode)
|
||||
# 3. Builds the Vue frontend (production mode)
|
||||
# 4. Installs the new binary and web UI
|
||||
# 5. Restarts the archipelago service
|
||||
# 6. Verifies health after restart
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
REPO_DIR="$HOME/archy"
|
||||
BACKEND_DIR="$REPO_DIR/core"
|
||||
FRONTEND_DIR="$REPO_DIR/neode-ui"
|
||||
INSTALL_BIN="/usr/local/bin/archipelago"
|
||||
INSTALL_WEB="/opt/archipelago/web-ui"
|
||||
STATE_FILE="/var/lib/archipelago/update_state.json"
|
||||
LOG_FILE="/var/lib/archipelago/update.log"
|
||||
LOCK_FILE="/tmp/archipelago-update.lock"
|
||||
|
||||
# Colors
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m'
|
||||
|
||||
log() { echo -e "${BLUE}[$(date '+%H:%M:%S')]${NC} $*" | tee -a "$LOG_FILE"; }
|
||||
ok() { echo -e "${GREEN}[$(date '+%H:%M:%S')] OK${NC} $*" | tee -a "$LOG_FILE"; }
|
||||
err() { echo -e "${RED}[$(date '+%H:%M:%S')] ERROR${NC} $*" | tee -a "$LOG_FILE"; }
|
||||
warn(){ echo -e "${YELLOW}[$(date '+%H:%M:%S')] WARN${NC} $*" | tee -a "$LOG_FILE"; }
|
||||
|
||||
cleanup() {
|
||||
rm -f "$LOCK_FILE"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
# Prevent concurrent updates
|
||||
if [ -f "$LOCK_FILE" ]; then
|
||||
pid=$(cat "$LOCK_FILE" 2>/dev/null)
|
||||
if kill -0 "$pid" 2>/dev/null; then
|
||||
err "Update already in progress (PID $pid)"
|
||||
exit 1
|
||||
fi
|
||||
warn "Stale lock file found, removing"
|
||||
rm -f "$LOCK_FILE"
|
||||
fi
|
||||
echo $$ > "$LOCK_FILE"
|
||||
|
||||
# Parse args
|
||||
CHECK_ONLY=false
|
||||
FORCE=false
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--check) CHECK_ONLY=true; shift ;;
|
||||
--force) FORCE=true; shift ;;
|
||||
*) shift ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Ensure repo exists
|
||||
if [ ! -d "$REPO_DIR/.git" ]; then
|
||||
err "Repo not found at $REPO_DIR"
|
||||
err "Clone it first: git clone http://146.59.87.168:3000/lfg2025/archy ~/archy"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cd "$REPO_DIR"
|
||||
|
||||
if ! command -v nano >/dev/null 2>&1; then
|
||||
log "Installing nano for Archipelago terminal..."
|
||||
if sudo apt-get update -qq 2>>"$LOG_FILE" && sudo apt-get install -y -qq nano 2>>"$LOG_FILE"; then
|
||||
ok "nano installed"
|
||||
else
|
||||
warn "Unable to install nano automatically; continuing update"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Fetch latest
|
||||
log "Fetching from origin..."
|
||||
git fetch origin main --quiet 2>>"$LOG_FILE"
|
||||
|
||||
# Check if there are updates
|
||||
LOCAL=$(git rev-parse HEAD)
|
||||
REMOTE=$(git rev-parse origin/main)
|
||||
|
||||
if [ "$LOCAL" = "$REMOTE" ] && [ "$FORCE" = "false" ]; then
|
||||
ok "Already up to date ($LOCAL)"
|
||||
if [ "$CHECK_ONLY" = "true" ]; then
|
||||
echo '{"update_available": false, "current": "'"$LOCAL"'"}'
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Calculate what changed
|
||||
COMMITS_BEHIND=$(git rev-list HEAD..origin/main --count)
|
||||
log "Update available: $COMMITS_BEHIND commits behind"
|
||||
log " Local: $LOCAL"
|
||||
log " Remote: $REMOTE"
|
||||
|
||||
if [ "$CHECK_ONLY" = "true" ]; then
|
||||
CHANGELOG=$(git log HEAD..origin/main --oneline --no-merges | head -20)
|
||||
echo '{"update_available": true, "current": "'"$LOCAL"'", "latest": "'"$REMOTE"'", "commits_behind": '"$COMMITS_BEHIND"'}'
|
||||
echo ""
|
||||
echo "Changes:"
|
||||
echo "$CHANGELOG"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Backup current binary
|
||||
BACKUP_DIR="/var/lib/archipelago/update-backup"
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
if [ -f "$INSTALL_BIN" ]; then
|
||||
cp "$INSTALL_BIN" "$BACKUP_DIR/archipelago.bak"
|
||||
log "Backed up current binary"
|
||||
fi
|
||||
|
||||
# Pull latest code
|
||||
log "Pulling latest code..."
|
||||
git pull origin main --ff-only 2>>"$LOG_FILE" || {
|
||||
err "Git pull failed — local changes? Run: git reset --hard origin/main"
|
||||
exit 1
|
||||
}
|
||||
|
||||
NEW_VERSION=$(git rev-parse --short HEAD)
|
||||
log "Now at: $NEW_VERSION"
|
||||
|
||||
# Build backend
|
||||
log "Building Rust backend (release)..."
|
||||
cd "$BACKEND_DIR"
|
||||
if cargo build --release --workspace 2>>"$LOG_FILE"; then
|
||||
ok "Backend built successfully"
|
||||
else
|
||||
err "Backend build failed — rolling back"
|
||||
cd "$REPO_DIR"
|
||||
git reset --hard "$LOCAL" 2>>"$LOG_FILE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Install binary
|
||||
BUILT_BIN="$BACKEND_DIR/target/release/archipelago"
|
||||
if [ ! -f "$BUILT_BIN" ]; then
|
||||
err "Built binary not found at $BUILT_BIN"
|
||||
exit 1
|
||||
fi
|
||||
sudo cp "$BUILT_BIN" "$INSTALL_BIN"
|
||||
sudo chmod +x "$INSTALL_BIN"
|
||||
ok "Backend installed"
|
||||
|
||||
# Build frontend
|
||||
log "Building Vue frontend (production)..."
|
||||
cd "$FRONTEND_DIR"
|
||||
npm ci --silent 2>>"$LOG_FILE" || npm install --silent 2>>"$LOG_FILE"
|
||||
if npm run build 2>>"$LOG_FILE"; then
|
||||
ok "Frontend built successfully"
|
||||
else
|
||||
err "Frontend build failed — backend already updated, service may need manual fix"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Install frontend (always ship fresh AIUI from demo/aiui; preserve claude-login.html)
|
||||
BUILT_WEB="$REPO_DIR/web/dist/neode-ui"
|
||||
if [ -d "$BUILT_WEB" ]; then
|
||||
# Bake AIUI into the built tree so rsync --delete does not wipe it.
|
||||
# demo/aiui is the canonical AIUI bundle checked into the repo; copying
|
||||
# it here means every self-update ships a matching AIUI version instead
|
||||
# of preserving whatever stale copy happened to be on disk (which is
|
||||
# empty on nodes where an earlier ad-hoc deploy blew it away).
|
||||
if [ -d "$REPO_DIR/demo/aiui" ] && [ -f "$REPO_DIR/demo/aiui/index.html" ]; then
|
||||
log "Staging AIUI bundle from demo/aiui into frontend dist..."
|
||||
rm -rf "$BUILT_WEB/aiui"
|
||||
cp -r "$REPO_DIR/demo/aiui" "$BUILT_WEB/aiui"
|
||||
else
|
||||
warn "demo/aiui not found in repo; existing /opt/archipelago/web-ui/aiui will be wiped by rsync --delete"
|
||||
fi
|
||||
# Sync new files, preserving claude-login.html (per-node admin bookmark)
|
||||
sudo rsync -a --delete \
|
||||
--exclude 'claude-login.html' \
|
||||
"$BUILT_WEB/" "$INSTALL_WEB/"
|
||||
ok "Frontend installed"
|
||||
else
|
||||
warn "Frontend build output not found at $BUILT_WEB — skipping"
|
||||
fi
|
||||
|
||||
# Update helper scripts in /opt/archipelago/scripts/
|
||||
# These are canonical home; keep a copy at /opt/archipelago/image-versions.sh
|
||||
# for backward compatibility with older binaries that still look there.
|
||||
SCRIPTS_DEST="/opt/archipelago/scripts"
|
||||
sudo mkdir -p "$SCRIPTS_DEST"
|
||||
for script in image-versions.sh reconcile-containers.sh container-specs.sh container-doctor.sh sync-npm-public-hosts.sh app-surface-smoke-test.sh bitcoin-stack-lifecycle-test.sh; do
|
||||
src="$REPO_DIR/scripts/$script"
|
||||
if [ -f "$src" ]; then
|
||||
sudo install -m 755 "$src" "$SCRIPTS_DEST/$script"
|
||||
ok "Updated $script"
|
||||
else
|
||||
warn "Missing $src — skipping"
|
||||
fi
|
||||
done
|
||||
# Legacy path for image-versions.sh (older binaries looked here first)
|
||||
if [ -f "$REPO_DIR/scripts/image-versions.sh" ]; then
|
||||
sudo cp "$REPO_DIR/scripts/image-versions.sh" /opt/archipelago/image-versions.sh
|
||||
fi
|
||||
|
||||
# Sync app manifests and app-local build contexts into the canonical
|
||||
# production manifest root. The backend orchestrator loads install specs from
|
||||
# /opt/archipelago/apps; updating only the binary/frontend can leave a node
|
||||
# with new installer logic but stale or missing app manifests.
|
||||
APPS_DEST="/opt/archipelago/apps"
|
||||
if [ -d "$REPO_DIR/apps" ]; then
|
||||
sudo mkdir -p "$APPS_DEST"
|
||||
sudo rsync -a --delete "$REPO_DIR/apps/" "$APPS_DEST/"
|
||||
ok "App manifests synced"
|
||||
else
|
||||
warn "Apps directory not found at $REPO_DIR/apps — install manifests may be stale"
|
||||
fi
|
||||
|
||||
# Update first-boot-containers.sh too (the canonical first-boot orchestrator).
|
||||
# Nodes run it once on install, but keeping a fresh copy on disk means any
|
||||
# future boot or reconciler invocation uses current port specs and caps.
|
||||
if [ -f "$REPO_DIR/scripts/first-boot-containers.sh" ]; then
|
||||
sudo install -m 755 "$REPO_DIR/scripts/first-boot-containers.sh" \
|
||||
"$SCRIPTS_DEST/first-boot-containers.sh"
|
||||
fi
|
||||
|
||||
# Sync UI container source trees (docker/bitcoin-ui, docker/lnd-ui,
|
||||
# docker/electrs-ui) into /opt/archipelago/docker/<name>/. If any file in a
|
||||
# UI tree changed since last update, rebuild that image and recreate its
|
||||
# container using the spec from container-specs.sh. This is what prevented
|
||||
# the lnd-ui port mismatch from reaching nodes through OTA: self-update used
|
||||
# to update only the backend + frontend, never the UI container images.
|
||||
UI_DOCKER_DEST="/opt/archipelago/docker"
|
||||
sudo mkdir -p "$UI_DOCKER_DEST"
|
||||
UI_REBUILD_LIST=""
|
||||
for ui in bitcoin-ui lnd-ui electrs-ui; do
|
||||
src="$REPO_DIR/docker/$ui"
|
||||
dst="$UI_DOCKER_DEST/$ui"
|
||||
[ -d "$src" ] || continue
|
||||
# Hash source tree to decide if rebuild is needed. Any content change
|
||||
# (Dockerfile, nginx.conf, index.html, assets) triggers a rebuild.
|
||||
# Hash file contents only (not paths or metadata) so src and dst match
|
||||
# when their contents are identical regardless of directory prefix.
|
||||
src_hash=$( (cd "$src" && find . -type f | LC_ALL=C sort | xargs sha256sum 2>/dev/null) | sha256sum | cut -d' ' -f1)
|
||||
dst_hash=""
|
||||
if [ -d "$dst" ]; then
|
||||
dst_hash=$( (cd "$dst" && find . -type f | LC_ALL=C sort | xargs sha256sum 2>/dev/null) | sha256sum | cut -d' ' -f1)
|
||||
fi
|
||||
if [ "$src_hash" != "$dst_hash" ]; then
|
||||
log "UI source changed for $ui; syncing and marking for rebuild"
|
||||
sudo rsync -a --delete "$src/" "$dst/"
|
||||
UI_REBUILD_LIST="$UI_REBUILD_LIST $ui"
|
||||
else
|
||||
ok "UI source unchanged for $ui"
|
||||
fi
|
||||
done
|
||||
|
||||
# Rebuild changed UI images + recreate containers as the archipelago user
|
||||
# (rootless podman storage lives under ~archipelago). Port mappings and caps
|
||||
# come from scripts/container-specs.sh so spec drift can't sneak in.
|
||||
if [ -n "$UI_REBUILD_LIST" ]; then
|
||||
log "Rebuilding UI containers:$UI_REBUILD_LIST"
|
||||
# shellcheck disable=SC1091
|
||||
# container-specs.sh provides load_spec_archy-<ui> and mem_limit <name>.
|
||||
SPECS="$SCRIPTS_DEST/container-specs.sh"
|
||||
if [ ! -f "$SPECS" ]; then
|
||||
warn "container-specs.sh missing at $SPECS; skipping UI rebuild"
|
||||
else
|
||||
for ui in $UI_REBUILD_LIST; do
|
||||
cname="archy-$ui"
|
||||
log " rebuilding $cname from $UI_DOCKER_DEST/$ui"
|
||||
# Build image as archipelago user so it lands in the right store.
|
||||
if ! sudo -u archipelago bash -c "
|
||||
export XDG_RUNTIME_DIR=/run/user/\$(id -u archipelago)
|
||||
cd '$UI_DOCKER_DEST/$ui' &&
|
||||
podman build --no-cache -t 'localhost/$ui:local' . >>'$LOG_FILE' 2>&1
|
||||
"; then
|
||||
err " build failed for $ui; keeping existing container"
|
||||
continue
|
||||
fi
|
||||
# Recreate container using spec from container-specs.sh.
|
||||
if ! sudo -u archipelago bash -c "
|
||||
export XDG_RUNTIME_DIR=/run/user/\$(id -u archipelago)
|
||||
source '$SPECS'
|
||||
load_spec_$cname || { echo 'spec load failed for $cname'; exit 1; }
|
||||
podman stop '$cname' 2>/dev/null || true
|
||||
podman rm '$cname' 2>/dev/null || true
|
||||
PORT_ARG=''
|
||||
[ -n \"\$SPEC_PORTS\" ] && PORT_ARG=\"-p \$SPEC_PORTS\"
|
||||
NET_ARG=''
|
||||
[ \"\$SPEC_NETWORK\" = 'host' ] && NET_ARG='--network host'
|
||||
CAP_ARGS='--cap-drop ALL'
|
||||
for c in \$SPEC_CAPS; do CAP_ARGS=\"\$CAP_ARGS --cap-add \$c\"; done
|
||||
podman run -d --name '$cname' \$PORT_ARG \$NET_ARG \\
|
||||
--user 0:0 \$CAP_ARGS \\
|
||||
--memory=\"\$SPEC_MEMORY\" \\
|
||||
--restart unless-stopped \\
|
||||
--security-opt \"\$SPEC_SECURITY\" \\
|
||||
'localhost/$ui:local' >>'$LOG_FILE' 2>&1
|
||||
"; then
|
||||
err " recreate failed for $cname"
|
||||
continue
|
||||
fi
|
||||
ok " $cname rebuilt and running"
|
||||
done
|
||||
fi
|
||||
fi
|
||||
|
||||
# Update kiosk display helpers used by HDMI/TV installs.
|
||||
if [ -f "$REPO_DIR/image-recipe/configs/archipelago-kiosk-launcher.sh" ]; then
|
||||
sudo install -m 755 "$REPO_DIR/image-recipe/configs/archipelago-kiosk-launcher.sh" \
|
||||
/usr/local/bin/archipelago-kiosk-launcher
|
||||
ok "Updated archipelago-kiosk-launcher"
|
||||
fi
|
||||
|
||||
# Update systemd services if changed
|
||||
SYSTEMD_UNITS_CHANGED=false
|
||||
for unit in archipelago.service archipelago-fips.service archipelago-kiosk.service archipelago-kiosk-watchdog.service; do
|
||||
src="$REPO_DIR/image-recipe/configs/$unit"
|
||||
dst="/etc/systemd/system/$unit"
|
||||
[ -f "$src" ] || continue
|
||||
if [ ! -f "$dst" ] || ! diff -q "$src" "$dst" &>/dev/null; then
|
||||
sudo install -m 644 "$src" "$dst"
|
||||
SYSTEMD_UNITS_CHANGED=true
|
||||
ok "Updated $unit"
|
||||
fi
|
||||
done
|
||||
if [ "$SYSTEMD_UNITS_CHANGED" = "true" ]; then
|
||||
sudo systemctl daemon-reload
|
||||
fi
|
||||
|
||||
# Keep the doctor timer/service current too. Container uptime fixes rely on
|
||||
# these units as much as on the helper scripts themselves.
|
||||
DOCTOR_UNITS_CHANGED=false
|
||||
for unit in archipelago-doctor.service archipelago-doctor.timer; do
|
||||
src="$REPO_DIR/image-recipe/configs/$unit"
|
||||
dst="/etc/systemd/system/$unit"
|
||||
[ -f "$src" ] || continue
|
||||
if [ ! -f "$dst" ] || ! diff -q "$src" "$dst" &>/dev/null; then
|
||||
sudo install -m 644 "$src" "$dst"
|
||||
DOCTOR_UNITS_CHANGED=true
|
||||
ok "Updated $unit"
|
||||
fi
|
||||
done
|
||||
if [ "$DOCTOR_UNITS_CHANGED" = "true" ]; then
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now archipelago-doctor.timer 2>>"$LOG_FILE" || \
|
||||
warn "Failed to enable archipelago-doctor.timer"
|
||||
fi
|
||||
|
||||
# Install/refresh tmpfiles.d rules. The logs rule creates
|
||||
# /var/log/archipelago/ + container-installs.log with archipelago:archipelago
|
||||
# ownership so the non-root backend can append install audit lines.
|
||||
# Apply immediately so existing nodes don't need a reboot.
|
||||
if [ -f "$REPO_DIR/image-recipe/configs/archipelago-tmpfiles.conf" ]; then
|
||||
sudo install -m 644 "$REPO_DIR/image-recipe/configs/archipelago-tmpfiles.conf" \
|
||||
/usr/lib/tmpfiles.d/archipelago-logs.conf
|
||||
sudo systemd-tmpfiles --create /usr/lib/tmpfiles.d/archipelago-logs.conf 2>/dev/null || true
|
||||
ok "Log tmpfiles rule installed"
|
||||
fi
|
||||
|
||||
# Restart service
|
||||
log "Restarting archipelago service..."
|
||||
sudo systemctl restart archipelago
|
||||
|
||||
# Wait for health
|
||||
log "Waiting for backend health..."
|
||||
for i in $(seq 1 30); do
|
||||
if curl -sf http://127.0.0.1:5678/health > /dev/null 2>&1; then
|
||||
ok "Backend healthy after ${i}s"
|
||||
break
|
||||
fi
|
||||
if [ "$i" = "30" ]; then
|
||||
err "Backend failed to start within 30s"
|
||||
warn "Rolling back binary..."
|
||||
if [ -f "$BACKUP_DIR/archipelago.bak" ]; then
|
||||
sudo cp "$BACKUP_DIR/archipelago.bak" "$INSTALL_BIN"
|
||||
sudo systemctl restart archipelago
|
||||
err "Rolled back to previous binary"
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
# Update state file for the UI
|
||||
python3 -c "
|
||||
import json, datetime
|
||||
state = {
|
||||
'current_version': '$NEW_VERSION',
|
||||
'last_check': datetime.datetime.utcnow().isoformat() + 'Z',
|
||||
'available_update': None,
|
||||
'update_in_progress': False,
|
||||
'rollback_available': True,
|
||||
'schedule': 'daily_check'
|
||||
}
|
||||
with open('$STATE_FILE', 'w') as f:
|
||||
json.dump(state, f, indent=2)
|
||||
" 2>/dev/null || true
|
||||
|
||||
echo ""
|
||||
ok "Update complete: $LOCAL -> $NEW_VERSION"
|
||||
log "Changelog:"
|
||||
git log "$LOCAL".."$NEW_VERSION" --oneline --no-merges | head -10 | tee -a "$LOG_FILE"
|
||||
Executable
+169
@@ -0,0 +1,169 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Setup AIUI + Claude API proxy + FileBrowser on any Archipelago server
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/setup-aiui-server.sh <host>
|
||||
# ./scripts/setup-aiui-server.sh archipelago@192.168.1.198
|
||||
# ./scripts/setup-aiui-server.sh archipelago@192.168.1.228
|
||||
#
|
||||
# What it does:
|
||||
# 1. Deploys AIUI files (from local build)
|
||||
# 2. Configures nginx Claude API proxy (direct to Anthropic with API key)
|
||||
# 3. Fixes FileBrowser container (removes read-only root if needed)
|
||||
# 4. Reloads nginx
|
||||
#
|
||||
# Prerequisites:
|
||||
# - AIUI must be built locally first: cd AIUI/packages/app && VITE_BASE_PATH=/aiui/ npx vite build
|
||||
# - SSH key access to target server
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
|
||||
SSH_KEY="${ARCHIPELAGO_SSH_KEY:-$HOME/.ssh/archipelago-deploy}"
|
||||
SSH_OPTS="-o StrictHostKeyChecking=no -i $SSH_KEY"
|
||||
|
||||
# Anthropic API key — used by all servers for AIUI Claude chat
|
||||
ANTHROPIC_API_KEY="***REMOVED-ANTHROPIC-KEY***"
|
||||
|
||||
TARGET_HOST="$1"
|
||||
if [ -z "$TARGET_HOST" ]; then
|
||||
echo "Usage: $0 <user@host>"
|
||||
echo " e.g. $0 archipelago@192.168.1.198"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
AIUI_DIST="$PROJECT_DIR/../AIUI/packages/app/dist"
|
||||
if [ ! -f "$AIUI_DIST/index.html" ]; then
|
||||
echo "ERROR: AIUI build not found at $AIUI_DIST"
|
||||
echo "Build it first: cd ../AIUI/packages/app && VITE_BASE_PATH=/aiui/ npx vite build"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
timestamp() { echo "[$(date +%H:%M:%S)]"; }
|
||||
|
||||
echo "╔════════════════════════════════════════════════════════════╗"
|
||||
echo "║ Archipelago AIUI + Claude API Setup ║"
|
||||
echo "║ Target: $TARGET_HOST"
|
||||
echo "╚════════════════════════════════════════════════════════════╝"
|
||||
|
||||
# --- Step 1: Deploy AIUI files ---
|
||||
echo ""
|
||||
echo "$(timestamp) 📦 Deploying AIUI files..."
|
||||
|
||||
# Check if rsync is available on remote
|
||||
if ssh $SSH_OPTS "$TARGET_HOST" "which rsync" &>/dev/null; then
|
||||
rsync -avz --delete -e "ssh $SSH_OPTS" "$AIUI_DIST/" "$TARGET_HOST:/opt/archipelago/web-ui/aiui/" 2>&1 | tail -3
|
||||
else
|
||||
echo " rsync not available, using tar+scp..."
|
||||
TMPTAR=$(mktemp /tmp/aiui-dist-XXXXX.tar.gz)
|
||||
(cd "$AIUI_DIST" && tar czf "$TMPTAR" .)
|
||||
scp $SSH_OPTS "$TMPTAR" "$TARGET_HOST:/tmp/aiui-dist.tar.gz"
|
||||
ssh $SSH_OPTS "$TARGET_HOST" "sudo mkdir -p /opt/archipelago/web-ui/aiui && cd /opt/archipelago/web-ui/aiui && sudo tar xzf /tmp/aiui-dist.tar.gz --overwrite"
|
||||
rm -f "$TMPTAR"
|
||||
fi
|
||||
echo " AIUI deployed."
|
||||
|
||||
# --- Step 2: Configure nginx Claude API proxy ---
|
||||
echo ""
|
||||
echo "$(timestamp) 🔧 Configuring nginx Claude API proxy..."
|
||||
|
||||
# Create a Python script to patch nginx config
|
||||
cat << 'PYSCRIPT' > /tmp/patch-nginx-claude.py
|
||||
import sys
|
||||
import re
|
||||
|
||||
API_KEY = sys.argv[1]
|
||||
|
||||
with open("/etc/nginx/sites-available/archipelago") as f:
|
||||
content = f.read()
|
||||
|
||||
# The new Claude API proxy block
|
||||
new_block = '''location /aiui/api/claude/ {
|
||||
if ($cookie_session = "") {
|
||||
return 401 '{"error":"Unauthorized"}';
|
||||
}
|
||||
proxy_pass https://api.anthropic.com/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host api.anthropic.com;
|
||||
proxy_set_header x-api-key "''' + API_KEY + '''";
|
||||
proxy_set_header anthropic-version "2023-06-01";
|
||||
proxy_set_header anthropic-dangerous-direct-browser-access "true";
|
||||
proxy_ssl_server_name on;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
proxy_connect_timeout 120s;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_send_timeout 120s;
|
||||
}'''
|
||||
|
||||
# Replace existing Claude API proxy blocks (handles both old proxy and direct patterns)
|
||||
pattern = r'location /aiui/api/claude/ \{[^}]*(?:\{[^}]*\}[^}]*)*\}'
|
||||
content = re.sub(pattern, new_block, content)
|
||||
|
||||
with open("/etc/nginx/sites-available/archipelago", "w") as f:
|
||||
f.write(content)
|
||||
|
||||
# Verify
|
||||
count = content.count("api.anthropic.com")
|
||||
print(f" Patched {count // 2} Claude API proxy blocks (HTTP + HTTPS)")
|
||||
PYSCRIPT
|
||||
|
||||
scp $SSH_OPTS /tmp/patch-nginx-claude.py "$TARGET_HOST:/tmp/patch-nginx-claude.py"
|
||||
ssh $SSH_OPTS "$TARGET_HOST" "sudo python3 /tmp/patch-nginx-claude.py '$ANTHROPIC_API_KEY'"
|
||||
|
||||
# Test and reload nginx
|
||||
echo " Testing nginx config..."
|
||||
ssh $SSH_OPTS "$TARGET_HOST" "sudo nginx -t 2>&1 && sudo systemctl reload nginx && echo ' Nginx reloaded OK'" || {
|
||||
echo " ERROR: nginx config test failed!"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --- Step 3: Fix FileBrowser container ---
|
||||
echo ""
|
||||
echo "$(timestamp) 📁 Checking FileBrowser..."
|
||||
|
||||
FB_STATUS=$(ssh $SSH_OPTS "$TARGET_HOST" "podman inspect filebrowser 2>/dev/null | grep -oP '\"ReadonlyRootfs\":\s*\K\w+'" 2>/dev/null || echo "not_found")
|
||||
|
||||
if [ "$FB_STATUS" = "true" ]; then
|
||||
echo " FileBrowser has read-only root — recreating..."
|
||||
ssh $SSH_OPTS "$TARGET_HOST" "
|
||||
podman stop filebrowser 2>/dev/null
|
||||
podman rm filebrowser 2>/dev/null
|
||||
sudo mkdir -p /var/lib/archipelago/filebrowser
|
||||
podman run -d --name filebrowser --restart=always \
|
||||
-p 8083:80 \
|
||||
-v /var/lib/archipelago/filebrowser:/srv \
|
||||
filebrowser/filebrowser:v2.27.0
|
||||
" 2>&1 | tail -2
|
||||
echo " FileBrowser recreated."
|
||||
elif [ "$FB_STATUS" = "not_found" ]; then
|
||||
echo " FileBrowser not found — creating..."
|
||||
ssh $SSH_OPTS "$TARGET_HOST" "
|
||||
sudo mkdir -p /var/lib/archipelago/filebrowser
|
||||
podman run -d --name filebrowser --restart=always \
|
||||
-p 8083:80 \
|
||||
-v /var/lib/archipelago/filebrowser:/srv \
|
||||
filebrowser/filebrowser:v2.27.0
|
||||
" 2>&1 | tail -2
|
||||
echo " FileBrowser created."
|
||||
else
|
||||
echo " FileBrowser OK (ReadonlyRootfs: $FB_STATUS)"
|
||||
fi
|
||||
|
||||
# --- Step 4: Verify ---
|
||||
echo ""
|
||||
echo "$(timestamp) ✅ Verification..."
|
||||
ssh $SSH_OPTS "$TARGET_HOST" "
|
||||
echo \" AIUI index: \$(ls -la /opt/archipelago/web-ui/aiui/index.html 2>/dev/null | awk '{print \$6,\$7,\$8}')\"
|
||||
echo \" FileBrowser: \$(podman ps --format '{{.Names}} {{.Status}}' | grep filebrowser)\"
|
||||
echo \" Nginx: \$(systemctl is-active nginx)\"
|
||||
echo \" Backend: \$(systemctl is-active archipelago)\"
|
||||
echo \" Claude API test: \$(curl -s -o /dev/null -w '%{http_code}' -X POST http://localhost/aiui/api/claude/v1/messages -H 'Content-Type: application/json' -H 'Cookie: session=test' -d '{\"model\":\"claude-sonnet-4-20250514\",\"max_tokens\":5,\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}')\"
|
||||
"
|
||||
|
||||
echo ""
|
||||
echo "$(timestamp) Done! Server configured."
|
||||
echo " Access: http://$(echo $TARGET_HOST | cut -d@ -f2)"
|
||||
@@ -0,0 +1,280 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Set up HTTPS on Archipelago dev server for PWA installability.
|
||||
# Browsers require HTTPS (or localhost) to install PWAs.
|
||||
# Generates a self-signed certificate and configures nginx.
|
||||
#
|
||||
# Run on the target server: sudo ./setup-https-dev.sh
|
||||
# Or via deploy: the deploy script runs this automatically.
|
||||
#
|
||||
|
||||
set -e
|
||||
|
||||
SSL_DIR="/etc/archipelago/ssl"
|
||||
NGINX_CFG="/etc/nginx/sites-available/archipelago"
|
||||
CERT="$SSL_DIR/archipelago.crt"
|
||||
KEY="$SSL_DIR/archipelago.key"
|
||||
|
||||
# Create SSL directory
|
||||
mkdir -p "$SSL_DIR"
|
||||
chmod 755 "$SSL_DIR"
|
||||
|
||||
# Generate self-signed cert if missing (valid 365 days)
|
||||
# SAN includes common dev IPs so cert works when accessing via IP
|
||||
# Build dynamic SAN with all node IPs (LAN + Tailscale + loopback)
|
||||
SAN_IPS="DNS:archipelago.local,DNS:localhost,IP:127.0.0.1"
|
||||
# Add all IPv4 addresses on this machine (LAN, Tailscale, etc.)
|
||||
for ip in $(hostname -I 2>/dev/null | tr ' ' '\n' | grep -E '^[0-9]+\.' | grep -v '^127\.'); do
|
||||
SAN_IPS="$SAN_IPS,IP:$ip"
|
||||
done
|
||||
# Always include common LAN IPs as fallback
|
||||
for ip in 192.168.1.228 192.168.1.198 10.0.0.1; do
|
||||
echo "$SAN_IPS" | grep -q "$ip" || SAN_IPS="$SAN_IPS,IP:$ip"
|
||||
done
|
||||
|
||||
# Regenerate cert if missing OR if current cert doesn't include this node's primary IP
|
||||
REGEN=false
|
||||
if [ ! -f "$CERT" ] || [ ! -f "$KEY" ]; then
|
||||
REGEN=true
|
||||
else
|
||||
# Check if cert has this node's primary IP
|
||||
MY_IP=$(hostname -I 2>/dev/null | awk '{print $1}')
|
||||
if [ -n "$MY_IP" ] && ! openssl x509 -in "$CERT" -noout -text 2>/dev/null | grep -q "$MY_IP"; then
|
||||
echo " Certificate missing this node's IP ($MY_IP) — regenerating..."
|
||||
REGEN=true
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$REGEN" = true ]; then
|
||||
echo "Generating self-signed certificate for PWA (HTTPS)..."
|
||||
echo " SAN: $SAN_IPS"
|
||||
openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
|
||||
-keyout "$KEY" \
|
||||
-out "$CERT" \
|
||||
-subj "/CN=archipelago.local/O=Archipelago/C=US" \
|
||||
-addext "subjectAltName=$SAN_IPS"
|
||||
chmod 644 "$CERT"
|
||||
chmod 600 "$KEY"
|
||||
echo " Certificate created at $CERT"
|
||||
fi
|
||||
|
||||
# PWA snippet for manifest + service worker headers (required for Android install)
|
||||
NGINX_SNIPPETS="/etc/nginx/snippets"
|
||||
PWA_SNIPPET="$NGINX_SNIPPETS/archipelago-pwa.conf"
|
||||
mkdir -p "$NGINX_SNIPPETS"
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
if [ -f "$SCRIPT_DIR/nginx-pwa-snippet.conf" ]; then
|
||||
cp "$SCRIPT_DIR/nginx-pwa-snippet.conf" "$PWA_SNIPPET"
|
||||
echo " PWA nginx snippet installed at $PWA_SNIPPET"
|
||||
fi
|
||||
|
||||
# Add PWA snippet include to existing HTTPS block if missing
|
||||
if grep -q "listen 443 ssl" "$NGINX_CFG" 2>/dev/null && [ -f "$PWA_SNIPPET" ]; then
|
||||
if ! grep -q "archipelago-pwa" "$NGINX_CFG" 2>/dev/null; then
|
||||
echo " Adding PWA snippet include to HTTPS block..."
|
||||
# Insert include after "index index.html;" within the HTTPS server block (listen 443 to next })
|
||||
sed -i '/listen 443 ssl/,/^}$/{
|
||||
/index index.html;/a\
|
||||
include snippets/archipelago-pwa.conf;
|
||||
}' "$NGINX_CFG" 2>/dev/null || true
|
||||
fi
|
||||
fi
|
||||
|
||||
# Install app proxies snippet (mempool, fedimint, lnd, etc.) - fixes apps not opening over HTTPS (mixed content)
|
||||
APPS_SNIPPET="$NGINX_SNIPPETS/archipelago-https-app-proxies.conf"
|
||||
if [ -f "$SCRIPT_DIR/nginx-https-app-proxies.conf" ]; then
|
||||
cp "$SCRIPT_DIR/nginx-https-app-proxies.conf" "$APPS_SNIPPET"
|
||||
echo " HTTPS app proxies snippet installed at $APPS_SNIPPET"
|
||||
# Add include to HTTPS block if missing
|
||||
if grep -q "listen 443 ssl" "$NGINX_CFG" 2>/dev/null && ! grep -q "archipelago-https-app-proxies" "$NGINX_CFG" 2>/dev/null; then
|
||||
echo " Adding app proxies include to HTTPS block..."
|
||||
sed -i '/listen 443 ssl/,/^}$/{
|
||||
/location \/ws {/i\
|
||||
include snippets/archipelago-https-app-proxies.conf;
|
||||
}' "$NGINX_CFG" 2>/dev/null || true
|
||||
fi
|
||||
fi
|
||||
|
||||
# Check if HTTPS is already configured
|
||||
if grep -q "listen 443 ssl" "$NGINX_CFG" 2>/dev/null; then
|
||||
echo "HTTPS already configured in nginx."
|
||||
nginx -t 2>/dev/null && systemctl reload nginx
|
||||
MY_IP=$(hostname -I 2>/dev/null | awk '{print $1}')
|
||||
echo ""
|
||||
echo "PWA: Use https://${MY_IP:-192.168.1.228} (not http) - accept cert once, then Install app."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Add HTTPS server block (duplicate of HTTP block with SSL)
|
||||
# PWA requires HTTPS for install on Android
|
||||
HTTPS_BLOCK='
|
||||
# HTTPS - required for PWA install (Add to Home Screen) from dev servers
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name _;
|
||||
|
||||
ssl_certificate '"$CERT"';
|
||||
ssl_certificate_key '"$KEY"';
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
|
||||
|
||||
root /opt/archipelago/web-ui;
|
||||
index index.html;
|
||||
include snippets/archipelago-pwa.conf;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
location /archipelago/ {
|
||||
proxy_pass http://127.0.0.1:5678;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
|
||||
location /rpc/ {
|
||||
proxy_pass http://127.0.0.1:5678;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_connect_timeout 600s;
|
||||
proxy_send_timeout 600s;
|
||||
proxy_read_timeout 600s;
|
||||
}
|
||||
|
||||
location /app/nextcloud/ {
|
||||
proxy_pass http://127.0.0.1:8085/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_hide_header X-Frame-Options;
|
||||
proxy_hide_header Content-Security-Policy;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
}
|
||||
location /app/vaultwarden/ {
|
||||
proxy_pass http://127.0.0.1:8082/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_hide_header X-Frame-Options;
|
||||
proxy_hide_header Content-Security-Policy;
|
||||
}
|
||||
location /app/immich/ {
|
||||
proxy_pass http://127.0.0.1:2283/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_hide_header X-Frame-Options;
|
||||
proxy_hide_header Content-Security-Policy;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
}
|
||||
location /app/penpot/ {
|
||||
proxy_pass http://127.0.0.1:9001/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_hide_header X-Frame-Options;
|
||||
proxy_hide_header Content-Security-Policy;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
}
|
||||
location /app/btcpay/ {
|
||||
proxy_pass http://127.0.0.1:23000/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_hide_header X-Frame-Options;
|
||||
proxy_hide_header Content-Security-Policy;
|
||||
}
|
||||
location /app/homeassistant/ {
|
||||
proxy_pass http://127.0.0.1:8123/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_hide_header X-Frame-Options;
|
||||
proxy_hide_header Content-Security-Policy;
|
||||
proxy_read_timeout 86400s;
|
||||
proxy_send_timeout 86400s;
|
||||
}
|
||||
location /app/mempool/ {
|
||||
proxy_pass http://127.0.0.1:4080/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_hide_header X-Frame-Options;
|
||||
proxy_hide_header Content-Security-Policy;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
}
|
||||
location /app/fedimint/ {
|
||||
proxy_pass http://127.0.0.1:8175/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_hide_header X-Frame-Options;
|
||||
proxy_hide_header Content-Security-Policy;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
}
|
||||
location /app/lnd/ {
|
||||
proxy_pass http://127.0.0.1:18083/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_hide_header X-Frame-Options;
|
||||
proxy_hide_header Content-Security-Policy;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
}
|
||||
location /app/bitcoin-ui/ {
|
||||
proxy_pass http://127.0.0.1:8334/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_hide_header X-Frame-Options;
|
||||
proxy_hide_header Content-Security-Policy;
|
||||
}
|
||||
|
||||
location /ws {
|
||||
proxy_pass http://127.0.0.1:5678;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_read_timeout 86400s;
|
||||
}
|
||||
}
|
||||
'
|
||||
|
||||
# Append HTTPS block to nginx config
|
||||
echo "$HTTPS_BLOCK" >> "$NGINX_CFG"
|
||||
echo "Added HTTPS (port 443) to nginx config."
|
||||
|
||||
# Test and reload
|
||||
nginx -t && systemctl reload nginx
|
||||
echo ""
|
||||
MY_IP=$(hostname -I 2>/dev/null | awk '{print $1}')
|
||||
echo "HTTPS enabled. PWA install: https://${MY_IP:-192.168.1.228} (accept the certificate warning once, then Install app)."
|
||||
Executable
+93
@@ -0,0 +1,93 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Setup development environment on Archipelago target machine
|
||||
#
|
||||
# Run this ON the HP ProDesk via SSH:
|
||||
# curl -sSL https://raw.githubusercontent.com/.../setup-target-dev.sh | bash
|
||||
# Or copy and run locally:
|
||||
# scp scripts/setup-target-dev.sh archipelago@192.168.1.228:~/
|
||||
# ssh archipelago@192.168.1.228 'bash ~/setup-target-dev.sh'
|
||||
#
|
||||
|
||||
set -e
|
||||
|
||||
echo "╔════════════════════════════════════════════════════════════════╗"
|
||||
echo "║ Setting up Archipelago Development Environment ║"
|
||||
echo "╚════════════════════════════════════════════════════════════════╝"
|
||||
echo ""
|
||||
|
||||
# Update packages
|
||||
echo "📦 Updating packages..."
|
||||
sudo apt update
|
||||
|
||||
# Install Node.js (for Vue.js frontend)
|
||||
echo ""
|
||||
echo "📦 Installing Node.js..."
|
||||
if ! command -v node &> /dev/null; then
|
||||
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
|
||||
sudo apt install -y nodejs
|
||||
else
|
||||
echo " Node.js already installed: $(node --version)"
|
||||
fi
|
||||
|
||||
# Install Rust (for backend)
|
||||
echo ""
|
||||
echo "📦 Installing Rust..."
|
||||
if ! command -v cargo &> /dev/null; then
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
|
||||
source ~/.cargo/env
|
||||
else
|
||||
echo " Rust already installed: $(rustc --version)"
|
||||
fi
|
||||
|
||||
# Install build tools
|
||||
echo ""
|
||||
echo "📦 Installing build tools..."
|
||||
sudo apt install -y \
|
||||
build-essential \
|
||||
pkg-config \
|
||||
libssl-dev \
|
||||
git
|
||||
|
||||
# Create development directory
|
||||
echo ""
|
||||
echo "📁 Creating development directory..."
|
||||
mkdir -p ~/archy
|
||||
|
||||
# Fix XDG_RUNTIME_DIR for rootless Podman (add to bashrc)
|
||||
if ! grep -q "XDG_RUNTIME_DIR" ~/.bashrc; then
|
||||
echo ""
|
||||
echo "🔧 Fixing Podman rootless setup..."
|
||||
cat >> ~/.bashrc << 'EOF'
|
||||
|
||||
# Fix for rootless Podman
|
||||
if [ -z "$XDG_RUNTIME_DIR" ]; then
|
||||
export XDG_RUNTIME_DIR=/run/user/$(id -u)
|
||||
if [ ! -d "$XDG_RUNTIME_DIR" ]; then
|
||||
sudo mkdir -p "$XDG_RUNTIME_DIR"
|
||||
sudo chown $(whoami):$(whoami) "$XDG_RUNTIME_DIR"
|
||||
sudo chmod 700 "$XDG_RUNTIME_DIR"
|
||||
fi
|
||||
fi
|
||||
EOF
|
||||
fi
|
||||
|
||||
# Enable user lingering for Podman
|
||||
sudo loginctl enable-linger archipelago 2>/dev/null || true
|
||||
|
||||
echo ""
|
||||
echo "╔════════════════════════════════════════════════════════════════╗"
|
||||
echo "║ ✅ Development environment ready! ║"
|
||||
echo "╚════════════════════════════════════════════════════════════════╝"
|
||||
echo ""
|
||||
echo "Installed:"
|
||||
echo " • Node.js: $(node --version 2>/dev/null || echo 'not found')"
|
||||
echo " • npm: $(npm --version 2>/dev/null || echo 'not found')"
|
||||
echo " • Rust: $(rustc --version 2>/dev/null || echo 'not found')"
|
||||
echo " • Cargo: $(cargo --version 2>/dev/null || echo 'not found')"
|
||||
echo ""
|
||||
echo "Next steps:"
|
||||
echo " 1. Log out and back in (or run: source ~/.bashrc)"
|
||||
echo " 2. From your Mac, run: ./scripts/deploy-to-target.sh"
|
||||
echo " 3. To start Vue.js dev server: cd ~/archy/neode-ui && npm run dev -- --host 0.0.0.0"
|
||||
echo ""
|
||||
Executable
+43
@@ -0,0 +1,43 @@
|
||||
#!/usr/bin/env bash
|
||||
# One-step release-catalog signer.
|
||||
#
|
||||
# Run: bash scripts/sign-catalog.sh
|
||||
# Then: paste your 24-word release master mnemonic, press Enter, then Ctrl-D.
|
||||
#
|
||||
# It signs releases/app-catalog.json in place and checks the signature was made
|
||||
# by the expected release-root key. Your mnemonic is read from the terminal only
|
||||
# (never stored, never in shell history, never passed to Claude).
|
||||
set -euo pipefail
|
||||
|
||||
REPO="/home/archipelago/Projects/archy"
|
||||
CATALOG="$REPO/releases/app-catalog.json"
|
||||
EXPECTED_DID="did:key:z6MkkidEnEpo6qHMCNSZoNKWtvQvxq3whnaME9wGgEFhq7ur"
|
||||
|
||||
# Use ONLY the prebuilt signer. If it isn't ready, stop cleanly — never compile
|
||||
# here (compiling caused the earlier hangs). Claude builds it in the background.
|
||||
BIN="/tmp/archy-sign-bin/release/archipelago"
|
||||
if [[ ! -x "$BIN" ]]; then
|
||||
echo "⏳ The signer isn't ready yet — Claude is still building it."
|
||||
echo " Wait until Claude says 'READY', then run this again. Nothing was changed."
|
||||
exit 0
|
||||
fi
|
||||
SIGN=("$BIN" ceremony sign "$CATALOG")
|
||||
|
||||
echo "════════════════════════════════════════════════════════════════"
|
||||
echo " Paste your 24-word release master mnemonic below, press Enter,"
|
||||
echo " then press Ctrl-D on a new line."
|
||||
echo "════════════════════════════════════════════════════════════════"
|
||||
"${SIGN[@]}"
|
||||
|
||||
# Verify the signature is present and made by the expected key.
|
||||
echo
|
||||
if grep -q "\"signed_by\": \"$EXPECTED_DID\"" "$CATALOG" \
|
||||
&& grep -q '"signature":' "$CATALOG"; then
|
||||
echo "✅ SUCCESS — catalog signed by the correct release-root key."
|
||||
echo " Tell Claude \"signed\" and it will commit + push for you."
|
||||
else
|
||||
echo "❌ Something is off — the catalog is NOT signed by the expected key."
|
||||
echo " Expected signer: $EXPECTED_DID"
|
||||
echo " Do NOT commit. Check the mnemonic and re-run, or ask Claude."
|
||||
exit 1
|
||||
fi
|
||||
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
+47
@@ -0,0 +1,47 @@
|
||||
#!/usr/bin/env bash
|
||||
# One-step OTA-manifest signer (counterpart to sign-catalog.sh).
|
||||
#
|
||||
# Run: bash scripts/sign-manifest.sh
|
||||
# Then: paste your 24-word release master mnemonic, press Enter, then Ctrl-D.
|
||||
#
|
||||
# Signs releases/manifest.json in place and cryptographically verifies the
|
||||
# result against the pinned release-root anchor. The mnemonic is read from the
|
||||
# terminal only (never stored, never in shell history, never passed to Claude).
|
||||
#
|
||||
# Normally create-release.sh signs the manifest inline; this script exists for
|
||||
# re-signing (e.g. a manifest edited after creation) or signing on a box where
|
||||
# the release run was non-interactive.
|
||||
set -euo pipefail
|
||||
|
||||
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
MANIFEST="$REPO/releases/manifest.json"
|
||||
|
||||
# Use ONLY a prebuilt signer — never compile here (compiling caused hangs in
|
||||
# the earlier catalog ceremony). Prefer the repo's release build.
|
||||
BIN=""
|
||||
for candidate in "$REPO/core/target/release/archipelago" /tmp/archy-sign-bin/release/archipelago; do
|
||||
if [[ -x "$candidate" ]]; then BIN="$candidate"; break; fi
|
||||
done
|
||||
if [[ -z "$BIN" ]]; then
|
||||
echo "⏳ No prebuilt signer found. Build one first:"
|
||||
echo " (cd core && cargo build --release -p archipelago)"
|
||||
echo " Nothing was changed."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "════════════════════════════════════════════════════════════════"
|
||||
echo " Paste your 24-word release master mnemonic below, press Enter,"
|
||||
echo " then press Ctrl-D on a new line."
|
||||
echo "════════════════════════════════════════════════════════════════"
|
||||
"$BIN" ceremony sign "$MANIFEST"
|
||||
|
||||
echo
|
||||
if "$BIN" ceremony verify "$MANIFEST"; then
|
||||
echo "✅ SUCCESS — manifest signed by the pinned release root."
|
||||
echo " Commit + push releases/manifest.json (and release-manifest.json if present)."
|
||||
cp "$MANIFEST" "$REPO/release-manifest.json" 2>/dev/null || true
|
||||
else
|
||||
echo "❌ Signature did NOT verify against the pinned release-root anchor."
|
||||
echo " Do NOT commit. Check the mnemonic and re-run."
|
||||
exit 1
|
||||
fi
|
||||
Executable
+86
@@ -0,0 +1,86 @@
|
||||
#!/bin/bash
|
||||
# Smoke test for Archipelago — verifies critical endpoints
|
||||
# Usage: ./scripts/smoke-test.sh [host]
|
||||
# Exit 0 if all pass, exit 1 on any failure.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
HOST="${1:-192.168.1.198}"
|
||||
PASS=0
|
||||
FAIL=0
|
||||
FAILURES=""
|
||||
|
||||
check() {
|
||||
local name="$1" cmd="$2"
|
||||
if eval "$cmd" >/dev/null 2>&1; then
|
||||
echo " ✓ $name"
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
echo " ✗ $name"
|
||||
FAIL=$((FAIL + 1))
|
||||
FAILURES="$FAILURES\n - $name"
|
||||
fi
|
||||
}
|
||||
|
||||
echo "=== Archipelago Smoke Test ==="
|
||||
echo "Target: $HOST"
|
||||
echo ""
|
||||
|
||||
# 1. Health endpoint
|
||||
check "GET /health returns OK" \
|
||||
"curl -sf http://${HOST}/health | grep -q '\"status\"'"
|
||||
|
||||
# 2. Login via RPC
|
||||
SESSION=$(curl -sf -X POST "http://${HOST}/rpc/v1" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"method":"auth.login","params":{"password":"'"${TEST_PASSWORD:-password123}"'"}}' \
|
||||
-c - 2>/dev/null | grep session | awk '{print $NF}' || echo "")
|
||||
|
||||
if [ -n "$SESSION" ]; then
|
||||
echo " ✓ Login via RPC"
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
echo " ✗ Login via RPC"
|
||||
FAIL=$((FAIL + 1))
|
||||
FAILURES="$FAILURES\n - Login via RPC"
|
||||
fi
|
||||
|
||||
# 3. Authenticated RPC call
|
||||
check "server.get-info returns JSON" \
|
||||
"curl -sf -X POST http://${HOST}/rpc/v1 \
|
||||
-H 'Content-Type: application/json' \
|
||||
-b 'session=${SESSION}' \
|
||||
-d '{\"method\":\"server.get-info\"}' | grep -q '\"result\"'"
|
||||
|
||||
# 4. Container list
|
||||
check "container.list returns JSON" \
|
||||
"curl -sf -X POST http://${HOST}/rpc/v1 \
|
||||
-H 'Content-Type: application/json' \
|
||||
-b 'session=${SESSION}' \
|
||||
-d '{\"method\":\"container.list\"}' | grep -q '\"result\"'"
|
||||
|
||||
# 5. WebSocket upgrade
|
||||
check "WebSocket upgrade (101)" \
|
||||
"curl -sf -o /dev/null -w '%{http_code}' \
|
||||
-H 'Upgrade: websocket' -H 'Connection: Upgrade' \
|
||||
-H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==' \
|
||||
-H 'Sec-WebSocket-Version: 13' \
|
||||
http://${HOST}/ws/db | grep -q '101'"
|
||||
|
||||
# 6. Static assets served
|
||||
check "Frontend index.html served" \
|
||||
"curl -sf http://${HOST}/ | grep -q '<div id=\"app\"'"
|
||||
|
||||
# 7. Onboarding check (unauthenticated)
|
||||
check "auth.isOnboardingComplete RPC" \
|
||||
"curl -sf -X POST http://${HOST}/rpc/v1 \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{\"method\":\"auth.isOnboardingComplete\"}' | grep -q '\"result\"'"
|
||||
|
||||
echo ""
|
||||
echo "=== Results: $PASS passed, $FAIL failed ==="
|
||||
if [ $FAIL -gt 0 ]; then
|
||||
echo -e "Failures:$FAILURES"
|
||||
exit 1
|
||||
fi
|
||||
exit 0
|
||||
@@ -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
+152
@@ -0,0 +1,152 @@
|
||||
#!/bin/bash
|
||||
# tor-helper.sh — Privileged Tor operations for the Archipelago backend.
|
||||
# Runs as root via systemd (archipelago-tor-helper.service), triggered by
|
||||
# a path unit watching /var/lib/archipelago/tor-config/tor-action.
|
||||
#
|
||||
# The backend writes a JSON action file, the path unit triggers this script.
|
||||
# This avoids calling sudo from within a NoNewPrivileges=yes service.
|
||||
set -euo pipefail
|
||||
|
||||
ACTION_FILE="/var/lib/archipelago/tor-config/tor-action"
|
||||
TORRC_STAGED="/var/lib/archipelago/tor-config/torrc.staged"
|
||||
RESULT_FILE="/var/lib/archipelago/tor-config/tor-result"
|
||||
HOSTNAMES_DIR="/var/lib/archipelago/tor-hostnames"
|
||||
|
||||
log() { echo "[tor-helper] $*"; }
|
||||
|
||||
write_result() {
|
||||
echo "$1" > "$RESULT_FILE"
|
||||
chown archipelago:archipelago "$RESULT_FILE" 2>/dev/null || true
|
||||
}
|
||||
|
||||
sync_hostnames() {
|
||||
mkdir -p "$HOSTNAMES_DIR"
|
||||
# Clear stale copies first
|
||||
rm -f "$HOSTNAMES_DIR"/* 2>/dev/null || true
|
||||
# Prefer /var/lib/tor (system Tor, authoritative) over /var/lib/archipelago/tor
|
||||
# Only copy from secondary if not already found in primary
|
||||
for base in /var/lib/tor /var/lib/archipelago/tor; do
|
||||
for dir in "$base"/hidden_service_*; do
|
||||
[ -d "$dir" ] || continue
|
||||
svc=$(basename "$dir" | sed 's/^hidden_service_//')
|
||||
echo "$svc" | grep -q '_old_' && continue
|
||||
# Skip if already synced from a higher-priority location
|
||||
[ -f "${HOSTNAMES_DIR}/${svc}" ] && continue
|
||||
if [ -f "$dir/hostname" ]; then
|
||||
cp "$dir/hostname" "${HOSTNAMES_DIR}/${svc}"
|
||||
log "Synced hostname: $svc ($base)"
|
||||
fi
|
||||
done
|
||||
done
|
||||
chown -R archipelago:archipelago "$HOSTNAMES_DIR" 2>/dev/null || true
|
||||
}
|
||||
|
||||
# ─── Main ─────────────────────────────────────────────────────────
|
||||
|
||||
if [ ! -f "$ACTION_FILE" ]; then
|
||||
log "No action file found"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
ACTION=$(cat "$ACTION_FILE")
|
||||
rm -f "$ACTION_FILE"
|
||||
|
||||
ACTION_TYPE=$(echo "$ACTION" | python3 -c "import sys,json; print(json.load(sys.stdin).get('action',''))" 2>/dev/null || echo "")
|
||||
|
||||
case "$ACTION_TYPE" in
|
||||
write-torrc-and-restart)
|
||||
if [ ! -f "$TORRC_STAGED" ]; then
|
||||
log "ERROR: No staged torrc at $TORRC_STAGED"
|
||||
write_result '{"ok":false,"error":"No staged torrc"}'
|
||||
exit 1
|
||||
fi
|
||||
cp "$TORRC_STAGED" /etc/tor/torrc
|
||||
chown debian-tor:debian-tor /etc/tor/torrc 2>/dev/null || true
|
||||
log "torrc updated from staged file"
|
||||
|
||||
systemctl restart tor
|
||||
log "Tor restarted"
|
||||
|
||||
# Wait for SOCKS port
|
||||
for i in $(seq 1 30); do
|
||||
if timeout 1 bash -c 'echo > /dev/tcp/127.0.0.1/9050' 2>/dev/null; then
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
sync_hostnames
|
||||
write_result '{"ok":true}'
|
||||
;;
|
||||
|
||||
restart)
|
||||
systemctl restart tor
|
||||
log "Tor restarted"
|
||||
sleep 3
|
||||
sync_hostnames
|
||||
write_result '{"ok":true}'
|
||||
;;
|
||||
|
||||
delete-service)
|
||||
NAME=$(echo "$ACTION" | python3 -c "import sys,json; print(json.load(sys.stdin).get('name',''))" 2>/dev/null || echo "")
|
||||
if [ -z "$NAME" ]; then
|
||||
write_result '{"ok":false,"error":"Missing service name"}'
|
||||
exit 1
|
||||
fi
|
||||
if ! echo "$NAME" | grep -qE '^[a-zA-Z0-9_-]+$'; then
|
||||
write_result '{"ok":false,"error":"Invalid service name"}'
|
||||
exit 1
|
||||
fi
|
||||
rm -rf "/var/lib/tor/hidden_service_${NAME}" 2>/dev/null || true
|
||||
rm -rf "/var/lib/archipelago/tor/hidden_service_${NAME}" 2>/dev/null || true
|
||||
rm -f "${HOSTNAMES_DIR}/${NAME}" 2>/dev/null || true
|
||||
log "Deleted hidden service: $NAME"
|
||||
write_result '{"ok":true}'
|
||||
;;
|
||||
|
||||
rename-service)
|
||||
NAME=$(echo "$ACTION" | python3 -c "import sys,json; print(json.load(sys.stdin).get('name',''))" 2>/dev/null || echo "")
|
||||
TIMESTAMP=$(echo "$ACTION" | python3 -c "import sys,json; print(json.load(sys.stdin).get('timestamp',''))" 2>/dev/null || echo "")
|
||||
if [ -z "$NAME" ] || [ -z "$TIMESTAMP" ]; then
|
||||
write_result '{"ok":false,"error":"Missing service name or timestamp"}'
|
||||
exit 1
|
||||
fi
|
||||
if ! echo "$NAME" | grep -qE '^[a-zA-Z0-9_-]+$'; then
|
||||
write_result '{"ok":false,"error":"Invalid service name"}'
|
||||
exit 1
|
||||
fi
|
||||
if ! echo "$TIMESTAMP" | grep -qE '^[0-9]+$'; then
|
||||
write_result '{"ok":false,"error":"Invalid timestamp"}'
|
||||
exit 1
|
||||
fi
|
||||
OLD_SUFFIX="${NAME}_old_${TIMESTAMP}"
|
||||
for base in /var/lib/tor /var/lib/archipelago/tor; do
|
||||
SRC="${base}/hidden_service_${NAME}"
|
||||
DST="${base}/hidden_service_${OLD_SUFFIX}"
|
||||
if [ -d "$SRC" ]; then
|
||||
mv "$SRC" "$DST"
|
||||
log "Renamed $SRC -> $DST"
|
||||
fi
|
||||
done
|
||||
rm -f "${HOSTNAMES_DIR}/${NAME}" 2>/dev/null || true
|
||||
write_result '{"ok":true}'
|
||||
;;
|
||||
|
||||
sync-hostnames)
|
||||
sync_hostnames
|
||||
write_result '{"ok":true}'
|
||||
;;
|
||||
|
||||
reboot)
|
||||
write_result '{"ok":true}'
|
||||
log "System reboot initiated"
|
||||
sleep 1
|
||||
systemctl reboot
|
||||
;;
|
||||
|
||||
*)
|
||||
log "Unknown action: $ACTION_TYPE"
|
||||
write_result '{"ok":false,"error":"Unknown action"}'
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
@@ -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
+73
@@ -0,0 +1,73 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Trust the Archipelago server's self-signed certificate on macOS.
|
||||
# Run this to eliminate "Not secure" when accessing https://192.168.1.228
|
||||
#
|
||||
# Usage: ./scripts/trust-archipelago-cert.sh [host]
|
||||
# Default host: 192.168.1.228
|
||||
#
|
||||
# Requires: SSH access to archipelago@host (uses deploy-config.sh password)
|
||||
#
|
||||
|
||||
set -e
|
||||
|
||||
HOST="${1:-192.168.1.228}"
|
||||
CERT_FILE="/tmp/archipelago-${HOST}.crt"
|
||||
KEYCHAIN="${HOME}/Library/Keychains/login.keychain-db"
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
|
||||
|
||||
# Try to fetch cert from server via SSH (most reliable)
|
||||
SSH_KEY="${ARCHIPELAGO_SSH_KEY:-$HOME/.ssh/archipelago-deploy}"
|
||||
echo "Fetching certificate from server..."
|
||||
if [ -f "$SSH_KEY" ]; then
|
||||
ssh -o StrictHostKeyChecking=no -i "$SSH_KEY" archipelago@${HOST} \
|
||||
'sudo -n cat /etc/archipelago/ssl/archipelago.crt' > "$CERT_FILE" 2>/dev/null || true
|
||||
elif [ -f "$SCRIPT_DIR/deploy-config.sh" ]; then
|
||||
# Last-resort fallback: password auth (leaks credentials to process list)
|
||||
. "$SCRIPT_DIR/deploy-config.sh"
|
||||
echo "WARNING: SSH key not found at $SSH_KEY — falling back to password auth"
|
||||
if command -v sshpass >/dev/null 2>&1; then
|
||||
sshpass -p "$ARCHIPELAGO_PASSWORD" ssh -o StrictHostKeyChecking=no archipelago@${HOST} \
|
||||
'sudo -n cat /etc/archipelago/ssl/archipelago.crt' > "$CERT_FILE" 2>/dev/null || true
|
||||
else
|
||||
echo "WARNING: No SSH key and sshpass not installed — skipping SSH fetch"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Fallback: fetch via openssl (can hang on some systems)
|
||||
if [ ! -s "$CERT_FILE" ]; then
|
||||
echo "Fetching certificate via TLS..."
|
||||
(echo "Q"; sleep 1) | openssl s_client -connect "${HOST}:443" -servername "${HOST}" 2>/dev/null | \
|
||||
openssl x509 -outform PEM > "$CERT_FILE"
|
||||
fi
|
||||
|
||||
if [ ! -s "$CERT_FILE" ]; then
|
||||
echo "Failed to fetch certificate. Ensure deploy-config.sh exists and SSH works, or the server is reachable."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Adding to your login keychain..."
|
||||
|
||||
# Remove old cert if present (by common name)
|
||||
security delete-certificate -c "archipelago.local" "$KEYCHAIN" 2>/dev/null || true
|
||||
|
||||
# Add to user keychain with trust (no sudo needed)
|
||||
if security add-trusted-cert -d -r trustRoot -k "$KEYCHAIN" "$CERT_FILE" 2>/dev/null; then
|
||||
echo " Certificate trusted successfully."
|
||||
elif security add-trusted-cert -d -r trustAsRoot -k "$KEYCHAIN" "$CERT_FILE" 2>/dev/null; then
|
||||
echo " Certificate trusted successfully."
|
||||
else
|
||||
# Fallback: add cert and open Keychain Access for manual trust
|
||||
cp "$CERT_FILE" "$HOME/Desktop/archipelago-${HOST}.crt"
|
||||
echo ""
|
||||
echo " Could not auto-trust. Certificate saved to Desktop."
|
||||
echo " Double-click archipelago-${HOST}.crt to add it, then in Keychain Access"
|
||||
echo " find it, double-click, expand Trust → set to 'Always Trust'."
|
||||
CERT_FILE="" # Don't delete, we copied to Desktop
|
||||
fi
|
||||
|
||||
rm -f "$CERT_FILE"
|
||||
|
||||
echo ""
|
||||
echo "✅ Done. Restart your browser fully (quit Chrome/Safari) and visit https://${HOST}"
|
||||
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
+167
@@ -0,0 +1,167 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# validate-app-manifest.sh — Validate a community-submitted app manifest
|
||||
#
|
||||
# Usage: ./scripts/validate-app-manifest.sh <manifest.yml>
|
||||
#
|
||||
# Checks:
|
||||
# 1. Valid YAML syntax
|
||||
# 2. Required fields present (id, title, version, image, description)
|
||||
# 3. Image from trusted registry (docker.io, ghcr.io, quay.io)
|
||||
# 4. No :latest tag (must pin specific version)
|
||||
# 5. Resource limits specified (memory, cpu)
|
||||
# 6. Security: no privileged mode, no host networking
|
||||
# 7. No hardcoded secrets/passwords in environment
|
||||
# 8. Port conflicts with existing apps
|
||||
#
|
||||
# Exit 0 = valid, Exit 1 = issues found
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
if [[ $# -lt 1 ]]; then
|
||||
echo "Usage: $0 <manifest.yml>"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
MANIFEST="$1"
|
||||
PASS=0
|
||||
FAIL=0
|
||||
WARN=0
|
||||
|
||||
check() {
|
||||
local desc="$1" result="$2"
|
||||
if [[ "$result" == "pass" ]]; then
|
||||
PASS=$((PASS + 1))
|
||||
echo " PASS: $desc"
|
||||
elif [[ "$result" == "warn" ]]; then
|
||||
WARN=$((WARN + 1))
|
||||
echo " WARN: $desc"
|
||||
else
|
||||
FAIL=$((FAIL + 1))
|
||||
echo " FAIL: $desc"
|
||||
fi
|
||||
}
|
||||
|
||||
echo "Validating: $MANIFEST"
|
||||
echo ""
|
||||
|
||||
# 1. File exists and is readable
|
||||
if [[ ! -f "$MANIFEST" ]]; then
|
||||
echo " FAIL: File not found: $MANIFEST"
|
||||
exit 1
|
||||
fi
|
||||
check "File exists" "pass"
|
||||
|
||||
# 2. Valid YAML
|
||||
if ! python3 -c "import yaml; yaml.safe_load(open('$MANIFEST'))" 2>/dev/null; then
|
||||
check "Valid YAML syntax" "fail"
|
||||
echo " Cannot continue with invalid YAML"
|
||||
exit 1
|
||||
fi
|
||||
check "Valid YAML syntax" "pass"
|
||||
|
||||
# 3. Required fields
|
||||
CONTENT=$(python3 -c "
|
||||
import yaml, json
|
||||
with open('$MANIFEST') as f:
|
||||
d = yaml.safe_load(f)
|
||||
print(json.dumps(d))
|
||||
" 2>/dev/null)
|
||||
|
||||
for field in id title version description; do
|
||||
val=$(echo "$CONTENT" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('$field',''))" 2>/dev/null)
|
||||
if [[ -n "$val" && "$val" != "None" ]]; then
|
||||
check "Required field '$field' present" "pass"
|
||||
else
|
||||
check "Required field '$field' present" "fail"
|
||||
fi
|
||||
done
|
||||
|
||||
# 4. Image reference
|
||||
IMAGE=$(echo "$CONTENT" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('image','') or d.get('docker_image','') or '')" 2>/dev/null)
|
||||
if [[ -z "$IMAGE" || "$IMAGE" == "None" ]]; then
|
||||
check "Container image specified" "fail"
|
||||
else
|
||||
check "Container image specified" "pass"
|
||||
|
||||
# Check trusted registry
|
||||
TRUSTED=false
|
||||
for reg in "docker.io" "ghcr.io" "quay.io" "registry.hub.docker.com" "146.59.87.168:3000"; do
|
||||
if echo "$IMAGE" | grep -q "$reg"; then
|
||||
TRUSTED=true
|
||||
break
|
||||
fi
|
||||
done
|
||||
# Also allow short-form Docker Hub images (no registry prefix)
|
||||
if ! echo "$IMAGE" | grep -q "/"; then
|
||||
TRUSTED=true # single-name images are Docker Hub official
|
||||
fi
|
||||
if [[ "$TRUSTED" == "true" ]]; then
|
||||
check "Image from trusted registry" "pass"
|
||||
else
|
||||
check "Image from trusted registry ($IMAGE)" "warn"
|
||||
fi
|
||||
|
||||
# Check no :latest
|
||||
if echo "$IMAGE" | grep -q ":latest$"; then
|
||||
check "No :latest tag (pin specific version)" "fail"
|
||||
elif ! echo "$IMAGE" | grep -q ":"; then
|
||||
check "No version tag specified (should pin version)" "warn"
|
||||
else
|
||||
check "Version tag pinned" "pass"
|
||||
fi
|
||||
fi
|
||||
|
||||
# 5. Security checks
|
||||
PRIVILEGED=$(echo "$CONTENT" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('privileged', False))" 2>/dev/null)
|
||||
if [[ "$PRIVILEGED" == "True" ]]; then
|
||||
check "No privileged mode" "fail"
|
||||
else
|
||||
check "No privileged mode" "pass"
|
||||
fi
|
||||
|
||||
HOST_NET=$(echo "$CONTENT" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('host_network', d.get('network_mode','')))" 2>/dev/null)
|
||||
if [[ "$HOST_NET" == "host" ]]; then
|
||||
check "No host networking" "fail"
|
||||
else
|
||||
check "No host networking" "pass"
|
||||
fi
|
||||
|
||||
# 6. Check for hardcoded secrets in env vars
|
||||
ENV_VARS=$(echo "$CONTENT" | python3 -c "
|
||||
import sys,json
|
||||
d=json.load(sys.stdin)
|
||||
env = d.get('environment', d.get('env', {}))
|
||||
if isinstance(env, dict):
|
||||
for k,v in env.items():
|
||||
print(f'{k}={v}')
|
||||
elif isinstance(env, list):
|
||||
for e in env:
|
||||
print(e)
|
||||
" 2>/dev/null || echo "")
|
||||
|
||||
SECRET_PATTERNS="password|secret|api_key|private_key|token"
|
||||
if echo "$ENV_VARS" | grep -iqE "$SECRET_PATTERNS"; then
|
||||
check "No hardcoded secrets in environment" "warn"
|
||||
else
|
||||
check "No hardcoded secrets in environment" "pass"
|
||||
fi
|
||||
|
||||
# 7. Memory limit
|
||||
MEM=$(echo "$CONTENT" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('memory', d.get('mem_limit', d.get('resources',{}).get('memory',''))))" 2>/dev/null)
|
||||
if [[ -n "$MEM" && "$MEM" != "None" && "$MEM" != "" ]]; then
|
||||
check "Memory limit specified ($MEM)" "pass"
|
||||
else
|
||||
check "Memory limit specified" "warn"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Results: $PASS passed, $FAIL failed, $WARN warnings"
|
||||
|
||||
if [[ "$FAIL" -gt 0 ]]; then
|
||||
echo "STATUS: REJECTED — fix failures before resubmitting"
|
||||
exit 1
|
||||
else
|
||||
echo "STATUS: APPROVED (with $WARN warnings)"
|
||||
exit 0
|
||||
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