merge: bring the open-source readiness work onto the phase-13 branch

Merges gitea-ai/main (65 commits) into the phase-13 branch (419) so one
build carries both lines — the AIUI/assistant/container work and the
open-source readiness work (licensing, the marketplace DID signature layer,
the registry domain migration, the secrets and infrastructure scrub).

Every Rust file auto-merged. The container fixes from this branch and main's
registry-domain migration and node-name genericisation coexist without
manual intervention.

Conflict resolution — all of them were modify/delete, and all were resolved
in main's favour deliberately:

`.planning/**`, `scripts/deploy-to-target.sh` and `scripts/setup-aiui-server.sh`
were deleted by main's `6ba05996` ("security: remove all infrastructure and
internal process material from the repo") and added to .gitignore there.
Keeping this branch's copies would have re-committed internal process and
infrastructure material into a repo being prepared for publication, silently
undoing that cleanup. Resolved with `git rm --cached`, so every file remains
on disk locally and in this branch's history — it is untracked, not lost.
The remaining .planning files this branch added after the merge base were
untracked the same way, so the result is consistent rather than half-tracked.

Container suite 221/221 on the merged tree.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-08-08 09:02:32 -04:00
co-authored by Claude Opus 5
542 changed files with 5638 additions and 83054 deletions
+2 -2
View File
@@ -13,7 +13,7 @@ Checks:
Usage:
scripts/app-catalog-image-smoke-test.py \
--target archipelago@192.168.1.198 \
--target archipelago@192.0.2.11 \
--ssh-key /home/archipelago/.ssh/id_ed25519
"""
@@ -31,7 +31,7 @@ from pathlib import Path
import yaml
INSECURE_REGISTRIES = ("146.59.87.168:3000", "23.182.128.160:3000")
INSECURE_REGISTRIES = ("source.archipelago-foundation.org", "23.182.128.160:3000")
def run(cmd: list[str], timeout: int = 120) -> subprocess.CompletedProcess[str]:
+1 -1
View File
@@ -7,7 +7,7 @@
# 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
# scripts/app-surface-smoke-test.sh --target archipelago@192.0.2.10 --ssh-key /path/key
set -euo pipefail
+34 -10
View File
@@ -18,14 +18,26 @@ PATTERNS=(
"api_key\s*=\s*['\"][^'\"]*['\"]"
"secret\s*=\s*['\"][^'\"]*['\"]"
"private_key\s*=\s*['\"][^'\"]*['\"]"
"sk-ant-"
"sk-ant-[A-Za-z0-9_-]{20,}"
"AKIA[A-Z0-9]{16}"
"ghp_[a-zA-Z0-9]{36}"
"glpat-[a-zA-Z0-9_-]{20}"
# Credentialed URLs: scheme://user:pass@host
"://[A-Za-z0-9_.-]+:[A-Za-z0-9_.@!%-]{8,}@"
# sshpass with an inline literal
"sshpass\s+-p\s*['\"][^'\"]+['\"]"
)
# Allowed files (config templates, docs, test fixtures)
ALLOW_PATTERNS="test|e2e|mock|demo|example|Example|template|CLAUDE.md|deploy-config|\.md$|node_modules|dist|target|default\)|grep.*rpc|audit-secrets|startsWith|should start with"
# Path allowlist — anchored to the PATH only, never to line content.
# The old version allow-matched the whole "file:line:content" string against
# bare words like "test" and "\.md$", so any hit whose path or text contained
# "test"/"demo"/"example" was silently dropped, and *.md was never scanned at
# all. That is why live API keys and node passwords survived this audit.
ALLOW_PATHS="(^|/)node_modules/|(^|/)(dist|target|\.git)/|\.example($|\.)|(^|/)package-lock\.json$|(^|/)Cargo\.lock$|(^|/)scripts/audit-secrets\.sh$"
# File types to scan. Markdown and YAML are in scope: docs and CI workflows are
# where the real leaks have historically lived.
SCAN_EXTS='\.(rs|ts|vue|js|mjs|cjs|json|sh|py|md|ya?ml|toml|kt|java|gradle|env)$'
main() {
log "=== Secrets Audit ==="
@@ -60,16 +72,26 @@ main() {
# 3. Scan source for hardcoded credentials
log "3. Scanning source for hardcoded secrets..."
local found_secrets=0
# Scan TRACKED files only — that is exactly the set that would be published.
local scan_files
scan_files=$(cd "$REPO_ROOT" && git ls-files | grep -E "$SCAN_EXTS" | grep -vE "$ALLOW_PATHS" || echo "")
if [ -z "$scan_files" ]; then
fail "No tracked files matched the scan set (is this a git repo?)"
return 1
fi
for pattern in "${PATTERNS[@]}"; do
local matches
matches=$(cd "$REPO_ROOT" && 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 "")
matches=$(cd "$REPO_ROOT" && echo "$scan_files" | tr '\n' '\0' \
| xargs -0 grep -niE "$pattern" 2>/dev/null || echo "")
if [ -n "$matches" ]; then
# Filter out false positives (empty strings, variable declarations, etc.)
# Filter out false positives: empty strings, variable indirection, and
# scrubbed <PLACEHOLDER> tokens. NOTE: the previous version wrote the
# single-quote class as \x27\x27, which GNU grep does not expand in an
# ERE — so the empty-string rule silently never matched. Use a literal
# quote via a shell variable instead.
local q="'"
local real_matches
real_matches=$(echo "$matches" | grep -vE '""|\x27\x27|None|null|undefined|TODO|placeholder|example|Option<|\$\{[A-Z0-9_]+:-\}|\$[A-Z0-9_]+|TestPassword|password123|entertoexit' || echo "")
real_matches=$(echo "$matches" | grep -vE "\"\"|${q}${q}|<[A-Z_]+>|None|null|undefined|TODO|placeholder|Option<|\\\$\{[A-Za-z0-9_]+(:-[^}]*)?\}|\\\$[A-Za-z0-9_]+|TestPassword|password123|entertoexit|…|\\.\\.\\." || echo "")
if [ -n "$real_matches" ]; then
echo " WARNING: Pattern '$pattern' found:"
echo "$real_matches" | head -5 | sed 's/^/ /'
@@ -96,7 +118,9 @@ main() {
# 5. Check for credential files in repo
log "5. Checking for credential files..."
local cred_files
cred_files=$(cd "$REPO_ROOT" && git ls-files | grep -Ei '(\.pem$|\.key$|\.p12$|\.pfx$|\.jks$|\.keystore$|id_rsa|id_ed25519|macaroon)' | grep -vE '\.(rs|ts)$' || echo "")
# `testdata/` holds throwaway keypairs generated for unit tests (appgate TLS);
# they are not credentials for anything real. Narrow, path-anchored exemption.
cred_files=$(cd "$REPO_ROOT" && git ls-files | grep -Ei '(\.pem$|\.key$|\.p12$|\.pfx$|\.jks$|\.keystore$|id_rsa|id_ed25519|macaroon)' | grep -vE '\.(rs|ts|sh)$|(^|/)testdata/' || echo "")
if [ -z "$cred_files" ]; then
pass "No credential files tracked in git"
else
+3 -3
View File
@@ -10,8 +10,8 @@
# 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
# scripts/bitcoin-stack-lifecycle-test.sh --target archipelago@192.0.2.10
# scripts/bitcoin-stack-lifecycle-test.sh --target archipelago@192.0.2.12 --cycles 5
set -euo pipefail
@@ -50,7 +50,7 @@ while [ "$#" -gt 0 ]; do
done
if [ -z "$TARGET" ]; then
echo "--target is required, for example archipelago@192.168.1.228" >&2
echo "--target is required, for example archipelago@192.0.2.10" >&2
exit 2
fi
+2 -2
View File
@@ -78,7 +78,7 @@ if $DOCKER ps -a --format '{{.Names}}' 2>/dev/null | grep -q '^electrumx$'; then
-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}"
"${ELECTRUMX_IMAGE:-source.archipelago-foundation.org/lfg2025/electrumx:v1.18.0}"
fi
# Mempool API
@@ -98,7 +98,7 @@ if $DOCKER ps -a --format '{{.Names}}' 2>/dev/null | grep -q '^mempool-api$'; th
-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}"
"${MEMPOOL_API_IMAGE:-source.archipelago-foundation.org/lfg2025/mempool-api:v3.2.0}"
fi
# Stop Tor tunnel if it was active
+40 -3
View File
@@ -52,12 +52,49 @@ LEGACY_STACK_CATALOG_IDS = {
def load_catalog(path: Path) -> dict[str, dict[str, Any]]:
"""Load either catalog shape into {app_id: app-fields}.
Two formats exist and only one used to be understood here:
* app-catalog/catalog.json — `apps` is a LIST of entries carrying `id`.
* releases/app-catalog.json — `apps` is a DICT keyed by app id, and each
entry wraps the app's full manifest under `manifest.app` (the signed
release catalog; EMBED_MANIFESTS has been on since 2026-06-23).
The signed release catalog is the one nodes actually resolve apps through,
so a drift checker that only parsed the list form was checking the file
that governs nothing and crashing on the file that governs everything.
"""
with path.open("r", encoding="utf-8") as fh:
data = json.load(fh)
apps = data.get("apps", [])
if 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")}
if isinstance(apps, list):
return {
str(app.get("id", "")): app
for app in apps
if isinstance(app, dict) and app.get("id")
}
if isinstance(apps, dict):
out: dict[str, dict[str, Any]] = {}
for app_id, entry in apps.items():
if not isinstance(entry, dict):
continue
manifest = entry.get("manifest")
if isinstance(manifest, dict) and isinstance(manifest.get("app"), dict):
# Embedded manifest: compare against the same fields the disk
# manifests expose, plus the entry's own version.
app = dict(manifest["app"])
else:
app = {}
app.setdefault("id", app_id)
if entry.get("version") is not None:
app["version"] = entry["version"]
out[str(app_id)] = app
return out
raise ValueError(f"{path}: expected .apps to be a list or an object")
def load_manifests(apps_dir: Path) -> dict[str, dict[str, Any]]:
+163
View File
@@ -0,0 +1,163 @@
#!/usr/bin/env python3
"""Refuse to publish a catalog naming registry hosts the fleet cannot pull from.
The signed app catalog is authoritative for deployed nodes: `catalog_image_override`
makes its image reference win over the on-disk manifest. So a catalog that names a
registry host the *deployed* binaries do not trust turns every install into
"not from a trusted registry" — fleet-wide, at publish time, with no local signal.
The subtlety this guard exists for: TRUSTED_REGISTRIES in the working tree
describes a binary being built today. Nodes run what was shipped to them. Those
two lists diverge for exactly as long as it takes an OTA to reach the fleet, and
that window is when a catalog regeneration silently breaks everything.
So the floor is tracked explicitly in releases/registry-trust-floor.json and the
catalog is checked against that, never against the source tree.
Usage:
scripts/check-catalog-registry-trust.py # check the release catalog
scripts/check-catalog-registry-trust.py --catalog path.json
scripts/check-catalog-registry-trust.py --show # print current state
"""
from __future__ import annotations
import argparse
import json
import re
import sys
from pathlib import Path
from typing import Any, Iterator
DEFAULT_CATALOG = "releases/app-catalog.json"
DEFAULT_FLOOR = "releases/registry-trust-floor.json"
IMAGE_POLICY = "core/archipelago/src/container/image_policy.rs"
def iter_images(node: Any) -> Iterator[str]:
"""Yield every value stored under an `image` key, at any depth."""
if isinstance(node, dict):
for key, value in node.items():
if key == "image" and isinstance(value, str) and value:
yield value
else:
yield from iter_images(value)
elif isinstance(node, list):
for item in node:
yield from iter_images(item)
def registry_host(image: str) -> str | None:
"""Registry host of an image ref, or None for Docker Hub shorthand.
A ref's first segment is a registry only if it contains a '.' or ':'
(docker.io, host:3000). Otherwise it is a Docker Hub namespace — `nginx`,
`btcpayserver/btcpayserver` — which resolves via registries.conf, not an
attacker-controlled host. This mirrors is_valid_docker_image() in
image_policy.rs; keep the two in step.
"""
head = image.split("/", 1)[0]
if "/" not in image:
return None
if "." in head or ":" in head:
return head
return None
def source_trusted_registries(repo: Path) -> list[str]:
"""TRUSTED_REGISTRIES as the working tree currently defines it (advisory)."""
path = repo / IMAGE_POLICY
try:
text = path.read_text(encoding="utf-8")
except OSError:
return []
match = re.search(r"TRUSTED_REGISTRIES:\s*&\[&str\]\s*=\s*&\[(.*?)\];", text, re.S)
if not match:
return []
body = match.group(1)
hosts = re.findall(r'"([^"]+)"', body)
# Entries may be consts (LEGACY_REGISTRY_HOST); resolve those too.
for const in re.findall(r"\b([A-Z][A-Z0-9_]+)\b", body):
const_match = re.search(rf'{const}:\s*&str\s*=\s*"([^"]+)"', text)
if const_match:
hosts.append(const_match.group(1))
return sorted(set(hosts))
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--catalog", default=DEFAULT_CATALOG)
parser.add_argument("--floor", default=DEFAULT_FLOOR)
parser.add_argument("--repo", default=".")
parser.add_argument("--show", action="store_true",
help="print the floor, the source list and the catalog's hosts")
args = parser.parse_args()
repo = Path(args.repo)
catalog_path = repo / args.catalog
floor_path = repo / args.floor
try:
catalog = json.loads(catalog_path.read_text(encoding="utf-8"))
except OSError as exc:
print(f"ERROR: cannot read catalog: {exc}", file=sys.stderr)
return 2
try:
floor_doc = json.loads(floor_path.read_text(encoding="utf-8"))
except OSError as exc:
print(f"ERROR: cannot read trust floor: {exc}", file=sys.stderr)
return 2
floor = set(floor_doc.get("hosts") or [])
if not floor:
print(f"ERROR: {args.floor} lists no hosts; refusing to pass vacuously.",
file=sys.stderr)
return 2
hosts: dict[str, list[str]] = {}
for image in iter_images(catalog):
host = registry_host(image)
if host:
hosts.setdefault(host, []).append(image)
if args.show:
print("trust floor (deployed binaries):")
for h in sorted(floor):
print(f" {h}")
pending = floor_doc.get("pending") or {}
if pending:
print("pending (not yet in the fleet):")
for h, meta in pending.items():
print(f" {h} — trusted_from_binary={meta.get('trusted_from_binary')}")
print("working-tree TRUSTED_REGISTRIES (advisory):")
for h in source_trusted_registries(repo) or ["(could not parse)"]:
print(f" {h}")
print(f"catalog hosts ({catalog_path}):")
for h in sorted(hosts):
print(f" {h} ({len(hosts[h])} image refs)")
violations = sorted(set(hosts) - floor)
if violations:
print("")
print("REFUSING: the catalog names registry hosts the deployed fleet does not trust.")
for host in violations:
examples = hosts[host][:3]
print(f"\n {host}{len(hosts[host])} image refs, e.g.")
for ref in examples:
print(f" {ref}")
print("")
print("Publishing this would make every install fail with")
print('"not from a trusted registry" on every node in the field.')
print("")
print(f"Fix by ordering the migration — see the _comment in {args.floor}:")
print(" ship a binary that trusts the host, confirm the fleet is on it,")
print(" promote the host in the floor file, and only then regenerate.")
return 1
print(f"OK: all {len(hosts)} registry host(s) in {args.catalog} are trusted by the deployed fleet.")
return 0
if __name__ == "__main__":
sys.exit(main())
+134
View File
@@ -0,0 +1,134 @@
#!/usr/bin/env python3
"""Fail when a hardcoded installer image tag disagrees with the app manifest.
The legacy stack installers in core/archipelago/src/api/rpc/package/stacks.rs
carry image references as string literals. Those literals are a second source of
truth for a version, sitting behind the manifest and the signed catalog, and
nothing keeps them in step.
That is not cosmetic. BTCPay shipped 2.4.2 for an actively exploited 2FA bypass
on 2026-08-07 while the legacy installer still named 2.3.9, so the fallback
install path would have deployed the withdrawn release. The same shape applies
to any app whose installer literal is left behind.
The rule enforced here: if an installer literal names the same image repository
as an app manifest, the tags must match. Repositories with no manifest are
ignored, and so are floating tags, which carry no version claim.
Usage:
scripts/check-installer-image-pins.py
scripts/check-installer-image-pins.py --show
"""
from __future__ import annotations
import argparse
import re
import sys
from pathlib import Path
import yaml
# Files that pin images as literals on an install path. Test modules inside them
# are stripped before scanning: fixtures deliberately name old versions.
INSTALLER_SOURCES = [
"core/archipelago/src/api/rpc/package/stacks.rs",
]
FLOATING_TAGS = {"latest", "stable", "release", "main", "edge"}
IMAGE_RE = re.compile(r'"([a-z0-9][a-z0-9._-]*(?:\.[a-z]+|:[0-9]+)?/[a-z0-9._/-]+:[A-Za-z0-9._-]+)"')
def strip_test_modules(text: str) -> str:
"""Remove #[cfg(test)] modules so fixture literals are not treated as pins."""
marker = "#[cfg(test)]"
idx = text.find(marker)
return text if idx == -1 else text[:idx]
def repo_of(image: str) -> str:
"""Image repository without registry host or tag."""
without_tag = image.rsplit(":", 1)[0] if ":" in image.rsplit("/", 1)[-1] else image
head, _, rest = without_tag.partition("/")
if "." in head or ":" in head or head == "localhost":
return rest
return without_tag
def tag_of(image: str) -> str:
last = image.rsplit("/", 1)[-1]
return last.rsplit(":", 1)[1] if ":" in last else "latest"
def manifest_images(repo_root: Path) -> dict[str, tuple[str, str]]:
"""{image repo: (tag, manifest path)} across apps/*/manifest.yml."""
out: dict[str, tuple[str, str]] = {}
for path in sorted((repo_root / "apps").glob("*/manifest.yml")):
try:
data = yaml.safe_load(path.read_text(encoding="utf-8"))
except Exception:
continue
app = (data or {}).get("app")
if not isinstance(app, dict):
continue
image = (app.get("container") or {}).get("image")
if isinstance(image, str) and image:
out[repo_of(image)] = (tag_of(image), str(path.relative_to(repo_root)))
return out
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--repo", default=".")
parser.add_argument("--show", action="store_true")
args = parser.parse_args()
repo_root = Path(args.repo)
manifests = manifest_images(repo_root)
problems: list[str] = []
checked = 0
for rel in INSTALLER_SOURCES:
path = repo_root / rel
if not path.exists():
continue
text = strip_test_modules(path.read_text(encoding="utf-8"))
for line_no, line in enumerate(text.splitlines(), start=1):
for image in IMAGE_RE.findall(line):
repo = repo_of(image)
if repo not in manifests:
continue
tag = tag_of(image)
manifest_tag, manifest_path = manifests[repo]
checked += 1
if args.show:
print(f" {rel}:{line_no} {repo}:{tag} (manifest {manifest_tag})")
if tag in FLOATING_TAGS or manifest_tag in FLOATING_TAGS:
continue
if tag != manifest_tag:
problems.append(
f"{rel}:{line_no}\n"
f" installer pins {repo}:{tag}\n"
f" manifest wants {repo}:{manifest_tag} ({manifest_path})"
)
if problems:
print("")
print("Installer image pins disagree with their app manifests:")
for problem in problems:
print(f"\n {problem}")
print("")
print("An installer literal left behind deploys the older image on the")
print("fallback install path — which is how a withdrawn, vulnerable")
print("release gets installed after it has supposedly been replaced.")
print("Update the literal to match the manifest.")
return 1
print(f"OK: {checked} installer image pin(s) agree with their app manifests.")
return 0
if __name__ == "__main__":
sys.exit(main())
+105
View File
@@ -0,0 +1,105 @@
#!/usr/bin/env bash
# check-release-assets.sh — prove a release's artifacts are actually fetchable
# BEFORE its manifest goes live on main.
#
# The manifest is the trigger: nodes read releases/manifest.json from branch
# main, and the moment it names a new version they try to download it. So the
# assets must resolve before the manifest lands, not after. On 2026-08-07 the
# order was reversed — the v1.7.126-alpha manifest went live while its binary
# 500'd and its frontend tarball had never uploaded — and every polling node
# would have advertised an update it could not fetch.
#
# For each component in the manifest this checks:
# 1. the download URL returns HTTP 200
# 2. the downloaded bytes match the manifest's sha256 and size
#
# It downloads each asset in full, because a HEAD 200 is not proof the body is
# intact — the corrupt binary that day passed HEAD-shaped checks and still
# served a broken stream. Slower, but this is the last gate before publish.
#
# Usage:
# scripts/check-release-assets.sh # check releases/manifest.json
# scripts/check-release-assets.sh path/to/manifest.json
#
# Exit 0 = every asset is downloadable and matches. Non-zero = do NOT publish.
set -euo pipefail
MANIFEST="${1:-releases/manifest.json}"
if [[ ! -f "$MANIFEST" ]]; then
echo "ERROR: manifest not found: $MANIFEST" >&2
exit 2
fi
command -v python3 >/dev/null 2>&1 || { echo "ERROR: python3 required" >&2; exit 2; }
command -v sha256sum >/dev/null 2>&1 || { echo "ERROR: sha256sum required" >&2; exit 2; }
TMP="$(mktemp -d)"
trap 'rm -rf "$TMP"' EXIT
# Emit "url<TAB>sha256<TAB>size<TAB>name" per component, tolerating the field
# name variations the manifest has used (download_url/url, size_bytes/size).
rows="$(python3 - "$MANIFEST" <<'PY'
import json, sys
d = json.load(open(sys.argv[1]))
comps = d.get("components") or []
if not comps:
sys.exit("manifest has no components")
for c in comps:
url = c.get("download_url") or c.get("url") or ""
sha = c.get("sha256") or ""
size = c.get("size_bytes") or c.get("size") or ""
name = c.get("name") or "(unnamed)"
if not url or not sha:
sys.exit(f"component {name!r} missing url or sha256")
print(f"{url}\t{sha}\t{size}\t{name}")
PY
)"
version="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get("version","?"))' "$MANIFEST")"
echo "Checking release assets for v${version} ($MANIFEST)"
echo ""
fail=0
n=0
while IFS=$'\t' read -r url sha size name; do
[ -z "$url" ] && continue
n=$((n + 1))
out="$TMP/asset.$n"
echo " [$name]"
echo " $url"
code="$(curl -sL -o "$out" -w '%{http_code}' "$url" || echo "000")"
if [ "$code" != "200" ]; then
echo " FAIL: HTTP $code (asset not served)"
fail=1
continue
fi
got_size="$(stat -c%s "$out")"
if [ -n "$size" ] && [ "$size" != "$got_size" ]; then
echo " FAIL: size $got_size, manifest says $size"
fail=1
continue
fi
got_sha="$(sha256sum "$out" | awk '{print $1}')"
if [ "$got_sha" != "$sha" ]; then
echo " FAIL: sha256 mismatch"
echo " served: $got_sha"
echo " manifest: $sha"
fail=1
continue
fi
echo " OK: HTTP 200, ${got_size} bytes, sha256 matches"
done <<< "$rows"
echo ""
if [ "$fail" -ne 0 ]; then
echo "REFUSING: one or more assets are not fetchable or do not match the manifest."
echo "Do NOT publish the manifest — nodes would advertise an update they cannot"
echo "apply. Upload/repair the assets, re-run this, and only then flip the"
echo "manifest live on main."
exit 1
fi
echo "OK: all $n asset(s) for v${version} download and match the manifest."
+3 -3
View File
@@ -428,7 +428,7 @@ print(' '.join(['\"' + a + '\"' if ' ' in a else a for a in args[2:]]))
# 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
# (aardvark-dns, podman's pause process) survive — observed on a test node
# 2026-07-10, where the old stop/start-only cycle bounced all 35 containers
# every timer run for ~an hour without ever restoring egress. So: stop the
# containers, kill the netns holders, `podman system migrate`, clear the
@@ -610,7 +610,7 @@ fix_npm_public_hosts() {
# 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
# this invoice" (observed on a test node 2026-07-10 with a Blink payer). Route
# hints are a no-op with public channels and essential with private ones, so
# the doctor enforces the flag on every store. BTCPay reads store blobs from
# Postgres per request; no restart needed.
@@ -636,7 +636,7 @@ fix_btcpay_route_hints() {
# 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
# deploys with a missing-init error (observed on a test node 2026-07-10
# deploying sites via Portainer). install-podman.sh covers fresh ISO
# installs; this heals nodes that predate it.
fix_missing_catatonit() {
+1 -1
View File
@@ -586,7 +586,7 @@ load_spec_archy-lnd-ui() {
# created by first-boot-containers.sh, which is host-networked and never
# consults this file; the spec is only read when self-update.sh rebuilds a
# UI image, and that only fires when a file under docker/lnd-ui/ changes.
# Verified on archi-dev-box: recreating from the old spec left :18083
# Verified on a test node: recreating from the old spec left :18083
# refusing connections.
SPEC_NETWORK="host"
SPEC_MEMORY="$(mem_limit archy-lnd-ui)"
+1 -1
View File
@@ -18,7 +18,7 @@ RELEASE_DATE=""
OUTPUT_FILE="manifest.json"
BACKEND_BINARY=""
FRONTEND_ARCHIVE=""
BASE_URL="http://146.59.87.168:3000/lfg2025/archy/releases/download"
BASE_URL="https://source.archipelago-foundation.org/lfg2025/archy/releases/download"
usage() {
echo "Usage: $0 --version VERSION [--date DATE] [--output FILE]"
+2 -2
View File
@@ -128,7 +128,7 @@ if $DRY_RUN; then
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"
echo " - Build ISOs on server: ssh archipelago@192.0.2.10"
exit 0
fi
@@ -293,4 +293,4 @@ 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"
echo " curl -fsS https://source.archipelago-foundation.org/lfg2025/archy/raw/branch/main/releases/manifest.json"
-31
View File
@@ -1,31 +0,0 @@
#!/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'
+1 -1
View File
@@ -74,7 +74,7 @@ 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}
FROM ${NGINX_ALPINE_IMAGE:-source.archipelago-foundation.org/lfg2025/nginx:1.29.6-alpine}
# Copy the static UI
COPY index.html /usr/share/nginx/html/
-7
View File
@@ -1,7 +0,0 @@
#!/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"
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+9 -4
View File
@@ -16,10 +16,15 @@ 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_HOST="${ARCHIPELAGO_SSH_HOST:-}"
if [ -z "$SSH_HOST" ]; then
echo "ARCHIPELAGO_SSH_HOST must be set, e.g. archipelago@<node-host>" >&2
exit 2
fi
HOST_ONLY="${SSH_HOST#*@}"
SSH_OPTS="-o StrictHostKeyChecking=no -o ServerAliveInterval=15 -i $SSH_KEY"
REMOTE_DIR="/home/archipelago/archy"
RPC_URL="http://192.168.1.228/rpc/v1"
RPC_URL="http://${HOST_ONLY}/rpc/v1"
COOKIE=""
ONCE=false
[ "$1" = "--once" ] && ONCE=true
@@ -66,7 +71,7 @@ login() {
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
if curl -sf "http://${HOST_ONLY}/health" >/dev/null 2>&1; then
return 0
fi
sleep 1
@@ -146,7 +151,7 @@ run_smoke_tests() {
# 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"
local install_img="source.archipelago-foundation.org/lfg2025/filebrowser:v2.27.0"
# Check if already installed
local fb_state
fb_state=$(ssh $SSH_OPTS "$SSH_HOST" "podman inspect filebrowser --format '{{.State.Status}}' 2>/dev/null || echo 'none'")
+1 -1
View File
@@ -82,7 +82,7 @@ case $choice in
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 " b) Download an ISO from FileBrowser (http://192.0.2.10:8083)"
echo " then drop it on your Desktop and re-run this option."
echo ""
echo " Files you can edit:"
+2 -2
View File
@@ -4,12 +4,12 @@
# Creates core containers so My Apps works out of the box after ISO install
# Runs after archipelago-load-images.service and archipelago-setup-tor.service
#
# Based on scripts/deploy-to-target.sh (--live) container logic - do not diverge.
# Container logic mirrors the deploy path - do not diverge.
# No set -e: each section continues even if one fails (idempotent, best-effort).
#
# Image versions: sourced from /opt/archipelago/image-versions.sh (single source of truth).
# All container image references use the $*_IMAGE variables defined there.
# Images pull from the Archipelago app registry (146.59.87.168:3000/lfg2025/).
# Images pull from the Archipelago app registry (source.archipelago-foundation.org/lfg2025/).
#
# --- PLANNED REFACTOR (post-beta) ---
# This script is ~995 lines and should be split into a modular library.
-176
View File
@@ -1,176 +0,0 @@
#!/bin/bash
# LAN fast-path pairing for our 4 dev fleet nodes.
#
# ── Is this needed for every archipelago install? No. ────────────────
# For nodes deployed anywhere in the world, FIPS-to-FIPS routing by
# npub works via the anchor peer network (fips.v0l.io ships by default
# in /etc/fips/fips.yaml on every install — that anchor bootstraps DHT
# routing for any npub the node has ever heard about). The peer's
# fips_npub is advertised in our federation invite codes (since v1.4),
# so accepting an invite is enough for `dial::peer_base_url(npub)` to
# reach the peer through the anchor mesh.
#
# ── Why this script exists ───────────────────────────────────────────
# Our 4 fleet nodes are all on 192.168.1.0/24. Hopping through the
# fips.v0l.io anchor for intra-LAN traffic is wasteful when the peers
# are on the same wire. This script writes per-node fips.yaml with:
# 1. The public anchor (fips.v0l.io) so internet peers still route.
# 2. The other 3 fleet nodes as static LAN peers (UDP 2121 / TCP
# 8443) so LAN traffic stays on LAN.
# 3. `persistent: true` so the npub is stable across restarts —
# without this the daemon rolls a new keypair on every restart
# and any federation invite we advertised goes stale.
#
# Idempotent: re-running picks up any newly-added or removed nodes.
#
# For a production install on an unknown LAN, this script isn't the
# mechanism — the ISO install writes the anchor-only fips.yaml and
# identity comes from the archipelago seed; peer discovery is purely
# through the DHT + federation invites.
#
# Usage:
# scripts/fleet-fips-pair.sh # apply to all nodes
# scripts/fleet-fips-pair.sh --verify # just print the peer state
set -eo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
. "$SCRIPT_DIR/lib/common.sh"
# Fleet roster: "<ip-last-octet> <nic-name> <fips-npub>"
NODES=(
"116 enp0s25 npub1mxavs6scfgl056k6lm4mk73ddnrhjewg78zlyzfn2lmr0rfyrs5qhcr03g"
"198 enp2s0 npub13cy4lml94cj4rdu8runrr945z2muszuvr5tql8mr9m063d7xzpqqu3k8se"
"228 enp2s0 npub1a0xxcqce2tsv8ulwastep23jtf3h4wvvry8r8nklnl36jtrdnefqh5qn6h"
"253 enx9cbf0d0129f9 npub1dl0m0yfzfw6467c3z6q63s7ggzd77yg97j90ptfrheprxeypt3msj0mq4g"
)
LAN_PREFIX="192.168.1"
UDP_PORT=2121
TCP_PORT=8443
if [ "${1:-}" = "--verify" ]; then
for row in "${NODES[@]}"; do
read -r node _nic _npub <<< "$row"
echo "=== .$node ==="
ssh_cmd "$LAN_PREFIX.$node" "sudo fipsctl show peers 2>/dev/null | python3 -c 'import sys,json; d=json.load(sys.stdin); print(f\"{len(d[\"peers\"])} authenticated peers\"); [print(\" npub=\", p.get(\"npub\",\"?\"), \"alias=\", p.get(\"alias\",\"?\")) for p in d[\"peers\"]]' || echo ' fipsctl show peers failed'"
done
exit 0
fi
TMP_ROOT=$(mktemp -d)
trap 'rm -rf "$TMP_ROOT"' EXIT
generate_yaml() {
# $1 = self node octet, $2 = self nic
local self_node="$1"
local self_nic="$2"
local out="$TMP_ROOT/fips.yaml.$self_node"
cat > "$out" <<YAML
# FIPS Node Configuration — managed by scripts/fleet-fips-pair.sh
# DO NOT hand-edit: re-run the script to regenerate.
node:
identity:
# Persistent identity so the npub stays stable across restarts.
# Without this, every restart rolls a new keypair and federation
# peer lists go stale.
persistent: true
tun:
enabled: true
name: fips0
mtu: 1280
dns:
enabled: true
bind_addr: "127.0.0.1"
port: 5354
transports:
udp:
bind_addr: "0.0.0.0:$UDP_PORT"
tcp:
bind_addr: "0.0.0.0:$TCP_PORT"
ethernet:
interface: "$self_nic"
discovery: true
announce: true
auto_connect: true
accept_connections: true
peers:
# Public anchor — bootstraps DHT routing for any npub heard via
# federation invites. Every archipelago install ships this peer.
- npub: "npub1zv58cn7v83mxvttl70w5fwjwuclfmntv9cnmv5wmz2nzz88u5urqvdx96n"
alias: "fips.v0l.io"
addresses:
- transport: tcp
addr: "fips.v0l.io:8443"
- transport: udp
addr: "fips.v0l.io:2121"
connect_policy: auto_connect
# Fleet LAN fast-path — other archipelago nodes on this subnet.
YAML
for other_row in "${NODES[@]}"; do
read -r o_node _o_nic o_npub <<< "$other_row"
[ "$o_node" = "$self_node" ] && continue
cat >> "$out" <<YAML
- npub: "$o_npub"
alias: "archi-$o_node"
addresses:
- transport: udp
addr: "$LAN_PREFIX.$o_node:$UDP_PORT"
- transport: tcp
addr: "$LAN_PREFIX.$o_node:$TCP_PORT"
connect_policy: auto_connect
YAML
done
echo "$out"
}
deploy_to() {
local node="$1"
local nic="$2"
local ip="$LAN_PREFIX.$node"
local yaml
yaml=$(generate_yaml "$node" "$nic")
log_info "[.${node}] uploading fips.yaml"
scp_cmd "$yaml" "archipelago@${ip}:/tmp/fips.yaml.new"
log_info "[.${node}] installing + restarting fips.service"
ssh_cmd "$ip" '
set -e
sudo install -o root -g root -m 0600 /tmp/fips.yaml.new /etc/fips/fips.yaml
rm -f /tmp/fips.yaml.new
sudo systemctl restart fips.service
# Give the daemon a beat to come up before we ask about peers
for i in $(seq 1 10); do
if sudo systemctl is-active fips.service >/dev/null 2>&1; then break; fi
sleep 0.5
done
sudo systemctl is-active fips.service
'
}
for row in "${NODES[@]}"; do
read -r node nic _npub <<< "$row"
deploy_to "$node" "$nic"
done
echo
log_info "Waiting 10s for peer handshakes to settle…"
sleep 10
echo
log_info "Post-pair peer state:"
for row in "${NODES[@]}"; do
read -r node _nic _npub <<< "$row"
count=$(ssh_cmd "$LAN_PREFIX.$node" "sudo fipsctl show peers 2>/dev/null | grep -c '\"npub\"' || echo 0")
log_info " .$node: $count authenticated peers"
done
-135
View File
@@ -1,135 +0,0 @@
#!/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
+2 -2
View File
@@ -172,7 +172,7 @@ if os.environ.get("EMBED_MANIFESTS") and apps_dir:
# 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")
REGISTRY = os.environ.get("ARCHY_REGISTRY", "source.archipelago-foundation.org/lfg2025")
VERSIONS = {
# Curated Core set (latest patch per major, current → 25). Images built +
# verified (SHA-256 + OpenPGP, fail-closed) and pushed by
@@ -203,7 +203,7 @@ VERSIONS = {
# 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).
# the bitcoin multi-version design).
"bitcoin-knots": [
{"version": "latest",
"image": f"{REGISTRY}/bitcoin-knots:29.3.knots20260508", "default": True},
+4 -4
View File
@@ -5,12 +5,12 @@
# 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
# Tags MUST match what's actually in the registry at source.archipelago-foundation.org/lfg2025/
# Run: podman images --format '{{.Repository}}:{{.Tag}}' | grep 'source.archipelago-foundation.org' | sort
# to verify against the registry.
# Archipelago app registries (primary + fallback)
ARCHY_REGISTRY="146.59.87.168:3000/lfg2025"
ARCHY_REGISTRY="source.archipelago-foundation.org/lfg2025"
# No fallback registry: the old tx1138 registry host was retired (2026-06-13); empty disables the fallback path.
ARCHY_REGISTRY_FALLBACK=""
@@ -25,7 +25,7 @@ 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"
BTCPAY_IMAGE="docker.io/btcpayserver/btcpayserver:2.4.2"
NBXPLORER_IMAGE="$ARCHY_REGISTRY/nbxplorer:2.6.0"
POSTGRES_IMAGE="$ARCHY_REGISTRY/postgres:15.17"
BTCPAY_POSTGRES_IMAGE="$ARCHY_REGISTRY/postgres:15.17"
+3 -3
View File
@@ -576,18 +576,18 @@ screen_complete() {
# URL in orange
goto $row 1
p " ${ORANGE}http://192.168.1.198${NC}"
p " ${ORANGE}http://192.0.2.11${NC}"
row=$((row + 2))
# Credentials — white, NOT orange (user request)
goto $row 1
p " ${WHITE}SSH ssh archipelago@192.168.1.198${NC}"
p " ${WHITE}SSH ssh archipelago@192.0.2.11${NC}"
row=$((row + 1))
goto $row 1
p " ${WHITE}Password archipelago${NC}"
row=$((row + 1))
goto $row 1
p " ${WHITE}Web Login password123${NC}"
p " ${WHITE}Web Login create your password on first visit${NC}"
row=$((row + 2))
goto $row 1; hrule; row=$((row + 2))
-252
View File
@@ -1,252 +0,0 @@
#!/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
+23 -20
View File
@@ -62,8 +62,19 @@ repo_path=${repo_path%.git}
api="$scheme://$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}"
# ORDER MATTERS. The manifest is the trigger — nodes read releases/manifest.json
# from branch main and try to download the named version the moment it appears.
# So main (which carries the live manifest) must be pushed LAST, only after the
# assets are uploaded and their bytes verified against the manifest. The tag is
# pushed first because the Gitea release and its asset download URLs hang off it,
# but the tag alone changes nothing for nodes.
#
# This used to push main and the tag together, up front, then upload assets. That
# left the manifest live for the entire upload+verify window — and on 2026-08-07
# an upload failed inside that window, so every polling node briefly advertised a
# v1.7.126-alpha update whose binary 500'd and whose tarball did not exist.
echo "Pushing tag v${VERSION} to $REMOTE (not main yet)..."
git -C "$PROJECT_ROOT" push "$REMOTE" "refs/tags/v${VERSION}"
release_json=$(curl -fsS -u "$auth" "$release_url" || true)
if [ -z "$release_json" ]; then
@@ -107,24 +118,16 @@ upload_asset() {
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
echo "Verifying public download URLs (full GET + size + sha256)..."
# Delegated to check-release-assets.sh so the same verifier is used here and by
# hand during recovery. It fails hard on the first bad asset — the previous
# inline `while read` ran in a pipe subshell, where a `fail` (exit) killed only
# the subshell and let this script march on to "published and verified".
"$PROJECT_ROOT/scripts/check-release-assets.sh" "$PROJECT_ROOT/releases/manifest.json" \
|| fail "asset verification failed — NOT pushing main. The manifest stays off the branch nodes read, so no node sees a version it cannot fetch. Repair the assets and re-run."
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
# Assets are proven fetchable — only now does the manifest become live.
echo "Assets verified. Pushing main to $REMOTE (this makes v${VERSION} live)..."
git -C "$PROJECT_ROOT" push "$REMOTE" main
echo "Release v${VERSION} published and verified on $REMOTE."
+5 -5
View File
@@ -28,23 +28,23 @@ will never catch. This harness is the gate.
Against the .228 test node:
scripts/resilience/resilience.sh archipelago@192.168.1.228
scripts/resilience/resilience.sh archipelago@192.0.2.10
Or non-interactive (CI):
RESILIENCE_SSH_PASS=… RESILIENCE_UI_PASS=… \
scripts/resilience/resilience.sh archipelago@192.168.1.228
scripts/resilience/resilience.sh archipelago@192.0.2.10
Filters:
# Smoke test (3 apps, no reboot, ~15min)
scripts/resilience/resilience.sh archipelago@192.168.1.228 smoke
scripts/resilience/resilience.sh archipelago@192.0.2.10 smoke
# Single app
scripts/resilience/resilience.sh archipelago@192.168.1.228 bitcoin-knots
scripts/resilience/resilience.sh archipelago@192.0.2.10 bitcoin-knots
# Subset
scripts/resilience/resilience.sh archipelago@192.168.1.228 bitcoin-knots,lnd
scripts/resilience/resilience.sh archipelago@192.0.2.10 bitcoin-knots,lnd
Without a filter, the harness sweeps **every** app in the catalog
(~24 apps × 7 per-app transitions + 2 batch transitions) and runs the
+1 -1
View File
@@ -3,7 +3,7 @@
# 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
# TARGET — ssh target, e.g. archipelago@192.0.2.10
# RPC_URL — http://<host>:5678/rpc/v1
# COOKIE_JAR — path for curl cookie store
# SSH_PASS — sshpass password
+1 -1
View File
@@ -8,7 +8,7 @@
# remains in the expected state at every step.
#
# Usage:
# scripts/resilience/resilience.sh archipelago@192.168.1.228 [filter]
# scripts/resilience/resilience.sh archipelago@192.0.2.10 [filter]
#
# `filter` is a comma-separated list of app IDs (or "smoke" for the curated
# fast subset). Default: every app in app-catalog/catalog.json.
+11 -3
View File
@@ -2,8 +2,9 @@
# 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)
# Usage: bash run-post-install-tests.sh --password-stdin # read password from stdin (preferred)
# bash run-post-install-tests.sh [password] # argv form; visible in `ps`, local use only
# bash run-post-install-tests.sh --phase1-only # Install checks only (no auth)
#
# Tests:
# Phase 1: Install verification (services, files, logs) — safe, no side effects
@@ -12,15 +13,22 @@
set -u
PHASE1_ONLY=false
PASSWORD="testpass123!"
PASSWORD=""
for arg in "$@"; do
case "$arg" in
--phase1-only) PHASE1_ONLY=true ;;
--password-stdin) IFS= read -r PASSWORD || true ;;
*) PASSWORD="$arg" ;;
esac
done
if [ "$PHASE1_ONLY" = false ] && [ -z "$PASSWORD" ]; then
echo "ERROR: no password supplied. Use --password-stdin, pass one as an argument," >&2
echo " or run --phase1-only for the no-auth install checks." >&2
exit 2
fi
BASE="http://127.0.0.1:5678"
JAR="/tmp/e2e-cookies.txt"
rm -f "$JAR"
+58
View File
@@ -268,6 +268,57 @@ if [ -n "$FAIL" ]; then
die "rotation verification FAILED:$FAIL — old material is in $BACKUP"
fi
# ── BTCPay's inline copy ──────────────────────────────────────────────
# BTCPay reaches the internal LND node with a connection string that carries
# the macaroon INLINE as hex, not as a file path: LND's datadir is owned by its
# container's mapped uid, so btcpay cannot bind-mount the file. That copy is
# therefore now a dead credential, and nothing else will notice — the daemon
# only regenerates this secret when LND's TLS *cert* thumbprint changes, which
# macaroon rotation does not touch. The node keeps looking healthy (btcpay up,
# LND up) while every Lightning invoice BTCPay tries to create fails.
#
# Deleting the secret file gets the daemon to regenerate it from the new
# macaroon on its next reconcile tick. That is necessary but NOT sufficient, and
# the difference matters: the RUNNING container still holds the dead value, and
# the periodic reconciler only ever runs in `ExistingOnly` mode, where env drift
# on a restart-sensitive app (btcpay-server is one) is detected and then
# deliberately skipped — "leaving running restart-sensitive app untouched". So
# the container has to be recreated on purpose. The dashboard path
# (Settings → Lightning credentials) does this itself by flagging the app as
# credential-rotated; a shell script cannot reach that in-process flag, so it
# removes the container instead and lets the orchestrator's own desired-state
# recovery rebuild it around unchanged data, ports and volumes.
#
# Nothing is printed but a path — never the value.
BTCPAY_SECRET="/var/lib/archipelago/secrets/btcpay-lnd-connection"
BTCPAY_NOTE=no
if sudo test -f "$BTCPAY_SECRET"; then
if sudo rm -f "$BTCPAY_SECRET"; then
say
say "btcpay : removed its stale connection string ($BTCPAY_SECRET)."
say " The daemon regenerates it from the new macaroon within a minute."
BTCPAY_NOTE=yes
if podman container exists btcpay-server 2>/dev/null; then
say " Recreating btcpay-server so it stops using the dead one."
podman stop btcpay-server >/dev/null 2>&1 || true
if podman rm -f btcpay-server >/dev/null 2>&1; then
say " Removed; the orchestrator rebuilds it around its existing"
say " data (it was running, so desired-state recovery restores it)."
else
say " ⚠ could not remove btcpay-server. Its Lightning payments will"
say " fail until it is recreated."
BTCPAY_NOTE=warn
fi
fi
else
say
say "btcpay : ⚠ could not remove $BTCPAY_SECRET. BTCPay is still holding"
say " the OLD macaroon, so its Lightning payments will fail until"
say " that file is deleted and btcpay-server is recreated."
BTCPAY_NOTE=warn
fi
fi
say
say "✅ Rotated. Every macaroon issued before now no longer verifies."
say
@@ -278,6 +329,13 @@ say " and scan the new pairing QR; it serves the new macaroon."
say
say " Your funds and channels are untouched: the node kept its identity and"
say " no channel was closed."
if [ "${BTCPAY_NOTE:-no}" != no ]; then
say
say " CONFIRM BTCPAY CAME BACK. A silent failure here looks identical to success:"
say " btcpay stays up and healthy while every Lightning payment it tries fails."
say " podman inspect btcpay-server --format '{{.Created}}' # should be just now"
say " sudo test -f $BTCPAY_SECRET && echo regenerated"
fi
say
say " Once every client is re-paired, delete the backup — it holds the OLD"
say " root key, which is still sensitive:"
+5 -5
View File
@@ -1,5 +1,5 @@
#!/bin/bash
# Self-update: pull latest code from the OVH Gitea (146.59.87.168:3000) and apply
# Self-update: pull latest code from the OVH Gitea (source.archipelago-foundation.org) and apply
# Designed to run on installed Archipelago nodes (as archipelago user)
#
# Usage:
@@ -8,7 +8,7 @@
# ./self-update.sh --force # Apply even if already up to date
#
# The script:
# 1. Pulls latest code from origin (146.59.87.168:3000)
# 1. Pulls latest code from origin (source.archipelago-foundation.org)
# 2. Builds the Rust backend (release mode)
# 3. Builds the Vue frontend (production mode)
# 4. Installs the new binary and web UI
@@ -69,7 +69,7 @@ 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"
err "Clone it first: git clone https://source.archipelago-foundation.org/lfg2025/archy ~/archy"
exit 1
fi
@@ -217,7 +217,7 @@ ok "Backend installed"
# Non-fatal: archipelago falls back to its dev venv path if the packaged
# binaries aren't present, so a missing/failed build here degrades mesh
# Reticulum support rather than breaking the update. This mirrors
# deploy-to-target.sh's existing manual-deploy step, which until now was the
# the existing manual-deploy step, which until now was the
# only path that ever installed these — a node that only ever received OTA
# self-updates had neither binary.
if [ -f "$REPO_DIR/reticulum-daemon/build.sh" ]; then
@@ -329,7 +329,7 @@ UI_REBUILD_LIST=""
# /opt/archipelago/docker/<ui>, and nothing was ever updating that directory.
# So source edits to those two trees reached nodes through no path at all:
# their nginx kept listening on 0.0.0.0 and served the Guardian and FIPS
# screens unauthenticated on every interface (found by scanning archi-dev-box
# screens unauthenticated on every interface (found by scanning a test node
# from outside, 2026-08-05 — the in-node audit could not see them).
for ui in bitcoin-ui lnd-ui electrs-ui fips-ui fedimint-ui; do
src="$REPO_DIR/docker/$ui"
-102
View File
@@ -1,102 +0,0 @@
#!/bin/bash
#
# Deploy the AIUI (Chat mode iframe) build to an 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:
# Rsyncs (or tar+scp, if rsync is unavailable on the target) a locally
# built AIUI dist/ into /opt/archipelago/web-ui/aiui/ on the target node.
#
# What it no longer does (13-02-PLAN.md — closing a live production
# exposure): it used to also patch nginx to route /aiui/api/claude/ to a
# standalone Python proxy holding its own ANTHROPIC_API_KEY, with no session
# gate — anyone who could reach the node's web port could spend the owner's
# API budget. That proxy, its systemd unit, and this script's nginx-patch
# step are all deleted (see scripts/deploy-to-target.sh's "Removing legacy
# Claude API proxy sidecar" step). AIUI's Claude/Ollama calls now route
# through the Rust daemon (127.0.0.1:5678), which enforces the session
# cookie itself and reads the node's single key ledger. Set the key via
# `system.settings.set claude_api_key` (Settings > AIUI in neode-ui) — this
# script has nothing to do with the key anymore.
#
# Prerequisites:
# - SSH key access to target server
# - AIUI is built automatically (via scripts/build-aiui.sh) when its dist
# is missing or stale — D-19 (2026-08-03): AIUI lives in-repo at aiui/
# now, so there is no second checkout to build separately first.
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"
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"
AIUI_SRC="$PROJECT_DIR/aiui/packages/app/src"
timestamp() { echo "[$(date +%H:%M:%S)]"; }
# D-19 (2026-08-03): AIUI lives in-repo at aiui/ — no second checkout to
# build separately first. Build it automatically when the dist is missing
# or stale, via the one supported build path (scripts/build-aiui.sh
# enforces VITE_BASE_PATH, installs from the committed lockfile, and
# attributes the build to this repo's own commit). D-15's "enforced, not
# remembered" applies here too — a script that only prints instructions is
# the remembered form.
if [ ! -f "$AIUI_DIST/index.html" ] || [ "$(find "$AIUI_SRC" -newer "$AIUI_DIST/index.html" -print -quit 2>/dev/null)" != "" ]; then
echo "$(timestamp) AIUI dist missing or stale — building via scripts/build-aiui.sh..."
bash "$PROJECT_DIR/scripts/build-aiui.sh"
fi
if [ ! -f "$AIUI_DIST/index.html" ]; then
echo "ERROR: AIUI build not found at $AIUI_DIST after running scripts/build-aiui.sh"
exit 1
fi
echo "╔════════════════════════════════════════════════════════════╗"
echo "║ Archipelago AIUI deploy ║"
echo "║ Target: $TARGET_HOST"
echo "╚════════════════════════════════════════════════════════════╝"
# --- Deploy AIUI files ---
echo ""
echo "$(timestamp) 📦 Deploying AIUI files..."
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."
# --- 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 \" Nginx: \$(systemctl is-active nginx)\"
echo \" Backend: \$(systemctl is-active archipelago)\"
"
echo ""
echo "$(timestamp) Done! AIUI deployed."
echo " Set the Claude API key (if not already set) via Settings > AIUI in"
echo " neode-ui — it now lives only at <data_dir>/secrets/claude-api-key."
echo " Access: http://$(echo $TARGET_HOST | cut -d@ -f2)"
-280
View File
@@ -1,280 +0,0 @@
#!/bin/bash
#
# Set up HTTPS on Archipelago dev server for PWA installability.
# Browsers require HTTPS (or localhost) to install PWAs.
# Generates a self-signed certificate and configures nginx.
#
# Run on the target server: sudo ./setup-https-dev.sh
# Or via deploy: the deploy script runs this automatically.
#
set -e
SSL_DIR="/etc/archipelago/ssl"
NGINX_CFG="/etc/nginx/sites-available/archipelago"
CERT="$SSL_DIR/archipelago.crt"
KEY="$SSL_DIR/archipelago.key"
# Create SSL directory
mkdir -p "$SSL_DIR"
chmod 755 "$SSL_DIR"
# Generate self-signed cert if missing (valid 365 days)
# SAN includes common dev IPs so cert works when accessing via IP
# Build dynamic SAN with all node IPs (LAN + Tailscale + loopback)
SAN_IPS="DNS:archipelago.local,DNS:localhost,IP:127.0.0.1"
# Add all IPv4 addresses on this machine (LAN, Tailscale, etc.)
for ip in $(hostname -I 2>/dev/null | tr ' ' '\n' | grep -E '^[0-9]+\.' | grep -v '^127\.'); do
SAN_IPS="$SAN_IPS,IP:$ip"
done
# Always include common LAN IPs as fallback
for ip in 192.168.1.228 192.168.1.198 10.0.0.1; do
echo "$SAN_IPS" | grep -q "$ip" || SAN_IPS="$SAN_IPS,IP:$ip"
done
# Regenerate cert if missing OR if current cert doesn't include this node's primary IP
REGEN=false
if [ ! -f "$CERT" ] || [ ! -f "$KEY" ]; then
REGEN=true
else
# Check if cert has this node's primary IP
MY_IP=$(hostname -I 2>/dev/null | awk '{print $1}')
if [ -n "$MY_IP" ] && ! openssl x509 -in "$CERT" -noout -text 2>/dev/null | grep -q "$MY_IP"; then
echo " Certificate missing this node's IP ($MY_IP) — regenerating..."
REGEN=true
fi
fi
if [ "$REGEN" = true ]; then
echo "Generating self-signed certificate for PWA (HTTPS)..."
echo " SAN: $SAN_IPS"
openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
-keyout "$KEY" \
-out "$CERT" \
-subj "/CN=archipelago.local/O=Archipelago/C=US" \
-addext "subjectAltName=$SAN_IPS"
chmod 644 "$CERT"
chmod 600 "$KEY"
echo " Certificate created at $CERT"
fi
# PWA snippet for manifest + service worker headers (required for Android install)
NGINX_SNIPPETS="/etc/nginx/snippets"
PWA_SNIPPET="$NGINX_SNIPPETS/archipelago-pwa.conf"
mkdir -p "$NGINX_SNIPPETS"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
if [ -f "$SCRIPT_DIR/nginx-pwa-snippet.conf" ]; then
cp "$SCRIPT_DIR/nginx-pwa-snippet.conf" "$PWA_SNIPPET"
echo " PWA nginx snippet installed at $PWA_SNIPPET"
fi
# Add PWA snippet include to existing HTTPS block if missing
if grep -q "listen 443 ssl" "$NGINX_CFG" 2>/dev/null && [ -f "$PWA_SNIPPET" ]; then
if ! grep -q "archipelago-pwa" "$NGINX_CFG" 2>/dev/null; then
echo " Adding PWA snippet include to HTTPS block..."
# Insert include after "index index.html;" within the HTTPS server block (listen 443 to next })
sed -i '/listen 443 ssl/,/^}$/{
/index index.html;/a\
include snippets/archipelago-pwa.conf;
}' "$NGINX_CFG" 2>/dev/null || true
fi
fi
# Install app proxies snippet (mempool, fedimint, lnd, etc.) - fixes apps not opening over HTTPS (mixed content)
APPS_SNIPPET="$NGINX_SNIPPETS/archipelago-https-app-proxies.conf"
if [ -f "$SCRIPT_DIR/nginx-https-app-proxies.conf" ]; then
cp "$SCRIPT_DIR/nginx-https-app-proxies.conf" "$APPS_SNIPPET"
echo " HTTPS app proxies snippet installed at $APPS_SNIPPET"
# Add include to HTTPS block if missing
if grep -q "listen 443 ssl" "$NGINX_CFG" 2>/dev/null && ! grep -q "archipelago-https-app-proxies" "$NGINX_CFG" 2>/dev/null; then
echo " Adding app proxies include to HTTPS block..."
sed -i '/listen 443 ssl/,/^}$/{
/location \/ws {/i\
include snippets/archipelago-https-app-proxies.conf;
}' "$NGINX_CFG" 2>/dev/null || true
fi
fi
# Check if HTTPS is already configured
if grep -q "listen 443 ssl" "$NGINX_CFG" 2>/dev/null; then
echo "HTTPS already configured in nginx."
nginx -t 2>/dev/null && systemctl reload nginx
MY_IP=$(hostname -I 2>/dev/null | awk '{print $1}')
echo ""
echo "PWA: Use https://${MY_IP:-192.168.1.228} (not http) - accept cert once, then Install app."
exit 0
fi
# Add HTTPS server block (duplicate of HTTP block with SSL)
# PWA requires HTTPS for install on Android
HTTPS_BLOCK='
# HTTPS - required for PWA install (Add to Home Screen) from dev servers
server {
listen 443 ssl;
server_name _;
ssl_certificate '"$CERT"';
ssl_certificate_key '"$KEY"';
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
root /opt/archipelago/web-ui;
index index.html;
include snippets/archipelago-pwa.conf;
location / {
try_files $uri $uri/ /index.html;
}
location /archipelago/ {
proxy_pass http://127.0.0.1:5678;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
location /rpc/ {
proxy_pass http://127.0.0.1:5678;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_connect_timeout 600s;
proxy_send_timeout 600s;
proxy_read_timeout 600s;
}
location /app/nextcloud/ {
proxy_pass http://127.0.0.1:8085/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_hide_header X-Frame-Options;
proxy_hide_header Content-Security-Policy;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
}
location /app/vaultwarden/ {
proxy_pass http://127.0.0.1:8082/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_hide_header X-Frame-Options;
proxy_hide_header Content-Security-Policy;
}
location /app/immich/ {
proxy_pass http://127.0.0.1:2283/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_hide_header X-Frame-Options;
proxy_hide_header Content-Security-Policy;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
}
location /app/penpot/ {
proxy_pass http://127.0.0.1:9001/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_hide_header X-Frame-Options;
proxy_hide_header Content-Security-Policy;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
}
location /app/btcpay/ {
proxy_pass http://127.0.0.1:23000/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_hide_header X-Frame-Options;
proxy_hide_header Content-Security-Policy;
}
location /app/homeassistant/ {
proxy_pass http://127.0.0.1:8123/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_hide_header X-Frame-Options;
proxy_hide_header Content-Security-Policy;
proxy_read_timeout 86400s;
proxy_send_timeout 86400s;
}
location /app/mempool/ {
proxy_pass http://127.0.0.1:4080/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_hide_header X-Frame-Options;
proxy_hide_header Content-Security-Policy;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
}
location /app/fedimint/ {
proxy_pass http://127.0.0.1:8175/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_hide_header X-Frame-Options;
proxy_hide_header Content-Security-Policy;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
}
location /app/lnd/ {
proxy_pass http://127.0.0.1:18083/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_hide_header X-Frame-Options;
proxy_hide_header Content-Security-Policy;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
}
location /app/bitcoin-ui/ {
proxy_pass http://127.0.0.1:8334/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_hide_header X-Frame-Options;
proxy_hide_header Content-Security-Policy;
}
location /ws {
proxy_pass http://127.0.0.1:5678;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_read_timeout 86400s;
}
}
'
# Append HTTPS block to nginx config
echo "$HTTPS_BLOCK" >> "$NGINX_CFG"
echo "Added HTTPS (port 443) to nginx config."
# Test and reload
nginx -t && systemctl reload nginx
echo ""
MY_IP=$(hostname -I 2>/dev/null | awk '{print $1}')
echo "HTTPS enabled. PWA install: https://${MY_IP:-192.168.1.228} (accept the certificate warning once, then Install app)."
-93
View File
@@ -1,93 +0,0 @@
#!/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 ""
+12
View File
@@ -23,6 +23,18 @@ if [[ ! -x "$BIN" ]]; then
fi
SIGN=("$BIN" ceremony sign "$CATALOG")
# Preflight BEFORE asking for the mnemonic. Signing is the point of no return:
# a signed catalog is authoritative for every node, and its image refs override
# the on-disk manifests. If it names a registry host the deployed fleet does not
# trust, every install fails "not from a trusted registry" — so catch that here
# rather than after publication.
if ! python3 "$REPO/scripts/check-catalog-registry-trust.py" --repo "$REPO"; then
echo
echo "✋ Refusing to sign. Nothing was changed and your mnemonic was not requested."
exit 1
fi
echo
echo "════════════════════════════════════════════════════════════════"
echo " Paste your 24-word release master mnemonic below, press Enter,"
echo " then press Ctrl-D on a new line."
+5 -1
View File
@@ -5,7 +5,11 @@
set -euo pipefail
HOST="${1:-192.168.1.198}"
HOST="${1:-${ARCHY_HOST:-}}"
if [ -z "$HOST" ]; then
echo "usage: $0 <node-host> (or set ARCHY_HOST)" >&2
exit 2
fi
PASS=0
FAIL=0
FAILURES=""
+7 -3
View File
@@ -1,17 +1,21 @@
#!/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
# Run this to eliminate "Not secure" when accessing https://<node-host>
#
# Usage: ./scripts/trust-archipelago-cert.sh [host]
# Default host: 192.168.1.228
# Host is required: pass it as $1 or set ARCHY_HOST
#
# Requires: SSH access to archipelago@host (uses deploy-config.sh password)
#
set -e
HOST="${1:-192.168.1.228}"
HOST="${1:-${ARCHY_HOST:-}}"
if [ -z "$HOST" ]; then
echo "usage: $0 <node-host> (or set ARCHY_HOST)" >&2
exit 2
fi
CERT_FILE="/tmp/archipelago-${HOST}.crt"
KEYCHAIN="${HOME}/Library/Keychains/login.keychain-db"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+119 -56
View File
@@ -47,24 +47,68 @@ check() {
esac
}
# Preflight the YAML parser BEFORE any check runs. This used to shell out to
# ruby with stderr discarded, so a machine without ruby reported "invalid YAML"
# and rejected every manifest that was in fact perfectly valid — the first tool
# an app developer runs, failing with a message that sent them to fix the wrong
# thing. Fail loudly about the real cause instead.
if ! command -v python3 >/dev/null 2>&1; then
echo "ERROR: python3 is required to validate manifests, but was not found." >&2
exit 3
fi
if ! python3 -c 'import yaml' >/dev/null 2>&1; then
echo "ERROR: the PyYAML module is required to validate manifests." >&2
echo " Install it with: python3 -m pip install pyyaml" >&2
echo " (Debian/Ubuntu: apt-get install python3-yaml)" >&2
exit 3
fi
# Evaluate a path expression against the manifest's top-level `app` block.
# Missing keys yield an empty string rather than an error, so callers can write
# a plain chain like app["container"]["build"]["tag"] without guarding each hop.
yaml_eval() {
ruby -ryaml -e '
path, expr = ARGV
data = YAML.load_file(path)
app = data.is_a?(Hash) ? data["app"] : nil
abort "missing top-level app block" unless app.is_a?(Hash)
value = eval(expr)
case value
when Array
puts value.join("\n")
when Hash
puts value.to_a.map { |k, v| "#{k}=#{v}" }.join("\n")
when NilClass
puts ""
else
puts value
end
' "$MANIFEST" "$1"
python3 -c '
import sys, yaml
class Nil:
"""Absent value: indexes to itself, is falsy, prints as empty."""
def __getitem__(self, key): return self
def __bool__(self): return False
def __str__(self): return ""
def __iter__(self): return iter(())
NIL = Nil()
class SafeDict(dict):
def __missing__(self, key): return NIL
def wrap(value):
if isinstance(value, dict):
return SafeDict({k: wrap(v) for k, v in value.items()})
if isinstance(value, list):
return [wrap(v) for v in value]
return value
path, expr = sys.argv[1], sys.argv[2]
with open(path) as fh:
data = yaml.safe_load(fh)
app = data.get("app") if isinstance(data, dict) else None
if not isinstance(app, dict):
sys.exit("missing top-level app block")
app = wrap(app)
value = eval(expr, {"__builtins__": {}}, {"app": app})
if isinstance(value, list):
print("\n".join(str(v) for v in value))
elif isinstance(value, dict):
print("\n".join(f"{k}={v}" for k, v in value.items()))
elif value is None or isinstance(value, Nil):
print("")
elif isinstance(value, bool):
print("true" if value else "false")
else:
print(value)
' "$MANIFEST" "$1"
}
echo "Validating: $MANIFEST"
@@ -76,7 +120,12 @@ if [[ ! -f "$MANIFEST" ]]; then
fi
check "File exists" "pass"
if ! ruby -ryaml -e 'data = YAML.load_file(ARGV[0]); exit(data.is_a?(Hash) && data["app"].is_a?(Hash) ? 0 : 1)' "$MANIFEST" 2>/dev/null; then
if ! python3 -c '
import sys, yaml
with open(sys.argv[1]) as fh:
data = yaml.safe_load(fh)
sys.exit(0 if isinstance(data, dict) and isinstance(data.get("app"), dict) else 1)
' "$MANIFEST" 2>/dev/null; then
check "Valid YAML with top-level app block" "fail"
echo ""
echo "Results: $PASS passed, $FAIL failed, $WARN warnings"
@@ -90,9 +139,9 @@ APP_NAME="$(yaml_eval 'app["name"]')"
APP_VERSION="$(yaml_eval 'app["version"]')"
APP_DESCRIPTION="$(yaml_eval 'app["description"]')"
APP_INTERNAL="$(yaml_eval 'app["internal"]')"
IMAGE="$(yaml_eval '(app["container"] || {})["image"]')"
BUILD_CONTEXT="$(yaml_eval '(((app["container"] || {})["build"] || {})["context"])')"
BUILD_TAG="$(yaml_eval '(((app["container"] || {})["build"] || {})["tag"])')"
IMAGE="$(yaml_eval 'app["container"]["image"]')"
BUILD_CONTEXT="$(yaml_eval 'app["container"]["build"]["context"]')"
BUILD_TAG="$(yaml_eval 'app["container"]["build"]["tag"]')"
if [[ "$APP_ID" =~ ^[a-z0-9]+(-[a-z0-9]+)*$ ]]; then
check "app.id is lowercase kebab-case ($APP_ID)" "pass"
@@ -137,7 +186,7 @@ fi
if [[ -n "$IMAGE" ]]; then
TRUSTED=false
for reg in "docker.io" "ghcr.io" "quay.io" "registry.hub.docker.com" "146.59.87.168:3000" "localhost/"; do
for reg in "docker.io" "ghcr.io" "quay.io" "registry.hub.docker.com" "source.archipelago-foundation.org" "localhost/"; do
if [[ "$IMAGE" == *"$reg"* ]]; then
TRUSTED=true
break
@@ -164,15 +213,15 @@ if [[ -n "$IMAGE" ]]; then
fi
fi
MEMORY_LIMIT="$(yaml_eval '((app["resources"] || {})["memory_limit"] || (app["resources"] || {})["memory"])')"
CPU_LIMIT="$(yaml_eval '((app["resources"] || {})["cpu_limit"] || (app["resources"] || {})["cpu"])')"
MEMORY_LIMIT="$(yaml_eval 'app["resources"]["memory_limit"] or app["resources"]["memory"]')"
CPU_LIMIT="$(yaml_eval 'app["resources"]["cpu_limit"] or app["resources"]["cpu"]')"
[[ -n "$MEMORY_LIMIT" ]] && check "resources.memory_limit specified ($MEMORY_LIMIT)" "pass" || check "resources.memory_limit specified" "warn"
[[ -n "$CPU_LIMIT" ]] && check "resources.cpu_limit specified ($CPU_LIMIT)" "pass" || check "resources.cpu_limit specified" "warn"
READONLY_ROOT="$(yaml_eval '((app["security"] || {})["readonly_root"])')"
NO_NEW_PRIVS="$(yaml_eval '((app["security"] || {})["no_new_privileges"])')"
NETWORK_POLICY="$(yaml_eval '((app["security"] || {})["network_policy"])')"
CONTAINER_NETWORK="$(yaml_eval '((app["container"] || {})["network"])')"
READONLY_ROOT="$(yaml_eval 'app["security"]["readonly_root"]')"
NO_NEW_PRIVS="$(yaml_eval 'app["security"]["no_new_privileges"]')"
NETWORK_POLICY="$(yaml_eval 'app["security"]["network_policy"]')"
CONTAINER_NETWORK="$(yaml_eval 'app["container"]["network"]')"
if [[ "$READONLY_ROOT" == "true" || -z "$READONLY_ROOT" ]]; then
check "security.readonly_root true (explicit or Rust default)" "pass"
@@ -200,7 +249,7 @@ else
check "container.network does not share another namespace" "pass"
fi
SECRET_ENV="$(yaml_eval '(app["environment"] || [])')"
SECRET_ENV="$(yaml_eval 'app["environment"]')"
if echo "$SECRET_ENV" | grep -iqE '^[A-Z0-9_]*(PASSWORD|PASS|SECRET|TOKEN|API_KEY|PRIVATE_KEY)[A-Z0-9_]*=.+$'; then
check "no hardcoded secret-like values in app.environment" "warn"
else
@@ -218,33 +267,47 @@ if [[ -n "$APP_ID" && -n "$MANIFEST" ]]; then
fi
fi
PORT_CHECK="$(ruby -ryaml -e '
current = ARGV[0]
current_id = File.basename(File.dirname(current))
ports = {}
Dir.glob("apps/*/manifest.yml").sort.each do |path|
data = YAML.load_file(path)
app = data.is_a?(Hash) ? data["app"] : nil
next unless app.is_a?(Hash)
id = app["id"] || File.basename(File.dirname(path))
next if id == current_id
Array(app["ports"]).each do |p|
next unless p.is_a?(Hash)
proto = p["protocol"] || "tcp"
bind = p["bind"] || ""
host = p["host"]
ports[[host, proto, bind]] = id if host
end
end
data = YAML.load_file(current)
app = data["app"]
conflicts = []
Array(app["ports"]).each do |p|
next unless p.is_a?(Hash)
key = [p["host"], p["protocol"] || "tcp", p["bind"] || ""]
conflicts << "#{key[2].empty? ? "*" : key[2]}:#{key[0]}/#{key[1]} already used by #{ports[key]}" if ports.key?(key)
end
puts conflicts.join("\n")
PORT_CHECK="$(python3 -c '
import glob, os, sys, yaml
def load_app(path):
try:
with open(path) as fh:
data = yaml.safe_load(fh)
except Exception:
return None
return data.get("app") if isinstance(data, dict) else None
current = sys.argv[1]
current_id = os.path.basename(os.path.dirname(current))
def port_keys(app):
for entry in (app.get("ports") or []):
if not isinstance(entry, dict):
continue
host = entry.get("host")
if not host:
continue
yield (host, entry.get("protocol") or "tcp", entry.get("bind") or "")
claimed = {}
for path in sorted(glob.glob("apps/*/manifest.yml")):
app = load_app(path)
if not isinstance(app, dict):
continue
app_id = app.get("id") or os.path.basename(os.path.dirname(path))
if app_id == current_id:
continue
for key in port_keys(app):
claimed[key] = app_id
app = load_app(current)
if isinstance(app, dict):
for key in port_keys(app):
if key in claimed:
host, proto, bind = key
shown = bind if bind else "*"
print(f"{shown}:{host}/{proto} already used by {claimed[key]}")
' "$MANIFEST")"
if [[ -n "$PORT_CHECK" ]]; then
while IFS= read -r conflict; do