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:
@@ -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]:
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]]:
|
||||
|
||||
Executable
+163
@@ -0,0 +1,163 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Refuse to publish a catalog naming registry hosts the fleet cannot pull from.
|
||||
|
||||
The signed app catalog is authoritative for deployed nodes: `catalog_image_override`
|
||||
makes its image reference win over the on-disk manifest. So a catalog that names a
|
||||
registry host the *deployed* binaries do not trust turns every install into
|
||||
"not from a trusted registry" — fleet-wide, at publish time, with no local signal.
|
||||
|
||||
The subtlety this guard exists for: TRUSTED_REGISTRIES in the working tree
|
||||
describes a binary being built today. Nodes run what was shipped to them. Those
|
||||
two lists diverge for exactly as long as it takes an OTA to reach the fleet, and
|
||||
that window is when a catalog regeneration silently breaks everything.
|
||||
|
||||
So the floor is tracked explicitly in releases/registry-trust-floor.json and the
|
||||
catalog is checked against that, never against the source tree.
|
||||
|
||||
Usage:
|
||||
scripts/check-catalog-registry-trust.py # check the release catalog
|
||||
scripts/check-catalog-registry-trust.py --catalog path.json
|
||||
scripts/check-catalog-registry-trust.py --show # print current state
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterator
|
||||
|
||||
DEFAULT_CATALOG = "releases/app-catalog.json"
|
||||
DEFAULT_FLOOR = "releases/registry-trust-floor.json"
|
||||
IMAGE_POLICY = "core/archipelago/src/container/image_policy.rs"
|
||||
|
||||
|
||||
def iter_images(node: Any) -> Iterator[str]:
|
||||
"""Yield every value stored under an `image` key, at any depth."""
|
||||
if isinstance(node, dict):
|
||||
for key, value in node.items():
|
||||
if key == "image" and isinstance(value, str) and value:
|
||||
yield value
|
||||
else:
|
||||
yield from iter_images(value)
|
||||
elif isinstance(node, list):
|
||||
for item in node:
|
||||
yield from iter_images(item)
|
||||
|
||||
|
||||
def registry_host(image: str) -> str | None:
|
||||
"""Registry host of an image ref, or None for Docker Hub shorthand.
|
||||
|
||||
A ref's first segment is a registry only if it contains a '.' or ':'
|
||||
(docker.io, host:3000). Otherwise it is a Docker Hub namespace — `nginx`,
|
||||
`btcpayserver/btcpayserver` — which resolves via registries.conf, not an
|
||||
attacker-controlled host. This mirrors is_valid_docker_image() in
|
||||
image_policy.rs; keep the two in step.
|
||||
"""
|
||||
head = image.split("/", 1)[0]
|
||||
if "/" not in image:
|
||||
return None
|
||||
if "." in head or ":" in head:
|
||||
return head
|
||||
return None
|
||||
|
||||
|
||||
def source_trusted_registries(repo: Path) -> list[str]:
|
||||
"""TRUSTED_REGISTRIES as the working tree currently defines it (advisory)."""
|
||||
path = repo / IMAGE_POLICY
|
||||
try:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
except OSError:
|
||||
return []
|
||||
match = re.search(r"TRUSTED_REGISTRIES:\s*&\[&str\]\s*=\s*&\[(.*?)\];", text, re.S)
|
||||
if not match:
|
||||
return []
|
||||
body = match.group(1)
|
||||
hosts = re.findall(r'"([^"]+)"', body)
|
||||
# Entries may be consts (LEGACY_REGISTRY_HOST); resolve those too.
|
||||
for const in re.findall(r"\b([A-Z][A-Z0-9_]+)\b", body):
|
||||
const_match = re.search(rf'{const}:\s*&str\s*=\s*"([^"]+)"', text)
|
||||
if const_match:
|
||||
hosts.append(const_match.group(1))
|
||||
return sorted(set(hosts))
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
parser.add_argument("--catalog", default=DEFAULT_CATALOG)
|
||||
parser.add_argument("--floor", default=DEFAULT_FLOOR)
|
||||
parser.add_argument("--repo", default=".")
|
||||
parser.add_argument("--show", action="store_true",
|
||||
help="print the floor, the source list and the catalog's hosts")
|
||||
args = parser.parse_args()
|
||||
|
||||
repo = Path(args.repo)
|
||||
catalog_path = repo / args.catalog
|
||||
floor_path = repo / args.floor
|
||||
|
||||
try:
|
||||
catalog = json.loads(catalog_path.read_text(encoding="utf-8"))
|
||||
except OSError as exc:
|
||||
print(f"ERROR: cannot read catalog: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
try:
|
||||
floor_doc = json.loads(floor_path.read_text(encoding="utf-8"))
|
||||
except OSError as exc:
|
||||
print(f"ERROR: cannot read trust floor: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
floor = set(floor_doc.get("hosts") or [])
|
||||
if not floor:
|
||||
print(f"ERROR: {args.floor} lists no hosts; refusing to pass vacuously.",
|
||||
file=sys.stderr)
|
||||
return 2
|
||||
|
||||
hosts: dict[str, list[str]] = {}
|
||||
for image in iter_images(catalog):
|
||||
host = registry_host(image)
|
||||
if host:
|
||||
hosts.setdefault(host, []).append(image)
|
||||
|
||||
if args.show:
|
||||
print("trust floor (deployed binaries):")
|
||||
for h in sorted(floor):
|
||||
print(f" {h}")
|
||||
pending = floor_doc.get("pending") or {}
|
||||
if pending:
|
||||
print("pending (not yet in the fleet):")
|
||||
for h, meta in pending.items():
|
||||
print(f" {h} — trusted_from_binary={meta.get('trusted_from_binary')}")
|
||||
print("working-tree TRUSTED_REGISTRIES (advisory):")
|
||||
for h in source_trusted_registries(repo) or ["(could not parse)"]:
|
||||
print(f" {h}")
|
||||
print(f"catalog hosts ({catalog_path}):")
|
||||
for h in sorted(hosts):
|
||||
print(f" {h} ({len(hosts[h])} image refs)")
|
||||
|
||||
violations = sorted(set(hosts) - floor)
|
||||
if violations:
|
||||
print("")
|
||||
print("REFUSING: the catalog names registry hosts the deployed fleet does not trust.")
|
||||
for host in violations:
|
||||
examples = hosts[host][:3]
|
||||
print(f"\n {host} — {len(hosts[host])} image refs, e.g.")
|
||||
for ref in examples:
|
||||
print(f" {ref}")
|
||||
print("")
|
||||
print("Publishing this would make every install fail with")
|
||||
print('"not from a trusted registry" on every node in the field.')
|
||||
print("")
|
||||
print(f"Fix by ordering the migration — see the _comment in {args.floor}:")
|
||||
print(" ship a binary that trusts the host, confirm the fleet is on it,")
|
||||
print(" promote the host in the floor file, and only then regenerate.")
|
||||
return 1
|
||||
|
||||
print(f"OK: all {len(hosts)} registry host(s) in {args.catalog} are trusted by the deployed fleet.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Executable
+134
@@ -0,0 +1,134 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Fail when a hardcoded installer image tag disagrees with the app manifest.
|
||||
|
||||
The legacy stack installers in core/archipelago/src/api/rpc/package/stacks.rs
|
||||
carry image references as string literals. Those literals are a second source of
|
||||
truth for a version, sitting behind the manifest and the signed catalog, and
|
||||
nothing keeps them in step.
|
||||
|
||||
That is not cosmetic. BTCPay shipped 2.4.2 for an actively exploited 2FA bypass
|
||||
on 2026-08-07 while the legacy installer still named 2.3.9, so the fallback
|
||||
install path would have deployed the withdrawn release. The same shape applies
|
||||
to any app whose installer literal is left behind.
|
||||
|
||||
The rule enforced here: if an installer literal names the same image repository
|
||||
as an app manifest, the tags must match. Repositories with no manifest are
|
||||
ignored, and so are floating tags, which carry no version claim.
|
||||
|
||||
Usage:
|
||||
scripts/check-installer-image-pins.py
|
||||
scripts/check-installer-image-pins.py --show
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
# Files that pin images as literals on an install path. Test modules inside them
|
||||
# are stripped before scanning: fixtures deliberately name old versions.
|
||||
INSTALLER_SOURCES = [
|
||||
"core/archipelago/src/api/rpc/package/stacks.rs",
|
||||
]
|
||||
|
||||
FLOATING_TAGS = {"latest", "stable", "release", "main", "edge"}
|
||||
|
||||
IMAGE_RE = re.compile(r'"([a-z0-9][a-z0-9._-]*(?:\.[a-z]+|:[0-9]+)?/[a-z0-9._/-]+:[A-Za-z0-9._-]+)"')
|
||||
|
||||
|
||||
def strip_test_modules(text: str) -> str:
|
||||
"""Remove #[cfg(test)] modules so fixture literals are not treated as pins."""
|
||||
marker = "#[cfg(test)]"
|
||||
idx = text.find(marker)
|
||||
return text if idx == -1 else text[:idx]
|
||||
|
||||
|
||||
def repo_of(image: str) -> str:
|
||||
"""Image repository without registry host or tag."""
|
||||
without_tag = image.rsplit(":", 1)[0] if ":" in image.rsplit("/", 1)[-1] else image
|
||||
head, _, rest = without_tag.partition("/")
|
||||
if "." in head or ":" in head or head == "localhost":
|
||||
return rest
|
||||
return without_tag
|
||||
|
||||
|
||||
def tag_of(image: str) -> str:
|
||||
last = image.rsplit("/", 1)[-1]
|
||||
return last.rsplit(":", 1)[1] if ":" in last else "latest"
|
||||
|
||||
|
||||
def manifest_images(repo_root: Path) -> dict[str, tuple[str, str]]:
|
||||
"""{image repo: (tag, manifest path)} across apps/*/manifest.yml."""
|
||||
out: dict[str, tuple[str, str]] = {}
|
||||
for path in sorted((repo_root / "apps").glob("*/manifest.yml")):
|
||||
try:
|
||||
data = yaml.safe_load(path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
continue
|
||||
app = (data or {}).get("app")
|
||||
if not isinstance(app, dict):
|
||||
continue
|
||||
image = (app.get("container") or {}).get("image")
|
||||
if isinstance(image, str) and image:
|
||||
out[repo_of(image)] = (tag_of(image), str(path.relative_to(repo_root)))
|
||||
return out
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
parser.add_argument("--repo", default=".")
|
||||
parser.add_argument("--show", action="store_true")
|
||||
args = parser.parse_args()
|
||||
|
||||
repo_root = Path(args.repo)
|
||||
manifests = manifest_images(repo_root)
|
||||
problems: list[str] = []
|
||||
checked = 0
|
||||
|
||||
for rel in INSTALLER_SOURCES:
|
||||
path = repo_root / rel
|
||||
if not path.exists():
|
||||
continue
|
||||
text = strip_test_modules(path.read_text(encoding="utf-8"))
|
||||
for line_no, line in enumerate(text.splitlines(), start=1):
|
||||
for image in IMAGE_RE.findall(line):
|
||||
repo = repo_of(image)
|
||||
if repo not in manifests:
|
||||
continue
|
||||
tag = tag_of(image)
|
||||
manifest_tag, manifest_path = manifests[repo]
|
||||
checked += 1
|
||||
if args.show:
|
||||
print(f" {rel}:{line_no} {repo}:{tag} (manifest {manifest_tag})")
|
||||
if tag in FLOATING_TAGS or manifest_tag in FLOATING_TAGS:
|
||||
continue
|
||||
if tag != manifest_tag:
|
||||
problems.append(
|
||||
f"{rel}:{line_no}\n"
|
||||
f" installer pins {repo}:{tag}\n"
|
||||
f" manifest wants {repo}:{manifest_tag} ({manifest_path})"
|
||||
)
|
||||
|
||||
if problems:
|
||||
print("")
|
||||
print("Installer image pins disagree with their app manifests:")
|
||||
for problem in problems:
|
||||
print(f"\n {problem}")
|
||||
print("")
|
||||
print("An installer literal left behind deploys the older image on the")
|
||||
print("fallback install path — which is how a withdrawn, vulnerable")
|
||||
print("release gets installed after it has supposedly been replaced.")
|
||||
print("Update the literal to match the manifest.")
|
||||
return 1
|
||||
|
||||
print(f"OK: {checked} installer image pin(s) agree with their app manifests.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Executable
+105
@@ -0,0 +1,105 @@
|
||||
#!/usr/bin/env bash
|
||||
# check-release-assets.sh — prove a release's artifacts are actually fetchable
|
||||
# BEFORE its manifest goes live on main.
|
||||
#
|
||||
# The manifest is the trigger: nodes read releases/manifest.json from branch
|
||||
# main, and the moment it names a new version they try to download it. So the
|
||||
# assets must resolve before the manifest lands, not after. On 2026-08-07 the
|
||||
# order was reversed — the v1.7.126-alpha manifest went live while its binary
|
||||
# 500'd and its frontend tarball had never uploaded — and every polling node
|
||||
# would have advertised an update it could not fetch.
|
||||
#
|
||||
# For each component in the manifest this checks:
|
||||
# 1. the download URL returns HTTP 200
|
||||
# 2. the downloaded bytes match the manifest's sha256 and size
|
||||
#
|
||||
# It downloads each asset in full, because a HEAD 200 is not proof the body is
|
||||
# intact — the corrupt binary that day passed HEAD-shaped checks and still
|
||||
# served a broken stream. Slower, but this is the last gate before publish.
|
||||
#
|
||||
# Usage:
|
||||
# scripts/check-release-assets.sh # check releases/manifest.json
|
||||
# scripts/check-release-assets.sh path/to/manifest.json
|
||||
#
|
||||
# Exit 0 = every asset is downloadable and matches. Non-zero = do NOT publish.
|
||||
set -euo pipefail
|
||||
|
||||
MANIFEST="${1:-releases/manifest.json}"
|
||||
if [[ ! -f "$MANIFEST" ]]; then
|
||||
echo "ERROR: manifest not found: $MANIFEST" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
command -v python3 >/dev/null 2>&1 || { echo "ERROR: python3 required" >&2; exit 2; }
|
||||
command -v sha256sum >/dev/null 2>&1 || { echo "ERROR: sha256sum required" >&2; exit 2; }
|
||||
|
||||
TMP="$(mktemp -d)"
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
|
||||
# Emit "url<TAB>sha256<TAB>size<TAB>name" per component, tolerating the field
|
||||
# name variations the manifest has used (download_url/url, size_bytes/size).
|
||||
rows="$(python3 - "$MANIFEST" <<'PY'
|
||||
import json, sys
|
||||
d = json.load(open(sys.argv[1]))
|
||||
comps = d.get("components") or []
|
||||
if not comps:
|
||||
sys.exit("manifest has no components")
|
||||
for c in comps:
|
||||
url = c.get("download_url") or c.get("url") or ""
|
||||
sha = c.get("sha256") or ""
|
||||
size = c.get("size_bytes") or c.get("size") or ""
|
||||
name = c.get("name") or "(unnamed)"
|
||||
if not url or not sha:
|
||||
sys.exit(f"component {name!r} missing url or sha256")
|
||||
print(f"{url}\t{sha}\t{size}\t{name}")
|
||||
PY
|
||||
)"
|
||||
|
||||
version="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get("version","?"))' "$MANIFEST")"
|
||||
echo "Checking release assets for v${version} ($MANIFEST)"
|
||||
echo ""
|
||||
|
||||
fail=0
|
||||
n=0
|
||||
while IFS=$'\t' read -r url sha size name; do
|
||||
[ -z "$url" ] && continue
|
||||
n=$((n + 1))
|
||||
out="$TMP/asset.$n"
|
||||
echo " [$name]"
|
||||
echo " $url"
|
||||
|
||||
code="$(curl -sL -o "$out" -w '%{http_code}' "$url" || echo "000")"
|
||||
if [ "$code" != "200" ]; then
|
||||
echo " FAIL: HTTP $code (asset not served)"
|
||||
fail=1
|
||||
continue
|
||||
fi
|
||||
|
||||
got_size="$(stat -c%s "$out")"
|
||||
if [ -n "$size" ] && [ "$size" != "$got_size" ]; then
|
||||
echo " FAIL: size $got_size, manifest says $size"
|
||||
fail=1
|
||||
continue
|
||||
fi
|
||||
|
||||
got_sha="$(sha256sum "$out" | awk '{print $1}')"
|
||||
if [ "$got_sha" != "$sha" ]; then
|
||||
echo " FAIL: sha256 mismatch"
|
||||
echo " served: $got_sha"
|
||||
echo " manifest: $sha"
|
||||
fail=1
|
||||
continue
|
||||
fi
|
||||
|
||||
echo " OK: HTTP 200, ${got_size} bytes, sha256 matches"
|
||||
done <<< "$rows"
|
||||
|
||||
echo ""
|
||||
if [ "$fail" -ne 0 ]; then
|
||||
echo "REFUSING: one or more assets are not fetchable or do not match the manifest."
|
||||
echo "Do NOT publish the manifest — nodes would advertise an update they cannot"
|
||||
echo "apply. Upload/repair the assets, re-run this, and only then flip the"
|
||||
echo "manifest live on main."
|
||||
exit 1
|
||||
fi
|
||||
echo "OK: all $n asset(s) for v${version} download and match the manifest."
|
||||
@@ -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() {
|
||||
|
||||
@@ -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)"
|
||||
|
||||
@@ -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]"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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'
|
||||
@@ -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/
|
||||
|
||||
@@ -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
@@ -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'")
|
||||
|
||||
@@ -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:"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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},
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
@@ -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."
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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:"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)"
|
||||
@@ -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)."
|
||||
@@ -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 ""
|
||||
@@ -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,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=""
|
||||
|
||||
@@ -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)"
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user