From 6d22978c3a36282dddb52b51ceacda9fbc2ae723 Mon Sep 17 00:00:00 2001 From: Benjamen Keroack Date: Tue, 8 Sep 2026 14:37:57 -0600 Subject: [PATCH 01/22] docker: ship sat-tui and add a HEALTHCHECK MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The runtime image carried satd and sat-cli only, so `docker exec -it satd sat-tui` — the obvious way to watch a containerised node — did not work, and the Umbrel/StartOS packages that expose sat-tui as their terminal would each have had to bolt it on themselves. Cook and build now select the same three bins, which the cargo-chef cache requires. The HEALTHCHECK reports liveness by default rather than readiness. The probe cannot see the daemon's credentials (the CMD may set -rpcuser, and the cookie may be unreadable) or its network flags, so it treats any HTTP status line from the RPC listener — 401 included — as healthy, which is the strongest claim it can honestly make. Operators who want a real readiness gate, which is what `depends_on: condition: service_healthy` needs, point SATD_HEALTH_URL at /readyz; contrib/stack does exactly that. No curl or wget: the runtime image is deliberately thin, so the probe speaks HTTP over bash's /dev/tcp. That transport is easy to get subtly wrong, so it has a test — a bodyless request assembled with command substitution loses the blank line that terminates it, and a healthy node then reads as down until the probe's own timeout fires. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01K9Nb3m8HuhNbjkJYp9Frmt --- Dockerfile | 28 +++- contrib/docker/satd-healthcheck | 107 +++++++++++++++ contrib/docker/tests/healthcheck-test.sh | 165 +++++++++++++++++++++++ 3 files changed, 297 insertions(+), 3 deletions(-) create mode 100755 contrib/docker/satd-healthcheck create mode 100755 contrib/docker/tests/healthcheck-test.sh diff --git a/Dockerfile b/Dockerfile index 9ed0628bc..2e631ace4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -135,14 +135,15 @@ FROM chef AS builder # #2). COPY --from=planner /src/recipe.json recipe.json COPY satd-events-proto/proto satd-events-proto/proto -RUN cargo chef cook --release --locked --bin satd --bin sat-cli --recipe-path recipe.json +RUN cargo chef cook --release --locked --bin satd --bin sat-cli --bin sat-tui --recipe-path recipe.json # Compile first-party crates on top of the cooked dependency artifacts already # sitting in target/. COPY . . -RUN cargo build --release --locked --bin satd --bin sat-cli \ +RUN cargo build --release --locked --bin satd --bin sat-cli --bin sat-tui \ && install -Dm755 target/release/satd /out/satd \ - && install -Dm755 target/release/sat-cli /out/sat-cli + && install -Dm755 target/release/sat-cli /out/sat-cli \ + && install -Dm755 target/release/sat-tui /out/sat-tui FROM docker.io/library/debian:${DEBIAN_VERSION}-slim AS runtime @@ -171,6 +172,12 @@ RUN groupadd --system --gid ${SATD_GID} satd \ COPY --from=builder /out/satd /usr/local/bin/satd COPY --from=builder /out/sat-cli /usr/local/bin/sat-cli +# sat-tui ships so `docker exec -it satd sat-tui` works against a running +# container without a second install. It is also what the Umbrel and +# StartOS packages expose as their terminal, so it has to be in the image +# those packages consume rather than bolted on per package. +COPY --from=builder /out/sat-tui /usr/local/bin/sat-tui +COPY contrib/docker/satd-healthcheck /usr/local/bin/satd-healthcheck USER satd WORKDIR /var/lib/satd @@ -181,5 +188,20 @@ VOLUME ["/var/lib/satd"] # operators who run a single network at a time. EXPOSE 8332 8333 +# Liveness, not readiness, by default: the probe cannot see the daemon's +# credentials or its network flags, so it reports healthy as soon as the RPC +# listener answers at all (a 401 included). Point it at the readiness +# endpoint for a stricter gate — which is what contrib/stack does, and what +# `depends_on: condition: service_healthy` needs to mean anything: +# -e SATD_HEALTH_URL=http://127.0.0.1:9332/readyz (with -metricsport=9332) +# Non-mainnet containers set -e SATD_RPCPORT=; see the script header. +# +# start-period is 10 minutes because opening a mainnet chainstate is not +# instant and failures inside the window do not count against the retries. +# A node that is reindexing stays "starting" far longer than that; raise it +# with --health-start-period if you gate anything on the status. +HEALTHCHECK --interval=30s --timeout=10s --start-period=10m --retries=3 \ + CMD ["/usr/local/bin/satd-healthcheck"] + ENTRYPOINT ["/usr/bin/tini", "--", "/usr/local/bin/satd"] CMD ["--datadir=/var/lib/satd"] diff --git a/contrib/docker/satd-healthcheck b/contrib/docker/satd-healthcheck new file mode 100755 index 000000000..2fcde4091 --- /dev/null +++ b/contrib/docker/satd-healthcheck @@ -0,0 +1,107 @@ +#!/bin/bash +# satd-healthcheck — Docker HEALTHCHECK probe for the satd runtime image. +# +# "Healthy" means the node is answering on a surface a client would use. +# Two probe modes, in order: +# +# 1. SATD_HEALTH_URL — an http:// URL to GET. Set this to satd's +# readiness endpoint when the metrics listener is on, which is what +# contrib/stack does: +# SATD_HEALTH_URL=http://127.0.0.1:9332/readyz +# /readyz is satd's real readiness gate: it reports not-ready until +# the chainstate is loaded and every configured listener is bound. +# Only a 2xx counts, so a node that is still starting reads as +# unhealthy rather than ready. +# +# 2. JSON-RPC liveness on SATD_RPCPORT (default 8332). The probe sends a +# getblockchaininfo and treats ANY HTTP status line as healthy — +# including 401. The point is deliberate: this mode cannot see the +# daemon's credentials (the container's CMD may set -rpcuser, or the +# cookie may be unreadable), so authenticating is not something it can +# do reliably. A 401 still proves the RPC listener is bound and +# serving, which is the strongest claim this mode can honestly make. +# Use mode 1 when you want readiness rather than liveness. +# +# Non-mainnet containers must set SATD_RPCPORT (18332 testnet, 38332 +# signet, 18443 regtest) or SATD_HEALTH_URL, since the probe cannot see +# the network flags passed to the daemon. +# +# No curl/wget dependency: the runtime image is deliberately thin, so the +# probe speaks HTTP over bash's /dev/tcp. +# +# Note on request construction: every request string is assembled with +# ANSI-C quoting rather than `$(printf ...)`. Command substitution strips +# trailing newlines, which would eat the blank line that terminates a +# bodyless GET — the server then waits for the rest of the request until +# the probe's own read timeout fires and a healthy node reads as down. + +set -u + +readonly TIMEOUT="${SATD_HEALTH_TIMEOUT:-5}" +readonly CRLF=$'\r\n' + +# Send `payload` to host:port; echo the response's first line. Non-zero if +# the connection could not be established or nothing came back in time. +http_probe() { + local host="$1" port="$2" payload="$3" + # The redirection's own failure message is not useful here (the caller + # prints a better one), and `2>/dev/null` on the exec itself does not + # suppress it — the diagnostic is emitted by the shell, not the command. + { exec 3<>"/dev/tcp/${host}/${port}"; } 2>/dev/null || return 1 + local status_line="" + if printf '%s' "$payload" >&3; then + # `read -t` bounds the wait on a listener that accepts but never + # answers, which is otherwise indistinguishable from a healthy one. + IFS= read -r -t "$TIMEOUT" status_line <&3 + fi + exec 3<&- 2>/dev/null + exec 3>&- 2>/dev/null + [[ -n "$status_line" ]] || return 1 + printf '%s\n' "${status_line%$'\r'}" +} + +if [[ -n "${SATD_HEALTH_URL:-}" ]]; then + # Parse http://host:port/path — no https here on purpose. This probe + # runs inside the container against a loopback listener, and bash has + # no TLS. TLS-terminated surfaces are probed from outside the container + # (contrib/stack/tests/smoke.sh does exactly that). + url="${SATD_HEALTH_URL#http://}" + hostport="${url%%/*}" + if [[ "$url" == */* ]]; then + path="/${url#*/}" + else + path="/" + fi + host="${hostport%%:*}" + port="${hostport##*:}" + [[ "$port" == "$host" ]] && port=80 + + request="GET ${path} HTTP/1.1${CRLF}Host: ${hostport}${CRLF}Connection: close${CRLF}User-Agent: satd-healthcheck${CRLF}${CRLF}" + if ! status_line=$(http_probe "$host" "$port" "$request"); then + echo "satd-healthcheck: no response from $SATD_HEALTH_URL" >&2 + exit 1 + fi + # Readiness endpoint: only 2xx counts. /readyz answers 503 while the + # node is still starting, and calling that healthy defeats the point. + if [[ "$status_line" == *" 2"[0-9][0-9]* ]]; then + exit 0 + fi + echo "satd-healthcheck: $SATD_HEALTH_URL -> $status_line" >&2 + exit 1 +fi + +host="${SATD_RPCCONNECT:-127.0.0.1}" +port="${SATD_RPCPORT:-8332}" +body='{"jsonrpc":"2.0","id":"healthcheck","method":"getblockchaininfo","params":[]}' +request="POST / HTTP/1.1${CRLF}Host: ${host}:${port}${CRLF}Content-Type: application/json${CRLF}Content-Length: ${#body}${CRLF}Connection: close${CRLF}User-Agent: satd-healthcheck${CRLF}${CRLF}${body}" + +if ! status_line=$(http_probe "$host" "$port" "$request"); then + echo "satd-healthcheck: RPC listener ${host}:${port} not answering" >&2 + exit 1 +fi +# Any HTTP status line means the listener is bound and serving. See header. +if [[ "$status_line" == HTTP/* ]]; then + exit 0 +fi +echo "satd-healthcheck: ${host}:${port} -> $status_line" >&2 +exit 1 diff --git a/contrib/docker/tests/healthcheck-test.sh b/contrib/docker/tests/healthcheck-test.sh new file mode 100755 index 000000000..109ce7724 --- /dev/null +++ b/contrib/docker/tests/healthcheck-test.sh @@ -0,0 +1,165 @@ +#!/bin/bash +# Unit test for contrib/docker/satd-healthcheck. +# +# The probe speaks raw HTTP over /dev/tcp, which is exactly the kind of +# code that breaks silently: a malformed request makes the server wait for +# more input, the probe's read times out, and a perfectly healthy node is +# reported as down. So each case here asserts the exit status against a +# real listener rather than mocking the transport. +# +# No dependencies beyond bash and python3 (already required by the repo's +# other test tooling). + +set -euo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +HEALTHCHECK="$HERE/../satd-healthcheck" +[[ -x "$HEALTHCHECK" ]] || { echo "not executable: $HEALTHCHECK" >&2; exit 1; } + +WORKDIR="$(mktemp -d)" +PIDS=() +cleanup() { + for pid in ${PIDS[@]+"${PIDS[@]}"}; do + kill "$pid" 2>/dev/null || true + done + rm -rf "$WORKDIR" +} +trap cleanup EXIT + +cat > "$WORKDIR/server.py" <<'PY' +"""Minimal HTTP responder that answers a fixed status code. + +`silent` mode accepts the connection and never writes, which is how a +wedged listener behaves: the probe must time out rather than hang. +""" +import socket +import sys +import threading + +mode = sys.argv[1] +port = int(sys.argv[2]) + +srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM) +srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) +srv.bind(("127.0.0.1", port)) +srv.listen(8) +print("ready", flush=True) + + +def serve(conn): + try: + conn.settimeout(10) + # Read the request head. A correctly-formed request ends with a + # blank line; if the probe forgets the terminator this loop spins + # until the timeout, which is the failure this test exists to catch. + data = b"" + while b"\r\n\r\n" not in data: + chunk = conn.recv(4096) + if not chunk: + return + data += chunk + if mode == "silent": + return + code = int(mode) + body = b"{}" + conn.sendall( + b"HTTP/1.1 %d X\r\nContent-Length: %d\r\nConnection: close\r\n\r\n%s" + % (code, len(body), body) + ) + except Exception: + pass + finally: + try: + conn.close() + except Exception: + pass + + +while True: + c, _ = srv.accept() + threading.Thread(target=serve, args=(c,), daemon=True).start() +PY + +# Ports are picked from a fixed high base plus an index. The suite is +# single-process and cleans up after itself, so a fixed base is fine; the +# base is uncommon enough not to collide with the canaries' ranges. +PORT_BASE=${SATD_HEALTHCHECK_TEST_PORT_BASE:-19540} + +start_server() { + local mode="$1" port="$2" + python3 "$WORKDIR/server.py" "$mode" "$port" > "$WORKDIR/ready.$port" 2>&1 & + PIDS+=($!) + local deadline=$(($(date +%s) + 15)) + while [[ $(date +%s) -lt $deadline ]]; do + grep -q ready "$WORKDIR/ready.$port" 2>/dev/null && return 0 + sleep 0.1 + done + echo "server on $port never became ready" >&2 + cat "$WORKDIR/ready.$port" >&2 || true + return 1 +} + +FAILURES=0 +check() { + local name="$1" expected="$2" + shift 2 + local actual=0 + "$@" > "$WORKDIR/out" 2>&1 || actual=$? + if [[ "$actual" == "$expected" ]]; then + echo "ok — $name" + else + echo "FAIL — $name: expected exit $expected, got $actual" + sed 's/^/ /' "$WORKDIR/out" + FAILURES=$((FAILURES + 1)) + fi +} + +P_OK=$((PORT_BASE + 0)) +P_503=$((PORT_BASE + 1)) +P_401=$((PORT_BASE + 2)) +P_SILENT=$((PORT_BASE + 3)) +P_DEAD=$((PORT_BASE + 4)) + +start_server 200 "$P_OK" +start_server 503 "$P_503" +start_server 401 "$P_401" +start_server silent "$P_SILENT" +# P_DEAD is deliberately never bound. + +# --- readiness mode (SATD_HEALTH_URL) --- +check "readyz 200 is healthy" 0 \ + env SATD_HEALTH_URL="http://127.0.0.1:$P_OK/readyz" "$HEALTHCHECK" +check "readyz 503 is unhealthy (node still starting)" 1 \ + env SATD_HEALTH_URL="http://127.0.0.1:$P_503/readyz" "$HEALTHCHECK" +check "readyz on a refused port is unhealthy" 1 \ + env SATD_HEALTH_URL="http://127.0.0.1:$P_DEAD/readyz" "$HEALTHCHECK" +check "readyz against a silent listener times out unhealthy" 1 \ + env SATD_HEALTH_TIMEOUT=2 SATD_HEALTH_URL="http://127.0.0.1:$P_SILENT/readyz" "$HEALTHCHECK" +check "URL with no path still terminates the request" 0 \ + env SATD_HEALTH_URL="http://127.0.0.1:$P_OK" "$HEALTHCHECK" +check "nested path is preserved" 0 \ + env SATD_HEALTH_URL="http://127.0.0.1:$P_OK/a/b/c" "$HEALTHCHECK" + +# --- RPC liveness mode --- +check "RPC 200 is healthy" 0 \ + env SATD_RPCPORT="$P_OK" "$HEALTHCHECK" +# The load-bearing case: a bound listener that rejects the probe's +# credentials is still a live listener, and must not read as down. +check "RPC 401 is healthy (listener is bound and serving)" 0 \ + env SATD_RPCPORT="$P_401" "$HEALTHCHECK" +check "RPC 503 is healthy in liveness mode" 0 \ + env SATD_RPCPORT="$P_503" "$HEALTHCHECK" +check "RPC on a refused port is unhealthy" 1 \ + env SATD_RPCPORT="$P_DEAD" "$HEALTHCHECK" +check "RPC against a silent listener times out unhealthy" 1 \ + env SATD_HEALTH_TIMEOUT=2 SATD_RPCPORT="$P_SILENT" "$HEALTHCHECK" + +# SATD_HEALTH_URL wins when both are set, even if the RPC port is fine. +check "SATD_HEALTH_URL takes precedence over SATD_RPCPORT" 1 \ + env SATD_HEALTH_URL="http://127.0.0.1:$P_503/readyz" SATD_RPCPORT="$P_OK" "$HEALTHCHECK" + +if [[ $FAILURES -ne 0 ]]; then + echo "$FAILURES healthcheck test(s) failed" >&2 + exit 1 +fi +echo "all healthcheck tests passed" From 3f9eedbfa527f535016671f802b7d90d3a2627bf Mon Sep 17 00:00:00 2001 From: Benjamen Keroack Date: Tue, 8 Sep 2026 14:38:14 -0600 Subject: [PATCH 02/22] sat-cli, sat-tui: talk to a TLS-terminated RPC listener MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit satd has served TLS on -rpctlsbind for several releases, but both shipped clients formatted `http://{host}:{port}/` and had no CA option. An operator who turned RPC TLS on therefore had to keep the plain listener bound purely so the project's own tooling could reach the node — which means the deployment most in need of TLS was the one that could not have it end to end. Four flags on each binary, spelled the same and accepted in Core's single-dash form: -rpctls, -rpccacert, -rpcclientcert, -rpcclientkey. They are additive; nothing changes for an invocation that does not pass them. The shared implementation lives in tls-config beside the acceptor the same operator configured, behind a `client` feature so satd does not build it. Two behaviours are deliberate: - TLS material without -rpctls is an error, not a warning. Ignoring it would send the RPC credential over plain HTTP and look like success. - A CA file containing no PEM certificates is an error. reqwest parses such a file into an empty list, so pointing -rpccacert at a private key or the wrong path would add no anchor and surface later as a generic handshake failure, indistinguishable from the node being down. The tests run a real handshake against an acceptor built by this crate's server half, because loading a PEM proves nothing about verification: the positive case is paired with an untrusted server, the wrong CA, and a name mismatch, all of which must fail. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01K9Nb3m8HuhNbjkJYp9Frmt --- Cargo.lock | 4 + sat-cli/Cargo.toml | 1 + sat-cli/src/main.rs | 58 ++++- sat-tui/Cargo.toml | 1 + sat-tui/src/main.rs | 34 ++- sat-tui/src/rpc.rs | 56 +++-- tls-config/Cargo.toml | 11 + tls-config/src/client.rs | 512 +++++++++++++++++++++++++++++++++++++++ tls-config/src/lib.rs | 10 + 9 files changed, 664 insertions(+), 23 deletions(-) create mode 100644 tls-config/src/client.rs diff --git a/Cargo.lock b/Cargo.lock index 4c7d0c024..90eb3be2d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4040,6 +4040,7 @@ dependencies = [ "serde", "serde_json", "shlex", + "tls-config", "tokio", "zeroize", ] @@ -4056,6 +4057,7 @@ dependencies = [ "reqwest", "serde", "serde_json", + "tls-config", "tokio", ] @@ -4915,9 +4917,11 @@ name = "tls-config" version = "0.5.2-pre" dependencies = [ "rcgen", + "reqwest", "rustls-pemfile", "tempfile", "thiserror 2.0.18", + "tokio", "tokio-rustls", "x509-parser", ] diff --git a/sat-cli/Cargo.toml b/sat-cli/Cargo.toml index 353be3340..ed6a4d902 100644 --- a/sat-cli/Cargo.toml +++ b/sat-cli/Cargo.toml @@ -19,3 +19,4 @@ zeroize = { workspace = true } rpassword = { workspace = true } shlex = { workspace = true } satd-policy = { path = "../satd-policy" } +tls-config = { path = "../tls-config", features = ["client"] } diff --git a/sat-cli/src/main.rs b/sat-cli/src/main.rs index c6504fc68..e94a971b8 100644 --- a/sat-cli/src/main.rs +++ b/sat-cli/src/main.rs @@ -57,6 +57,41 @@ struct Cli { #[arg(long, help = "Path to cookie file", global = true)] rpccookiefile: Option, + /// Connect over HTTPS. satd serves TLS-RPC on `-rpctlsbind`, which is + /// a different listener (and usually a different port) from the plain + /// `-rpcbind` one — pass `-rpcport` to match. + #[arg(long, help = "Connect to the RPC server over TLS", global = true)] + rpctls: bool, + + /// Trust this CA in addition to the platform trust store. Point it at + /// the CA that issued the node's certificate — `contrib/stack/tls/mkca.sh` + /// writes one per install — or at a self-signed server certificate, + /// which is accepted as its own trust anchor. + #[arg( + long, + value_name = "FILE", + help = "PEM CA certificate to trust for -rpctls", + global = true + )] + rpccacert: Option, + + /// Client certificate, for a listener started with `-rpcmtls`. + #[arg( + long, + value_name = "FILE", + help = "PEM client certificate for mTLS", + global = true + )] + rpcclientcert: Option, + + #[arg( + long, + value_name = "FILE", + help = "PEM private key for -rpcclientcert", + global = true + )] + rpcclientkey: Option, + #[arg( long, help = "Data directory (for locating cookie file)", @@ -280,6 +315,10 @@ fn normalize_args(args: Vec) -> Vec { "rpcuser", "rpcpassword", "rpccookiefile", + "rpctls", + "rpccacert", + "rpcclientcert", + "rpcclientkey", "datadir", "rpcwait", "output", @@ -781,7 +820,13 @@ async fn main() { } }; - let url = format!("http://{}:{}/", cli.rpcconnect, rpcport); + let tls = tls_config::client::ClientTlsOptions { + enabled: cli.rpctls, + ca_cert: cli.rpccacert.clone(), + client_cert: cli.rpcclientcert.clone(), + client_key: cli.rpcclientkey.clone(), + }; + let url = tls.endpoint(&cli.rpcconnect, rpcport); let output = OutputFormat::parse(cli.output.as_deref()); let (method, params) = resolve_cmd(&cli.command); @@ -834,7 +879,16 @@ async fn main() { "params": json_params, }); - let client = reqwest::Client::new(); + // A bad flag combination or unreadable PEM is fatal here rather than + // per-request: every retry would fail identically, and `-rpcwait` would + // spin forever on a typo in a path. + let client = match tls.build(reqwest::Client::builder()) { + Ok(client) => client, + Err(e) => { + eprintln!("error: {e}"); + std::process::exit(1); + } + }; loop { let auth_header = format!( diff --git a/sat-tui/Cargo.toml b/sat-tui/Cargo.toml index f5d805d1c..7ebfab48a 100644 --- a/sat-tui/Cargo.toml +++ b/sat-tui/Cargo.toml @@ -16,3 +16,4 @@ reqwest = { workspace = true } ratatui = { workspace = true } crossterm = { workspace = true } parking_lot = { workspace = true } +tls-config = { path = "../tls-config", features = ["client"] } diff --git a/sat-tui/src/main.rs b/sat-tui/src/main.rs index 02226ce4a..186ac575c 100644 --- a/sat-tui/src/main.rs +++ b/sat-tui/src/main.rs @@ -46,6 +46,23 @@ struct CliArgs { #[arg(long, help = "Path to cookie file")] rpccookiefile: Option, + /// Connect over HTTPS. satd serves TLS-RPC on `-rpctlsbind`, a + /// different listener (usually a different port) from plain `-rpcbind` + /// — pass `-rpcport` to match. + #[arg(long, help = "Connect to the RPC server over TLS")] + rpctls: bool, + + /// Trust this CA in addition to the platform trust store. A + /// self-signed server certificate works here too, as its own anchor. + #[arg(long, value_name = "FILE", help = "PEM CA certificate to trust for -rpctls")] + rpccacert: Option, + + #[arg(long, value_name = "FILE", help = "PEM client certificate for mTLS")] + rpcclientcert: Option, + + #[arg(long, value_name = "FILE", help = "PEM private key for -rpcclientcert")] + rpcclientkey: Option, + #[arg(long, help = "Data directory")] datadir: Option, } @@ -55,6 +72,7 @@ fn normalize_args(args: Vec) -> Vec { let known_flags = [ "regtest", "testnet", "signet", "rpcconnect", "rpcport", "rpcuser", "rpcpassword", "rpccookiefile", "datadir", + "rpctls", "rpccacert", "rpcclientcert", "rpcclientkey", ]; args.into_iter() .map(|arg| { @@ -87,9 +105,16 @@ fn main() -> Result<(), Box> { 8332 }); + let tls = tls_config::client::ClientTlsOptions { + enabled: cli.rpctls, + ca_cert: cli.rpccacert.clone(), + client_cert: cli.rpcclientcert.clone(), + client_key: cli.rpcclientkey.clone(), + }; + // Resolve auth — use cookie path for automatic re-auth on satd restart let rpc_client = if let (Some(u), Some(p)) = (&cli.rpcuser, &cli.rpcpassword) { - Arc::new(RpcClient::new(&cli.rpcconnect, rpcport, u, p)) + Arc::new(RpcClient::new(&cli.rpcconnect, rpcport, u, p, &tls)?) } else { let cookie_path = cli.rpccookiefile.unwrap_or_else(|| { let base = cli.datadir.clone().unwrap_or_else(rpc::default_datadir); @@ -108,7 +133,12 @@ fn main() -> Result<(), Box> { base.join(net_subdir).join(".cookie") } }); - Arc::new(RpcClient::with_cookie(&cli.rpcconnect, rpcport, cookie_path)) + Arc::new(RpcClient::with_cookie( + &cli.rpcconnect, + rpcport, + cookie_path, + &tls, + )?) }; let state = Arc::new(Mutex::new(AppState::new())); diff --git a/sat-tui/src/rpc.rs b/sat-tui/src/rpc.rs index 5b1bdf5b8..19836f386 100644 --- a/sat-tui/src/rpc.rs +++ b/sat-tui/src/rpc.rs @@ -1,6 +1,7 @@ use base64::Engine; use base64::engine::general_purpose::STANDARD as BASE64; use std::path::{Path, PathBuf}; +use tls_config::client::{ClientTlsError, ClientTlsOptions}; /// RPC client for communicating with satd. /// Automatically re-reads the cookie file on auth failure (handles satd restarts). @@ -19,19 +20,32 @@ pub struct RpcClient { client: reqwest::Client, } +/// Build the HTTP client the TUI polls with. +/// +/// Fallible because the TLS options carry operator-supplied file paths: an +/// unreadable CA or a half-specified client identity has to surface as an +/// error message before the alternate screen is entered, not as a panic +/// behind a terminal the TUI has already taken over. +fn build_client(tls: &ClientTlsOptions) -> Result { + tls.build(reqwest::Client::builder().timeout(std::time::Duration::from_secs(30))) +} + impl RpcClient { - pub fn new(host: &str, port: u16, user: &str, pass: &str) -> Self { + pub fn new( + host: &str, + port: u16, + user: &str, + pass: &str, + tls: &ClientTlsOptions, + ) -> Result { let auth_header = format!("Basic {}", BASE64.encode(format!("{}:{}", user, pass))); - Self { - url: format!("http://{}:{}/", host, port), + Ok(Self { + url: tls.endpoint(host, port), auth_header: parking_lot::RwLock::new(auth_header), cookie_path: None, cookie_error: parking_lot::RwLock::new(None), - client: reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(30)) - .build() - .unwrap(), - } + client: build_client(tls)?, + }) } /// Create with cookie file path for automatic re-auth on satd restart. @@ -42,7 +56,12 @@ impl RpcClient { /// e.g. "Permission denied" while satd holds the cookie `0600` until it /// reaches READY. `refresh_auth` retries the read on each auth failure, /// so the client recovers automatically once the cookie becomes readable. - pub fn with_cookie(host: &str, port: u16, cookie_path: PathBuf) -> Self { + pub fn with_cookie( + host: &str, + port: u16, + cookie_path: PathBuf, + tls: &ClientTlsOptions, + ) -> Result { let (auth_header, cookie_error) = match read_cookie_file(&cookie_path) { Ok((u, p)) => ( format!("Basic {}", BASE64.encode(format!("{}:{}", u, p))), @@ -50,16 +69,13 @@ impl RpcClient { ), Err(e) => (String::new(), Some(e)), }; - Self { - url: format!("http://{}:{}/", host, port), + Ok(Self { + url: tls.endpoint(host, port), auth_header: parking_lot::RwLock::new(auth_header), cookie_path: Some(cookie_path), cookie_error: parking_lot::RwLock::new(cookie_error), - client: reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(30)) - .build() - .unwrap(), - } + client: build_client(tls)?, + }) } /// The current cookie-file read error, if the cookie is unreadable. @@ -299,7 +315,8 @@ mod tests { fn with_cookie_surfaces_read_error_for_missing_file() { let path = scratch("missing.cookie"); let _ = std::fs::remove_file(&path); - let c = RpcClient::with_cookie("127.0.0.1", 8332, path); + let c = RpcClient::with_cookie("127.0.0.1", 8332, path, &ClientTlsOptions::default()) + .unwrap(); // The real read error is kept, not laundered into an empty auth // header that would only ever produce a confusing downstream 401. let err = c.cookie_error().expect("a missing cookie must surface a read error"); @@ -314,7 +331,8 @@ mod tests { fn refresh_auth_recovers_once_cookie_becomes_readable() { let path = scratch("recover.cookie"); let _ = std::fs::remove_file(&path); - let c = RpcClient::with_cookie("127.0.0.1", 8332, path.clone()); + let c = RpcClient::with_cookie("127.0.0.1", 8332, path.clone(), &ClientTlsOptions::default()) + .unwrap(); assert!(c.cookie_error().is_some(), "missing cookie -> error recorded"); // satd relaxes the cookie to 0640 at READY; the next auth-failure @@ -332,7 +350,7 @@ mod tests { #[test] fn user_pass_client_has_no_cookie_error() { - let c = RpcClient::new("127.0.0.1", 8332, "u", "p"); + let c = RpcClient::new("127.0.0.1", 8332, "u", "p", &ClientTlsOptions::default()).unwrap(); assert!(c.cookie_error().is_none()); } } diff --git a/tls-config/Cargo.toml b/tls-config/Cargo.toml index 222f90aa9..0f7048cca 100644 --- a/tls-config/Cargo.toml +++ b/tls-config/Cargo.toml @@ -15,7 +15,18 @@ thiserror = { workspace = true } # exposes verification primitives but not subject-DN / SAN extraction; # x509-parser is the standard pure-Rust crate for that. x509-parser = "0.16" +# Client-side only (`client` feature): sat-cli / sat-tui build their reqwest +# client through this crate so the two ends of a satd TLS connection are +# configured from one place. satd itself does not enable the feature. +reqwest = { workspace = true, optional = true } + +[features] +default = [] +client = ["dep:reqwest"] [dev-dependencies] rcgen = { workspace = true } tempfile = "3" +# Handshake tests in `client`: the client half is exercised against an +# acceptor built by this crate's own server half, which needs a runtime. +tokio = { workspace = true } diff --git a/tls-config/src/client.rs b/tls-config/src/client.rs new file mode 100644 index 000000000..fb7078863 --- /dev/null +++ b/tls-config/src/client.rs @@ -0,0 +1,512 @@ +//! Client-side TLS options for satd's own RPC clients. +//! +//! satd terminates TLS natively on its RPC listener (`-rpctlsbind`), but +//! `sat-cli` and `sat-tui` historically only ever spoke `http://`. That +//! left an operator who had turned TLS on with no first-party client: the +//! plain listener had to stay bound on loopback purely so the shipped +//! tooling could talk to the node. This module closes that gap, and it +//! lives here — beside the server-side acceptor the same operator +//! configured — so the two ends of one connection are described in one +//! crate rather than drifting apart in two binaries. +//! +//! Both binaries expose the same four flags: +//! +//! | Flag | Meaning | +//! |---|---| +//! | `-rpctls` | speak `https://` instead of `http://` | +//! | `-rpccacert=` | trust this CA (or self-signed server cert) | +//! | `-rpcclientcert=` | client certificate, for an mTLS listener | +//! | `-rpcclientkey=` | its private key | +//! +//! `-rpccacert` is *additive*: the platform trust store still applies, so +//! a node behind a publicly-trusted certificate needs no CA flag at all. A +//! private CA — which is what `contrib/stack/tls/mkca.sh` issues, and what +//! the appliance image installs — is named explicitly. +//! +//! Point `-rpccacert` at the certificate that **issued** the one the server +//! presents. For a node using a genuinely self-signed certificate, which is +//! its own issuer, that is the server certificate itself. It is NOT the +//! leaf of a chain: a leaf issued by a CA does not anchor its own path, and +//! passing one produces a handshake failure that reads like a connection +//! error. +//! +//! There is deliberately no "skip verification" flag. The two cases above +//! cover every certificate an operator can actually have, and an unverified +//! TLS connection carrying an RPC cookie is a worse posture than the +//! plain-HTTP loopback listener it would replace. + +use std::path::{Path, PathBuf}; + +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum ClientTlsError { + #[error("cannot read {what} {path}: {source}")] + Io { + what: &'static str, + path: String, + source: std::io::Error, + }, + #[error("{path} is not a usable CA certificate: {source}")] + BadCa { + path: String, + source: reqwest::Error, + }, + #[error("{path} contains no PEM certificates")] + EmptyCa { path: String }, + #[error("client certificate/key pair in {cert} + {key} is unusable: {source}")] + BadIdentity { + cert: String, + key: String, + source: reqwest::Error, + }, + #[error("--rpcclientcert requires --rpcclientkey (and vice versa)")] + IncompleteIdentity, + #[error( + "--rpccacert / --rpcclientcert have no effect without --rpctls; \ + add --rpctls to connect over https" + )] + TlsMaterialWithoutTls, + #[error("cannot build the HTTPS client: {0}")] + Build(reqwest::Error), +} + +/// The client-side TLS flags, as parsed from the command line. +/// +/// `Default` is "plain HTTP", which is what every existing invocation +/// gets: the flags are strictly additive and change nothing until +/// `enabled` is set. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ClientTlsOptions { + /// `-rpctls`: use `https://` for the RPC endpoint. + pub enabled: bool, + /// `-rpccacert`: extra trust anchor, in addition to the platform roots. + pub ca_cert: Option, + /// `-rpcclientcert`: client certificate for an mTLS listener. + pub client_cert: Option, + /// `-rpcclientkey`: the matching private key. + pub client_key: Option, +} + +impl ClientTlsOptions { + /// The URL scheme these options imply. + pub fn scheme(&self) -> &'static str { + if self.enabled { "https" } else { "http" } + } + + /// Build the endpoint URL for an RPC host/port under these options. + pub fn endpoint(&self, host: &str, port: u16) -> String { + // Bare IPv6 literals need brackets in a URL authority. `sat-cli + // -rpcconnect=::1` is a reasonable thing to type, and without this + // it produces `https://::1:8332/`, which does not parse. + if host.contains(':') && !host.starts_with('[') { + format!("{}://[{}]:{}/", self.scheme(), host, port) + } else { + format!("{}://{}:{}/", self.scheme(), host, port) + } + } + + /// Reject flag combinations that cannot mean what the operator wrote. + /// + /// Silently ignoring `-rpccacert` when `-rpctls` was forgotten is the + /// bad outcome here: the request goes out over plain HTTP carrying the + /// RPC credential, and everything looks like it worked. + pub fn validate(&self) -> Result<(), ClientTlsError> { + match (&self.client_cert, &self.client_key) { + (Some(_), None) | (None, Some(_)) => { + return Err(ClientTlsError::IncompleteIdentity); + } + _ => {} + } + if !self.enabled && (self.ca_cert.is_some() || self.client_cert.is_some()) { + return Err(ClientTlsError::TlsMaterialWithoutTls); + } + Ok(()) + } + + /// Apply these options to a `reqwest` client builder. + /// + /// Callers keep ownership of the builder so they can set their own + /// timeouts and headers; this only layers on the TLS material. + pub fn apply( + &self, + builder: reqwest::ClientBuilder, + ) -> Result { + self.validate()?; + if !self.enabled { + return Ok(builder); + } + + let mut builder = builder; + + if let Some(path) = &self.ca_cert { + let pem = read_file(path, "CA certificate")?; + // A PEM bundle may hold an intermediate as well as the root, and + // an operator handed a chain file should not have to split it. + let certs = reqwest::Certificate::from_pem_bundle(&pem).map_err(|source| { + ClientTlsError::BadCa { + path: path.display().to_string(), + source, + } + })?; + // A file with no PEM blocks parses "successfully" into an empty + // list. Accepting that would add no trust anchor at all and then + // fail the handshake with an opaque TLS error — the operator who + // pointed this at a private key, a DER file, or the wrong path + // would have no way to tell that from an unrelated network + // problem. Refuse by name instead. + if certs.is_empty() { + return Err(ClientTlsError::EmptyCa { + path: path.display().to_string(), + }); + } + for cert in certs { + builder = builder.add_root_certificate(cert); + } + } + + if let (Some(cert_path), Some(key_path)) = (&self.client_cert, &self.client_key) { + let mut pem = read_file(cert_path, "client certificate")?; + let key = read_file(key_path, "client key")?; + // reqwest's rustls `Identity::from_pem` wants one buffer holding + // both the certificate chain and the key, in either order. + if !pem.ends_with(b"\n") { + pem.push(b'\n'); + } + pem.extend_from_slice(&key); + let identity = + reqwest::Identity::from_pem(&pem).map_err(|source| ClientTlsError::BadIdentity { + cert: cert_path.display().to_string(), + key: key_path.display().to_string(), + source, + })?; + builder = builder.identity(identity); + } + + Ok(builder) + } + + /// Build a client with these options applied to `builder`. + pub fn build( + &self, + builder: reqwest::ClientBuilder, + ) -> Result { + self.apply(builder)?.build().map_err(ClientTlsError::Build) + } +} + +fn read_file(path: &Path, what: &'static str) -> Result, ClientTlsError> { + std::fs::read(path).map_err(|source| ClientTlsError::Io { + what, + path: path.display().to_string(), + source, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_is_plain_http() { + let opts = ClientTlsOptions::default(); + assert_eq!(opts.scheme(), "http"); + assert_eq!(opts.endpoint("127.0.0.1", 8332), "http://127.0.0.1:8332/"); + opts.validate().expect("the default must always be valid"); + } + + #[test] + fn enabled_switches_scheme() { + let opts = ClientTlsOptions { + enabled: true, + ..Default::default() + }; + assert_eq!(opts.endpoint("node.local", 8336), "https://node.local:8336/"); + } + + #[test] + fn ipv6_literals_are_bracketed() { + let opts = ClientTlsOptions { + enabled: true, + ..Default::default() + }; + assert_eq!(opts.endpoint("::1", 8336), "https://[::1]:8336/"); + // Already-bracketed input must not be double-bracketed. + assert_eq!(opts.endpoint("[::1]", 8336), "https://[::1]:8336/"); + } + + #[test] + fn half_an_identity_is_rejected() { + let opts = ClientTlsOptions { + enabled: true, + client_cert: Some(PathBuf::from("/nonexistent/cert.pem")), + ..Default::default() + }; + assert!(matches!( + opts.validate(), + Err(ClientTlsError::IncompleteIdentity) + )); + } + + /// The quiet-failure case: TLS material supplied but `-rpctls` + /// forgotten would otherwise send the RPC credential in the clear. + #[test] + fn tls_material_without_rpctls_is_an_error() { + let opts = ClientTlsOptions { + enabled: false, + ca_cert: Some(PathBuf::from("/nonexistent/ca.pem")), + ..Default::default() + }; + assert!(matches!( + opts.validate(), + Err(ClientTlsError::TlsMaterialWithoutTls) + )); + } + + #[test] + fn missing_ca_file_names_the_path() { + let opts = ClientTlsOptions { + enabled: true, + ca_cert: Some(PathBuf::from("/nonexistent/ca.pem")), + ..Default::default() + }; + let err = opts.apply(reqwest::Client::builder()).unwrap_err(); + assert!( + err.to_string().contains("/nonexistent/ca.pem"), + "error should name the unreadable file, got: {err}" + ); + } + + /// A file with no certificates in it must be refused by name. Left + /// unchecked this adds no trust anchor and surfaces later as a generic + /// handshake failure, which is indistinguishable from the node being + /// down. + #[test] + fn a_ca_file_with_no_certificates_is_rejected() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("ca.pem"); + std::fs::write(&path, b"this is not a certificate\n").unwrap(); + let opts = ClientTlsOptions { + enabled: true, + ca_cert: Some(path.clone()), + ..Default::default() + }; + let err = opts.apply(reqwest::Client::builder()).unwrap_err(); + assert!( + matches!(err, ClientTlsError::EmptyCa { .. }), + "expected EmptyCa, got: {err}" + ); + assert!(err.to_string().contains(&path.display().to_string())); + } + + /// The specific mistake worth naming: pointing `-rpccacert` at the + /// private key instead of the certificate. It is a valid PEM file, so + /// only the "are there certificates in it" check catches it. + #[test] + fn a_private_key_passed_as_the_ca_is_rejected() { + let cert = rcgen::generate_simple_self_signed(["localhost".to_string()]).unwrap(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("leaf.key"); + std::fs::write(&path, cert.key_pair.serialize_pem()).unwrap(); + let opts = ClientTlsOptions { + enabled: true, + ca_cert: Some(path), + ..Default::default() + }; + assert!( + opts.apply(reqwest::Client::builder()).is_err(), + "a key file must not pass as a CA bundle" + ); + } + + // --------------------------------------------------------------------- + // Handshake tests + // --------------------------------------------------------------------- + // + // Loading a PEM proves nothing about whether the connection verifies: + // `reqwest::Certificate::from_pem` accepts any certificate, including + // ones that cannot anchor a path. These tests therefore run a real + // handshake against an acceptor built by this crate's own server half, + // which is the same acceptor satd's RPC listener uses. + + /// Minimal TLS server: one connection, one canned HTTP response. + /// Returns the bound port and the task handle. + async fn spawn_tls_server( + cert_pem: &str, + key_pem: &str, + ) -> (u16, tokio::task::JoinHandle<()>) { + let dir = tempfile::tempdir().unwrap(); + let cert_path = dir.path().join("cert.pem"); + let key_path = dir.path().join("key.pem"); + std::fs::write(&cert_path, cert_pem).unwrap(); + std::fs::write(&key_path, key_pem).unwrap(); + let acceptor = + crate::build_acceptor(&cert_path, &key_path, &crate::ClientAuthPolicy::Disabled) + .expect("acceptor"); + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let handle = tokio::spawn(async move { + // `dir` is moved in so the PEM files outlive the acceptor build. + let _dir = dir; + loop { + let Ok((stream, _)) = listener.accept().await else { + return; + }; + let acceptor = acceptor.clone(); + tokio::spawn(async move { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + let Ok(mut tls) = acceptor.accept(stream).await else { + return; + }; + let mut buf = [0u8; 1024]; + let _ = tls.read(&mut buf).await; + let _ = tls + .write_all( + b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\ + Content-Length: 2\r\nConnection: close\r\n\r\n{}", + ) + .await; + let _ = tls.shutdown().await; + }); + } + }); + (port, handle) + } + + async fn get(client: &reqwest::Client, port: u16) -> Result { + client + .get(format!("https://localhost:{port}/")) + .send() + .await + .map(|r| r.status().as_u16()) + } + + /// A self-signed server certificate is its own issuer, so handing it to + /// `-rpccacert` has to verify. This is the documented fallback for + /// operators who ran `openssl req -x509` rather than `mkca.sh`. + #[tokio::test] + async fn self_signed_server_cert_verifies_when_named_as_the_ca() { + let cert = rcgen::generate_simple_self_signed(["localhost".to_string()]).unwrap(); + let (port, server) = spawn_tls_server(&cert.cert.pem(), &cert.key_pair.serialize_pem()).await; + + let dir = tempfile::tempdir().unwrap(); + let ca_path = dir.path().join("ca.pem"); + std::fs::write(&ca_path, cert.cert.pem()).unwrap(); + + let client = ClientTlsOptions { + enabled: true, + ca_cert: Some(ca_path), + ..Default::default() + } + .build(reqwest::Client::builder()) + .unwrap(); + + assert_eq!(get(&client, port).await.unwrap(), 200); + server.abort(); + } + + /// The same server, with no `-rpccacert`: the platform trust store does + /// not know this certificate, so the handshake must fail. Without this + /// the test above would pass even if the CA argument were ignored + /// entirely. + #[tokio::test] + async fn an_untrusted_server_cert_is_rejected() { + let cert = rcgen::generate_simple_self_signed(["localhost".to_string()]).unwrap(); + let (port, server) = spawn_tls_server(&cert.cert.pem(), &cert.key_pair.serialize_pem()).await; + + let client = ClientTlsOptions { + enabled: true, + ..Default::default() + } + .build(reqwest::Client::builder()) + .unwrap(); + + assert!( + get(&client, port).await.is_err(), + "a certificate signed by nothing the client trusts must not verify" + ); + server.abort(); + } + + /// Trusting the wrong CA must fail. This is the case that separates + /// "verification happens" from "any supplied PEM makes it work". + #[tokio::test] + async fn the_wrong_ca_is_rejected() { + let server_cert = rcgen::generate_simple_self_signed(["localhost".to_string()]).unwrap(); + let (port, server) = + spawn_tls_server(&server_cert.cert.pem(), &server_cert.key_pair.serialize_pem()).await; + + let other = rcgen::generate_simple_self_signed(["localhost".to_string()]).unwrap(); + let dir = tempfile::tempdir().unwrap(); + let ca_path = dir.path().join("other-ca.pem"); + std::fs::write(&ca_path, other.cert.pem()).unwrap(); + + let client = ClientTlsOptions { + enabled: true, + ca_cert: Some(ca_path), + ..Default::default() + } + .build(reqwest::Client::builder()) + .unwrap(); + + assert!( + get(&client, port).await.is_err(), + "an unrelated CA must not verify this server" + ); + server.abort(); + } + + /// The name on the certificate still has to match. A private CA is a + /// trust anchor, not a licence to ignore the SAN — an appliance issues + /// its leaf for `.local` precisely so this check passes. + #[tokio::test] + async fn a_name_mismatch_is_rejected() { + let cert = rcgen::generate_simple_self_signed(["not-the-host".to_string()]).unwrap(); + let (port, server) = spawn_tls_server(&cert.cert.pem(), &cert.key_pair.serialize_pem()).await; + + let dir = tempfile::tempdir().unwrap(); + let ca_path = dir.path().join("ca.pem"); + std::fs::write(&ca_path, cert.cert.pem()).unwrap(); + + let client = ClientTlsOptions { + enabled: true, + ca_cert: Some(ca_path), + ..Default::default() + } + .build(reqwest::Client::builder()) + .unwrap(); + + assert!( + get(&client, port).await.is_err(), + "a certificate issued for another name must not verify" + ); + server.abort(); + } + + #[test] + fn client_identity_is_loaded_from_a_split_pair() { + let cert = rcgen::generate_simple_self_signed(["client".to_string()]).unwrap(); + let dir = tempfile::tempdir().unwrap(); + let cert_path = dir.path().join("client.crt"); + let key_path = dir.path().join("client.key"); + // Deliberately written without a trailing newline: concatenating the + // two files has to insert the separator, or the PEM parser sees + // `-----END CERTIFICATE----------BEGIN PRIVATE KEY-----`. + let mut pem = cert.cert.pem(); + while pem.ends_with('\n') { + pem.pop(); + } + std::fs::write(&cert_path, pem).unwrap(); + std::fs::write(&key_path, cert.key_pair.serialize_pem()).unwrap(); + + let opts = ClientTlsOptions { + enabled: true, + client_cert: Some(cert_path), + client_key: Some(key_path), + ..Default::default() + }; + opts.build(reqwest::Client::builder()) + .expect("a cert/key pair in separate files must load"); + } +} diff --git a/tls-config/src/lib.rs b/tls-config/src/lib.rs index 88158587e..203c4f4f2 100644 --- a/tls-config/src/lib.rs +++ b/tls-config/src/lib.rs @@ -30,6 +30,16 @@ //! The acceptor and `ServerConnection` types are re-exported so //! consumers can refer to them through this crate without adding their //! own `tokio-rustls` dependency just to spell the types. +//! +//! ## Client side +//! +//! The `client` feature adds [`client::ClientTlsOptions`], the matching +//! client-side configuration used by `sat-cli` and `sat-tui` to reach a +//! TLS-terminated RPC listener. It is behind a feature because it pulls +//! `reqwest`, which the server surfaces have no use for. + +#[cfg(feature = "client")] +pub mod client; use std::collections::HashSet; use std::fs::File; From 25d82cd24c313e7d5f66e82ea4b99952b0e72291 Mon Sep 17 00:00:00 2001 From: Benjamen Keroack Date: Tue, 8 Sep 2026 14:57:34 -0600 Subject: [PATCH 03/22] contrib/stack: the reference deployment, TLS on every surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A docker-compose stack that runs satd with RPC, Electrum, Esplora, metrics and (optionally) MCP all on, each TLS-terminated by a certificate the install issues for itself. It is the shared substrate the rest of the appliance work builds on: the image runs this stack, and the Umbrel and StartOS packages are derived from compose.yml, so the satd service is defined once and cannot drift between them. tls/mkca.sh is the single certificate script all three use. It creates a CA for one install only and issues one leaf that every surface presents, so a client imports one thing and trusts everything. Reissue happens on expiry, on a change to the machine's names or addresses, or on --force; the CA is never rotated automatically, because that invalidates trust every client already established. Nothing key-like is in any image — the smoke test asserts that, and it is what makes the appliance image redistributable. Two decisions worth stating. Plain RPC/Electrum/Esplora/metrics listeners bind the compose network and are never published: they exist because the overlay containers cannot be taught to trust a private CA, and nothing unencrypted leaves the host. And the internal RPC port is pinned to 8332 on every network so overlays, the proxy and the app-store packages address one fixed port — the cost, documented, is that in-container sat-cli needs -rpcport on a non-mainnet stack. LND runs in Neutrino mode because satd implements no raw ZMQ topics and rejects them by design; Neutrino needs none, pulling BIP157/158 filters over P2P from the peerblockfilters listener the stack turns on. Core Lightning is unaffected and runs as a full-node client. The image now also carries openssl, mkca.sh, satd-init and the config template, so a deployment that cannot mount repository files gets identical first-run behaviour. smoke.sh brings the stack up on regtest and probes every TLS listener from outside the container against the generated CA with -verify_return_error, paired with the negative control that the same handshake without the CA must fail — a probe that would pass unverified proves nothing about the certificate. Verified locally: core stack, LND syncing to tip over Neutrino, and RTL served through the proxy. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01K9Nb3m8HuhNbjkJYp9Frmt --- Dockerfile | 15 ++ contrib/stack/.env.example | 48 +++++ contrib/stack/README.md | 181 ++++++++++++++++ contrib/stack/ark/ark-init | 80 +++++++ contrib/stack/caddy/Caddyfile | 33 +++ contrib/stack/compose.ark.yml | 144 +++++++++++++ contrib/stack/compose.btcpay.yml | 99 +++++++++ contrib/stack/compose.cashu.yml | 53 +++++ contrib/stack/compose.cln.yml | 52 +++++ contrib/stack/compose.lightning.yml | 107 ++++++++++ contrib/stack/compose.proxy.yml | 48 +++++ contrib/stack/compose.yml | 89 ++++++++ contrib/stack/satd/satd-init | 201 ++++++++++++++++++ contrib/stack/satd/satd.conf.tmpl | 93 ++++++++ contrib/stack/tests/mkca-test.sh | 175 +++++++++++++++ contrib/stack/tests/smoke.sh | 317 ++++++++++++++++++++++++++++ contrib/stack/tls/mkca.sh | 280 ++++++++++++++++++++++++ 17 files changed, 2015 insertions(+) create mode 100644 contrib/stack/.env.example create mode 100644 contrib/stack/README.md create mode 100755 contrib/stack/ark/ark-init create mode 100644 contrib/stack/caddy/Caddyfile create mode 100644 contrib/stack/compose.ark.yml create mode 100644 contrib/stack/compose.btcpay.yml create mode 100644 contrib/stack/compose.cashu.yml create mode 100644 contrib/stack/compose.cln.yml create mode 100644 contrib/stack/compose.lightning.yml create mode 100644 contrib/stack/compose.proxy.yml create mode 100644 contrib/stack/compose.yml create mode 100755 contrib/stack/satd/satd-init create mode 100644 contrib/stack/satd/satd.conf.tmpl create mode 100755 contrib/stack/tests/mkca-test.sh create mode 100755 contrib/stack/tests/smoke.sh create mode 100755 contrib/stack/tls/mkca.sh diff --git a/Dockerfile b/Dockerfile index 2e631ace4..5a699c6bb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -154,9 +154,16 @@ ENV DEBIAN_FRONTEND=noninteractive # - libssl3: reqwest's openssl backend (matches the build stage) # - ca-certificates: outbound HTTPS for fee oracles, webhooks, etc. # - tini: PID 1 signal forwarding so SIGTERM reaches satd cleanly +# - openssl: the CLI, for satd-mkca (below). It issues the per-install CA +# and server certificate that satd's TLS surfaces present. Carrying the +# tool in the image is what lets the compose stack, the appliance and +# the app-store packages all generate identical TLS material without +# each shipping their own copy. libssl3 is already here, so this adds +# about a megabyte. RUN apt-get update && apt-get install -y --no-install-recommends \ ca-certificates \ libssl3 \ + openssl \ tini \ && rm -rf /var/lib/apt/lists/* @@ -179,6 +186,14 @@ COPY --from=builder /out/sat-cli /usr/local/bin/sat-cli COPY --from=builder /out/sat-tui /usr/local/bin/sat-tui COPY contrib/docker/satd-healthcheck /usr/local/bin/satd-healthcheck +# The reference stack's first-run tooling. Baked in rather than bind-mounted +# so that a deployment which cannot mount repository files — an Umbrel app, +# a StartOS package — gets exactly the same certificate issuance and config +# rendering as `docker compose up` from contrib/stack. +COPY contrib/stack/tls/mkca.sh /usr/local/bin/satd-mkca +COPY contrib/stack/satd/satd-init /usr/local/bin/satd-init +COPY contrib/stack/satd/satd.conf.tmpl /etc/satd/satd.conf.tmpl + USER satd WORKDIR /var/lib/satd VOLUME ["/var/lib/satd"] diff --git a/contrib/stack/.env.example b/contrib/stack/.env.example new file mode 100644 index 000000000..70629934c --- /dev/null +++ b/contrib/stack/.env.example @@ -0,0 +1,48 @@ +# Copy to `.env` and edit. Every value here has a working default; the file +# exists so the common changes are in one place. + +# --- network ---------------------------------------------------------------- +# signet | mainnet | testnet4 | testnet | regtest +# +# signet is the default because it is the only network where the whole stack +# is exercisable in an evening: a full index syncs in well under an hour and +# needs tens of GB, faucets supply coins, and Lightning, ecash and Ark all +# work end to end with no real money. +# +# mainnet has no prune option here — Electrum and Esplora need txindex, and +# txindex and prune are mutually exclusive — so budget for the full node plus +# every index. See docs/manual/src/disk-footprint.md before switching. +NETWORK=signet + +# Must match the standard P2P port for NETWORK; satd-init refuses to start +# if it does not, because a mismatch publishes a port that maps to nothing. +# mainnet 8333 | signet 38333 | testnet4 48333 | testnet 18333 | regtest 18444 +SATD_P2P_PORT=38333 + +# --- image ------------------------------------------------------------------ +# Pin to a release tag for anything you intend to keep running. +SATD_IMAGE=ghcr.io/epochbtc/satd:latest + +# --- published TLS ports ---------------------------------------------------- +# Host-side ports for the three TLS surfaces. Change them if something else +# on the host already owns one; the container-side ports are fixed. +SATD_RPC_TLS_PORT=8336 +SATD_ELECTRUM_TLS_PORT=50002 +SATD_ESPLORA_TLS_PORT=3001 + +# --- TLS -------------------------------------------------------------------- +# The primary name on the generated certificate. Use the name clients will +# actually type — on a LAN with mDNS that is usually `.local`, which +# keeps working when DHCP changes the address. +SATD_TLS_HOSTNAME=satd + +# --- MCP -------------------------------------------------------------------- +# 0 in the plain stack, 1 in the appliance image and the app-store packages. +# Enabling it generates a bearer token in the data volume under +# secrets/mcp-token and publishes MCP over TLS on 8339. +SATD_MCP=0 + +# --- internals -------------------------------------------------------------- +# The compose network. Change only if it collides with an existing network; +# bitcoin.conf's rpcallowip is derived from it. +SATD_STACK_SUBNET=10.77.0.0/24 diff --git a/contrib/stack/README.md b/contrib/stack/README.md new file mode 100644 index 000000000..72cce3154 --- /dev/null +++ b/contrib/stack/README.md @@ -0,0 +1,181 @@ +# satd reference stack + +A docker-compose stack that runs satd with every client-facing surface on, +TLS everywhere, plus optional overlays for the third-party software people +actually point at a Bitcoin node. + +```sh +cp .env.example .env # edit NETWORK if you want something other than signet +docker compose up -d +docker compose logs -f satd +``` + +This directory is also the shared substrate for the other two deliverables: +the appliance image runs this stack, and the Umbrel / StartOS packages are +derived from `compose.yml`. The satd service is defined once so that its +configuration cannot drift between them. + +## Support + +**satd is supported.** The `satd` and `satd-init` services run the same +release artifact as the tarballs and the published container image, and are +covered by the same policy. + +**The overlays are best-effort.** They bundle third-party software — LND, +RTL, Nutshell, Core Lightning, NBXplorer, BTCPay — so that satd's +compatibility claims can be exercised end to end rather than asserted. We do +not track their security advisories in real time, and a critical fix in one +of them may not reach a pinned digest here until the next scheduled bump. +Run them for evaluation and testing. For production, operate those +components yourself. + +## What you get + +| Surface | Reachable at | TLS | +|---|---|---| +| JSON-RPC | `https://:8336` | native, stack certificate | +| Electrum | `ssl://:50002` | native, stack certificate | +| Esplora REST | `https://:3001/api` | native, stack certificate | +| MCP (opt-in) | `https://:8339` | native, plus a bearer token | +| P2P | `:38333` on signet | n/a — Bitcoin P2P, BIP 324 v2 is on | +| metrics / `readyz` | compose network, or `https://:9443` with the proxy overlay | reverse proxy | + +Plain listeners exist for RPC, Electrum, Esplora and metrics, but they bind +the compose network only and are never published. They are how the overlay +containers reach satd, since none of them can be taught to trust a private +CA. Nothing unencrypted leaves the host. + +## Networks and disk + +`NETWORK=signet` by default. signet is the only network on which this whole +stack is a one-evening exercise: a fully indexed node syncs in well under an +hour, faucets supply coins, and Lightning and ecash work end to end with no +real money. + +There is no prune option, on any network. Electrum and Esplora both require +`txindex`, and `txindex` cannot coexist with pruning, so a mainnet stack +stores the full chain plus every index. Read +`docs/manual/src/disk-footprint.md` before setting `NETWORK=mainnet`; +budget a 2 TB volume. + +For mainnet, `--fast-start` can load a Bitcoin Core AssumeUTXO snapshot so +the node is usable in hours rather than days. See +`docs/manual/src/ibd.md`; satd hosts no snapshots. + +## TLS + +`satd-init` runs `tls/mkca.sh` on first start. It creates a CA **for this +install only**, then issues one server certificate that every satd surface +presents. Nothing key-like exists in any image; the CA private key is +generated on the machine that will use it and never leaves. + +Export the CA once and every surface becomes trusted at once: + +```sh +docker compose exec satd cat /var/lib/satd/tls/ca.crt > satd-ca.crt +``` + +Then: + +- `curl --cacert satd-ca.crt https://:3001/api/blocks/tip/height` +- `sat-cli --rpctls --rpccacert=satd-ca.crt --rpcport=8336 -rpcconnect= getblockchaininfo` +- Import `satd-ca.crt` into your OS or browser trust store for the web UIs. +- Sparrow, Electrum and Liana pin the server certificate on first use + instead; accept it once when connecting to `ssl://:50002`. + +The certificate covers `localhost`, `127.0.0.1`, `::1`, the configured +hostname, `.local`, and the machine's non-bridge addresses. Prefer +the mDNS name (`.local`) on a LAN: it survives a DHCP change, +where an address in the SAN list does not. `mkca.sh` reissues automatically +when the address set changes, on a start where fewer than 30 days remain, +or with `--force`. + +`tls/mkca.sh` is also what the appliance image and the app-store packages +run, so all three produce identical material and the client instructions +above are the same everywhere. + +## Layout + +``` +compose.yml satd + satd-init. Supported. +compose.lightning.yml LND in Neutrino mode + Ride The Lightning. +compose.cln.yml Core Lightning, as an alternative to LND. +compose.cashu.yml Nutshell mint, backed by the LND above. +compose.btcpay.yml Postgres + NBXplorer + BTCPay Server. +compose.ark.yml An Ark server (arkd), via NBXplorer. Experimental. +compose.proxy.yml Caddy, terminating TLS for the web UIs and metrics. +satd/satd.conf.tmpl The node configuration, with @NAME@ substitutions. +satd/satd-init Renders it, issues the certificates, mints the MCP token. +tls/mkca.sh The one CA/certificate script, shared by all three deliverables. +tests/smoke.sh Brings the stack up on regtest and probes every surface. +tests/mkca-test.sh Unit tests for the certificate script. +``` + +Combine overlays by repeating `-f`: + +```sh +docker compose -f compose.yml -f compose.lightning.yml -f compose.proxy.yml up -d +``` + +Some overlays require a secret with no default, and refuse to start without +it rather than shipping one everybody shares: + +```sh +echo "MINT_PRIVATE_KEY=$(openssl rand -hex 32)" >> .env # compose.cashu.yml +echo "POSTGRES_PASSWORD=$(openssl rand -hex 24)" >> .env # compose.btcpay.yml +echo "ARK_POSTGRES_PASSWORD=$(openssl rand -hex 24)" >> .env # compose.ark.yml +``` + +## Why LND runs in Neutrino mode + +LND's `bitcoind` backend needs Bitcoin Core's raw ZMQ topics +(`zmqpubrawblock` / `zmqpubrawtx`). satd does not implement them — it +rejects those settings outright, and `CORE_DIFFERENCES.md` records that as +deliberate. Neutrino needs no ZMQ: it pulls BIP 157/158 filter headers and +filters over P2P, which satd serves because the stack sets +`peerblockfilters=1`. + +Core Lightning is unaffected — its `bcli` plugin polls JSON-RPC — which is +why `compose.cln.yml` runs it as a full-node client. + +Ark is unaffected for a third reason: `arkd`'s wallet takes its chain data +from NBXplorer, which speaks satd's JSON-RPC and P2P. See +`compose.ark.yml`. + +## Local overrides + +`satd-init` rewrites `bitcoin.conf` on every start, so edits to it are lost. +Put additions in `conf.d/local.conf` inside the data volume instead; they +are appended last, and satd takes the last value for a repeated key. + +```sh +docker compose exec satd sh -c 'mkdir -p /var/lib/satd/conf.d && \ + printf "dbcache=4000\n" >> /var/lib/satd/conf.d/local.conf' +docker compose restart satd +``` + +## Using the CLI and the TUI + +```sh +docker compose exec satd sat-cli -rpcport=8332 getblockchaininfo +docker compose exec -it satd sat-tui -rpcport=8332 +``` + +`-rpcport=8332` is needed on any network but mainnet: the stack pins the +internal RPC port to 8332 everywhere so that the overlays, the proxy and the +app-store packages address one fixed port, while `sat-cli` derives its +default from the chain. + +## Tests + +```sh +tests/mkca-test.sh # certificate script, no docker needed +tests/smoke.sh # regtest bring-up, every TLS surface probed +tests/smoke.sh --with lightning --with proxy # + LND syncing over Neutrino +SATD_IMAGE=satd:dev tests/smoke.sh # against a locally built image +``` + +`smoke.sh` verifies every TLS listener from outside the container against +the generated CA with `-verify_return_error`, and includes the negative +control — the same handshake without the CA must fail — because a probe that +would also pass without verification proves nothing about the certificate. diff --git a/contrib/stack/ark/ark-init b/contrib/stack/ark/ark-init new file mode 100755 index 000000000..7b8139672 --- /dev/null +++ b/contrib/stack/ark/ark-init @@ -0,0 +1,80 @@ +#!/bin/sh +# ark-init — first-run initialisation for the Ark overlay. +# +# docker compose -f compose.yml -f compose.ark.yml run --rm ark-init +# +# arkd will not start without a signer key, and its wallet must be created +# and unlocked before the service answers anything. Both are per-install +# secrets, so both are generated here into the data volume rather than +# shipped or committed. +# +# Idempotent: an existing key and wallet are left alone. + +set -eu + +DATA=/app/data +KEY_FILE="$DATA/signer.key" +PW_FILE="$DATA/wallet.password" +ADMIN="http://arkd:7071" + +mkdir -p "$DATA" + +# First run generates the key and stops. It has to: arkd will not start +# without a signer key, so on a first run there is no admin API to talk to +# yet, and waiting for one would just burn the timeout before telling the +# operator the one thing they need to do. +if [ ! -s "$KEY_FILE" ]; then + # arkd's own generator, rather than an ad-hoc one: the key has to be + # valid for its signer, and that is arkd's definition to make. + /app/arkd genkey | grep -oE '[0-9a-f]{64}' | head -1 > "$KEY_FILE" + chmod 600 "$KEY_FILE" + cat </dev/null 2>&1; then break; fi + i=$((i + 1)) + sleep 5 +done +if ! /app/arkd --url "$ADMIN" wallet status >/dev/null 2>&1; then + echo "ark-init: arkd's admin API never came up." >&2 + echo "ark-init: if this is the first run, set ARKD_SIGNER_KEY (above) and re-run." >&2 + exit 1 +fi + +if /app/arkd --url "$ADMIN" wallet status 2>/dev/null | grep -q "initialized: true"; then + echo "ark-init: wallet already initialised" +else + if [ ! -s "$PW_FILE" ]; then + head -c 32 /dev/urandom | od -An -tx1 | tr -d ' \n' > "$PW_FILE" + chmod 600 "$PW_FILE" + fi + echo "ark-init: creating the wallet (the mnemonic below is the only copy)" + /app/arkd --url "$ADMIN" wallet create --password "$(cat "$PW_FILE")" +fi + +/app/arkd --url "$ADMIN" wallet unlock --password "$(cat "$PW_FILE")" || true +sleep 5 +echo "ark-init: status:" +/app/arkd --url "$ADMIN" wallet status 2>&1 | sed 's/^/ /' diff --git a/contrib/stack/caddy/Caddyfile b/contrib/stack/caddy/Caddyfile new file mode 100644 index 000000000..a984e6d0f --- /dev/null +++ b/contrib/stack/caddy/Caddyfile @@ -0,0 +1,33 @@ +# Caddyfile for the satd stack's reverse proxy. See compose.proxy.yml. +{ + # No ACME. This proxy presents the per-install certificate that + # tls/mkca.sh issued, which is the same one every satd surface + # presents, so a client that imported the CA trusts all of them. + # Automatic HTTPS would try to obtain a public certificate for a + # name that does not exist publicly and fail on every start. + auto_https off + admin off +} + +(satd_tls) { + tls /satd/tls/fullchain.crt /satd/tls/leaf.key +} + +# Ride The Lightning — compose.lightning.yml. +:443 { + import satd_tls + reverse_proxy rtl:3000 +} + +# Cashu mint — compose.cashu.yml. +:8443 { + import satd_tls + reverse_proxy mint:3338 +} + +# satd's metrics, /healthz and /readyz. No native TLS on this listener, which +# is why it is here; it stays on the compose network otherwise. +:9443 { + import satd_tls + reverse_proxy satd:9332 +} diff --git a/contrib/stack/compose.ark.yml b/contrib/stack/compose.ark.yml new file mode 100644 index 000000000..01a7fa19c --- /dev/null +++ b/contrib/stack/compose.ark.yml @@ -0,0 +1,144 @@ +# Ark overlay — an Ark server (arkd) backed by satd. EXPERIMENTAL. +# +# docker compose -f compose.yml -f compose.ark.yml up -d +# docker compose -f compose.yml -f compose.ark.yml run --rm ark-init +# +# BEST-EFFORT, and more experimental than the other overlays: Ark is young, +# arkd's configuration surface is not documented in a form worth pinning, +# and every setting here was established by running the binary rather than +# by reading a specification. Expect it to need attention on a version bump. +# +# ## How it reaches satd +# +# satd -> NBXplorer -> arkd-wallet -> arkd +# +# arkd v0.9 splits the wallet into its own service, and that wallet's chain +# backend is NBXplorer — not Esplora, and not Bitcoin Core's ZMQ. That +# matters here for two reasons. satd implements no raw ZMQ topics, so a +# backend that needed them would rule Ark out entirely; and NBXplorer +# against satd is already a PR-gating canary in this repository, so the one +# link in this chain that touches satd is the link that is continuously +# tested. +# +# Postgres is NBXplorer's, not Ark's. If you are already running +# compose.btcpay.yml you have NBXplorer and Postgres; run one or the other, +# not both, or give this overlay its own project. +# +# ## First run +# +# arkd refuses to start without a signer key, and its wallet must be created +# and unlocked before the service answers. `ark-init` does both, once: +# +# docker compose -f compose.yml -f compose.ark.yml run --rm ark-init +# +# It writes the generated signer key and wallet password into the ark-data +# volume. They are generated per install, never shipped. + +services: + ark-db: + image: postgres:16-bookworm@sha256:bb3e1a57e5407e0a5280b4211980a5e537f4abd234a87014ac979849a78dd825 + environment: + POSTGRES_PASSWORD: ${ARK_POSTGRES_PASSWORD:?generate one with `openssl rand -hex 24` and put ARK_POSTGRES_PASSWORD in .env} + POSTGRES_DB: nbxplorer + volumes: + - ark-db:/var/lib/postgresql/data + networks: [satd] + restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 10s + timeout: 5s + retries: 10 + + ark-nbxplorer: + image: nicolasdorier/nbxplorer:2.5.21@sha256:0eaa2b165873face1ac699297b45f5110d57b557558d68588896dc386c7eb3cb + depends_on: + ark-db: + condition: service_healthy + environment: + NBXPLORER_NETWORK: ${NETWORK:-signet} + NBXPLORER_BIND: 0.0.0.0:32838 + NBXPLORER_CHAINS: btc + NBXPLORER_BTCRPCURL: http://${SATD_HOST:-satd}:8332 + NBXPLORER_BTCRPCCOOKIEFILE: /satd/rpc-cookie + NBXPLORER_BTCNODEENDPOINT: ${SATD_HOST:-satd}:${SATD_P2P_PORT:-38333} + NBXPLORER_POSTGRES: Host=ark-db;Port=5432;Database=nbxplorer;Username=postgres;Password=${ARK_POSTGRES_PASSWORD} + NBXPLORER_NOAUTH: "1" + # Without this, NBXplorer decides a short regtest chain needs warming + # up and calls a mining RPC satd does not implement, which kills its + # indexer loop before it ever connects. The repository's NBXplorer + # canary sets the same flag. + NBXPLORER_NOWARMUP: "1" + volumes: + - satd-data:/satd:ro + - ark-nbxplorer:/datadir + networks: [satd] + restart: unless-stopped + expose: ["32838"] + + arkd-wallet: + image: ghcr.io/arkade-os/arkd-wallet:v0.9.16@sha256:9c4092115440039e87f1fc7e053c388d508abb36edbe9fe07c9e0a140948edd2 + depends_on: [ark-nbxplorer] + environment: + ARKD_WALLET_NBXPLORER_URL: http://ark-nbxplorer:32838 + ARKD_WALLET_NETWORK: ${NETWORK:-signet} + ARKD_WALLET_DATADIR: /app/data + # Generated by ark-init into the shared volume on first run. + ARKD_WALLET_SIGNER_KEY: ${ARKD_SIGNER_KEY:-} + volumes: + - ark-data:/app/data + networks: [satd] + restart: unless-stopped + expose: ["6060"] + healthcheck: + # arkd exits rather than waits when the wallet is not yet serving, and + # the wallet itself waits on NBXplorer, which waits on satd. That is a + # long readiness chain with nothing gating it, so it gets a gate. + # /bin/sh in this image has no /dev/tcp, hence the wget probe: any + # answer on 6060 means the gRPC listener is bound. + test: ["CMD-SHELL", "wget -q -T 2 -O - http://127.0.0.1:6060 2>&1 | grep -q . || nc -z 127.0.0.1 6060"] + interval: 10s + timeout: 5s + start_period: 3m + retries: 12 + + arkd: + image: ghcr.io/arkade-os/arkd:v0.9.16@sha256:f723e26a1bff7fa529dc0abd414088f294e43be8916dbb8f081dccd6fcad90b5 + depends_on: + arkd-wallet: + condition: service_healthy + entrypoint: ["/app/arkd", "start"] + environment: + ARKD_WALLET_ADDR: arkd-wallet:6060 + ARKD_NETWORK: ${NETWORK:-signet} + ARKD_DATADIR: /app/data + ARKD_PORT: "7070" + ARKD_EVENT_DB_TYPE: badger + ARKD_DB_TYPE: badger + ARKD_LIVE_STORE_TYPE: inmemory + # Plain HTTP on the container network; the proxy overlay terminates + # TLS for anything that leaves the host, as it does for every other + # web surface here. + ARKD_NO_TLS: "true" + volumes: + - arkd-data:/app/data + networks: [satd] + restart: unless-stopped + expose: ["7070", "7071"] + + # One-shot, run explicitly: `docker compose ... run --rm ark-init`. + ark-init: + image: ghcr.io/arkade-os/arkd:v0.9.16@sha256:f723e26a1bff7fa529dc0abd414088f294e43be8916dbb8f081dccd6fcad90b5 + entrypoint: ["/bin/sh", "/ark-init"] + volumes: + - ./ark/ark-init:/ark-init:ro + - ark-data:/app/data + networks: [satd] + profiles: ["init"] + restart: "no" + +volumes: + ark-db: + ark-nbxplorer: + ark-data: + arkd-data: diff --git a/contrib/stack/compose.btcpay.yml b/contrib/stack/compose.btcpay.yml new file mode 100644 index 000000000..86999757e --- /dev/null +++ b/contrib/stack/compose.btcpay.yml @@ -0,0 +1,99 @@ +# BTCPay Server overlay — Postgres + NBXplorer + BTCPay against satd. +# +# docker compose -f compose.yml -f compose.btcpay.yml up -d +# +# BEST-EFFORT and heavy: three more containers, a database, and an initial +# NBXplorer scan that downloads every block over P2P. Off by default in the +# appliance image for that reason. +# +# NBXplorer is a full-node client, not a light client: it fetches blocks over +# P2P and queries JSON-RPC, so this overlay exercises satd's RPC surface far +# harder than the Lightning one does. The repository's NBXplorer and BTCPay +# canaries gate every PR on the same path — four Core-compatibility bugs were +# found that way — so what is new here is the packaging, not the claim. +# +# Authentication is by cookie: the satd volume is mounted read-only and +# `rpc-cookie` is the stable per-network symlink satd-init maintains. +# +# POSTGRES_PASSWORD has no default. The database is only reachable on the +# compose network, but a password baked into a file in a public repository +# is not a password: +# +# echo "POSTGRES_PASSWORD=$(openssl rand -hex 24)" >> .env + +services: + btcpay-db: + image: postgres:16-bookworm@sha256:bb3e1a57e5407e0a5280b4211980a5e537f4abd234a87014ac979849a78dd825 + environment: + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?generate one with `openssl rand -hex 24` and put POSTGRES_PASSWORD in .env} + POSTGRES_DB: nbxplorer + volumes: + - btcpay-db:/var/lib/postgresql/data + networks: + - satd + restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 10s + timeout: 5s + retries: 10 + + nbxplorer: + image: nicolasdorier/nbxplorer:2.5.21@sha256:0eaa2b165873face1ac699297b45f5110d57b557558d68588896dc386c7eb3cb + depends_on: + satd: + condition: service_healthy + btcpay-db: + condition: service_healthy + environment: + NBXPLORER_NETWORK: ${NETWORK:-signet} + NBXPLORER_BIND: 0.0.0.0:32838 + NBXPLORER_CHAINS: btc + NBXPLORER_BTCRPCURL: http://satd:8332 + NBXPLORER_BTCRPCCOOKIEFILE: /satd/rpc-cookie + NBXPLORER_BTCNODEENDPOINT: satd:${SATD_P2P_PORT:-38333} + NBXPLORER_POSTGRES: Host=btcpay-db;Port=5432;Database=nbxplorer;Username=postgres;Password=${POSTGRES_PASSWORD} + # No auth on the compose network only; nothing publishes this port. + NBXPLORER_NOAUTH: "1" + # On a short chain NBXplorer decides it should "warm up" by mining, + # via an RPC satd does not implement — which kills its indexer loop + # before it connects, on regtest in particular. The repository's + # NBXplorer canary disables it for the same reason. + NBXPLORER_NOWARMUP: "1" + volumes: + - nbxplorer-data:/datadir + - satd-data:/satd:ro + networks: + - satd + restart: unless-stopped + expose: + - "32838" + + btcpay: + image: btcpayserver/btcpayserver:2.3.9@sha256:7c4b79fd578d919da1bc3bb52fd4695c156fe309ab8c0b6602c36026f1780d27 + depends_on: + - nbxplorer + environment: + BTCPAY_NETWORK: ${NETWORK:-signet} + BTCPAY_BIND: 0.0.0.0:49392 + BTCPAY_ROOTPATH: / + BTCPAY_CHAINS: btc + BTCPAY_BTCEXPLORERURL: http://nbxplorer:32838/ + BTCPAY_POSTGRES: Host=btcpay-db;Port=5432;Database=btcpay;Username=postgres;Password=${POSTGRES_PASSWORD} + BTCPAY_EXPLORERPOSTGRES: Host=btcpay-db;Port=5432;Database=nbxplorer;Username=postgres;Password=${POSTGRES_PASSWORD} + volumes: + - btcpay-data:/datadir + networks: + - satd + restart: unless-stopped + # BTCPay speaks plain HTTP and builds absolute URLs from its own root, + # so it gets a dedicated proxy port rather than a path. Published + # directly here for stacks not running compose.proxy.yml; behind the + # proxy, prefer the TLS port. + ports: + - "${BTCPAY_PORT:-49392}:49392" + +volumes: + btcpay-db: + btcpay-data: + nbxplorer-data: diff --git a/contrib/stack/compose.cashu.yml b/contrib/stack/compose.cashu.yml new file mode 100644 index 000000000..2f0300bee --- /dev/null +++ b/contrib/stack/compose.cashu.yml @@ -0,0 +1,53 @@ +# Cashu overlay — a Nutshell mint whose Lightning backend is the stack's LND. +# +# docker compose -f compose.yml -f compose.lightning.yml \ +# -f compose.cashu.yml -f compose.proxy.yml up -d +# +# BEST-EFFORT, and emphatically a demo: a mint is a custodian. This one holds +# whatever you deposit, its keys live in a container volume, and it has no +# backup story. Signet only, in practice. +# +# Requires the Lightning overlay: the mint melts and mints against LND's REST +# API, which is in turn a Neutrino client of satd. So a working mint here is +# a three-link claim about satd — filters over P2P, LND on top, mint on top +# of that. +# +# MINT_PRIVATE_KEY is required and has no default. It is the seed for the +# mint's keysets: shipping one would mean every deployment shared it, and +# changing it invalidates every token already issued. Generate once: +# +# echo "MINT_PRIVATE_KEY=$(openssl rand -hex 32)" >> .env + +services: + mint: + image: cashubtc/nutshell:0.16.5@sha256:7209a93720a38e78244643329b2a75ee5730363f2a4ef80a82c5bebc188b49dc + depends_on: + lnd: + condition: service_healthy + entrypoint: ["poetry", "run", "mint"] + environment: + MINT_LISTEN_HOST: 0.0.0.0 + MINT_LISTEN_PORT: "3338" + MINT_PRIVATE_KEY: ${MINT_PRIVATE_KEY:?generate one with `openssl rand -hex 32` and put MINT_PRIVATE_KEY in .env} + # 0.16 spells the backend selector per-unit. The older + # MINT_LIGHTNING_BACKEND name is set too so a pin bump in either + # direction keeps working; an unrecognised variable is ignored. + MINT_BACKEND_BOLT11_SAT: LndRestWallet + MINT_LIGHTNING_BACKEND: LndRestWallet + MINT_LND_REST_ENDPOINT: https://lnd:8080 + MINT_LND_REST_CERT: /lnd/tls.cert + MINT_LND_REST_MACAROON: /lnd/data/chain/bitcoin/${NETWORK:-signet}/admin.macaroon + MINT_INFO_NAME: satd stack mint (demo) + MINT_INFO_DESCRIPTION: Nutshell mint backed by LND, backed by satd. + volumes: + - lnd-data:/lnd:ro + - mint-data:/root/.cashu + networks: + - satd + restart: unless-stopped + # Reached through the proxy overlay on 8443. Nutshell speaks plain HTTP. + expose: + - "3338" + +volumes: + mint-data: diff --git a/contrib/stack/compose.cln.yml b/contrib/stack/compose.cln.yml new file mode 100644 index 000000000..a00bbac9f --- /dev/null +++ b/contrib/stack/compose.cln.yml @@ -0,0 +1,52 @@ +# Core Lightning overlay — the alternative to compose.lightning.yml. +# +# docker compose -f compose.yml -f compose.cln.yml up -d +# +# BEST-EFFORT. Run this OR the LND overlay, not both: they are two answers to +# the same question, and running both doubles the stack's resource use for no +# added coverage. +# +# CLN's `bcli` plugin polls Bitcoin JSON-RPC and needs no ZMQ, so unlike LND +# it runs against satd in full-node mode rather than as a light client. That +# makes it the overlay that exercises satd's RPC surface hardest — the +# repository's CLN canary gates every PR on the same path. +# +# Authentication is by cookie: the satd data volume is mounted read-only and +# `rpc-cookie` is a stable symlink satd-init maintains to whichever +# per-network path the cookie actually lives at. + +services: + cln: + image: elementsproject/lightningd:v24.11@sha256:30cc9802955cc640a057d65b0ace5cd1c0c8b719e9a28cf516d85f9d00531e1f + depends_on: + satd: + condition: service_healthy + entrypoint: ["lightningd"] + command: + - --network=${CLN_NETWORK:-signet} + - --bitcoin-rpcconnect=satd + - --bitcoin-rpcport=8332 + - --bitcoin-rpccookiefile=/satd/rpc-cookie + # satd answers RPC quickly, but a node still catching up can block a + # call behind chainstate work; the canary uses the same allowance. + - --bitcoin-rpcclienttimeout=60 + - --addr=0.0.0.0:9736 + - --log-level=info + - --alias=satd-stack + volumes: + - cln-data:/root/.lightning + - satd-data:/satd:ro + ports: + - "${CLN_P2P_PORT:-9736}:9736" + networks: + - satd + restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "lightning-cli --network=${CLN_NETWORK:-signet} getinfo > /dev/null 2>&1"] + interval: 15s + timeout: 10s + start_period: 2m + retries: 5 + +volumes: + cln-data: diff --git a/contrib/stack/compose.lightning.yml b/contrib/stack/compose.lightning.yml new file mode 100644 index 000000000..a26a7328f --- /dev/null +++ b/contrib/stack/compose.lightning.yml @@ -0,0 +1,107 @@ +# Lightning overlay — LND in Neutrino mode, plus Ride The Lightning. +# +# docker compose -f compose.yml -f compose.lightning.yml up -d +# docker compose -f compose.yml -f compose.lightning.yml -f compose.proxy.yml up -d +# +# BEST-EFFORT. LND and RTL are third-party software bundled so satd's +# compatibility claims can be exercised end to end. We do not track their +# security advisories in real time. Do not put real funds here. +# +# ## Why Neutrino and not bitcoind mode +# +# LND's bitcoind backend requires Bitcoin Core's raw ZMQ topics +# (zmqpubrawblock / zmqpubrawtx). satd does not implement them — it rejects +# those settings outright, and CORE_DIFFERENCES.md records that as +# deliberate. Neutrino needs no ZMQ at all: it pulls BIP 157/158 filter +# headers and filters over P2P, which satd serves with `peerblockfilters=1` +# (set in the base config). This is the same configuration the repository's +# LND canary proves on every PR, so the overlay is a packaging of a tested +# path rather than a new claim. +# +# LND supports mainnet, signet and regtest here. testnet4 is not offered: +# the pinned LND release predates its testnet4 support. +# +# Wallet posture is deliberately a demo one: --noseedbackup creates an +# unencrypted wallet with no seed to write down, so the stack comes up +# unattended. That is the right trade for a throwaway signet node and the +# wrong one for anything else. + +services: + lnd: + image: lightninglabs/lnd:v0.18.5-beta@sha256:2b560c9beb559c57ab2f2da1dfed80d286cf11a6dc6e4354cab84aafba79b6f6 + depends_on: + satd: + condition: service_healthy + command: + - --bitcoin.active + - --bitcoin.${NETWORK:-signet} + - --bitcoin.node=neutrino + # The only peer LND talks to. `--nobootstrap` keeps it that way, so a + # green run is evidence about satd rather than about the network. + - --neutrino.connect=satd:${SATD_P2P_PORT:-38333} + - --nobootstrap + - --noseedbackup + - --rpclisten=0.0.0.0:10009 + - --restlisten=0.0.0.0:8080 + - --listen=0.0.0.0:9735 + # LND generates its own self-signed certificate. It is not reissued + # from the stack CA: lncli, RTL and every mobile wallet already know + # how to pin LND's cert, and rewriting it would break that flow for + # no gain. Listing the names it will be reached by keeps it valid. + - --tlsextradomain=lnd + - --tlsextradomain=${SATD_TLS_HOSTNAME:-satd} + - --debuglevel=info + volumes: + - lnd-data:/root/.lnd + ports: + - "${LND_P2P_PORT:-9735}:9735" + - "${LND_REST_PORT:-8080}:8080" + networks: + - satd + restart: unless-stopped + healthcheck: + # `lncli getinfo` succeeds only once the wallet is unlocked and the + # RPC is serving, which is what RTL waits for. + test: ["CMD-SHELL", "lncli --network=${NETWORK:-signet} getinfo > /dev/null 2>&1"] + interval: 15s + timeout: 10s + start_period: 2m + retries: 5 + + rtl: + image: shahanafarooqui/rtl:v0.15.4@sha256:f984095949b5b6c2c0c7d983e979cd34bd32a2952d106092bad0b46e6248168e + depends_on: + lnd: + condition: service_healthy + environment: + # RTL builds its own RTL-Config.json from these on first start. Env + # rather than a mounted config file because the macaroon path contains + # the network name, and a static file cannot follow ${NETWORK}. + LN_IMPLEMENTATION: LND + LN_SERVER_URL: https://lnd:8080 + MACAROON_PATH: /lnd/data/chain/bitcoin/${NETWORK:-signet} + CONFIG_PATH: "" + RTL_CONFIG_PATH: /RTL/config + CHANNEL_BACKUP_PATH: /RTL/database/backup + # RTL's own login. The appliance replaces this on first boot; set it + # in .env for any other deployment. + RTL_PASSWORD: ${RTL_PASSWORD:-satd-stack} + PORT: "3000" + DEFAULT_NODE_INDEX: "1" + volumes: + # Read-only: RTL needs LND's macaroon and certificate, nothing else. + - lnd-data:/lnd:ro + - rtl-config:/RTL/config + - rtl-db:/RTL/database + networks: + - satd + restart: unless-stopped + # Not published. RTL speaks plain HTTP; it is reached through the proxy + # overlay, which terminates TLS with the stack certificate. + expose: + - "3000" + +volumes: + lnd-data: + rtl-config: + rtl-db: diff --git a/contrib/stack/compose.proxy.yml b/contrib/stack/compose.proxy.yml new file mode 100644 index 000000000..1de6ab798 --- /dev/null +++ b/contrib/stack/compose.proxy.yml @@ -0,0 +1,48 @@ +# Reverse-proxy overlay — TLS for the surfaces that have no native TLS. +# +# docker compose -f compose.yml -f compose.proxy.yml up -d +# +# satd terminates TLS itself on RPC, Electrum, Esplora, MCP and events-gRPC. +# Two things it serves have no native TLS — the metrics/health endpoint and +# the streaming WebSocket — and neither do the third-party web UIs in the +# other overlays. This overlay puts Caddy in front of them with the same +# per-install certificate, so everything that leaves the host is encrypted +# by one certificate the client imported once. +# +# One port per app rather than one port with paths. RTL and BTCPay both +# build absolute URLs from their own root, so serving them under /rtl and +# /btcpay means rewriting HTML and breaking on the next release. Distinct +# ports are uglier to type and keep working. +# +# 443 Ride The Lightning (compose.lightning.yml) +# 8443 Cashu mint (compose.cashu.yml) +# 9443 satd metrics / healthz / readyz +# +# An overlay that is not enabled leaves its port answering 502, because +# Caddy resolves upstreams per request rather than at start. + +services: + caddy: + image: caddy:2.8-alpine@sha256:af32e97399febea808609119bb21544d0265c58a02836576e32a2d082c262c17 + depends_on: + satd-init: + condition: service_completed_successfully + volumes: + - ./caddy/Caddyfile:/etc/caddy/Caddyfile:ro + # Read-only, for the certificate and key only. Caddy runs as root in + # this image, so it can read the 0600 key; nothing else in the volume + # is touched. + - satd-data:/satd:ro + - caddy-data:/data + - caddy-config:/config + ports: + - "${PROXY_RTL_PORT:-443}:443" + - "${PROXY_MINT_PORT:-8443}:8443" + - "${PROXY_METRICS_PORT:-9443}:9443" + networks: + - satd + restart: unless-stopped + +volumes: + caddy-data: + caddy-config: diff --git a/contrib/stack/compose.yml b/contrib/stack/compose.yml new file mode 100644 index 000000000..a6abc60ce --- /dev/null +++ b/contrib/stack/compose.yml @@ -0,0 +1,89 @@ +# contrib/stack/compose.yml — the supported satd service, on its own. +# +# This file is the contract. The overlays in this directory extend it, the +# appliance image runs it, and the Umbrel / StartOS packages are derived from +# it, so the satd service definition exists once and cannot drift between +# deliverables. +# +# docker compose up -d # satd alone +# docker compose -f compose.yml -f compose.lightning.yml up -d +# +# Support: satd itself is supported exactly as the release tarballs and the +# container image are. The overlays bundle third-party software on a +# best-effort basis — see README.md. + +name: satd-stack + +x-satd-image: &satd-image ${SATD_IMAGE:-ghcr.io/epochbtc/satd:latest} + +services: + # One-shot: issues the per-install CA and certificate, renders bitcoin.conf + # for ${NETWORK}, and generates the MCP token when SATD_MCP=1. Everything it + # writes lands in the data volume; nothing key-like ships in an image. + satd-init: + image: *satd-image + entrypoint: ["/usr/local/bin/satd-init"] + user: satd + environment: + NETWORK: ${NETWORK:-signet} + SATD_MCP: ${SATD_MCP:-0} + SATD_STACK_SUBNET: ${SATD_STACK_SUBNET:-10.77.0.0/24} + SATD_TLS_HOSTNAME: ${SATD_TLS_HOSTNAME:-satd} + SATD_P2P_PORT: ${SATD_P2P_PORT:-38333} + volumes: + - satd-data:/var/lib/satd + restart: "no" + + satd: + image: *satd-image + depends_on: + satd-init: + condition: service_completed_successfully + # The network flag has to be an argument. satd accepts `signet=1` in a + # config file and then ignores it, which silently starts a mainnet node, + # so the stack never expresses the chain that way. + command: + - --datadir=/var/lib/satd + - --${NETWORK:-signet} + environment: + # Readiness, not liveness: /readyz stays negative until the chainstate + # is loaded and every listener is bound, which is what the overlays' + # `depends_on` needs to mean. + SATD_HEALTH_URL: http://127.0.0.1:9332/readyz + volumes: + - satd-data:/var/lib/satd + ports: + # P2P. Follows the chain, because other nodes expect the convention. + - "${SATD_P2P_PORT:-38333}:${SATD_P2P_PORT:-38333}" + # TLS surfaces only. The plain RPC / Electrum / Esplora / metrics + # listeners bind the compose network for the overlay containers to + # reach, and are deliberately not published: nothing unencrypted + # leaves the host. + - "${SATD_RPC_TLS_PORT:-8336}:8336" + - "${SATD_ELECTRUM_TLS_PORT:-50002}:50002" + - "${SATD_ESPLORA_TLS_PORT:-3001}:3001" + networks: + satd: + aliases: + - satd + stop_grace_period: 10m + restart: unless-stopped + healthcheck: + test: ["CMD", "/usr/local/bin/satd-healthcheck"] + interval: 30s + timeout: 10s + # Opening a mainnet chainstate is not instant, and a reindex is far + # slower still. Failures inside the start period do not count. + start_period: 10m + retries: 3 + +volumes: + satd-data: + +networks: + satd: + # A fixed subnet so bitcoin.conf's rpcallowip can name it exactly rather + # than opening RPC to every RFC1918 address. + ipam: + config: + - subnet: ${SATD_STACK_SUBNET:-10.77.0.0/24} diff --git a/contrib/stack/satd/satd-init b/contrib/stack/satd/satd-init new file mode 100755 index 000000000..cb4db5d0b --- /dev/null +++ b/contrib/stack/satd/satd-init @@ -0,0 +1,201 @@ +#!/bin/bash +# satd-init — one-shot preparation of the satd data volume. +# +# Runs to completion before satd starts (compose gates satd on +# `service_completed_successfully`). Three jobs, all idempotent: +# +# 1. Issue the per-install CA and server certificate (tls/mkca.sh). +# 2. Render bitcoin.conf from satd.conf.tmpl for the selected network. +# 3. Generate the authfile bearer token, when MCP is enabled. +# +# Everything it creates lives in the data volume, never in an image. That is +# the property that makes the appliance image redistributable: a shipped CA +# key would be a private key shared by every download. +# +# Re-running is the normal case — it happens on every `compose up`. Nothing +# here regenerates material that already exists and is still valid, so a +# restart loop cannot churn certificates or invalidate a token an operator +# has already configured somewhere. + +set -euo pipefail + +DATADIR="${SATD_DATADIR:-/var/lib/satd}" +TLS_DIR="$DATADIR/tls" +SECRETS_DIR="$DATADIR/secrets" +TEMPLATE="${SATD_CONF_TEMPLATE:-/etc/satd/satd.conf.tmpl}" +MKCA="${SATD_MKCA:-/usr/local/bin/satd-mkca}" + +NETWORK="${NETWORK:-signet}" +SUBNET="${SATD_STACK_SUBNET:-10.77.0.0/24}" +TLS_HOSTNAME="${SATD_TLS_HOSTNAME:-$(hostname)}" +MCP_ENABLED="${SATD_MCP:-0}" + +# Fixed inside the stack, on every network. Publishing and client +# configuration therefore do not change when the network does; only the P2P +# port follows the chain, because that one is a protocol-level convention +# other nodes rely on. +RPC_PORT=8332 +RPC_TLS_PORT=8336 +ELECTRUM_PORT=50001 +ELECTRUM_TLS_PORT=50002 +ESPLORA_PORT=3000 +ESPLORA_TLS_PORT=3001 +METRICS_PORT=9332 +ZMQ_PORT=28332 +MCP_PORT=8339 + +case "$NETWORK" in + mainnet) P2P_PORT=8333 ;; + signet) P2P_PORT=38333 ;; + testnet4) P2P_PORT=48333 ;; + testnet) P2P_PORT=18333 ;; + regtest) P2P_PORT=18444 ;; + *) + echo "satd-init: unknown NETWORK '$NETWORK'" >&2 + echo "satd-init: expected one of: mainnet signet testnet4 testnet regtest" >&2 + exit 2 + ;; +esac + +# SATD_P2P_PORT exists so the compose file can publish the same number it +# configures. Disagreement means the published port maps to nothing, which +# looks like a firewall problem, so refuse rather than guess. +if [[ -n "${SATD_P2P_PORT:-}" && "${SATD_P2P_PORT}" != "$P2P_PORT" ]]; then + echo "satd-init: SATD_P2P_PORT=${SATD_P2P_PORT} does not match the standard" \ + "P2P port for $NETWORK ($P2P_PORT)." >&2 + echo "satd-init: set SATD_P2P_PORT=$P2P_PORT in .env, or unset it." >&2 + exit 2 +fi + +echo "satd-init: network=$NETWORK datadir=$DATADIR" + +mkdir -p "$DATADIR" + +# --- 1. TLS ----------------------------------------------------------------- +# `--group-readable` is not used: satd reads its own key as its own user. +# `--extra-name satd` covers the compose service name, which is how the +# overlay containers address this node. +"$MKCA" \ + --dir "$TLS_DIR" \ + --hostname "$TLS_HOSTNAME" \ + --extra-name satd \ + --quiet + +# --- 2. Config -------------------------------------------------------------- +[[ -f "$TEMPLATE" ]] || { echo "satd-init: missing template $TEMPLATE" >&2; exit 1; } + +render() { + sed \ + -e "s|@P2P_PORT@|$P2P_PORT|g" \ + -e "s|@RPC_PORT@|$RPC_PORT|g" \ + -e "s|@RPC_TLS_PORT@|$RPC_TLS_PORT|g" \ + -e "s|@ELECTRUM_PORT@|$ELECTRUM_PORT|g" \ + -e "s|@ELECTRUM_TLS_PORT@|$ELECTRUM_TLS_PORT|g" \ + -e "s|@ESPLORA_PORT@|$ESPLORA_PORT|g" \ + -e "s|@ESPLORA_TLS_PORT@|$ESPLORA_TLS_PORT|g" \ + -e "s|@METRICS_PORT@|$METRICS_PORT|g" \ + -e "s|@ZMQ_PORT@|$ZMQ_PORT|g" \ + -e "s|@SUBNET@|$SUBNET|g" \ + -e "s|@TLS_DIR@|$TLS_DIR|g" \ + "$TEMPLATE" +} + +CONF="$DATADIR/bitcoin.conf" +render > "$CONF.new" + +# Any leftover @NAME@ means the template grew a placeholder this script does +# not know about. satd would reject the line as an unknown value rather than +# ignore it, but failing here names the actual cause. +if grep -q '@[A-Z_]\+@' "$CONF.new"; then + echo "satd-init: unsubstituted placeholders in the rendered config:" >&2 + grep -n '@[A-Z_]\+@' "$CONF.new" >&2 + rm -f "$CONF.new" + exit 1 +fi + +# MCP is off in the plain stack and on in the appliance and the app-store +# packages. It is appended rather than living in the template because the +# token has to exist first. +if [[ "$MCP_ENABLED" == "1" ]]; then + mkdir -p "$SECRETS_DIR" + chmod 0700 "$SECRETS_DIR" + AUTHFILE="$DATADIR/authfile.toml" + TOKEN_FILE="$SECRETS_DIR/mcp-token" + + if [[ ! -s "$TOKEN_FILE" ]]; then + # 32 bytes of urandom, hex. Regenerating this on every start would + # break every client that had already been configured with it. + token="$(head -c 32 /dev/urandom | od -An -tx1 | tr -d ' \n')" + umask 077 + printf '%s\n' "$token" > "$TOKEN_FILE" + echo "satd-init: generated an MCP bearer token in $TOKEN_FILE" + fi + token="$(cat "$TOKEN_FILE")" + token_hash="$(printf '%s' "$token" | sha256sum | cut -d' ' -f1)" + + umask 077 + cat > "$AUTHFILE" <> "$CONF.new" <> "$CONF.new" + cat "$LOCAL_CONF" >> "$CONF.new" +fi + +chmod 0644 "$CONF.new" +mv "$CONF.new" "$CONF" +echo "satd-init: wrote $CONF" + +# --- 2b. A stable path to the RPC cookie ------------------------------------ +# satd writes .cookie under the network's subdirectory (mainnet uses the +# datadir root), so the path moves when NETWORK changes. Overlay containers +# authenticate by mounting this volume read-only, and every one of them would +# otherwise need its own copy of that per-network rule. A symlink at a fixed +# name gives them one path that is correct on every network. +# +# The target does not exist yet — satd creates the cookie at startup, and +# rotates it on every restart — which is exactly why this is a symlink and +# not a copy. +case "$NETWORK" in + mainnet) COOKIE_TARGET=".cookie" ;; + testnet) COOKIE_TARGET="testnet3/.cookie" ;; + *) COOKIE_TARGET="$NETWORK/.cookie" ;; +esac +ln -sfn "$COOKIE_TARGET" "$DATADIR/rpc-cookie" +echo "satd-init: rpc-cookie -> $COOKIE_TARGET" + +# --- 3. Report -------------------------------------------------------------- +# The export instruction differs by deployment — this script runs in the +# compose stack, on the appliance and in the app-store packages — so the +# caller supplies it. Printing the compose recipe unconditionally told +# appliance users to run a command their machine does not have. +echo "satd-init: CA certificate at $TLS_DIR/ca.crt" +echo "satd-init: export it to clients with: ${SATD_CA_EXPORT_HINT:-docker compose exec satd cat $TLS_DIR/ca.crt}" diff --git a/contrib/stack/satd/satd.conf.tmpl b/contrib/stack/satd/satd.conf.tmpl new file mode 100644 index 000000000..8beb6562d --- /dev/null +++ b/contrib/stack/satd/satd.conf.tmpl @@ -0,0 +1,93 @@ +# satd.conf — rendered by contrib/stack/satd/satd-init from this template. +# +# Do not edit the rendered copy in the data volume: satd-init overwrites it +# on every start so that a change here reaches every existing deployment. +# Operator additions belong in a separate file — see the README's +# "Local overrides" section, which appends conf.d/local.conf. +# +# @-delimited names are substituted by satd-init. Everything else is +# literal and is the same on every network. +# +# ## Which listeners are reachable from where +# +# Plain listeners bind the compose network only and are NOT published to +# the host: RPC, Electrum, Esplora and the metrics endpoint are how the +# overlay containers (LND, NBXplorer, RTL, the mint) talk to satd, and +# those clients have no way to trust a private CA. `rpcallowip` narrows +# the RPC surface to this stack's own subnet. +# +# TLS listeners bind 0.0.0.0 and ARE published. Everything that leaves the +# host is TLS, presenting the per-install certificate from tls/mkca.sh. +# +# The metrics endpoint has no native TLS (and neither does the streaming +# WebSocket), so it stays inside the network; the appliance image fronts it +# with a reverse proxy on 443. + +# --- chain ------------------------------------------------------------------ +# The network is selected on the command line, never here: a `signet=1` line +# in a config file is accepted and then ignored, which silently starts a +# mainnet node. +port=@P2P_PORT@ + +# --- indices ---------------------------------------------------------------- +# Electrum and Esplora both require txindex and addressindex. Pruning is +# incompatible with txindex, so this stack never prunes — see the README for +# what that costs on mainnet. +txindex=1 +addressindex=1 +prune=0 +# BIP 157/158 filters: what LND needs to run in Neutrino mode against this +# node. `peerblockfilters` serves them over P2P and implies the index. +blockfilterindex=basic +peerblockfilters=1 + +# --- JSON-RPC --------------------------------------------------------------- +# The RPC port is fixed at 8332 on every network, not set to the chain's +# conventional default. Every overlay, the reverse proxy and the app-store +# packages address this node by name and port, and having that port move +# when the network changes would push the per-network rule into each of +# them. The cost is that in-container `sat-cli` needs `-rpcport=8332` on a +# non-mainnet stack, since it derives its default from the chain: +# +# docker compose exec satd sat-cli -rpcport=8332 getblockchaininfo +# +# P2P is the exception below: that port is a convention other nodes rely on. +server=1 +rpcbind=0.0.0.0 +rpcport=@RPC_PORT@ +# The stack's own subnet, plus loopback for `docker exec satd sat-cli`. +rpcallowip=127.0.0.1 +rpcallowip=@SUBNET@ +rpctlsbind=0.0.0.0:@RPC_TLS_PORT@ +rpctlscert=@TLS_DIR@/fullchain.crt +rpctlskey=@TLS_DIR@/leaf.key + +# --- Electrum --------------------------------------------------------------- +electrum=1 +electrumbind=0.0.0.0:@ELECTRUM_PORT@ +electrumtlsbind=0.0.0.0:@ELECTRUM_TLS_PORT@ +electrumtlscert=@TLS_DIR@/fullchain.crt +electrumtlskey=@TLS_DIR@/leaf.key + +# --- Esplora ---------------------------------------------------------------- +# Unauthenticated, like every public Esplora deployment and like the Electrum +# surface above: it serves public chain data. Set esploraauth=cookie if this +# stack's LAN is not somewhere you want that. +esplora=1 +esplorabind=0.0.0.0:@ESPLORA_PORT@ +esploraprefix=/api +esploratlsbind=0.0.0.0:@ESPLORA_TLS_PORT@ +esploratlscert=@TLS_DIR@/fullchain.crt +esploratlskey=@TLS_DIR@/leaf.key + +# --- observability ---------------------------------------------------------- +# /metrics, /healthz and /readyz. /readyz is the container healthcheck and +# what every overlay's `depends_on` waits for. +metricsbind=0.0.0.0 +metricsport=@METRICS_PORT@ + +# --- events ----------------------------------------------------------------- +# Core-compatible hashblock/hashtx plus satd's own JSON topics. Raw block and +# transaction topics do not exist here; that is why the Lightning overlay runs +# LND in Neutrino mode rather than bitcoind mode. +eventszmqbind=tcp://0.0.0.0:@ZMQ_PORT@ diff --git a/contrib/stack/tests/mkca-test.sh b/contrib/stack/tests/mkca-test.sh new file mode 100755 index 000000000..445d5a00f --- /dev/null +++ b/contrib/stack/tests/mkca-test.sh @@ -0,0 +1,175 @@ +#!/bin/bash +# Tests for contrib/stack/tls/mkca.sh. +# +# The script runs on every container start and on a systemd timer, so the +# properties under test are mostly about what it does NOT do: it must not +# reissue a healthy certificate, must not rotate the CA, and must not leave +# a half-written state that a later run would trust. Those are exactly the +# failures that stay invisible until a client's imported CA stops matching. +# +# openssl is the only dependency. + +set -euo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +MKCA="$HERE/../tls/mkca.sh" +[[ -x "$MKCA" ]] || { echo "not executable: $MKCA" >&2; exit 1; } + +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT + +FAILURES=0 +pass() { echo "ok — $1"; } +fail() { echo "FAIL — $1"; [[ $# -lt 2 ]] || sed 's/^/ /' <<< "$2"; FAILURES=$((FAILURES + 1)); } + +assert_eq() { + local name="$1" expected="$2" actual="$3" + if [[ "$expected" == "$actual" ]]; then pass "$name" + else fail "$name" "expected: $expected +actual: $actual"; fi +} + +# Detection of live interface addresses is off in every case here: the SAN +# set must be a function of the arguments alone, or the assertions below +# would depend on whatever addresses the test machine happens to hold. +mkca() { "$MKCA" --no-detect-ips "$@"; } + +sans_of() { openssl x509 -in "$1" -noout -ext subjectAltName | tail -n +2 | tr -d ' \n'; } +serial_of() { openssl x509 -in "$1" -noout -serial | cut -d= -f2; } + +# --- issuance -------------------------------------------------------------- +D="$WORK/basic" +mkca --dir "$D" --hostname satd-test --quiet > "$WORK/log" 2>&1 || fail "first run exits 0" "$(cat "$WORK/log")" + +for f in ca.key ca.crt leaf.key leaf.crt fullchain.crt leaf.sans; do + [[ -s "$D/$f" ]] && pass "creates $f" || fail "creates $f" +done + +if openssl verify -CAfile "$D/ca.crt" "$D/leaf.crt" > /dev/null 2>&1; then + pass "leaf verifies against the CA" +else + fail "leaf verifies against the CA" +fi + +# fullchain is what satd is pointed at; it must actually contain both certs, +# or a client that trusts the CA still sees an incomplete chain. +assert_eq "fullchain holds leaf + CA" "2" "$(grep -c 'BEGIN CERTIFICATE' "$D/fullchain.crt")" + +sans="$(sans_of "$D/leaf.crt")" +for expected in "DNS:localhost" "DNS:satd-test" "DNS:satd-test.local" "IPAddress:127.0.0.1"; do + [[ "$sans" == *"$expected"* ]] && pass "SAN includes $expected" \ + || fail "SAN includes $expected" "got: $sans" +done + +# ::1 renders as the expanded form in openssl's text output. +[[ "$sans" == *"IPAddress:0:0:0:0:0:0:0:1"* ]] && pass "SAN includes ::1" \ + || fail "SAN includes ::1" "got: $sans" + +# The certificate has to be usable as a TLS *server* credential; an EKU +# mismatch is the kind of thing that verifies fine with `openssl verify` and +# then fails in every real client. +eku="$(openssl x509 -in "$D/leaf.crt" -noout -ext extendedKeyUsage | tail -n +2 | tr -d ' \n')" +assert_eq "leaf EKU is serverAuth" "TLSWebServerAuthentication" "$eku" + +bc="$(openssl x509 -in "$D/ca.crt" -noout -ext basicConstraints | tail -n +2 | tr -d ' \n')" +[[ "$bc" == *"CA:TRUE"* ]] && pass "CA cert is marked CA:TRUE" || fail "CA cert is marked CA:TRUE" "got: $bc" + +bc_leaf="$(openssl x509 -in "$D/leaf.crt" -noout -ext basicConstraints | tail -n +2 | tr -d ' \n')" +[[ "$bc_leaf" == *"CA:FALSE"* ]] && pass "leaf is marked CA:FALSE" || fail "leaf is marked CA:FALSE" "got: $bc_leaf" + +assert_eq "CA key is 0600 by default" "600" "$(stat -c %a "$D/ca.key")" +assert_eq "leaf key is 0600 by default" "600" "$(stat -c %a "$D/leaf.key")" +assert_eq "CA cert is world-readable" "644" "$(stat -c %a "$D/ca.crt")" + +# --- idempotence ----------------------------------------------------------- +ca_serial_before="$(serial_of "$D/ca.crt")" +leaf_serial_before="$(serial_of "$D/leaf.crt")" +ca_key_before="$(sha256sum < "$D/ca.key")" + +mkca --dir "$D" --hostname satd-test --quiet > /dev/null 2>&1 +assert_eq "re-running does not reissue the leaf" "$leaf_serial_before" "$(serial_of "$D/leaf.crt")" +assert_eq "re-running does not rotate the CA" "$ca_serial_before" "$(serial_of "$D/ca.crt")" +assert_eq "re-running does not touch the CA key" "$ca_key_before" "$(sha256sum < "$D/ca.key")" + +# --- reissue triggers ------------------------------------------------------ +mkca --dir "$D" --hostname satd-test --extra-name extra.example --quiet > /dev/null 2>&1 +new_serial="$(serial_of "$D/leaf.crt")" +[[ "$new_serial" != "$leaf_serial_before" ]] && pass "a new SAN reissues the leaf" \ + || fail "a new SAN reissues the leaf" +assert_eq "a new SAN does not rotate the CA" "$ca_serial_before" "$(serial_of "$D/ca.crt")" +[[ "$(sans_of "$D/leaf.crt")" == *"DNS:extra.example"* ]] && pass "the new SAN is present" \ + || fail "the new SAN is present" + +# Dropping the SAN again must also reissue — the check has to be a set +# comparison, not "did anything get added". +mkca --dir "$D" --hostname satd-test --quiet > /dev/null 2>&1 +[[ "$(sans_of "$D/leaf.crt")" != *"DNS:extra.example"* ]] && pass "a removed SAN reissues the leaf" \ + || fail "a removed SAN reissues the leaf" + +serial_before_force="$(serial_of "$D/leaf.crt")" +mkca --dir "$D" --hostname satd-test --force --quiet > /dev/null 2>&1 +[[ "$(serial_of "$D/leaf.crt")" != "$serial_before_force" ]] && pass "--force reissues the leaf" \ + || fail "--force reissues the leaf" + +# A leaf inside the renewal window must be replaced. Issued for 10 days with +# a 30-day window, so the very next run has to renew it. +D2="$WORK/expiring" +mkca --dir "$D2" --hostname short-lived --days 10 --renew-within 30 --quiet > /dev/null 2>&1 +short_serial="$(serial_of "$D2/leaf.crt")" +mkca --dir "$D2" --hostname short-lived --days 10 --renew-within 30 --quiet > /dev/null 2>&1 +[[ "$(serial_of "$D2/leaf.crt")" != "$short_serial" ]] && pass "a leaf inside the renewal window is renewed" \ + || fail "a leaf inside the renewal window is renewed" +# ... and the renewal must not have rotated the CA out from under clients. +assert_eq "renewal keeps the same CA" "1" "$(openssl verify -CAfile "$D2/ca.crt" "$D2/leaf.crt" > /dev/null 2>&1 && echo 1 || echo 0)" + +# --- CA rotation is deliberate --------------------------------------------- +# `rm ca.*` is the documented way to rotate. The stale leaf must not survive +# it: a leaf signed by a CA that no longer exists verifies nowhere. +D3="$WORK/rotate" +mkca --dir "$D3" --hostname rotate-me --quiet > /dev/null 2>&1 +old_leaf="$(serial_of "$D3/leaf.crt")" +rm -f "$D3/ca.key" "$D3/ca.crt" +mkca --dir "$D3" --hostname rotate-me --quiet > /dev/null 2>&1 +[[ "$(serial_of "$D3/leaf.crt")" != "$old_leaf" ]] && pass "removing the CA reissues the leaf too" \ + || fail "removing the CA reissues the leaf too" +if openssl verify -CAfile "$D3/ca.crt" "$D3/leaf.crt" > /dev/null 2>&1; then + pass "the reissued leaf chains to the new CA" +else + fail "the reissued leaf chains to the new CA" +fi + +# --- flags ----------------------------------------------------------------- +D4="$WORK/groupread" +mkca --dir "$D4" --hostname grp --group-readable --quiet > /dev/null 2>&1 +assert_eq "--group-readable sets 0640 on the leaf key" "640" "$(stat -c %a "$D4/leaf.key")" + +D5="$WORK/fqdn" +mkca --dir "$D5" --hostname node.example.com --quiet > /dev/null 2>&1 +# An FQDN must not gain a `.local` suffix — `node.example.com.local` is not +# a name anything resolves, and it would be a permanent extra SAN. +[[ "$(sans_of "$D5/leaf.crt")" != *".com.local"* ]] && pass "an FQDN hostname gets no .local SAN" \ + || fail "an FQDN hostname gets no .local SAN" "got: $(sans_of "$D5/leaf.crt")" + +if "$MKCA" --hostname x >/dev/null 2>&1; then + fail "--dir is required" +else + pass "--dir is required" +fi + +# --- keys are per-install -------------------------------------------------- +# Two installs must never share a CA key. This is the property that makes +# shipping the appliance image safe at all. +DA="$WORK/inst-a"; DB="$WORK/inst-b" +mkca --dir "$DA" --hostname same-name --quiet > /dev/null 2>&1 +mkca --dir "$DB" --hostname same-name --quiet > /dev/null 2>&1 +if [[ "$(sha256sum < "$DA/ca.key")" != "$(sha256sum < "$DB/ca.key")" ]]; then + pass "two installs get different CA keys" +else + fail "two installs get different CA keys" +fi + +if [[ $FAILURES -ne 0 ]]; then + echo "$FAILURES mkca test(s) failed" >&2 + exit 1 +fi +echo "all mkca tests passed" diff --git a/contrib/stack/tests/smoke.sh b/contrib/stack/tests/smoke.sh new file mode 100755 index 000000000..285a756d5 --- /dev/null +++ b/contrib/stack/tests/smoke.sh @@ -0,0 +1,317 @@ +#!/bin/bash +# smoke.sh — bring the reference stack up on regtest and prove every surface +# it advertises actually answers. +# +# contrib/stack/tests/smoke.sh # satd core only +# contrib/stack/tests/smoke.sh --with lightning # + LND (Neutrino) +# contrib/stack/tests/smoke.sh --with proxy +# SATD_IMAGE=satd:dev contrib/stack/tests/smoke.sh # test a local build +# +# The point of this test is the TLS half. satd's own test suite already +# proves the RPC, Electrum and Esplora protocols; what is unproven until +# something connects from outside the container is whether the certificate +# this stack generates is one a client will actually accept — right SANs, +# right chain, right key, on the right listener. So every probe here goes +# over TLS from the host, verifying against the generated CA with +# `-verify_return_error`, and a probe that would pass without verification +# is not a probe. +# +# Requires: docker (with compose v2) and openssl. + +set -euo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +STACK_DIR="$(cd "$HERE/.." && pwd)" + +OVERLAYS=() +KEEP=0 +while [[ $# -gt 0 ]]; do + case "$1" in + --with) OVERLAYS+=("$2"); shift 2 ;; + --keep) KEEP=1; shift ;; + -h|--help) sed -n '2,20p' "$0"; exit 0 ;; + *) echo "smoke.sh: unknown argument: $1" >&2; exit 2 ;; + esac +done + +# A distinct project name and a distinct port block, so this can run beside +# a real stack (or a second copy of itself) without fighting over either. +PROJECT="satd-smoke-$$" +PORT_BASE="${SATD_SMOKE_PORT_BASE:-21400}" +export SATD_RPC_TLS_PORT=$((PORT_BASE + 0)) +export SATD_ELECTRUM_TLS_PORT=$((PORT_BASE + 1)) +export SATD_ESPLORA_TLS_PORT=$((PORT_BASE + 2)) +export SATD_P2P_PORT=18444 +export PROXY_RTL_PORT=$((PORT_BASE + 3)) +export PROXY_MINT_PORT=$((PORT_BASE + 4)) +export PROXY_METRICS_PORT=$((PORT_BASE + 5)) +export LND_P2P_PORT=$((PORT_BASE + 6)) +export LND_REST_PORT=$((PORT_BASE + 7)) +export NETWORK=regtest +export SATD_IMAGE="${SATD_IMAGE:-ghcr.io/epochbtc/satd:latest}" +export SATD_TLS_HOSTNAME=satd +export SATD_STACK_SUBNET="${SATD_STACK_SUBNET:-10.77.0.0/24}" + +COMPOSE_ARGS=(-p "$PROJECT" -f "$STACK_DIR/compose.yml") +for overlay in ${OVERLAYS[@]+"${OVERLAYS[@]}"}; do + f="$STACK_DIR/compose.$overlay.yml" + [[ -f "$f" ]] || { echo "smoke.sh: no such overlay: $f" >&2; exit 2; } + COMPOSE_ARGS+=(-f "$f") +done + +compose() { docker compose "${COMPOSE_ARGS[@]}" "$@"; } + +WORK="$(mktemp -d)" +cleanup() { + report_incomplete + if [[ "$KEEP" == 1 ]]; then + echo "smoke.sh: --keep given; leaving project $PROJECT running" + else + compose down -v --remove-orphans > /dev/null 2>&1 || true + fi + rm -rf "$WORK" +} +trap cleanup EXIT + +# Any exit before the summary is a bug in this script, not a clean result. +# Without this, an `set -e` trip in the middle reads as a passing run. +COMPLETED=0 +report_incomplete() { + [[ "$COMPLETED" == 1 ]] || echo "smoke.sh: exited before finishing its checks" >&2 +} + +FAILURES=0 +pass() { echo "ok — $1"; } +fail() { echo "FAIL — $1"; [[ $# -lt 2 ]] || sed 's/^/ /' <<< "$2"; FAILURES=$((FAILURES + 1)); } + +echo "smoke.sh: project=$PROJECT image=$SATD_IMAGE overlays=${OVERLAYS[*]:-none}" +compose up -d --quiet-pull + +# --- readiness -------------------------------------------------------------- +echo "smoke.sh: waiting for satd to report ready..." +deadline=$(($(date +%s) + 300)) +ready=0 +while [[ $(date +%s) -lt $deadline ]]; do + status="$(compose ps --format json satd 2>/dev/null | python3 -c \ + 'import sys,json +raw = sys.stdin.read().strip() +if raw: + for line in raw.splitlines(): + d = json.loads(line) + print(d.get("Health") or d.get("State") or "") +' 2>/dev/null || true)" + status="$(awk 'NR==1' <<< "$status")" + if [[ "$status" == "healthy" ]]; then ready=1; break; fi + if [[ "$status" == "exited" ]]; then break; fi + sleep 3 +done +if [[ "$ready" == 1 ]]; then + pass "satd reaches the healthy state (/readyz)" +else + fail "satd reaches the healthy state (/readyz)" "$(compose logs --no-color --tail 60 satd 2>&1)" + COMPLETED=1 + echo "$FAILURES failure(s)" >&2 + exit 1 +fi + +# --- the init container did its job ---------------------------------------- +CA="$WORK/ca.crt" +if compose exec -T satd cat /var/lib/satd/tls/ca.crt > "$CA" 2>/dev/null && [[ -s "$CA" ]]; then + pass "the generated CA is readable from the data volume" +else + fail "the generated CA is readable from the data volume" +fi + +if compose exec -T satd cat /var/lib/satd/bitcoin.conf > "$WORK/conf" 2>/dev/null; then + if grep -q '@[A-Z_]\+@' "$WORK/conf"; then + fail "the rendered config has no unsubstituted placeholders" "$(grep '@[A-Z_]\+@' "$WORK/conf")" + else + pass "the rendered config has no unsubstituted placeholders" + fi + grep -q '^txindex=1' "$WORK/conf" && pass "txindex is on" || fail "txindex is on" + grep -q '^peerblockfilters=1' "$WORK/conf" && pass "BIP158 filters are served" || fail "BIP158 filters are served" + grep -q '^prune=0' "$WORK/conf" && pass "pruning is off" || fail "pruning is off" +else + fail "bitcoin.conf was rendered" +fi + +# --- sat-cli inside the container ------------------------------------------ +if compose exec -T satd sat-cli -regtest -datadir=/var/lib/satd -rpcport=8332 getblockchaininfo > "$WORK/chaininfo" 2>&1; then + pass "sat-cli authenticates over the plain loopback listener" +else + fail "sat-cli authenticates over the plain loopback listener" "$(cat "$WORK/chaininfo")" +fi + +# The stable cookie symlink is what every overlay authenticates through. +if compose exec -T satd test -r /var/lib/satd/rpc-cookie 2>/dev/null; then + pass "rpc-cookie resolves to a readable cookie" +else + fail "rpc-cookie resolves to a readable cookie" +fi + +# --- TLS probes from the host ---------------------------------------------- +# `-verify_return_error` turns a verification failure into a non-zero exit +# instead of a warning buried in the handshake transcript. +tls_handshake() { + local port="$1" servername="$2" + openssl s_client -connect "127.0.0.1:$port" -servername "$servername" \ + -CAfile "$CA" -verify_return_error -brief < /dev/null 2>&1 +} + +for probe in "RPC:$SATD_RPC_TLS_PORT" "Electrum:$SATD_ELECTRUM_TLS_PORT" "Esplora:$SATD_ESPLORA_TLS_PORT"; do + name="${probe%%:*}"; port="${probe##*:}" + out="$(tls_handshake "$port" localhost || true)" + if grep -q "Verification: OK" <<< "$out"; then + pass "$name TLS listener presents a certificate the CA verifies" + else + fail "$name TLS listener presents a certificate the CA verifies" "$out" + fi +done + +# Negative control. Without the CA the same handshake must fail, or the +# checks above prove only that something is listening. +out="$(openssl s_client -connect "127.0.0.1:$SATD_RPC_TLS_PORT" -servername localhost \ + -verify_return_error -brief < /dev/null 2>&1 || true)" +if grep -q "Verification: OK" <<< "$out"; then + fail "an untrusted client is rejected" "handshake succeeded without the CA" +else + pass "an untrusted client is rejected" +fi + +# --- RPC over TLS, end to end ---------------------------------------------- +COOKIE="$(compose exec -T satd cat /var/lib/satd/regtest/.cookie 2>/dev/null || true)" +if [[ -n "$COOKIE" ]]; then + code="$(curl -sS --cacert "$CA" --resolve "localhost:$SATD_RPC_TLS_PORT:127.0.0.1" \ + -u "$COOKIE" -o "$WORK/rpc.json" -w '%{http_code}' \ + --data '{"jsonrpc":"2.0","id":"smoke","method":"getblockchaininfo","params":[]}' \ + -H 'Content-Type: application/json' \ + "https://localhost:$SATD_RPC_TLS_PORT/" 2>&1 || true)" + if [[ "$code" == "200" ]] && grep -q '"chain"' "$WORK/rpc.json"; then + pass "JSON-RPC answers over TLS with cookie auth" + else + fail "JSON-RPC answers over TLS with cookie auth" "http $code: $(cat "$WORK/rpc.json" 2>/dev/null)" + fi +else + fail "the RPC cookie is readable" +fi + +# --- mine, then read the chain back through the client surfaces ------------ +ADDR="bcrt1ql3e9pgs3mmwuwrh95fecme0s0qtn2880hlwwpw" +compose exec -T satd sat-cli -regtest -datadir=/var/lib/satd -rpcport=8332 generatetoaddress 5 "$ADDR" > /dev/null 2>&1 || true +# `|| true` matters: under `set -e` a failing command substitution in an +# assignment exits the script, and with stderr discarded it would do so +# without printing anything at all. +HEIGHT="$(compose exec -T satd sat-cli -regtest -datadir=/var/lib/satd -rpcport=8332 getblockcount 2>/dev/null | tr -d '\r\n' || true)" +if [[ "$HEIGHT" == "5" ]]; then + pass "mined 5 regtest blocks" +else + fail "mined 5 regtest blocks" "height is '$HEIGHT'" +fi + +# Esplora over TLS must agree with the node about the tip. Anything less +# than agreement would also be produced by a stale cache or a wrong network. +esplora_tip="$(curl -sS --cacert "$CA" --resolve "localhost:$SATD_ESPLORA_TLS_PORT:127.0.0.1" \ + "https://localhost:$SATD_ESPLORA_TLS_PORT/api/blocks/tip/height" 2>&1 || true)" +if [[ "$esplora_tip" == "$HEIGHT" ]]; then + pass "Esplora over TLS reports the node's tip height" +else + fail "Esplora over TLS reports the node's tip height" "got '$esplora_tip', expected '$HEIGHT'" +fi + +# Electrum over TLS: a real protocol exchange, not just a handshake. +# `timeout` is not optional here: s_client holds the connection open after +# its stdin closes and Electrum keeps the session up waiting for the next +# request, so without a bound this probe never returns. +electrum_reply="$(printf '{"jsonrpc":"2.0","id":1,"method":"server.version","params":["smoke","1.4"]}\n' \ + | timeout 20 openssl s_client -connect "127.0.0.1:$SATD_ELECTRUM_TLS_PORT" -servername localhost \ + -CAfile "$CA" -verify_return_error -quiet 2>/dev/null | head -1 || true)" +if grep -q '"result"' <<< "$electrum_reply"; then + pass "Electrum over TLS answers server.version" +else + fail "Electrum over TLS answers server.version" "got: $electrum_reply" +fi + +# --- nothing key-like escaped into the image -------------------------------- +# The image is redistributed; the CA key must exist only in the volume. +# Captured rather than piped into `grep -q`. In an `if`, a SIGPIPE-failed +# pipeline reads as false and would take the `pass` branch — turning a real +# leak into a green check, which is the one direction this must never fail. +key_listing="$(compose exec -T satd sh -c 'ls /etc/satd/*.key /usr/local/share/satd/*.key 2>/dev/null' 2>/dev/null || true)" +if [[ -n "$(tr -d '[:space:]' <<< "$key_listing")" ]]; then + fail "the image carries no private keys" "$key_listing" +else + pass "the image carries no private keys" +fi + +# --- overlays --------------------------------------------------------------- +for overlay in ${OVERLAYS[@]+"${OVERLAYS[@]}"}; do + case "$overlay" in + lightning) + echo "smoke.sh: waiting for LND to sync to satd over Neutrino..." + lnd_deadline=$(($(date +%s) + 240)) + synced=0 + while [[ $(date +%s) -lt $lnd_deadline ]]; do + info="$(compose exec -T lnd lncli --network=regtest getinfo 2>/dev/null || echo '{}')" + if python3 -c " +import json,sys +d=json.loads(sys.argv[1] or '{}') +sys.exit(0 if d.get('synced_to_chain') and d.get('block_height')==$HEIGHT else 1) +" "$info" 2>/dev/null; then synced=1; break; fi + sleep 5 + done + if [[ "$synced" == 1 ]]; then + pass "LND syncs to the node's tip in Neutrino mode" + else + fail "LND syncs to the node's tip in Neutrino mode" \ + "$(compose logs --no-color --tail 40 lnd 2>&1)" + fi + ;; + proxy) + code="$(curl -sS --cacert "$CA" --resolve "localhost:$PROXY_METRICS_PORT:127.0.0.1" \ + -o /dev/null -w '%{http_code}' \ + "https://localhost:$PROXY_METRICS_PORT/readyz" 2>&1 || true)" + if [[ "$code" == "200" ]]; then + pass "the proxy serves /readyz over TLS with the stack certificate" + else + fail "the proxy serves /readyz over TLS with the stack certificate" "http $code" + fi + + # RTL only exists when the Lightning overlay is also up. It is a + # bundled Tier B app, and the rule is that a bundled app ships + # only if something checks it actually serves — RTL's config is + # built from environment variables here, which is exactly the + # kind of thing that silently produces a container that starts + # and then 502s. + if [[ " ${OVERLAYS[*]} " == *" lightning "* ]]; then + rtl_code="" + rtl_deadline=$(($(date +%s) + 120)) + while [[ $(date +%s) -lt $rtl_deadline ]]; do + rtl_code="$(curl -sS --cacert "$CA" --resolve "localhost:$PROXY_RTL_PORT:127.0.0.1" \ + -o /dev/null -w '%{http_code}' \ + "https://localhost:$PROXY_RTL_PORT/" 2>&1 || true)" + # 2xx or a redirect to the login page both mean RTL is up. + [[ "$rtl_code" =~ ^(200|301|302)$ ]] && break + sleep 5 + done + if [[ "$rtl_code" =~ ^(200|301|302)$ ]]; then + pass "Ride The Lightning serves over TLS through the proxy" + else + fail "Ride The Lightning serves over TLS through the proxy" \ + "http $rtl_code +$(compose logs --no-color --tail 30 rtl 2>&1)" + fi + fi + ;; + *) + echo "note — no automated checks for the '$overlay' overlay; it came up, which is all this asserts" + ;; + esac +done + +if [[ $FAILURES -ne 0 ]]; then + COMPLETED=1 + echo "$FAILURES smoke check(s) failed" >&2 + exit 1 +fi +COMPLETED=1 +echo "all stack smoke checks passed" diff --git a/contrib/stack/tls/mkca.sh b/contrib/stack/tls/mkca.sh new file mode 100755 index 000000000..f4ebcee61 --- /dev/null +++ b/contrib/stack/tls/mkca.sh @@ -0,0 +1,280 @@ +#!/bin/bash +# mkca.sh — issue the per-install local CA and server certificate that every +# satd TLS surface presents. +# +# One script, three consumers: the compose stack's entrypoint +# (contrib/stack/satd/entrypoint.sh), the appliance's first boot +# (contrib/appliance/provision/20-tls.sh), and the Umbrel / StartOS packages. +# They must all produce certificates with the same shape, because the same +# client instructions ("export the CA, import it once") are printed by all +# three. +# +# ca.crt / ca.key the install's own CA — 10 years, EC P-256 +# leaf.crt / leaf.key the server certificate every surface presents +# fullchain.crt leaf + CA, which is what satd is pointed at +# leaf.sans the SAN list the leaf was issued for (see below) +# +# ## Why a CA and a leaf, rather than one self-signed certificate +# +# The CA is what a client imports, once. Re-issuing the leaf — because the +# lease gave the box a new address, because the hostname changed, or because +# the year is up — then costs the user nothing: the CA they already trust +# signed the new leaf too. A bare self-signed certificate would have to be +# re-imported every time, and "accept this new certificate" prompts are +# exactly the habit an appliance should not be teaching. +# +# ## Idempotence +# +# Re-running this is the normal case: it runs on every container start and on +# a systemd timer. It reissues the leaf only when there is a reason to — +# the leaf is missing, expires within --renew-within days, or the SAN set has +# changed since it was issued (recorded in leaf.sans). Otherwise it does +# nothing and says so, so a restart loop cannot churn certificates. +# +# The CA is never reissued once it exists. Rotating it invalidates every +# client's imported trust, so that is a deliberate operator act: delete the +# directory. +# +# ## Nothing here ships in an image +# +# Both keys are generated at first run on the machine that will use them. A +# shipped CA key would be a shared private key on every download, which is +# not a CA at all. contrib/appliance's build asserts these files are absent +# from the built image. + +set -euo pipefail + +usage() { + cat <<'USAGE' +Usage: mkca.sh --dir [options] + + --dir Directory to hold the CA and leaf. Required. + --hostname Primary name for the leaf (default: `hostname`). + --extra-name Additional DNS SAN. Repeatable. + --extra-ip Additional IP SAN. Repeatable. + --no-detect-ips Do not add the machine's current addresses as SANs. + --ca-days CA validity (default 3650). + --days Leaf validity (default 365). + --renew-within Reissue the leaf when fewer than n days remain + (default 30). + --owner chown the generated files to this owner. + --group-readable Key mode 0640 instead of 0600 (needed when a + service runs as a different user in the owner's + group). + --force Reissue the leaf even if the current one is fine. + --quiet Only report actual changes. + +Exit status is 0 whether or not anything was reissued; `--force` aside, the +script is safe to run on every start. +USAGE +} + +DIR="" +HOSTNAME_ARG="" +EXTRA_NAMES=() +EXTRA_IPS=() +DETECT_IPS=1 +CA_DAYS=3650 +LEAF_DAYS=365 +RENEW_WITHIN=30 +OWNER="" +KEY_MODE=0600 +FORCE=0 +QUIET=0 + +while [[ $# -gt 0 ]]; do + case "$1" in + --dir) DIR="$2"; shift 2 ;; + --hostname) HOSTNAME_ARG="$2"; shift 2 ;; + --extra-name) EXTRA_NAMES+=("$2"); shift 2 ;; + --extra-ip) EXTRA_IPS+=("$2"); shift 2 ;; + --no-detect-ips) DETECT_IPS=0; shift ;; + --ca-days) CA_DAYS="$2"; shift 2 ;; + --days) LEAF_DAYS="$2"; shift 2 ;; + --renew-within) RENEW_WITHIN="$2"; shift 2 ;; + --owner) OWNER="$2"; shift 2 ;; + --group-readable) KEY_MODE=0640; shift ;; + --force) FORCE=1; shift ;; + --quiet) QUIET=1; shift ;; + -h|--help) usage; exit 0 ;; + *) echo "mkca.sh: unknown argument: $1" >&2; usage >&2; exit 2 ;; + esac +done + +[[ -n "$DIR" ]] || { echo "mkca.sh: --dir is required" >&2; exit 2; } +command -v openssl >/dev/null || { echo "mkca.sh: openssl not found" >&2; exit 1; } + +say() { [[ "$QUIET" == 1 ]] || echo "mkca.sh: $*"; } +changed() { echo "mkca.sh: $*"; } + +PRIMARY_HOSTNAME="${HOSTNAME_ARG:-$(hostname 2>/dev/null || echo satd)}" +# A hostname that is already a FQDN must not become `host.example.com.local`. +if [[ "$PRIMARY_HOSTNAME" == *.* ]]; then + MDNS_NAME="" +else + MDNS_NAME="${PRIMARY_HOSTNAME}.local" +fi + +# --------------------------------------------------------------------------- +# SAN set +# --------------------------------------------------------------------------- +# Order matters only in that the recorded list must be stable across runs, or +# every run would look like a SAN change and reissue. Hence the sort. +declare -a DNS_NAMES=("localhost" "$PRIMARY_HOSTNAME") +[[ -n "$MDNS_NAME" ]] && DNS_NAMES+=("$MDNS_NAME") +DNS_NAMES+=(${EXTRA_NAMES[@]+"${EXTRA_NAMES[@]}"}) + +declare -a IP_ADDRS=("127.0.0.1" "::1") +IP_ADDRS+=(${EXTRA_IPS[@]+"${EXTRA_IPS[@]}"}) + +if [[ "$DETECT_IPS" == 1 ]]; then + # Every non-loopback address the box currently holds. `hostname -I` is + # not used: it omits IPv6 on some configurations and is absent in a + # minimal container. + # + # Container and VM bridge addresses are skipped. They are not addresses a + # client ever connects to, and they come and go as compose projects and + # VMs start — which, since a changed SAN set triggers a reissue, would + # otherwise churn the certificate every time a stack overlay is enabled. + if command -v ip >/dev/null; then + while read -r ifname addr; do + case "$ifname" in + docker*|br-*|veth*|virbr*|cni*|podman*|kube*|lxcbr*) continue ;; + esac + [[ -n "$addr" ]] && IP_ADDRS+=("$addr") + done < <(ip -o addr show scope global 2>/dev/null \ + | awk '{ sub(/\/.*/, "", $4); print $2, $4 }' | sort -u) + fi +fi + +dedupe_sorted() { + printf '%s\n' "$@" | grep -v '^$' | sort -u +} + +mapfile -t DNS_NAMES < <(dedupe_sorted ${DNS_NAMES[@]+"${DNS_NAMES[@]}"}) +mapfile -t IP_ADDRS < <(dedupe_sorted ${IP_ADDRS[@]+"${IP_ADDRS[@]}"}) + +SAN_RECORD="" +for n in ${DNS_NAMES[@]+"${DNS_NAMES[@]}"}; do SAN_RECORD+="DNS:$n"$'\n'; done +for a in ${IP_ADDRS[@]+"${IP_ADDRS[@]}"}; do SAN_RECORD+="IP:$a"$'\n'; done + +# --------------------------------------------------------------------------- +# Paths +# --------------------------------------------------------------------------- +mkdir -p "$DIR" +chmod 0750 "$DIR" +CA_KEY="$DIR/ca.key" +CA_CRT="$DIR/ca.crt" +CA_SRL="$DIR/ca.srl" +LEAF_KEY="$DIR/leaf.key" +LEAF_CRT="$DIR/leaf.crt" +FULLCHAIN="$DIR/fullchain.crt" +SANS_FILE="$DIR/leaf.sans" + +apply_owner() { + [[ -n "$OWNER" ]] || return 0 + chown "$OWNER" "$@" 2>/dev/null || true +} + +# --------------------------------------------------------------------------- +# CA — created once, never rotated automatically. +# --------------------------------------------------------------------------- +if [[ ! -s "$CA_KEY" || ! -s "$CA_CRT" ]]; then + changed "creating the local CA in $DIR" + umask 077 + openssl ecparam -genkey -name prime256v1 -out "$CA_KEY.tmp" 2>/dev/null + # PKCS#8 rather than SEC1: it is the form every TLS stack in the tree + # reads without special-casing, rustls included. + openssl pkcs8 -topk8 -nocrypt -in "$CA_KEY.tmp" -out "$CA_KEY" + rm -f "$CA_KEY.tmp" + openssl req -x509 -new -key "$CA_KEY" -sha256 -days "$CA_DAYS" \ + -out "$CA_CRT" \ + -subj "/CN=satd local CA ($PRIMARY_HOSTNAME)/O=satd appliance" \ + -addext "basicConstraints=critical,CA:TRUE,pathlen:0" \ + -addext "keyUsage=critical,keyCertSign,cRLSign" 2>/dev/null + # A fresh CA cannot have signed the existing leaf. Dropping the leaf here + # is what makes `rm ca.*` a working rotation: without it the old leaf + # would survive with an unverifiable signature. + rm -f "$LEAF_CRT" "$FULLCHAIN" "$SANS_FILE" "$CA_SRL" +fi + +# --------------------------------------------------------------------------- +# Leaf — reissued on expiry, SAN change, or --force. +# --------------------------------------------------------------------------- +need_leaf=0 +reason="" +if [[ "$FORCE" == 1 ]]; then + need_leaf=1; reason="--force" +elif [[ ! -s "$LEAF_CRT" || ! -s "$LEAF_KEY" || ! -s "$FULLCHAIN" ]]; then + need_leaf=1; reason="no current certificate" +elif [[ ! -s "$SANS_FILE" ]] || ! diff -q <(printf '%s' "$SAN_RECORD") "$SANS_FILE" >/dev/null 2>&1; then + need_leaf=1; reason="the name/address set changed" +elif ! openssl x509 -in "$LEAF_CRT" -noout -checkend $((RENEW_WITHIN * 86400)) >/dev/null 2>&1; then + need_leaf=1; reason="expires within ${RENEW_WITHIN}d" +fi + +if [[ "$need_leaf" == 1 ]]; then + changed "issuing the server certificate ($reason)" + umask 077 + tmp="$(mktemp -d)" + trap 'rm -rf "$tmp"' EXIT + + openssl ecparam -genkey -name prime256v1 -out "$tmp/leaf.sec1" 2>/dev/null + openssl pkcs8 -topk8 -nocrypt -in "$tmp/leaf.sec1" -out "$tmp/leaf.key" + + { + echo "basicConstraints=critical,CA:FALSE" + # digitalSignature only: an ECDSA key signs, it does not encipher, and + # listing keyEncipherment here would be a lie some verifiers act on. + echo "keyUsage=critical,digitalSignature" + echo "extendedKeyUsage=serverAuth" + echo "subjectKeyIdentifier=hash" + echo "authorityKeyIdentifier=keyid,issuer" + printf 'subjectAltName=@alt_names\n\n[alt_names]\n' + i=0 + for n in ${DNS_NAMES[@]+"${DNS_NAMES[@]}"}; do + i=$((i + 1)); echo "DNS.$i=$n" + done + i=0 + for a in ${IP_ADDRS[@]+"${IP_ADDRS[@]}"}; do + i=$((i + 1)); echo "IP.$i=$a" + done + } > "$tmp/leaf.ext" + + openssl req -new -key "$tmp/leaf.key" -out "$tmp/leaf.csr" \ + -subj "/CN=$PRIMARY_HOSTNAME" 2>/dev/null + openssl x509 -req -in "$tmp/leaf.csr" \ + -CA "$CA_CRT" -CAkey "$CA_KEY" -CAcreateserial -CAserial "$CA_SRL" \ + -days "$LEAF_DAYS" -sha256 -extfile "$tmp/leaf.ext" \ + -out "$tmp/leaf.crt" 2>/dev/null + + # Verify before installing. A leaf that does not chain to its own CA is + # a silent outage on every surface at once, and it is cheap to rule out + # here rather than discover from a client. + openssl verify -CAfile "$CA_CRT" "$tmp/leaf.crt" >/dev/null + + # Install atomically-ish: key first, then the certs, then the SAN record. + # The SAN record is written last on purpose — if anything above fails, + # the next run sees a missing/stale record and reissues rather than + # trusting a half-written state. + mv "$tmp/leaf.key" "$LEAF_KEY" + mv "$tmp/leaf.crt" "$LEAF_CRT" + cat "$LEAF_CRT" "$CA_CRT" > "$FULLCHAIN" + printf '%s' "$SAN_RECORD" > "$SANS_FILE" + rm -rf "$tmp" + trap - EXIT +else + say "certificate is current; nothing to do" +fi + +chmod "$KEY_MODE" "$CA_KEY" "$LEAF_KEY" +chmod 0644 "$CA_CRT" "$LEAF_CRT" "$FULLCHAIN" "$SANS_FILE" +apply_owner "$CA_KEY" "$CA_CRT" "$LEAF_KEY" "$LEAF_CRT" "$FULLCHAIN" "$SANS_FILE" "$DIR" +[[ -f "$CA_SRL" ]] && { chmod 0644 "$CA_SRL"; apply_owner "$CA_SRL"; } + +if [[ "$QUIET" != 1 ]]; then + echo "mkca.sh: CA $CA_CRT" + echo "mkca.sh: certificate $FULLCHAIN (key $LEAF_KEY)" + echo "mkca.sh: valid for $(printf '%s' "$SAN_RECORD" | tr '\n' ' ')" + echo "mkca.sh: expires $(openssl x509 -in "$LEAF_CRT" -noout -enddate | cut -d= -f2)" +fi From 6fc1163ca57c93cee3b4fda552a6e570ba24b9bc Mon Sep 17 00:00:00 2001 From: Benjamen Keroack Date: Tue, 8 Sep 2026 15:51:04 -0600 Subject: [PATCH 04/22] contrib/appliance: a downloadable VM that boots into a working node MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A bootable image with satd, its tooling and — in the desktop flavour — Sparrow, Electrum, Liana and a Cashu wallet already pointed at the node's Electrum server. Signet by default, because it is the only network on which the whole thing is a one-evening exercise; `satd-appliance set-network mainnet` switches and refuses below 1.5 TB free. Built with mmdebstrap and a GRUB install onto a loop device rather than Packer. Packer's QEMU builder drives Debian's installer through a preseed inside a running VM: it needs KVM to finish in sensible time, needs a 700 MB ISO, and fails in ways you diagnose by watching a VNC console. This runs in a container with no root and no KVM on the host, takes minutes, and every failure is a shell command that exited non-zero. That is what made the boot gate practical rather than aspirational. Everything unique to an install is created on first boot — the console password, the CA and server certificate, the MCP token, the disk's real size — because an image that shipped any of them would be an image where every download shared them. 90-cleanup.sh asserts none of them are present and refuses to finish a build that would ship one. satd runs natively under systemd; the overlays run as containers from contrib/stack's files, used unmodified. Making that sharing real took two changes there, included here: the overlays address the node as ${SATD_HOST:-satd} so the same file works against a compose service and against the host, and they no longer depend on a satd service that does not exist in the appliance's project. Four defects found by booting the artifact rather than reasoning about it: - grub-mkconfig's UUID probe comes back empty in a build container, so 10_linux fell back to the BUILD HOST's loop device. The image booted, the kernel started, and the initramfs waited forever for a /dev/loopNpM that exists on no machine but the builder. Now rewritten to the UUID and asserted. - `tr -dc < /dev/urandom | head -c 18` killed first boot outright: head closes the pipe, tr dies of SIGPIPE, and pipefail aborts the unit. - `systemctl enable --now satd` from inside first boot deadlocked against its own Before= ordering — satd waited for first boot, first boot waited for satd. `--no-block` queues the job instead. - sat-cli has no signet selector and derives the cookie path from the chain, so satd-appliance could not authenticate on any network but mainnet. It now uses the stable rpc-cookie symlink satd-init maintains. The last of those exposed a real bug in the shipped systemd unit, fixed here: ExecStartPost relaxed only ${SATD_DATADIR}/.cookie, so on signet, testnet4 and regtest — where the cookie lives under the network's subdirectory — members of the satd group could not run sat-cli at all. boot-test.sh boots the artifact under QEMU and checks it through two channels: the guest agent for what is inside, and forwarded ports for the TLS surfaces, verified from outside against the CA the agent hands out. Checking a certificate from inside the guest proves much less than connecting to it the way a client will. KVM where there is one, TCG where there is not. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01K9Nb3m8HuhNbjkJYp9Frmt --- .gitignore | 4 + contrib/appliance/README.md | 191 ++++++++ contrib/appliance/bin/satd-appliance | 446 ++++++++++++++++++ contrib/appliance/build-in-docker.sh | 103 ++++ contrib/appliance/build-iso-in-docker.sh | 83 ++++ contrib/appliance/build-iso.sh | 164 +++++++ contrib/appliance/build.sh | 353 ++++++++++++++ contrib/appliance/files/compose.appliance.yml | 43 ++ contrib/appliance/files/configure-network | 67 +++ contrib/appliance/files/desktop/welcome.html | 87 ++++ .../firstboot/satd-appliance-firstboot | 127 +++++ .../satd-appliance-firstboot.service | 19 + contrib/appliance/lib.sh | 34 ++ contrib/appliance/mkova.sh | 124 +++++ contrib/appliance/provision/00-base.sh | 162 +++++++ contrib/appliance/provision/10-satd.sh | 91 ++++ contrib/appliance/provision/20-tls.sh | 43 ++ contrib/appliance/provision/30-desktop.sh | 109 +++++ contrib/appliance/provision/40-wallets.sh | 297 ++++++++++++ contrib/appliance/provision/50-containers.sh | 35 ++ contrib/appliance/provision/60-firstboot.sh | 37 ++ contrib/appliance/provision/90-cleanup.sh | 102 ++++ contrib/appliance/provision/common.sh | 55 +++ contrib/appliance/tests/boot-test.sh | 414 ++++++++++++++++ contrib/stack/caddy/Caddyfile | 2 +- contrib/stack/compose.btcpay.yml | 11 +- contrib/stack/compose.cln.yml | 10 +- contrib/stack/compose.lightning.yml | 10 +- contrib/stack/compose.proxy.yml | 17 +- contrib/stack/compose.yml | 5 + contrib/systemd/satd.service | 19 +- 31 files changed, 3245 insertions(+), 19 deletions(-) create mode 100644 contrib/appliance/README.md create mode 100755 contrib/appliance/bin/satd-appliance create mode 100755 contrib/appliance/build-in-docker.sh create mode 100755 contrib/appliance/build-iso-in-docker.sh create mode 100755 contrib/appliance/build-iso.sh create mode 100755 contrib/appliance/build.sh create mode 100644 contrib/appliance/files/compose.appliance.yml create mode 100755 contrib/appliance/files/configure-network create mode 100644 contrib/appliance/files/desktop/welcome.html create mode 100755 contrib/appliance/firstboot/satd-appliance-firstboot create mode 100644 contrib/appliance/firstboot/satd-appliance-firstboot.service create mode 100644 contrib/appliance/lib.sh create mode 100755 contrib/appliance/mkova.sh create mode 100755 contrib/appliance/provision/00-base.sh create mode 100755 contrib/appliance/provision/10-satd.sh create mode 100755 contrib/appliance/provision/20-tls.sh create mode 100755 contrib/appliance/provision/30-desktop.sh create mode 100755 contrib/appliance/provision/40-wallets.sh create mode 100755 contrib/appliance/provision/50-containers.sh create mode 100755 contrib/appliance/provision/60-firstboot.sh create mode 100755 contrib/appliance/provision/90-cleanup.sh create mode 100644 contrib/appliance/provision/common.sh create mode 100755 contrib/appliance/tests/boot-test.sh diff --git a/.gitignore b/.gitignore index 9510ba3b6..7c15bac40 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,7 @@ result-* # Core's framework seeds a cached chain next to the tests; run.sh redirects it # out of the tree, but a hand-run test would otherwise drop it here. /contrib/core-functional/cache/ + +# Built appliance images. Multi-GB disk images and ISOs; the build writes +# here by default and nothing in the tree should ever carry one. +contrib/appliance/out/ diff --git a/contrib/appliance/README.md b/contrib/appliance/README.md new file mode 100644 index 000000000..3c9b01f8a --- /dev/null +++ b/contrib/appliance/README.md @@ -0,0 +1,191 @@ +# satd appliance image + +A downloadable virtual machine that boots into a working Bitcoin node with +every satd surface on and TLS everywhere, plus the wallets and Lightning +software people actually point at a node. + +The point is not convenience alone. Every third-party application here is a +**compatibility claim** about satd, and one that a CI job re-checks rather +than a sentence in a README. + +## Support + +**satd in this image is supported** — the same release artifact as the +tarballs and the container image. + +**The bundled third-party software is best-effort, for evaluation and +testing, and is not a production deployment.** Its security advisories are +not tracked here in real time, and a critical fix in a bundled component may +not appear in an appliance image until the next scheduled build. For +production, run satd from a release artifact and operate the other +components yourself. + +## Flavours + +| Flavour | Contents | Disk | Built for | +|---|---|---|---| +| `core` | satd, `sat-cli`, `sat-tui`, MCP, the container overlays staged but idle | 6 GB grown at first boot | headless VMs, mini-PCs, the CI boot gate | +| `desktop` | the above plus XFCE, Firefox, Sparrow, Electrum, Liana | 16 GB grown at first boot | trying it on a laptop | + +Both boot on **signet** by default. It is the only network on which the +whole thing is a one-evening exercise: a fully indexed node syncs in well +under an hour, faucets supply coins, and Lightning and ecash work end to end +with no real money. `satd-appliance set-network mainnet` switches, and +refuses below 1.5 TB free. + +There is no prune option on any network. Electrum and Esplora both require +`txindex`, and `txindex` excludes pruning, so mainnet here means the full +chain plus every index. + +## Building + +```sh +# From a local build of satd. No root, no KVM: everything runs in a container. +cargo build --release --bin satd --bin sat-cli --bin sat-tui +contrib/appliance/build-in-docker.sh --flavor core --out contrib/appliance/out + +# From a published, signed release instead. +contrib/appliance/build-in-docker.sh --flavor desktop \ + --satd-source release --satd-version 0.5.1 --out out/ + +# With the tools already on the host. +sudo contrib/appliance/build.sh --flavor core --out out/ +``` + +Output: a raw image and a qcow2, plus a VMDK and OVA for the desktop +flavour, and a `SHA256SUMS`. + +### Live ISO + +```sh +contrib/appliance/build-iso-in-docker.sh --flavor desktop --out out/ +``` + +"Try it from a USB stick without installing anything." The root filesystem +is built by the same `build.sh` with `--rootfs-only` — same provision +scripts, same packages, same first-boot behaviour — and then squashed and +made bootable rather than written to a partitioned disk. Two build paths +that provisioned differently would drift, and the ISO is the one nobody +tests as often. + +A live session keeps everything in RAM, so first boot runs on every boot and +the chain it syncs is lost at power-off. The console password is generated +each time and printed on the login banner, which is where a live user reads +it from. + +`satd-appliance install-to-disk /dev/sdX` copies the running system onto a +real disk. It clears the CA, certificate, token and password the live +session generated first, so the installed system creates its own on its own +first boot rather than inheriting credentials that were displayed on a +screen. + +### Why not Packer + +Packer's QEMU builder drives Debian's installer through a preseed inside a +running VM: it needs KVM to finish in sensible time, needs a ~700 MB +installer ISO, and fails in ways you diagnose by watching a VNC console. +`build.sh` builds the filesystem directly with `mmdebstrap` and installs +GRUB onto a loop device — no VM, no KVM, no ISO, minutes rather than an +hour, and every failure is a shell command that exited non-zero with its +output on stdout. It runs unchanged on a GitHub-hosted runner and inside a +container, which is what made the boot gate below practical. + +The provisioning tree in `provision/` is plain, idempotent shell and is +shared by the disk builder and the ISO builder. + +## Running + +| Hypervisor | File | +|---|---| +| QEMU/KVM, virt-manager, Proxmox, UTM | `.qcow2` | +| VirtualBox, VMware | `.ova` (desktop flavour) | +| bare metal, a spare SSD | `.raw`, written with `dd` | + +Minimum: 2 vCPU / 4 GB for signet, 4+ vCPU / 16 GB and a 2 TB disk for a +fully indexed mainnet. + +The disk grows to fill whatever it is given on first boot, so attach a large +virtual disk rather than resizing later. + +## First boot + +Runs once, before satd starts, and creates everything that must be unique +per install — because an image that shipped any of it would be an image +where every download shared it: + +1. the root filesystem is grown to the disk; +2. a console password is generated and printed on the console, and must be + changed at first login; +3. the local CA and the server certificate are issued; +4. the node's configuration is rendered and the MCP bearer token minted; +5. satd starts. + +`90-cleanup.sh` asserts at build time that none of those exist in the image +— no keys, no cookie, no token, no machine-id, no usable password hash — +and refuses to finish a build that would ship one. + +## Operating + +```sh +satd-appliance status network, sync progress, overlays, certificate +satd-appliance tls export-ca the CA to import on other machines +satd-appliance tls renew reissue after a hostname or address change +sudo satd-appliance set-network mainnet +sudo satd-appliance enable lightning LND (Neutrino) + Ride The Lightning +sudo satd-appliance enable cashu a Cashu mint backed by that LND +sudo satd-appliance enable btcpay BTCPay Server +sudo satd-appliance disable lightning stop it; its data volumes are kept +satd-appliance logs [satd|] +sudo satd-appliance ssh enable sshd is off by default +``` + +satd runs natively under systemd — `systemctl status satd`, `journalctl -u +satd`, `sat-tui` — while the overlays run as containers from +`/opt/satd/stack`, which is `contrib/stack`'s overlay files used unmodified. +`files/compose.appliance.yml` supplies what `compose.yml` would have: the +data volume bound to the real `/var/lib/satd`, and a network whose gateway +is how the containers reach the host's node. + +## TLS + +One CA per install, one certificate presented by every surface. Export the +CA once and everything is trusted at once: + +```sh +satd-appliance tls export-ca > satd-ca.crt +``` + +- The OS trust store already has it, so `curl` and `sat-cli` on the + appliance itself need no flags. +- Firefox on the desktop flavour is policy-configured to import it. +- Sparrow, Electrum and Liana pin the server certificate on first use + instead; accept it once. +- Elsewhere: import `satd-ca.crt`, then use `satd.local` — the name is on + the certificate and survives a DHCP change, which an address does not. + +A daily timer reissues the certificate when fewer than 30 days remain or the +machine's names or addresses have changed. The CA is never rotated +automatically; that would invalidate trust every client has already +established. + +The firewall is default-deny inbound. Only Bitcoin P2P, the TLS surfaces, +the proxy ports and mDNS are open. The plain RPC, Electrum, Esplora and +metrics listeners are reachable from the machine and its containers only. + +## Testing + +```sh +contrib/appliance/tests/boot-test.sh --image out/....qcow2 --in-docker +``` + +Boots the actual artifact — no test-only build, no injected hooks — and +checks what a person who downloaded it would find. Two channels: the QEMU +guest agent for looking inside (did first boot run, is satd up, were the +certificates created), and forwarded ports for the TLS surfaces, verified +from outside against the CA the guest agent hands out. Checking a +certificate from inside the guest proves much less than connecting to it the +way a client on the network will, and the suite includes the negative +control that the same handshake without the CA must fail. + +It uses KVM when there is one and TCG when there is not, so it runs on a +hosted CI runner and on a laptop with no virtualisation. diff --git a/contrib/appliance/bin/satd-appliance b/contrib/appliance/bin/satd-appliance new file mode 100755 index 000000000..a06569371 --- /dev/null +++ b/contrib/appliance/bin/satd-appliance @@ -0,0 +1,446 @@ +#!/bin/bash +# satd-appliance — the operator command for the satd appliance image. +# +# Everything the image asks a person to do has a subcommand here, so that +# the README can say "run this" instead of describing a sequence of +# systemctl, docker compose and openssl invocations that have to be got +# right in order. +# +# satd-appliance status +# satd-appliance tls export-ca > satd-ca.crt +# satd-appliance tls renew +# satd-appliance set-network mainnet +# satd-appliance enable lightning +# satd-appliance disable lightning +# satd-appliance ssh enable +# satd-appliance logs [satd|] +# satd-appliance install-to-disk /dev/sdX (live ISO only) + +set -euo pipefail + +DATADIR=/var/lib/satd +TLS_DIR="$DATADIR/tls" +LIB=/usr/local/lib/satd-appliance +STACK=/opt/satd/stack +STATE=/var/lib/satd-appliance +NETWORK_FILE="$STATE/network" +ENABLED_DIR="$STATE/enabled" + +# Fixed by contrib/stack/satd/satd.conf.tmpl on every network. +RPC_PORT=8332 + +die() { echo "satd-appliance: $*" >&2; exit 1; } +need_root() { [[ "$(id -u)" == 0 ]] || die "this needs root; try: sudo satd-appliance $*"; } + +current_network() { + [[ -s "$NETWORK_FILE" ]] && cat "$NETWORK_FILE" || echo signet +} + +p2p_port_for() { + case "$1" in + mainnet) echo 8333 ;; + signet) echo 38333 ;; + testnet4) echo 48333 ;; + testnet) echo 18333 ;; + regtest) echo 18444 ;; + *) die "unknown network: $1" ;; + esac +} + +# `--rpccookiefile` rather than a network flag: sat-cli derives the cookie's +# location from --regtest/--testnet and has no selector for signet at all, so +# on most of the networks this appliance runs it would look in the mainnet +# path and fail to authenticate. `rpc-cookie` is the stable symlink satd-init +# maintains to whichever path the cookie actually has. +sat_cli() { + /usr/local/bin/sat-cli \ + --datadir="$DATADIR" \ + --rpcport="$RPC_PORT" \ + --rpccookiefile="$DATADIR/rpc-cookie" \ + "$@" +} + +# The docker network's gateway is the host, which is how the overlay +# containers reach a natively-run satd. Kept in step with the subnet in +# compose.appliance.yml. +stack_subnet() { echo "${SATD_STACK_SUBNET:-10.77.0.0/24}"; } +stack_gateway() { echo "${SATD_HOST:-10.77.0.1}"; } + +compose_args() { + local args=(-f "$STACK/compose.appliance.yml") + local overlay + for overlay in $(enabled_overlays); do + args+=(-f "$STACK/compose.$overlay.yml") + done + printf '%s\n' "${args[@]}" +} + +enabled_overlays() { + [[ -d "$ENABLED_DIR" ]] || return 0 + # `find` rather than a glob: an empty directory makes a glob expand to + # itself, which would then be treated as an overlay name. + find "$ENABLED_DIR" -maxdepth 1 -type f -printf '%f\n' 2>/dev/null | sort +} + +run_compose() { + local args=() + mapfile -t args < <(compose_args) + ( cd "$STACK" && SATD_HOST="$(stack_gateway)" \ + SATD_STACK_SUBNET="$(stack_subnet)" \ + NETWORK="$(current_network)" \ + SATD_P2P_PORT="$(p2p_port_for "$(current_network)")" \ + SATD_TLS_HOSTNAME="$(hostname)" \ + docker compose "${args[@]}" "$@" ) +} + +# --- render the node configuration ------------------------------------------ +# One script, shared with first boot. It renders bitcoin.conf through the +# compose stack's own satd-init — so the appliance and the stack cannot +# drift into different node configurations — and writes the chain selector +# into /etc/default/satd, which is the only place satd will actually read it +# from: a `signet=1` line in a config file is accepted and then ignored. +render_config() { + SATD_STACK_SUBNET="$(stack_subnet)" \ + SATD_TLS_HOSTNAME="$(hostname)" \ + "$LIB/configure-network" "$1" +} + +cmd_status() { + local net; net="$(current_network)" + echo "network: $net" + echo "satd: $(systemctl is-active satd 2>/dev/null || true) ($(systemctl is-enabled satd 2>/dev/null || true))" + if info="$(sat_cli getblockchaininfo 2>/dev/null)"; then + python3 - "$info" <<'PY' +import json, sys +d = json.loads(sys.argv[1]) +pct = d.get("verificationprogress", 0) * 100 +print(f"chain: {d.get('chain')} height {d.get('blocks')} ({pct:.2f}% verified)") +print(f"headers: {d.get('headers')}") +PY + else + echo "chain: (RPC not answering yet)" + fi + local overlays; overlays="$(enabled_overlays | tr '\n' ' ')" + echo "overlays: ${overlays:-none}" + if [[ -s "$TLS_DIR/leaf.crt" ]]; then + echo "cert: expires $(openssl x509 -in "$TLS_DIR/leaf.crt" -noout -enddate | cut -d= -f2)" + echo " $(openssl x509 -in "$TLS_DIR/leaf.crt" -noout -ext subjectAltName | tail -n +2 | sed 's/^ *//')" + else + echo "cert: (not issued yet)" + fi + echo + echo "Reach this appliance at $(hostname).local — the certificate covers that" + echo "name, and it keeps working when the address changes." +} + +cmd_tls() { + case "${1:-}" in + export-ca) + [[ -s "$TLS_DIR/ca.crt" ]] || die "no CA yet; has first boot finished?" + cat "$TLS_DIR/ca.crt" + ;; + renew) + need_root tls renew + shift || true + local quiet=() + [[ "${1:-}" == "--quiet" ]] && quiet=(--quiet) + "$LIB/mkca.sh" --dir "$TLS_DIR" --hostname "$(hostname)" \ + --owner satd:satd --group-readable "${quiet[@]}" + # Certificate paths are restart-only settings in satd; a reload + # would not pick up a reissued leaf, so a changed certificate has + # to restart the units that present it. + systemctl try-restart satd + run_compose restart caddy > /dev/null 2>&1 || true + ;; + show) + [[ -s "$TLS_DIR/leaf.crt" ]] || die "no certificate yet" + openssl x509 -in "$TLS_DIR/leaf.crt" -noout -text + ;; + *) die "usage: satd-appliance tls {export-ca|renew|show}" ;; + esac +} + +cmd_set_network() { + need_root set-network "$@" + local target="${1:-}" + [[ -n "$target" ]] || die "usage: satd-appliance set-network " + p2p_port_for "$target" > /dev/null + + if [[ "$target" == "mainnet" ]]; then + # No pruning is available here — Electrum and Esplora need txindex, + # and txindex excludes pruning — so mainnet means the full chain plus + # every index. Refusing up front beats filling the disk two days in. + local avail_gb + avail_gb="$(df -BG --output=avail "$DATADIR" | tail -1 | tr -dc '0-9')" + local required_gb=1500 + if [[ "${avail_gb:-0}" -lt "$required_gb" ]]; then + echo "satd-appliance: mainnet needs at least ${required_gb} GB free on $DATADIR;" >&2 + echo "satd-appliance: this disk has ${avail_gb} GB." >&2 + echo >&2 + echo "A fully indexed mainnet node stores the whole chain plus the" >&2 + echo "address, spend and transaction indices. Pruning is not an option:" >&2 + echo "Electrum and Esplora both require txindex, which pruning excludes." >&2 + echo "Grow the disk, or stay on signet." >&2 + exit 1 + fi + fi + + echo "satd-appliance: switching to $target" + systemctl stop satd 2>/dev/null || true + mkdir -p "$STATE" + echo "$target" > "$NETWORK_FILE" + render_config "$target" + + if [[ "$target" == "mainnet" ]]; then + cat <<'FAST' + +Mainnet initial sync takes days from genesis. To start from a Bitcoin Core +AssumeUTXO snapshot instead, so the node is usable in hours: + + sudo satd-appliance fast-start + +satd verifies any snapshot against a hash compiled into the binary, so the +host it came from is trusted for availability only — not for contents. +FAST + fi + + systemctl start satd + # Overlays follow the node onto the new chain; leaving them on the old + # one would have LND talking to a node whose chain it does not share. + if [[ -n "$(enabled_overlays)" ]]; then + echo "satd-appliance: restarting overlays on $target" + run_compose up -d + fi + echo "satd-appliance: now on $target" +} + +cmd_fast_start() { + need_root fast-start + [[ "$(current_network)" == "mainnet" ]] || die "fast-start applies to mainnet only" + # Deliberately not a fixed URL in this script: snapshot files come and go, + # and a stale hardcoded one fails in a confusing way. The manual carries + # the current published location and its hash. + cat <<'EOF' +Fast start loads a Bitcoin Core AssumeUTXO snapshot. + +satd does not host snapshots. Pick a published one whose height matches an +anchor compiled into this binary, then: + + sudo systemctl stop satd + sudo -u satd satd --datadir=/var/lib/satd \ + --fast-start=https:///utxo-.dat \ + --fast-start-sha256= + sudo systemctl start satd + +satd checks the snapshot's UTXO set against the anchor hash built into the +binary before it activates, so the host you fetch from is trusted for +availability only. Anchor heights this build accepts: +EOF + sat_cli getblockchaininfo > /dev/null 2>&1 || true + /usr/local/bin/satd --help 2>/dev/null | grep -A2 'fast-start' | sed 's/^/ /' || true + echo + echo "See the Operator Manual chapter 'Initial Block Download & Fast Sync'." +} + +cmd_enable() { + need_root enable "$@" + local overlay="${1:-}" + [[ -n "$overlay" ]] || die "usage: satd-appliance enable " + [[ -f "$STACK/compose.$overlay.yml" ]] || die "no such overlay: $overlay +available: $(cd "$STACK" && ls compose.*.yml | sed 's/compose\.\(.*\)\.yml/\1/' | grep -v appliance | tr '\n' ' ')" + + # Overlays that need a secret generate it here, once, rather than + # shipping one that every image would share. + mkdir -p "$STATE" + touch "$STATE/overlay.env"; chmod 0600 "$STATE/overlay.env" + case "$overlay" in + cashu) + grep -q '^MINT_PRIVATE_KEY=' "$STATE/overlay.env" || \ + echo "MINT_PRIVATE_KEY=$(openssl rand -hex 32)" >> "$STATE/overlay.env" + ;; + btcpay) + grep -q '^POSTGRES_PASSWORD=' "$STATE/overlay.env" || \ + echo "POSTGRES_PASSWORD=$(openssl rand -hex 24)" >> "$STATE/overlay.env" + ;; + esac + + systemctl enable --now docker > /dev/null 2>&1 || true + mkdir -p "$ENABLED_DIR" + touch "$ENABLED_DIR/$overlay" + echo "satd-appliance: pulling images for $overlay (this is the first network fetch for it)" + set -a; . "$STATE/overlay.env"; set +a + run_compose pull --quiet 2>/dev/null || run_compose pull + run_compose up -d + echo "satd-appliance: $overlay enabled" +} + +cmd_disable() { + need_root disable "$@" + local overlay="${1:-}" + [[ -n "$overlay" ]] || die "usage: satd-appliance disable " + [[ -f "$ENABLED_DIR/$overlay" ]] || die "$overlay is not enabled" + # Bring the whole project down while the overlay is still listed, then + # bring back what remains: `compose down` only knows about services in + # the files it is given, so removing the file first would strand the + # overlay's containers with nothing able to name them. + run_compose down --remove-orphans || true + rm -f "$ENABLED_DIR/$overlay" + if [[ -n "$(enabled_overlays)" ]]; then + # An `if`, not `[[ -s f ]] && . f`: under `set -e` that AND-list + # returns non-zero when the file is absent and aborts the command + # half-way through, leaving the remaining overlays down. + if [[ -s "$STATE/overlay.env" ]]; then + set -a; . "$STATE/overlay.env"; set +a + fi + run_compose up -d + else + systemctl disable --now docker > /dev/null 2>&1 || true + fi + echo "satd-appliance: $overlay disabled (its data volumes are kept)" +} + +cmd_ssh() { + need_root ssh "$@" + case "${1:-}" in + enable) + apt-get install -y --no-install-recommends openssh-server > /dev/null 2>&1 || true + systemctl enable --now ssh + nft add rule inet filter input tcp dport 22 accept 2>/dev/null || true + # nft rules are not persistent; record it so the boot-time + # ruleset includes it too. + if ! grep -q 'dport 22 accept' /etc/nftables.conf; then + sed -i 's|^\t\t# SSH is closed.*|\t\ttcp dport 22 accept\n\t\t# SSH was enabled by satd-appliance.|' /etc/nftables.conf + fi + echo "satd-appliance: sshd enabled. Set a password or install a key first:" + echo " sudo passwd $(stat -c %U /home/* 2>/dev/null | head -1)" + ;; + disable) + systemctl disable --now ssh 2>/dev/null || true + sed -i '/tcp dport 22 accept/d' /etc/nftables.conf + systemctl reload nftables 2>/dev/null || nft -f /etc/nftables.conf + echo "satd-appliance: sshd disabled" + ;; + *) die "usage: satd-appliance ssh {enable|disable}" ;; + esac +} + +# Only reachable from the live ISO: a running installed system copying +# itself over a disk is not something to make easy by accident. +cmd_install_to_disk() { + need_root install-to-disk "$@" + local target="${1:-}" + [[ -b "$target" ]] || die "usage: satd-appliance install-to-disk /dev/sdX (a block device)" + + if ! findmnt -no FSTYPE / | grep -q overlay; then + die "this is not a live session; install-to-disk only runs from the ISO" + fi + if grep -q " $target" /proc/mounts; then + die "$target has mounted partitions; unmount them first" + fi + + local size_gb + size_gb=$(( $(blockdev --getsize64 "$target") / 1000000000 )) + echo + echo "This will ERASE $target (${size_gb} GB) and install the satd appliance on it." + echo "Everything currently on that disk will be lost." + echo + read -r -p "Type the device path again to confirm: " confirm + [[ "$confirm" == "$target" ]] || die "not confirmed; nothing was changed" + + echo "==> partitioning $target" + # The same layout build.sh writes: a BIOS boot partition and an ESP, so + # the installed disk boots on either firmware, exactly as the VM image + # does. + parted -s "$target" mklabel gpt + parted -s "$target" mkpart bios_grub 1MiB 3MiB + parted -s "$target" set 1 bios_grub on + parted -s "$target" mkpart ESP fat32 3MiB 515MiB + parted -s "$target" set 2 esp on + parted -s "$target" mkpart root ext4 515MiB 100% + partprobe "$target" + sleep 2 + + # /dev/sda2 but /dev/nvme0n1p2: the partition suffix depends on whether + # the device name ends in a digit. + local p="" + [[ "$target" =~ [0-9]$ ]] && p="p" + + mkfs.vfat -F32 -n ESP "${target}${p}2" > /dev/null + mkfs.ext4 -q -L satd-root "${target}${p}3" + + local mnt; mnt="$(mktemp -d)" + mount "${target}${p}3" "$mnt" + mkdir -p "$mnt/boot/efi" + mount "${target}${p}2" "$mnt/boot/efi" + + echo "==> copying the system (several minutes)" + # -x keeps the copy on the live root; the pseudo-filesystems and the + # squashfs mounts underneath must not be walked into. + rsync -aHAXx --info=progress2 \ + --exclude=/proc --exclude=/sys --exclude=/dev --exclude=/run \ + --exclude=/tmp --exclude=/mnt --exclude=/media --exclude=/lib/live \ + / "$mnt/" + + local root_uuid esp_uuid + root_uuid="$(blkid -s UUID -o value "${target}${p}3")" + esp_uuid="$(blkid -s UUID -o value "${target}${p}2")" + cat > "$mnt/etc/fstab" < installing the bootloader" + mount --bind /dev "$mnt/dev" + mount -t proc proc "$mnt/proc" + mount -t sysfs sys "$mnt/sys" + chroot "$mnt" grub-install --target=i386-pc --boot-directory=/boot "$target" + chroot "$mnt" grub-install --target=x86_64-efi --efi-directory=/boot/efi \ + --boot-directory=/boot --removable --no-nvram + chroot "$mnt" update-grub + + # The installed system must run its own first boot: this live session + # already generated a password, a CA and a token in RAM, and copying + # those onto disk would put a credential that was displayed on a screen + # into permanent storage. + rm -f "$mnt/var/lib/satd-appliance/firstboot-done" + rm -f "$mnt/var/lib/satd-appliance/initial-password" + rm -rf "$mnt/var/lib/satd/tls" "$mnt/var/lib/satd/secrets" + rm -f "$mnt/var/lib/satd/authfile.toml" + : > "$mnt/etc/machine-id" + # live-config's session setup does not belong on an installed system. + rm -f "$mnt/etc/sudoers.d/live" 2>/dev/null || true + + umount -R "$mnt/dev" "$mnt/proc" "$mnt/sys" 2>/dev/null || true + umount -R "$mnt" + rmdir "$mnt" + + echo + echo "Installed. Remove the USB stick and reboot; the first boot on disk" + echo "will generate this machine's own password and certificates." +} + +cmd_logs() { + local what="${1:-satd}" + if [[ "$what" == "satd" ]]; then + journalctl -u satd -f -n 100 + else + run_compose logs -f --tail 100 "$what" + fi +} + +case "${1:-}" in + status) shift; cmd_status "$@" ;; + tls) shift; cmd_tls "$@" ;; + set-network) shift; cmd_set_network "$@" ;; + fast-start) shift; cmd_fast_start "$@" ;; + enable) shift; cmd_enable "$@" ;; + disable) shift; cmd_disable "$@" ;; + ssh) shift; cmd_ssh "$@" ;; + logs) shift; cmd_logs "$@" ;; + install-to-disk) shift; cmd_install_to_disk "$@" ;; + compose) shift; run_compose "$@" ;; + -h|--help|help|"") + sed -n '2,20p' "$0" | sed 's/^# \{0,1\}//' + ;; + *) die "unknown command: $1 (try: satd-appliance help)" ;; +esac diff --git a/contrib/appliance/build-in-docker.sh b/contrib/appliance/build-in-docker.sh new file mode 100755 index 000000000..a41a6b0b0 --- /dev/null +++ b/contrib/appliance/build-in-docker.sh @@ -0,0 +1,103 @@ +#!/bin/bash +# build-in-docker.sh — run build.sh in a container that has the build tools. +# +# contrib/appliance/build-in-docker.sh --flavor core --out out/ +# +# The host needs docker and nothing else: no root, no mmdebstrap, no +# qemu-img, and no KVM. The container is privileged because building a disk +# image means creating loop devices and chrooting into the result, which +# needs real block-device access. +# +# Every argument is passed through to build.sh. +set -euo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO="$(cd "$HERE/../.." && pwd)" +BUILDER_IMAGE="${SATD_APPLIANCE_BUILDER:-debian:trixie}" + +# --out is resolved inside the container, so the default has to be a path +# that exists in the mounted repository. +MOUNTS=(-v "$REPO:/repo") +EXTRA_ARGS=() + +# --out is a host path the caller expects to find the artifacts in, but the +# container only sees what is mounted. Without this the build succeeds, the +# image is written inside the container, and it disappears when the +# container exits — a silent success that produces nothing. +out_dir="" +prev="" +for arg in "$@"; do + [[ "$prev" == "--out" ]] && out_dir="$arg" + prev="$arg" +done +if [[ -n "$out_dir" ]]; then + mkdir -p "$out_dir" + out_dir="$(readlink -f "$out_dir")" + # Mounted at its own absolute path, unconditionally — including when it + # lies inside the repository. A path under $REPO is visible in the + # container as /repo/..., NOT at the host's absolute path, so skipping + # the mount for those would have the build create the directory inside + # the container, write several GB into it, and lose all of it on exit + # while reporting success. Nested bind mounts are fine. + echo "$(basename "$0"): mounting output directory $out_dir" + MOUNTS+=(-v "$out_dir:$out_dir") +fi + +# `target/` is commonly a symlink to a build cache on another filesystem. +# Bind-mounting the repository alone gives the container a dangling link — +# the symlink's literal destination does not exist inside — and the build +# fails on "no target/release/satd" while the binaries sit right there on +# the host. Mount the resolved directory at a path of our own and point +# build.sh at it, rather than trying to bind over the symlink itself. +if [[ -L "$REPO/target" ]] && [[ ! " $* " == *" --satd-bin "* ]]; then + real_target="$(readlink -f "$REPO/target")" + if [[ -d "$real_target/release" ]]; then + echo "build-in-docker.sh: target/ is a symlink; mounting $real_target as /satd-target" + MOUNTS+=(-v "$real_target:/satd-target:ro") + EXTRA_ARGS+=(--satd-bin /satd-target/release) + fi +fi + +echo "build-in-docker.sh: using $BUILDER_IMAGE" +# `exec` is deliberately not used: the point of the check after this is to +# still be here when the container exits. +# +# The arguments are passed in explicitly (`run_build "$@"` below). Inside a +# function `"$@"` is the FUNCTION's arguments, so wrapping the invocation +# without forwarding them silently drops every flag the caller gave — +# `--flavor desktop` included, which then builds a core image into the +# directory you asked the desktop one to go to. +run_build() { +docker run --rm --privileged \ + "${MOUNTS[@]}" \ + -w /repo \ + -e DEBIAN_FRONTEND=noninteractive \ + -e DEBIAN_MIRROR="${DEBIAN_MIRROR:-}" \ + "$BUILDER_IMAGE" \ + bash -c ' +set -euo pipefail +apt-get update -qq +apt-get install -y -qq --no-install-recommends \ + mmdebstrap parted dosfstools e2fsprogs qemu-utils \ + ca-certificates fdisk uidmap > /dev/null +exec /repo/contrib/appliance/build.sh "$@" +' -- "$@" ${EXTRA_ARGS[@]+"${EXTRA_ARGS[@]}"} +} + +run_build "$@" +status=$? + +if [[ $status -eq 0 && -n "$out_dir" ]]; then + # The failure this catches is a silent one: the build reports success + # having written its artifacts to a path that existed only inside the + # container. + shopt -s nullglob + produced=( "$out_dir"/* ) + shopt -u nullglob + if [[ ${#produced[@]} -eq 0 ]]; then + echo "$(basename "$0"): the build reported success but $out_dir is empty." >&2 + echo "$(basename "$0"): the output directory was not visible inside the container." >&2 + exit 1 + fi +fi +exit $status diff --git a/contrib/appliance/build-iso-in-docker.sh b/contrib/appliance/build-iso-in-docker.sh new file mode 100755 index 000000000..ab96cf729 --- /dev/null +++ b/contrib/appliance/build-iso-in-docker.sh @@ -0,0 +1,83 @@ +#!/bin/bash +# build-iso-in-docker.sh — run build-iso.sh in a container with the tools. +# The host needs docker and nothing else. See build-in-docker.sh. +set -euo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO="$(cd "$HERE/../.." && pwd)" +BUILDER_IMAGE="${SATD_APPLIANCE_BUILDER:-debian:trixie}" + +MOUNTS=(-v "$REPO:/repo") +EXTRA_ARGS=() + +# --out is a host path the caller expects to find the artifacts in, but the +# container only sees what is mounted. Without this the build succeeds, the +# image is written inside the container, and it disappears when the +# container exits — a silent success that produces nothing. +out_dir="" +prev="" +for arg in "$@"; do + [[ "$prev" == "--out" ]] && out_dir="$arg" + prev="$arg" +done +if [[ -n "$out_dir" ]]; then + mkdir -p "$out_dir" + out_dir="$(readlink -f "$out_dir")" + # Mounted at its own absolute path, unconditionally — including when it + # lies inside the repository. A path under $REPO is visible in the + # container as /repo/..., NOT at the host's absolute path, so skipping + # the mount for those would have the build create the directory inside + # the container, write several GB into it, and lose all of it on exit + # while reporting success. Nested bind mounts are fine. + echo "$(basename "$0"): mounting output directory $out_dir" + MOUNTS+=(-v "$out_dir:$out_dir") +fi +if [[ -L "$REPO/target" ]] && [[ ! " $* " == *" --satd-bin "* ]]; then + real_target="$(readlink -f "$REPO/target")" + if [[ -d "$real_target/release" ]]; then + MOUNTS+=(-v "$real_target:/satd-target:ro") + EXTRA_ARGS+=(--satd-bin /satd-target/release) + fi +fi + +# `exec` is deliberately not used: the point of the check after this is to +# still be here when the container exits. +# +# The arguments are passed in explicitly (`run_build "$@"` below). Inside a +# function `"$@"` is the FUNCTION's arguments, so wrapping the invocation +# without forwarding them silently drops every flag the caller gave — +# `--flavor desktop` included, which then builds a core image into the +# directory you asked the desktop one to go to. +run_build() { +docker run --rm --privileged \ + "${MOUNTS[@]}" -w /repo \ + -e DEBIAN_FRONTEND=noninteractive \ + "$BUILDER_IMAGE" \ + bash -c ' +set -euo pipefail +apt-get update -qq +apt-get install -y -qq --no-install-recommends \ + mmdebstrap parted dosfstools e2fsprogs qemu-utils \ + squashfs-tools xorriso grub-pc-bin grub-efi-amd64-bin grub-common mtools \ + ca-certificates fdisk > /dev/null +exec /repo/contrib/appliance/build-iso.sh "$@" +' -- "$@" ${EXTRA_ARGS[@]+"${EXTRA_ARGS[@]}"} +} + +run_build "$@" +status=$? + +if [[ $status -eq 0 && -n "$out_dir" ]]; then + # The failure this catches is a silent one: the build reports success + # having written its artifacts to a path that existed only inside the + # container. + shopt -s nullglob + produced=( "$out_dir"/* ) + shopt -u nullglob + if [[ ${#produced[@]} -eq 0 ]]; then + echo "$(basename "$0"): the build reported success but $out_dir is empty." >&2 + echo "$(basename "$0"): the output directory was not visible inside the container." >&2 + exit 1 + fi +fi +exit $status diff --git a/contrib/appliance/build-iso.sh b/contrib/appliance/build-iso.sh new file mode 100755 index 000000000..e83906b82 --- /dev/null +++ b/contrib/appliance/build-iso.sh @@ -0,0 +1,164 @@ +#!/bin/bash +# build-iso.sh — build a live ISO from the same provisioning tree the disk +# image uses. +# +# sudo contrib/appliance/build-iso.sh --flavor desktop --out out/ +# contrib/appliance/build-iso-in-docker.sh --flavor desktop --out out/ +# +# "Try it without installing anything, from a USB stick." The rootfs is +# built exactly as build.sh builds it — same provision/ scripts, same +# packages, same first-boot behaviour — and then squashed and made bootable +# rather than written to a partitioned disk. That sharing is the point: +# two build paths that provisioned differently would drift, and the ISO is +# the one nobody tests as often. +# +# Boots on BIOS and UEFI from the same file. `satd-appliance install-to-disk` +# copies the running live system onto a real disk. +# +# Note on persistence: a live session keeps everything in RAM, so first boot +# runs on every boot and the chain it syncs is lost at power-off. That is +# what live media are; install to disk for anything you want to keep. + +set -euo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO="$(cd "$HERE/../.." && pwd)" + +# shellcheck source=contrib/appliance/lib.sh +. "$HERE/lib.sh" + +FLAVOR=desktop +ARCH=amd64 +SUITE=trixie +NETWORK=signet +OUT="$HERE/out" +SATD_SOURCE=local +SATD_BIN="$REPO/target/release" +SATD_VERSION="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --flavor) FLAVOR="$2"; shift 2 ;; + --arch) ARCH="$2"; shift 2 ;; + --suite) SUITE="$2"; shift 2 ;; + --network) NETWORK="$2"; shift 2 ;; + --out) OUT="$2"; shift 2 ;; + --satd-source) SATD_SOURCE="$2"; shift 2 ;; + --satd-bin) SATD_BIN="$2"; shift 2 ;; + --satd-version) SATD_VERSION="$2"; shift 2 ;; + -h|--help) sed -n '2,22p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; + *) echo "build-iso.sh: unknown argument: $1" >&2; exit 2 ;; + esac +done + +[[ "$(id -u)" == 0 ]] || { echo "build-iso.sh: needs root" >&2; exit 1; } +for tool in mmdebstrap mksquashfs xorriso grub-mkrescue; do + command -v "$tool" > /dev/null || { echo "build-iso.sh: missing $tool" >&2; exit 1; } +done + +VERSION_TAG="${SATD_VERSION:-$(grep -m1 '^version' "$REPO/Cargo.toml" | cut -d'"' -f2)}" +ISO_NAME="satd-appliance-${VERSION_TAG}-${FLAVOR}-${ARCH}-live" + +WORK="$(mktemp -d /var/tmp/satd-iso.XXXXXX)" +ROOTFS="$WORK/rootfs" +ISOTREE="$WORK/iso" +# The status is captured on entry and re-raised at the end. Left to itself a +# trap whose last command fails reports THAT failure — and tearing down a +# chroot fails routinely (already-unmounted paths, a busy /dev) — so the +# builder would write a perfectly good ISO and then exit non-zero, which in +# CI is indistinguishable from a broken build. +cleanup_iso() { + local status=$? + # `set +e` first: with errexit still on, any non-zero step in the + # teardown aborts this function before it reaches the `exit` below, and + # bash then reports 1 — losing both the success it should have reported + # and any real failure code it was carrying. + set +e + unmount_chroot "$ROOTFS" + rm -rf "$WORK" 2>/dev/null + exit "$status" +} +trap cleanup_iso EXIT + +say() { echo "==> $*"; } + +say "building the root filesystem (the same provisioning tree as build.sh)" +# `--rootfs-only` stops build.sh where it would otherwise partition a disk, +# and hands back the provisioned tree. One provisioning path, two outputs — +# the alternative is a second copy of the same steps that drifts from the +# first. +"$HERE/build.sh" \ + --flavor "$FLAVOR" --arch "$ARCH" --suite "$SUITE" \ + --network "$NETWORK" --satd-source "$SATD_SOURCE" \ + --satd-bin "$SATD_BIN" --satd-version "$VERSION_TAG" \ + --rootfs-only "$ROOTFS" + +say "adding the live-boot components" +mount --bind /dev "$ROOTFS/dev" +mount -t proc proc "$ROOTFS/proc" +mount -t sysfs sys "$ROOTFS/sys" + +# Provisioning left /etc/resolv.conf as a symlink to systemd-resolved's stub, +# which does not exist inside a chroot — so a plain `cp` onto it refuses to +# write through a dangling symlink. Replace it for the duration of the apt +# work below, then put the symlink back: shipping the build host's +# nameserver in the image is precisely what 00-base.sh made it a symlink to +# avoid. +rm -f "$ROOTFS/etc/resolv.conf" +cp /etc/resolv.conf "$ROOTFS/etc/resolv.conf" +chroot "$ROOTFS" bash -c ' +set -e +export DEBIAN_FRONTEND=noninteractive +apt-get update +# live-boot supplies the initramfs hook that finds and mounts the squashfs; +# live-config sets up the live session (autologin, hostname) at boot. +apt-get install -y --no-install-recommends live-boot live-config live-config-systemd +update-initramfs -u -k all +apt-get clean +rm -rf /var/lib/apt/lists/* +' +ln -sf /run/systemd/resolve/stub-resolv.conf "$ROOTFS/etc/resolv.conf" +unmount_chroot "$ROOTFS" + +KERNEL="$(basename "$(ls "$ROOTFS"/boot/vmlinuz-* | sort -V | tail -1)")" +INITRD="$(basename "$(ls "$ROOTFS"/boot/initrd.img-* | sort -V | tail -1)")" + +say "assembling the ISO tree" +mkdir -p "$ISOTREE/live" "$ISOTREE/boot/grub" +cp "$ROOTFS/boot/$KERNEL" "$ISOTREE/live/vmlinuz" +cp "$ROOTFS/boot/$INITRD" "$ISOTREE/live/initrd.img" + +say "squashing the filesystem (this is the slow part)" +# -noappend so a rerun replaces rather than accumulates; xz for size, +# because this file is most of the download. +mksquashfs "$ROOTFS" "$ISOTREE/live/filesystem.squashfs" \ + -noappend -comp xz -e boot -quiet + +cat > "$ISOTREE/boot/grub/grub.cfg" <&1 | sed 's/^/ /' + +( cd "$OUT" && sha256sum "$ISO_NAME.iso" > "$ISO_NAME.iso.sha256" ) +say "built:" +ls -lh "$OUT/$ISO_NAME.iso" | sed 's/^/ /' diff --git a/contrib/appliance/build.sh b/contrib/appliance/build.sh new file mode 100755 index 000000000..431f3fd2c --- /dev/null +++ b/contrib/appliance/build.sh @@ -0,0 +1,353 @@ +#!/bin/bash +# build.sh — build a bootable satd appliance disk image. +# +# sudo contrib/appliance/build.sh --flavor core --out out/ +# contrib/appliance/build-in-docker.sh --flavor core --out out/ # no root +# +# Output: a raw image plus qcow2, and for the desktop flavour a VMDK and OVA +# that VirtualBox and VMware import directly. +# +# ## Why this and not Packer +# +# Packer's QEMU builder drives Debian's installer through a preseed inside a +# running VM. That needs KVM to finish in a sensible time, needs a ~700 MB +# installer ISO, and fails in ways that can only be diagnosed by watching a +# VNC console. This builds the filesystem directly with mmdebstrap and +# installs a bootloader onto a loop device: no virtual machine, no KVM, no +# ISO, a few minutes rather than an hour, and every failure is a shell +# command that exited non-zero with its output on stdout. It runs unchanged +# on a GitHub-hosted runner and inside a container. +# +# What it needs: root (for loop devices and chroot) plus mmdebstrap, parted, +# and qemu-img. build-in-docker.sh supplies all of that in a container so +# the host needs none of it. + +set -euo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO="$(cd "$HERE/../.." && pwd)" + +# shellcheck source=contrib/appliance/lib.sh +. "$HERE/lib.sh" + +FLAVOR=core +ARCH=amd64 +SUITE=trixie +MIRROR="${DEBIAN_MIRROR:-http://deb.debian.org/debian}" +NETWORK=signet +OUT="$HERE/out" +SIZE="" +SATD_SOURCE=local +SATD_BIN="$REPO/target/release" +SATD_VERSION="" +HOSTNAME_DEFAULT=satd +KEEP_ROOTFS=0 +# When set, build.sh stops after provisioning and leaves the finished root +# filesystem at this path instead of writing a disk image. build-iso.sh uses +# it so the ISO is squashed from a tree provisioned by exactly this script, +# rather than by a second copy of the same steps that would drift from it. +ROOTFS_ONLY="" + +usage() { sed -n '2,30p' "$0" | sed 's/^# \{0,1\}//'; } + +while [[ $# -gt 0 ]]; do + case "$1" in + --flavor) FLAVOR="$2"; shift 2 ;; + --arch) ARCH="$2"; shift 2 ;; + --suite) SUITE="$2"; shift 2 ;; + --network) NETWORK="$2"; shift 2 ;; + --out) OUT="$2"; shift 2 ;; + --size) SIZE="$2"; shift 2 ;; + --satd-source) SATD_SOURCE="$2"; shift 2 ;; + --satd-bin) SATD_BIN="$2"; shift 2 ;; + --satd-version) SATD_VERSION="$2"; shift 2 ;; + --hostname) HOSTNAME_DEFAULT="$2"; shift 2 ;; + --keep-rootfs) KEEP_ROOTFS=1; shift ;; + --rootfs-only) ROOTFS_ONLY="$2"; shift 2 ;; + -h|--help) usage; exit 0 ;; + *) echo "build.sh: unknown argument: $1" >&2; usage >&2; exit 2 ;; + esac +done + +case "$FLAVOR" in + core|desktop) ;; + *) echo "build.sh: --flavor must be core or desktop" >&2; exit 2 ;; +esac + +# The core flavour is a headless node; the desktop one adds XFCE and the +# bundled wallets, which are several GB of packages. +if [[ -z "$SIZE" ]]; then + [[ "$FLAVOR" == "core" ]] && SIZE=6G || SIZE=16G +fi + +[[ "$(id -u)" == 0 ]] || { echo "build.sh: needs root (or use build-in-docker.sh)" >&2; exit 1; } +REQUIRED_TOOLS=(mmdebstrap chroot) +# The disk-image tools are only needed when a disk image is actually built. +[[ -n "$ROOTFS_ONLY" ]] || REQUIRED_TOOLS+=(parted qemu-img losetup mkfs.ext4 mkfs.vfat) +for tool in "${REQUIRED_TOOLS[@]}"; do + command -v "$tool" > /dev/null || { echo "build.sh: missing $tool" >&2; exit 1; } +done + +VERSION_TAG="${SATD_VERSION:-$(grep -m1 '^version' "$REPO/Cargo.toml" | cut -d'"' -f2)}" +IMAGE_NAME="satd-appliance-${VERSION_TAG}-${FLAVOR}-${ARCH}" + +WORK="$(mktemp -d /var/tmp/satd-appliance.XXXXXX)" +if [[ -n "$ROOTFS_ONLY" ]]; then + ROOTFS="$ROOTFS_ONLY" + mkdir -p "$(dirname "$ROOTFS")" + rm -rf "$ROOTFS" +else + ROOTFS="$WORK/rootfs" +fi +RAW="$WORK/$IMAGE_NAME.raw" +LOOP="" + +cleanup() { + set +e + if [[ -n "$LOOP" ]]; then + umount -R "$WORK/mnt" 2>/dev/null + losetup -d "$LOOP" 2>/dev/null + fi + umount -R "$ROOTFS/dev" "$ROOTFS/proc" "$ROOTFS/sys" 2>/dev/null + [[ "$KEEP_ROOTFS" == 1 ]] || rm -rf "$WORK" + # $ROOTFS is outside $WORK in --rootfs-only mode; the caller owns it. + [[ "$KEEP_ROOTFS" == 0 ]] || echo "build.sh: --keep-rootfs; work tree at $WORK" +} +trap cleanup EXIT + +say() { echo "==> $*"; } + +# --------------------------------------------------------------------------- +# 1. Base filesystem +# --------------------------------------------------------------------------- +say "debootstrapping $SUITE/$ARCH" +# --variant=important is the smallest set that still has a working apt and +# systemd; `minbase` omits enough that the provision scripts would spend +# their first minutes reinstalling it. +mmdebstrap \ + --arch="$ARCH" \ + --variant=important \ + --components="main,contrib,non-free-firmware" \ + --include="systemd-sysv,dbus,locales" \ + "$SUITE" "$ROOTFS" "$MIRROR" + +# --------------------------------------------------------------------------- +# 2. Stage the provisioning tree and everything it installs +# --------------------------------------------------------------------------- +say "staging the provisioning tree" +mkdir -p "$ROOTFS/provision/files" +cp -a "$HERE/provision/." "$ROOTFS/provision/" + +# Files the provision scripts install, gathered here so each script can +# assume a flat /provision/files rather than knowing the repository layout. +cp "$REPO/contrib/stack/tls/mkca.sh" "$ROOTFS/provision/files/mkca.sh" +cp "$REPO/contrib/stack/satd/satd-init" "$ROOTFS/provision/files/satd-init" +cp "$REPO/contrib/stack/satd/satd.conf.tmpl" "$ROOTFS/provision/files/satd.conf.tmpl" +cp "$REPO/contrib/systemd/satd.service" "$ROOTFS/provision/files/satd.service" +cp "$HERE/bin/satd-appliance" "$ROOTFS/provision/files/satd-appliance" +cp "$HERE/firstboot/satd-appliance-firstboot" "$ROOTFS/provision/files/firstboot" +cp "$HERE/files/configure-network" "$ROOTFS/provision/files/configure-network" +cp "$HERE/firstboot/satd-appliance-firstboot.service" "$ROOTFS/provision/files/firstboot.service" + +# The compose stack, as the appliance runs it: every overlay, plus the +# appliance's replacement for compose.yml. +mkdir -p "$ROOTFS/provision/files/stack" +cp "$REPO"/contrib/stack/compose.*.yml "$ROOTFS/provision/files/stack/" +rm -f "$ROOTFS/provision/files/stack/compose.yml" +cp -a "$REPO/contrib/stack/caddy" "$ROOTFS/provision/files/stack/" +cp "$HERE/files/compose.appliance.yml" "$ROOTFS/provision/files/stack/" + +if [[ "$FLAVOR" == "desktop" ]]; then + cp -a "$HERE/files/desktop/." "$ROOTFS/provision/files/" 2>/dev/null || true +fi + +if [[ "$SATD_SOURCE" == "local" ]]; then + say "staging locally built binaries from $SATD_BIN" + mkdir -p "$ROOTFS/provision/satd-bin" + for bin in satd sat-cli sat-tui; do + [[ -x "$SATD_BIN/$bin" ]] || { echo "build.sh: no $SATD_BIN/$bin — build them first" >&2; exit 1; } + install -m 0755 "$SATD_BIN/$bin" "$ROOTFS/provision/satd-bin/$bin" + done +fi + +# --------------------------------------------------------------------------- +# 3. Provision, in the chroot +# --------------------------------------------------------------------------- +say "provisioning ($FLAVOR)" +mount --bind /dev "$ROOTFS/dev" +mount -t proc proc "$ROOTFS/proc" +mount -t sysfs sys "$ROOTFS/sys" +# apt needs working DNS inside the chroot. Replaced by a symlink to +# systemd-resolved's stub in 00-base.sh, so the build host's resolver does +# not survive into the image. +cp /etc/resolv.conf "$ROOTFS/etc/resolv.conf" + +# `policy-rc.d` returning 101 stops package postinsts from starting daemons +# inside the chroot, where there is no init to start them under. +cat > "$ROOTFS/usr/sbin/policy-rc.d" <<'POLICY' +#!/bin/sh +exit 101 +POLICY +chmod +x "$ROOTFS/usr/sbin/policy-rc.d" + +SCRIPTS=(00-base.sh 10-satd.sh 20-tls.sh) +[[ "$FLAVOR" == "desktop" ]] && SCRIPTS+=(30-desktop.sh 40-wallets.sh) +SCRIPTS+=(50-containers.sh 60-firstboot.sh 90-cleanup.sh) + +for script in "${SCRIPTS[@]}"; do + say " $script" + chroot "$ROOTFS" env \ + SATD_FLAVOR="$FLAVOR" \ + SATD_NETWORK="$NETWORK" \ + DEB_ARCH="$ARCH" \ + APPLIANCE_HOSTNAME="$HOSTNAME_DEFAULT" \ + SATD_SOURCE="$SATD_SOURCE" \ + SATD_VERSION="$VERSION_TAG" \ + /bin/bash "/provision/$script" +done + +rm -f "$ROOTFS/usr/sbin/policy-rc.d" + +unmount_chroot "$ROOTFS" + +if [[ -n "$ROOTFS_ONLY" ]]; then + say "provisioned root filesystem left at $ROOTFS" + exit 0 +fi + +# --------------------------------------------------------------------------- +# 4. Disk image +# --------------------------------------------------------------------------- +say "creating a $SIZE disk" +truncate -s "$SIZE" "$RAW" + +# GPT with a BIOS boot partition *and* an ESP. VirtualBox defaults to BIOS +# and most other hypervisors default to UEFI; carrying both means the same +# file boots either way, which is the whole point of shipping one image. +parted -s "$RAW" mklabel gpt +parted -s "$RAW" mkpart bios_grub 1MiB 3MiB +parted -s "$RAW" set 1 bios_grub on +parted -s "$RAW" mkpart ESP fat32 3MiB 515MiB +parted -s "$RAW" set 2 esp on +parted -s "$RAW" mkpart root ext4 515MiB 100% + +LOOP="$(losetup --find --show --partscan "$RAW")" + +# The kernel scans the partition table and creates the block devices, but +# the /dev nodes for them are made by udev — which is not running inside a +# build container. Without this the loop device exists, its partitions exist +# in sysfs, and `mkfs` fails on a path that is simply absent. +ensure_partition_nodes() { + local loop="$1" + local base; base="$(basename "$loop")" + partprobe "$loop" 2>/dev/null || true + local sysdir + for sysdir in /sys/block/"$base"/"$base"p*; do + [[ -d "$sysdir" ]] || continue + local node="/dev/$(basename "$sysdir")" + [[ -b "$node" ]] && continue + local devnum; devnum="$(cat "$sysdir/dev")" + mknod "$node" b "${devnum%%:*}" "${devnum##*:}" + echo " created $node (${devnum})" + done +} + +for _ in $(seq 1 20); do + ensure_partition_nodes "$LOOP" + [[ -b "${LOOP}p3" ]] && break + sleep 0.5 +done +[[ -b "${LOOP}p3" ]] || { echo "build.sh: partition devices never appeared for $LOOP" >&2; exit 1; } + +mkfs.vfat -F32 -n ESP "${LOOP}p2" > /dev/null +mkfs.ext4 -q -L satd-root "${LOOP}p3" + +mkdir -p "$WORK/mnt" +mount "${LOOP}p3" "$WORK/mnt" +mkdir -p "$WORK/mnt/boot/efi" +mount "${LOOP}p2" "$WORK/mnt/boot/efi" + +say "copying the filesystem" +# `-x` keeps the copy on one filesystem, so the bind mounts undone above +# cannot be walked into even if one lingered. +cp -ax "$ROOTFS/." "$WORK/mnt/" + +ROOT_UUID="$(blkid -s UUID -o value "${LOOP}p3")" +ESP_UUID="$(blkid -s UUID -o value "${LOOP}p2")" +cat > "$WORK/mnt/etc/fstab" < "$WORK/mnt/etc/default/grub" <<'GRUB' +GRUB_DEFAULT=0 +# Short but not zero: an operator who needs to reach recovery on a headless +# VM has no other way in. +GRUB_TIMEOUT=3 +GRUB_DISTRIBUTOR="satd appliance" +# console= twice: the kernel logs to both the graphical console and the +# serial port, which is the only console a headless boot test has. +GRUB_CMDLINE_LINUX_DEFAULT="console=tty0 console=ttyS0,115200n8" +GRUB_CMDLINE_LINUX="" +GRUB_TERMINAL="console serial" +GRUB_SERIAL_COMMAND="serial --speed=115200" +GRUB +chroot "$WORK/mnt" grub-install --target=i386-pc --boot-directory=/boot "$LOOP" +chroot "$WORK/mnt" grub-install --target="$( [[ $ARCH == amd64 ]] && echo x86_64 || echo arm64 )-efi" \ + --efi-directory=/boot/efi --boot-directory=/boot --removable --no-nvram +chroot "$WORK/mnt" update-grub 2>&1 | sed 's/^/ /' + +# grub-mkconfig derives root= from `grub-probe --target=fs_uuid /`. Inside a +# build container that probe can come back empty, and 10_linux then falls +# back to GRUB_DEVICE — which here is the BUILD HOST's loop device. The +# image boots, the kernel starts, and the initramfs then waits forever for +# a /dev/loopNpM that exists on no machine but the builder. +# +# So the root reference is rewritten to the UUID and then checked. The check +# is the point: this failure is invisible until someone boots the image. +sed -i "s|root=/dev/[^ ]*|root=UUID=$ROOT_UUID|g" "$WORK/mnt/boot/grub/grub.cfg" +if grep -q 'root=/dev/' "$WORK/mnt/boot/grub/grub.cfg"; then + echo "build.sh: grub.cfg still names a device path for root:" >&2 + grep -n 'root=/dev/' "$WORK/mnt/boot/grub/grub.cfg" >&2 + exit 1 +fi +if ! grep -q "root=UUID=$ROOT_UUID" "$WORK/mnt/boot/grub/grub.cfg"; then + echo "build.sh: grub.cfg does not reference the root filesystem UUID" >&2 + grep -n 'linux\s' "$WORK/mnt/boot/grub/grub.cfg" >&2 + exit 1 +fi +say " root=UUID=$ROOT_UUID" + +umount -R "$WORK/mnt/dev" "$WORK/mnt/proc" "$WORK/mnt/sys" +umount -R "$WORK/mnt" +losetup -d "$LOOP"; LOOP="" + +# --------------------------------------------------------------------------- +# 5. Output formats +# --------------------------------------------------------------------------- +mkdir -p "$OUT" +say "converting" +qemu-img convert -f raw -O qcow2 -c "$RAW" "$OUT/$IMAGE_NAME.qcow2" +mv "$RAW" "$OUT/$IMAGE_NAME.raw" + +if [[ "$FLAVOR" == "desktop" ]]; then + # VirtualBox and VMware want a stream-optimised VMDK inside an OVA. + qemu-img convert -f raw -O vmdk -o subformat=streamOptimized \ + "$OUT/$IMAGE_NAME.raw" "$WORK/$IMAGE_NAME.vmdk" + "$HERE/mkova.sh" \ + --vmdk "$WORK/$IMAGE_NAME.vmdk" \ + --name "$IMAGE_NAME" \ + --out "$OUT/$IMAGE_NAME.ova" +fi + +( cd "$OUT" && sha256sum "$IMAGE_NAME".* > "$IMAGE_NAME.SHA256SUMS" ) + +say "built:" +ls -lh "$OUT/$IMAGE_NAME".* | sed 's/^/ /' diff --git a/contrib/appliance/files/compose.appliance.yml b/contrib/appliance/files/compose.appliance.yml new file mode 100644 index 000000000..5b4151191 --- /dev/null +++ b/contrib/appliance/files/compose.appliance.yml @@ -0,0 +1,43 @@ +# compose.appliance.yml — what the appliance substitutes for compose.yml. +# +# The appliance runs satd natively under systemd, not as a container: it is a +# node appliance, and `systemctl status satd`, `journalctl -u satd` and a +# local `sat-tui` are what an operator reaches for. The overlay files are +# used unmodified; this supplies the two things compose.yml would otherwise +# have given them. +# +# 1. `satd-data`, bound to the real /var/lib/satd rather than a docker +# volume, so the containers read the same cookie and certificate the +# host node wrote — and so an operator sees all of it at a normal path. +# 2. the `satd` network, with an explicitly pinned gateway. That gateway is +# the host, and it is what SATD_HOST is set to: there is no satd +# container here to carry the name, and pinning the address means the +# overlays need no per-service `extra_hosts` — which could not be +# written here anyway without pre-declaring every overlay's services. +# +# `satd-appliance enable ` composes this with the overlay files and +# exports SATD_HOST: +# +# SATD_HOST=10.77.0.1 docker compose \ +# -f compose.appliance.yml -f compose.lightning.yml up -d +# +# satd itself binds 0.0.0.0 so the gateway reaches it. bitcoin.conf's +# rpcallowip limits that to this subnet, and nftables keeps every plain +# listener off the LAN entirely. + +name: satd-appliance + +volumes: + satd-data: + driver: local + driver_opts: + type: none + o: bind + device: /var/lib/satd + +networks: + satd: + ipam: + config: + - subnet: ${SATD_STACK_SUBNET:-10.77.0.0/24} + gateway: ${SATD_HOST:-10.77.0.1} diff --git a/contrib/appliance/files/configure-network b/contrib/appliance/files/configure-network new file mode 100755 index 000000000..0c133e178 --- /dev/null +++ b/contrib/appliance/files/configure-network @@ -0,0 +1,67 @@ +#!/bin/bash +# configure-network — point the appliance's satd at one network. +# +# Called by first boot and by `satd-appliance set-network`. It exists as one +# script because the two halves have to agree: the rendered bitcoin.conf and +# the flag systemd passes are both per-network, and a config that says signet +# under a unit that says nothing starts a mainnet node. +# +# configure-network +# +# Idempotent. Does not start or stop anything; the caller owns that. + +set -euo pipefail + +NETWORK="${1:-}" +DATADIR="${SATD_DATADIR:-/var/lib/satd}" +LIB="${SATD_APPLIANCE_LIB:-/usr/local/lib/satd-appliance}" +STATE="${SATD_APPLIANCE_STATE:-/var/lib/satd-appliance}" + +case "$NETWORK" in + mainnet) CHAIN_FLAG="--chain=main" ;; + signet) CHAIN_FLAG="--chain=signet" ;; + testnet) CHAIN_FLAG="--chain=test" ;; + regtest) CHAIN_FLAG="--chain=regtest" ;; + # --chain has no testnet4 selector; the dedicated flag is the only way. + testnet4) CHAIN_FLAG="--testnet4=1" ;; + *) + echo "configure-network: unknown network '$NETWORK'" >&2 + echo "configure-network: expected mainnet, signet, testnet4, testnet or regtest" >&2 + exit 2 + ;; +esac + +mkdir -p "$STATE" + +# The node's configuration, rendered by the compose stack's own first-run +# script so the appliance and the stack cannot drift apart. +SATD_DATADIR="$DATADIR" \ +SATD_CONF_TEMPLATE="$LIB/satd.conf.tmpl" \ +SATD_MKCA="$LIB/mkca.sh" \ +NETWORK="$NETWORK" \ +SATD_MCP=1 \ +SATD_STACK_SUBNET="${SATD_STACK_SUBNET:-10.77.0.0/24}" \ +SATD_TLS_HOSTNAME="${SATD_TLS_HOSTNAME:-$(hostname)}" \ +SATD_CA_EXPORT_HINT="satd-appliance tls export-ca" \ + "$LIB/satd-init" + +# The chain is NEVER expressed in the config file. satd accepts a +# `signet=1` line there and then ignores it, which silently starts a +# mainnet node — so the selector has to reach satd as an argument, and +# /etc/default/satd is where the shipped unit picks up extra arguments. +install -d -m 0755 /etc/default +cat > /etc/default/satd </dev/null || true + +echo "$NETWORK" > "$STATE/network" +echo "configure-network: $NETWORK ($CHAIN_FLAG)" diff --git a/contrib/appliance/files/desktop/welcome.html b/contrib/appliance/files/desktop/welcome.html new file mode 100644 index 000000000..4b913252a --- /dev/null +++ b/contrib/appliance/files/desktop/welcome.html @@ -0,0 +1,87 @@ + + +satd appliance + + +

satd appliance

+

A Bitcoin node, plus the software people actually point at one.

+ +

This machine is running satd, a Bitcoin Core-compatible node +written in Rust, with its Electrum, Esplora, JSON-RPC and MCP surfaces on and +each one served over TLS. It starts on signet, where a fully +indexed node syncs in under an hour and faucets hand out coins, so everything +here can be exercised end to end without real money.

+ +

First things

+
satd-appliance status          what the node is doing right now
+sat-tui -rpcport=8332          live dashboard: chain, mempool, peers
+satd-appliance tls export-ca   the certificate to trust on your other machines
+ +

Connecting from another machine

+

Reach this appliance at satd.local. That name is on the +certificate and keeps working when the address changes, which an IP address +does not.

+

Export the CA once and every surface below becomes trusted at once:

+
satd-appliance tls export-ca > satd-ca.crt
+ + + + + + + +
SurfaceAddress
Electrum (Sparrow, Electrum, BlueWallet)ssl://satd.local:50002
Esplora RESThttps://satd.local:3001/api
JSON-RPChttps://satd.local:8336
MCP (for AI agents)https://satd.local:8339 — see ~/.satd/mcp.json
+ +

Wallets on this desktop

+

Sparrow, Electrum and Liana are installed and already pointed at this node's +Electrum server. They pin the server certificate the first time they connect, +so you will be asked to accept it once.

+ +

There is no Cashu wallet on the desktop: its CLI does not currently build on +this Debian release. Run the mint with satd-appliance enable cashu +and point a phone wallet at it.

+ +

Lightning, ecash and more

+
sudo satd-appliance enable lightning   LND (Neutrino) + Ride The Lightning
+sudo satd-appliance enable cashu       a Cashu mint backed by that LND
+sudo satd-appliance enable btcpay      BTCPay Server
+sudo satd-appliance enable cln         Core Lightning instead of LND
+

Each pulls its containers on first use. satd-appliance disable +stops one again and keeps its data.

+ +

Going to mainnet

+
sudo satd-appliance set-network mainnet
+

This refuses on a disk smaller than 1.5 TB, and means it. There is no prune +option: Electrum and Esplora both need txindex, and pruning +excludes it, so a mainnet node here stores the whole chain plus every index.

+ +
+

This image bundles third-party software on a best-effort basis. + The wallets, Lightning, ecash and payment components here are included so you + can try satd end to end. They are not a production deployment: their security + advisories are not tracked in real time, and a critical fix in one of them may + not appear in an appliance image until the next scheduled build.

+

satd itself in this image is the same supported release as the project's + tarballs and container image. For production, run satd from a release artifact + and operate the other components yourself.

+
+ +

Where things are

+
/var/lib/satd                the node's data, config and certificates
+/var/lib/satd/tls/ca.crt     this appliance's CA
+/opt/satd/stack              the container overlays
+journalctl -u satd -f        the node's log
diff --git a/contrib/appliance/firstboot/satd-appliance-firstboot b/contrib/appliance/firstboot/satd-appliance-firstboot new file mode 100755 index 000000000..fa25cc9ef --- /dev/null +++ b/contrib/appliance/firstboot/satd-appliance-firstboot @@ -0,0 +1,127 @@ +#!/bin/bash +# satd-appliance-firstboot — runs once, on the first boot of a downloaded +# image, before satd starts. +# +# Its job is everything that must be unique per install and therefore cannot +# exist in a shipped image: the disk's real size, the login password, the CA +# private key, the certificates, and the MCP bearer token. An image that +# shipped any of those would be an image where every download shared them. +# +# Ordering matters. satd is not enabled at build time, so nothing races this: +# the certificates and the configuration exist before the node is started +# for the first time. +set -euo pipefail + +MARKER=/var/lib/satd-appliance/firstboot-done +STATE=/var/lib/satd-appliance +DATADIR=/var/lib/satd +LIB=/usr/local/lib/satd-appliance +USER_NAME="${APPLIANCE_USER:-satd-user}" + +log() { echo "satd-appliance-firstboot: $*"; } + +if [[ -f "$MARKER" ]]; then + log "already done; nothing to do" + exit 0 +fi + +mkdir -p "$STATE" + +# --- 1. Grow the root filesystem to the disk --------------------------------- +# The image ships at its build size, which is smaller than any disk it will +# be restored onto. Doing this before the node starts means the first sync +# does not stop on a full filesystem. +log "growing the root filesystem" +ROOT_SRC="$(findmnt -no SOURCE /)" +if [[ "$ROOT_SRC" =~ ^(/dev/[a-z]+|/dev/nvme[0-9]+n[0-9]+|/dev/vd[a-z]+)p?([0-9]+)$ ]]; then + DISK="${BASH_REMATCH[1]}" + PARTNUM="${BASH_REMATCH[2]}" + # growpart exits 1 when there is nothing to grow, which is a normal + # outcome on a re-run or an exactly-sized disk. + growpart "$DISK" "$PARTNUM" || log "partition already at full size" + resize2fs "$ROOT_SRC" || log "filesystem already at full size" +else + log "could not parse root device '$ROOT_SRC'; skipping resize" +fi + +# --- 2. A password unique to this install ------------------------------------ +# Not a default password, and not an empty one: a generated password shown +# on the console once, which the user must change at first login. +log "generating the console password" +# `head` reads a fixed block FIRST rather than truncating tr's output. +# The obvious spelling — `tr -dc ... < /dev/urandom | head -c 18` — makes +# head close the pipe, tr die of SIGPIPE, and `set -o pipefail` abort the +# whole first boot with "write error: Broken pipe". 512 bytes filtered down +# to this 34-character alphabet leaves ~68 characters, comfortably more +# than the 18 taken. +GEN_PW="$(head -c 512 /dev/urandom | LC_ALL=C tr -dc 'a-z2-9' | cut -c1-18 \ + | sed 's/\(.\{6\}\)/\1-/g; s/-$//')" +if [[ ${#GEN_PW} -lt 18 ]]; then + echo "satd-appliance-firstboot: could not generate a password" >&2 + exit 1 +fi +echo "$USER_NAME:$GEN_PW" | chpasswd +# `--expire` forces a change at first login; the generated one is a +# handover, not a credential to keep. +chage -d 0 "$USER_NAME" 2>/dev/null || true +printf '%s\n' "$GEN_PW" > "$STATE/initial-password" +chmod 0600 "$STATE/initial-password" + +cat > /etc/issue </dev/null || echo signet)" +log "configuring satd for $NETWORK" +"$LIB/configure-network" "$NETWORK" + +# --- 4. Start the node ------------------------------------------------------- +# `--no-block` is load-bearing. satd.service is ordered After this unit (it +# must not start before its configuration and certificates exist), so a +# blocking `systemctl start` from inside this unit deadlocks: the start job +# waits for this service to finish, and this service waits for the start +# job. `--no-block` queues the job instead; systemd runs it the moment this +# unit exits, which is exactly the intended order. +log "starting satd" +systemctl enable satd +systemctl start --no-block satd + +# --- 5. Desktop hand-off ----------------------------------------------------- +# The MCP snippet carries a live bearer token, so it is written per install +# into the user's home and never into the image. +if [[ -d "/home/$USER_NAME" && -s "$DATADIR/secrets/mcp-token" ]]; then + TOKEN="$(cat "$DATADIR/secrets/mcp-token")" + install -d -o "$USER_NAME" -g "$USER_NAME" -m 0700 "/home/$USER_NAME/.satd" + cat > "/home/$USER_NAME/.satd/mcp.json" < /dev/null 2>&1; then + fuser -km "$root/dev" > /dev/null 2>&1 || true + sleep 1 + fi + local mp i + for mp in "$root/dev" "$root/proc" "$root/sys"; do + mountpoint -q "$mp" 2>/dev/null || continue + for i in 1 2 3 4 5; do + umount -R "$mp" 2>/dev/null && break + sleep 1 + done + if mountpoint -q "$mp" 2>/dev/null; then + echo "==> $mp is still busy; detaching lazily" + umount -Rl "$mp" 2>/dev/null || true + fi + done +} diff --git a/contrib/appliance/mkova.sh b/contrib/appliance/mkova.sh new file mode 100755 index 000000000..d22f994e6 --- /dev/null +++ b/contrib/appliance/mkova.sh @@ -0,0 +1,124 @@ +#!/bin/bash +# mkova.sh — wrap a stream-optimised VMDK in an OVA that VirtualBox and +# VMware will import. +# +# An OVA is a tar (in a specific member order: the .ovf first, then the +# manifest, then the disk) of an OVF descriptor plus the disk. Nothing here +# needs VirtualBox installed, which matters because the CI runner that +# builds the image does not have it. +set -euo pipefail + +VMDK=""; NAME=""; OUT=""; MEMORY_MB=4096; CPUS=2 +while [[ $# -gt 0 ]]; do + case "$1" in + --vmdk) VMDK="$2"; shift 2 ;; + --name) NAME="$2"; shift 2 ;; + --out) OUT="$2"; shift 2 ;; + --memory) MEMORY_MB="$2"; shift 2 ;; + --cpus) CPUS="$2"; shift 2 ;; + *) echo "mkova.sh: unknown argument: $1" >&2; exit 2 ;; + esac +done +[[ -s "$VMDK" && -n "$NAME" && -n "$OUT" ]] || { echo "mkova.sh: --vmdk, --name and --out are required" >&2; exit 2; } + +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT + +DISK_BYTES="$(stat -c %s "$VMDK")" +# The virtual size the guest sees, which is what the OVF advertises; the +# file itself is smaller because it is stream-optimised. +CAPACITY="$(qemu-img info --output=json "$VMDK" | python3 -c 'import json,sys; print(json.load(sys.stdin)["virtual-size"])')" + +cp "$VMDK" "$WORK/$NAME-disk1.vmdk" + +cat > "$WORK/$NAME.ovf" < + + + + + + Virtual disk information + + + + The list of logical networks + + NAT. The appliance needs outbound access to reach the Bitcoin network. + + + + satd appliance + $NAME + + Debian GNU/Linux (64-bit) + Debian_64 + + + Virtual hardware requirements + + Virtual Hardware Family + 0 + virtualbox-2.2 + + + $CPUS virtual CPU + Number of virtual CPUs + $CPUS virtual CPU + 1 + 3 + $CPUS + + + MegaBytes + $MEMORY_MB MB of memory + $MEMORY_MB MB of memory + 2 + 4 + $MEMORY_MB + + + 0 + SATA Controller + SATA Controller + 3 + AHCI + 20 + + + 0 + disk1 + disk1 + /disk/vmdisk1 + 4 + 3 + 17 + + + true + Ethernet adapter on 'NAT' + NAT + Ethernet adapter on 'NAT' + 5 + 10 + + + + +OVF + +( cd "$WORK" && { + printf 'SHA256(%s)= %s\n' "$NAME.ovf" "$(sha256sum "$NAME.ovf" | cut -d' ' -f1)" + printf 'SHA256(%s)= %s\n' "$NAME-disk1.vmdk" "$(sha256sum "$NAME-disk1.vmdk" | cut -d' ' -f1)" + } > "$NAME.mf" ) + +# Member order is part of the format: importers read the descriptor as a +# stream and must meet the .ovf before the disk it references. +( cd "$WORK" && tar -cf "$OUT.tmp" "$NAME.ovf" "$NAME.mf" "$NAME-disk1.vmdk" ) +mv "$OUT.tmp" "$OUT" +echo "mkova.sh: wrote $OUT" diff --git a/contrib/appliance/provision/00-base.sh b/contrib/appliance/provision/00-base.sh new file mode 100755 index 000000000..51929b519 --- /dev/null +++ b/contrib/appliance/provision/00-base.sh @@ -0,0 +1,162 @@ +#!/bin/bash +# 00-base.sh — the base system every flavour shares. +# +# Runs inside the chroot of the image being built. Idempotent: the ISO and +# disk builders both run the whole provision tree, and a rebuild must not +# depend on which scripts ran before. +set -euo pipefail +. /provision/common.sh + +# mmdebstrap cleans the package lists as its last act, so the chroot starts +# with no index at all and every apt-get install would fail with "unable to +# locate package". +step "refreshing the package index" +apt-get update + +step "base packages" +apt_install \ + ca-certificates curl gnupg openssl \ + systemd-timesyncd systemd-resolved \ + nftables avahi-daemon libnss-mdns \ + sudo less vim-tiny bash-completion \ + jq python3-minimal \ + linux-image-"$DEB_ARCH" \ + initramfs-tools \ + dosfstools e2fsprogs parted cloud-guest-utils \ + qemu-guest-agent \ + minisign + +# GRUB is installed to the disk by build.sh, from outside the chroot, so the +# `-bin` packages are what is wanted here: the full grub-pc / grub-efi +# packages run grub-install from their postinst against a device that does +# not exist yet in a chroot. +step "bootloader components" +apt_install grub2-common grub-common grub-pc-bin "grub-efi-${GRUB_EFI_ARCH}-bin" + +step "hostname and hosts" +echo "$APPLIANCE_HOSTNAME" > /etc/hostname +cat > /etc/hosts < /etc/systemd/network/20-wired.network <<'NET' +[Match] +Name=en* eth* + +[Network] +DHCP=yes +# The appliance is addressed by its mDNS name, so that a DHCP lease change +# does not invalidate the TLS certificate's address SANs. +MulticastDNS=yes + +[DHCPv4] +UseDomains=yes +NET +systemctl enable systemd-networkd systemd-resolved systemd-timesyncd avahi-daemon > /dev/null + +# systemd-resolved owns /etc/resolv.conf. The symlink is created here rather +# than left to first boot because the build chroot has a real resolv.conf +# copied in, which would otherwise persist into the image as a stale file +# pointing at the build host's nameserver. +ln -sf /run/systemd/resolve/stub-resolv.conf /etc/resolv.conf + +step "users" +# The password is set at first boot and must be changed at first login; +# shipping a known one would make every downloaded image equally accessible. +if ! id -u "$APPLIANCE_USER" > /dev/null 2>&1; then + useradd --create-home --shell /bin/bash --groups sudo "$APPLIANCE_USER" +fi +# Locked until first boot generates one. `!` is "no password accepted", +# which is not the same as an empty password. +usermod -p '!' "$APPLIANCE_USER" +usermod -p '!' root + +step "firewall (default deny inbound)" +cat > /etc/nftables.conf <<'NFT' +#!/usr/sbin/nft -f +# Default-deny inbound. Only the surfaces the appliance advertises are open, +# and every one of them is either TLS-terminated or Bitcoin P2P. +# +# The plain RPC / Electrum / Esplora / metrics listeners are NOT here on +# purpose: they bind loopback and the container network, and are reachable +# from this machine only. +flush ruleset + +table inet filter { + chain input { + type filter hook input priority filter; policy drop; + + iif "lo" accept + ct state established,related accept + ct state invalid drop + + # ICMP, including path-MTU discovery. Dropping it silently + # breaks large transfers rather than blocking anything. + ip protocol icmp accept + ip6 nexthdr icmpv6 accept + + # mDNS: how clients find .local, which is the name on + # the TLS certificate. + udp dport 5353 accept + + # Bitcoin P2P. + tcp dport { 8333, 38333, 48333, 18333, 18444 } accept + + # The container overlays talk to a natively-run satd through the + # docker bridge gateway, which is this host — so their packets + # arrive on THIS chain, not on `forward`, and the default-drop + # above would silence them. Restricted to the stack's own subnet: + # these are the plain RPC, Electrum, Esplora, metrics and ZMQ + # listeners, and nothing outside the bridge may reach them. + # + # Keep the subnet in step with SATD_STACK_SUBNET in + # contrib/appliance/files/compose.appliance.yml. + ip saddr 10.77.0.0/24 tcp dport { 8332, 50001, 3000, 9332, 28332 } accept + + # satd's TLS surfaces: JSON-RPC, Electrum, Esplora, MCP. + tcp dport { 8336, 50002, 3001, 8339 } accept + + # Reverse proxy: web UIs and metrics, TLS with the same cert. + tcp dport { 443, 8443, 9443 } accept + + # Lightning P2P (LND / CLN), when an overlay is enabled. + tcp dport { 9735, 9736 } accept + + # SSH is closed. `satd-appliance ssh enable` opens it. + counter drop + } + + chain forward { + # Docker installs its own rules in the ip/ip6 filter tables for + # container traffic; this inet table's forward chain must not + # also drop, or published container ports stop working. + type filter hook forward priority filter; policy accept; + } + + chain output { + type filter hook output priority filter; policy accept; + } +} +NFT +systemctl enable nftables > /dev/null + +step "journald size cap" +mkdir -p /etc/systemd/journald.conf.d +cat > /etc/systemd/journald.conf.d/50-appliance.conf <<'JRN' +[Journal] +# A node that runs for months on a 64 GB disk must not fill it with logs. +SystemMaxUse=512M +JRN + +step "sshd off by default" +if [[ -f /lib/systemd/system/ssh.service ]]; then + systemctl disable ssh > /dev/null 2>&1 || true +fi + +step "done" diff --git a/contrib/appliance/provision/10-satd.sh b/contrib/appliance/provision/10-satd.sh new file mode 100755 index 000000000..173566d6f --- /dev/null +++ b/contrib/appliance/provision/10-satd.sh @@ -0,0 +1,91 @@ +#!/bin/bash +# 10-satd.sh — install satd, sat-cli and sat-tui, and its service unit. +# +# Two sources: +# SATD_SOURCE=release fetch the signed release tarball and verify it +# with the published minisign key (default) +# SATD_SOURCE=local install from /provision/satd-bin, which build.sh +# populates from a locally built tree +# +# The release path verifies; the local path is for CI runs that test the +# commit under review and for developers building their own image, and it +# says so on the console rather than pretending an unsigned binary was +# checked. +set -euo pipefail +. /provision/common.sh + +SATD_SOURCE="${SATD_SOURCE:-release}" +SATD_VERSION="${SATD_VERSION:-}" +# The primary release key, as published in SECURITY.md. Pinned here so the +# image build trusts the same key an operator verifying a tarball by hand +# would use. +SATD_MINISIGN_PUBKEY="${SATD_MINISIGN_PUBKEY:-RWQeP6MczCgPh6tU03GEMm4HsnGbXte3VT2Bc52TBSR7Q+X7WnL5vfQ3}" + +case "$DEB_ARCH" in + amd64) TARBALL_ARCH="x86_64-linux-gnu" ;; + arm64) TARBALL_ARCH="aarch64-linux-gnu" ;; +esac + +step "installing satd from source=$SATD_SOURCE" +install -d -m 0755 /usr/local/bin + +if [[ "$SATD_SOURCE" == "release" ]]; then + [[ -n "$SATD_VERSION" ]] || { echo "SATD_SOURCE=release requires SATD_VERSION" >&2; exit 1; } + base="https://github.com/epochbtc/satd/releases/download/v${SATD_VERSION}" + tarball="satd-${SATD_VERSION}-${TARBALL_ARCH}.tar.gz" + tmp="$(mktemp -d)" + fetch "$base/$tarball" "$tmp/$tarball" + fetch "$base/$tarball.minisig" "$tmp/$tarball.minisig" + + # Verify before unpacking, not after. An unpacked archive has already + # written whatever it wanted to the filesystem. + step "verifying $tarball against the published minisign key" + minisign -Vm "$tmp/$tarball" -P "$SATD_MINISIGN_PUBKEY" + + tar -xzf "$tmp/$tarball" -C "$tmp" + found=0 + for bin in satd sat-cli sat-tui; do + # `find ... | head -1` would abort here under pipefail whenever find + # is still walking when head closes the pipe: a size-dependent + # failure that passes on a small archive and not on a large one. + matches=() + mapfile -t matches < <(find "$tmp" -type f -name "$bin" -perm -u+x) + path="${matches[0]:-}" + [[ -n "$path" ]] || { echo "$bin missing from $tarball" >&2; exit 1; } + install -m 0755 "$path" "/usr/local/bin/$bin" + found=$((found + 1)) + done + [[ "$found" == 3 ]] + rm -rf "$tmp" +else + echo " NOTE: installing UNSIGNED binaries from a local build." + echo " NOTE: images built this way are for testing, not distribution." + for bin in satd sat-cli sat-tui; do + src="/provision/satd-bin/$bin" + [[ -x "$src" ]] || { echo "missing $src" >&2; exit 1; } + install -m 0755 "$src" "/usr/local/bin/$bin" + done + # Recorded in the image so a boot test — and anyone who later wonders + # where the image came from — can tell a test build from a release one. + echo "local" > /etc/satd-appliance-source +fi + +/usr/local/bin/satd --version +/usr/local/bin/sat-cli --version > /dev/null + +step "satd system user and datadir" +if ! getent group satd > /dev/null; then groupadd --system satd; fi +if ! id -u satd > /dev/null 2>&1; then + useradd --system --gid satd --home-dir /var/lib/satd --shell /usr/sbin/nologin satd +fi +install -d -o satd -g satd -m 0750 /var/lib/satd +# The console user reads the cookie through group membership rather than +# sudo; the shipped unit relaxes the cookie to 0640 on every start for +# exactly this. +usermod -aG satd "$APPLIANCE_USER" + +step "systemd unit" +install -Dm644 /provision/files/satd.service /etc/systemd/system/satd.service +# Not enabled here. First boot renders the configuration and issues the +# certificates before anything starts satd; an image that came up with a +# half-configured node would race that. diff --git a/contrib/appliance/provision/20-tls.sh b/contrib/appliance/provision/20-tls.sh new file mode 100755 index 000000000..fbb78958e --- /dev/null +++ b/contrib/appliance/provision/20-tls.sh @@ -0,0 +1,43 @@ +#!/bin/bash +# 20-tls.sh — install the certificate tooling and its renewal timer. +# +# No certificate is created here. The CA key is generated on first boot, on +# the machine that will use it: a CA shipped inside a downloadable image +# would be a private key every download shared, which is not a CA at all. +# 90-cleanup.sh asserts none exists, and the boot test asserts one appears. +set -euo pipefail +. /provision/common.sh + +step "certificate tooling" +install -Dm755 /provision/files/mkca.sh /usr/local/lib/satd-appliance/mkca.sh + +step "renewal timer" +install_unit satd-tls-renew.service <<'UNIT' +[Unit] +Description=Renew the satd appliance TLS certificate when it is near expiry +After=network-online.target +Wants=network-online.target + +[Service] +Type=oneshot +# Re-runs mkca.sh, which is a no-op unless the certificate expires within 30 +# days or the machine's names/addresses have changed. Both are worth acting +# on: the second is what happens when DHCP moves the appliance, and a +# certificate that no longer covers the address clients use fails closed. +ExecStart=/usr/local/bin/satd-appliance tls renew --quiet +UNIT + +install_unit satd-tls-renew.timer <<'UNIT' +[Unit] +Description=Daily check of the satd appliance TLS certificate + +[Timer] +OnCalendar=daily +# The appliance is often off overnight; a missed daily run must happen at +# the next boot rather than wait for the next window. +Persistent=true +RandomizedDelaySec=1h + +[Install] +WantedBy=timers.target +UNIT diff --git a/contrib/appliance/provision/30-desktop.sh b/contrib/appliance/provision/30-desktop.sh new file mode 100755 index 000000000..222928f88 --- /dev/null +++ b/contrib/appliance/provision/30-desktop.sh @@ -0,0 +1,109 @@ +#!/bin/bash +# 30-desktop.sh — XFCE, a browser that trusts the appliance, and a desktop +# that explains itself. Desktop flavour only. +set -euo pipefail +. /provision/common.sh + +step "XFCE and a display manager" +apt_install \ + xfce4 xfce4-terminal xfce4-notifyd \ + lightdm lightdm-gtk-greeter \ + dbus-x11 xdg-utils \ + firefox-esr \ + fonts-dejavu-core \ + qrencode \ + network-manager-gnome \ + mousepad ristretto + +step "autologin for the console user" +# Autologin because this is an appliance someone downloads and boots: the +# first thing they should see is the node, not a login prompt for a password +# that first boot has only just generated and printed on the console. +mkdir -p /etc/lightdm/lightdm.conf.d +cat > /etc/lightdm/lightdm.conf.d/50-satd-appliance.conf < /etc/firefox/policies/policies.json <<'POLICY' +{ + "policies": { + "Certificates": { + "ImportEnterpriseRoots": true, + "Install": ["/var/lib/satd/tls/ca.crt"] + }, + "DisableTelemetry": true, + "DisableFirefoxStudies": true, + "DontCheckDefaultBrowser": true, + "OverrideFirstRunPage": "file:///usr/share/satd-appliance/welcome.html", + "Homepage": { + "URL": "file:///usr/share/satd-appliance/welcome.html", + "StartPage": "homepage" + } + } +} +POLICY + +step "desktop launchers" +install -d -m 0755 /usr/share/applications +cat > /usr/share/applications/satd-tui.desktop <<'DESK' +[Desktop Entry] +Type=Application +Name=satd dashboard (sat-tui) +Comment=Live view of the node: chain, mempool, peers +Exec=xfce4-terminal --title="satd" --geometry=140x45 --command="sat-tui -rpcport=8332" +Icon=utilities-system-monitor +Terminal=false +Categories=System;Monitor; +DESK + +cat > /usr/share/applications/satd-status.desktop <<'DESK' +[Desktop Entry] +Type=Application +Name=Appliance status +Comment=Network, sync progress, enabled services, certificate +Exec=xfce4-terminal --hold --title="satd-appliance status" --command="satd-appliance status" +Icon=dialog-information +Terminal=false +Categories=System; +DESK + +cat > /usr/share/applications/satd-readme.desktop <<'DESK' +[Desktop Entry] +Type=Application +Name=Start here +Comment=What this appliance is and what to do with it +Exec=xdg-open /usr/share/satd-appliance/welcome.html +Icon=text-html +Terminal=false +Categories=Documentation; +DESK + +step "welcome page" +install -d -m 0755 /usr/share/satd-appliance +install -Dm644 /provision/files/welcome.html /usr/share/satd-appliance/welcome.html + +step "desktop shortcuts for the console user" +USER_HOME="$(getent passwd "$APPLIANCE_USER" | cut -d: -f6)" +install -d -o "$APPLIANCE_USER" -g "$APPLIANCE_USER" -m 0755 "$USER_HOME/Desktop" +for d in satd-readme satd-status satd-tui; do + cp "/usr/share/applications/$d.desktop" "$USER_HOME/Desktop/" + chmod +x "$USER_HOME/Desktop/$d.desktop" +done +chown -R "$APPLIANCE_USER:$APPLIANCE_USER" "$USER_HOME/Desktop" + +step "no screen lock or suspend" +# A node is meant to keep running. A suspended appliance stops syncing and +# looks broken. +mkdir -p /etc/xdg/autostart +systemctl mask sleep.target suspend.target hibernate.target hybrid-sleep.target > /dev/null diff --git a/contrib/appliance/provision/40-wallets.sh b/contrib/appliance/provision/40-wallets.sh new file mode 100755 index 000000000..a5e84d2e1 --- /dev/null +++ b/contrib/appliance/provision/40-wallets.sh @@ -0,0 +1,297 @@ +#!/bin/bash +# 40-wallets.sh — the bundled desktop wallets. Desktop flavour only. +# +# BEST-EFFORT SOFTWARE. These are third-party applications included so the +# appliance can demonstrate satd end to end. They are not tracked for +# security advisories here; see the support policy in the README. +# +# Every download is signature-verified against a key fingerprint pinned +# below. The fingerprints were taken from the projects' own published +# release signatures, and a build fails rather than installing anything that +# does not verify — an appliance that silently installed an unverified +# wallet would be worse than one that shipped without wallets at all. +# +# Pins are bumped deliberately, by a PR that re-checks the signature. +set -euo pipefail +. /provision/common.sh + +[[ "$SATD_FLAVOR" == "desktop" ]] || { step "not the desktop flavour; skipping"; exit 0; } + +SPARROW_VERSION="${SPARROW_VERSION:-2.5.4}" +# Craig Raw. Taken from the detached signature on the 2.5.4 manifest. +SPARROW_FPR="D4D0D3202FC06849A257B38DE94618334C674B40" + +ELECTRUM_VERSION="${ELECTRUM_VERSION:-4.6.2}" +# Electrum AppImages carry three signatures; any one verifying is the +# project's own documented check. ThomasV, SomberNight and Emzy. +ELECTRUM_FPRS=( + "637DB1E23370F84AFF88CCE03152347D07DA627C" + "AA0BC6824B397BBA99776E157ED8D82B37192688" + "0EEDCFD5CAFB459067349B23CA9EEEC43DF911DC" +) + +LIANA_VERSION="${LIANA_VERSION:-15.0}" +# Wizardsardine's release key, taken from the signature on the v15.0 +# shasums. Pinned like the two above, and for the same reason: deriving the +# key from the signature and then verifying against it proves only that the +# file is self-consistent, which a hostile file also is. +LIANA_FPR="4730DDCC64DFAEC16CEFEB5BE65F7A089C20DC8F" + +apt_install gnupg dirmngr + +# Verify a detached signature and require that a specific key made it. +# +# The output is captured first and matched afterwards, deliberately. The +# obvious spelling — `gpg --verify ... | grep -q "VALIDSIG $fpr"` — is wrong +# under `set -o pipefail` in a way that depends on FILE SIZE: grep -q exits +# at the first match and closes the pipe, gpg dies of SIGPIPE, and the +# pipeline reports failure even though the signature verified. On a small +# manifest gpg has already finished and it passes; on Electrum's 84 MB +# AppImage it has not, and a perfectly good signature is rejected. Capturing +# removes the pipe, and with it the dependence on how fast gpg finishes. +verify_detached_sig() { + local sig="$1" file="$2" fpr="$3" + local status + status="$(gpg --batch --status-fd 1 --verify "$sig" "$file" 2>/dev/null || true)" + grep -q "VALIDSIG $fpr" <<< "$status" +} + +# Fetch a key by fingerprint from a keyserver and confirm we got that key +# and not another. Asking a keyserver for a fingerprint and then trusting +# whatever comes back would defeat the point of pinning one. +import_key() { + local fpr="$1" + for server in keyserver.ubuntu.com keys.openpgp.org; do + if gpg --batch --keyserver "hkps://$server" --recv-keys "$fpr" 2>/dev/null; then + if gpg --batch --list-keys "$fpr" > /dev/null 2>&1; then + return 0 + fi + fi + done + echo " could not obtain key $fpr" >&2 + return 1 +} + +TMP="$(mktemp -d)" +# gpg starts gpg-agent and dirmngr as daemons that outlive the command that +# needed them. Left running inside the build chroot they hold /dev open, and +# the umount after provisioning then fails with "target is busy" — losing a +# completed build at the very last step. +cleanup_wallets() { + gpgconf --kill all > /dev/null 2>&1 || true + rm -rf "$TMP" +} +trap cleanup_wallets EXIT + +# Check one file against a signed checksum manifest. +# +# Manifests differ in shape between projects: Sparrow writes +# ` *` (coreutils binary mode), Liana writes +# ` `. Matching the name with a plain grep against one of +# those forms silently finds nothing on the other — and "no matching line" +# has to be a failure, not an empty success, which is what this exists to +# guarantee. The line is located by comparing the parsed name for equality +# and then handed to sha256sum, which understands both forms. +verify_from_manifest() { + local manifest="$1" name="$2" + local line + line="$(awk -v want="$name" 'BEGIN { FS = "[ \t]+" } + { + n = $2 + sub(/^[*]/, "", n) + if (n == want) print + }' "$manifest")" + if [[ -z "$line" ]]; then + echo " $name is not listed in $(basename "$manifest")" >&2 + return 1 + fi + if [[ "$(wc -l <<< "$line")" != 1 ]]; then + echo " $name is listed more than once in $(basename "$manifest")" >&2 + return 1 + fi + ( cd "$(dirname "$manifest")" && printf '%s\n' "$line" | sha256sum -c - ) +} + +# --- Sparrow --------------------------------------------------------------- +step "Sparrow Wallet $SPARROW_VERSION" +base="https://github.com/sparrowwallet/sparrow/releases/download/$SPARROW_VERSION" +deb="sparrowwallet_${SPARROW_VERSION}-1_${DEB_ARCH}.deb" +manifest="sparrow-${SPARROW_VERSION}-manifest.txt" +fetch "$base/$deb" "$TMP/$deb" +fetch "$base/$manifest" "$TMP/$manifest" +fetch "$base/$manifest.asc" "$TMP/$manifest.asc" +import_key "$SPARROW_FPR" +# Sparrow signs a manifest of SHA-256 sums rather than each file, so the +# check is two-step: the signature covers the manifest, the manifest covers +# the .deb. +verify_detached_sig "$TMP/$manifest.asc" "$TMP/$manifest" "$SPARROW_FPR" \ + || { echo " Sparrow manifest signature did not verify against $SPARROW_FPR" >&2; exit 1; } +verify_from_manifest "$TMP/$manifest" "$deb" \ + || { echo " $deb does not match the signed manifest" >&2; exit 1; } +apt-get install -y "$TMP/$deb" +step " Sparrow verified and installed" + +# --- Electrum -------------------------------------------------------------- +step "Electrum $ELECTRUM_VERSION" +appimage="electrum-${ELECTRUM_VERSION}-x86_64.AppImage" +if [[ "$DEB_ARCH" == "amd64" ]]; then + fetch "https://download.electrum.org/$ELECTRUM_VERSION/$appimage" "$TMP/$appimage" + fetch "https://download.electrum.org/$ELECTRUM_VERSION/$appimage.asc" "$TMP/$appimage.asc" + verified=0 + for fpr in "${ELECTRUM_FPRS[@]}"; do + import_key "$fpr" || continue + if verify_detached_sig "$TMP/$appimage.asc" "$TMP/$appimage" "$fpr"; then + verified=1 + step " Electrum verified against $fpr" + break + fi + done + [[ "$verified" == 1 ]] || { echo " no pinned Electrum key verified the AppImage" >&2; exit 1; } + install -Dm755 "$TMP/$appimage" /opt/electrum/electrum.AppImage + # AppImages need FUSE, which is awkward in a VM; --appimage-extract-and-run + # avoids it entirely at the cost of a slower start. + cat > /usr/local/bin/electrum <<'WRAP' +#!/bin/sh +# FUSE is not always available in a guest, and an AppImage that cannot +# mount itself fails with a confusing error rather than falling back. +exec /opt/electrum/electrum.AppImage --appimage-extract-and-run "$@" +WRAP + chmod +x /usr/local/bin/electrum + cat > /usr/share/applications/electrum.desktop <<'DESK' +[Desktop Entry] +Type=Application +Name=Electrum +Comment=Electrum wallet, pointed at this appliance's Electrum server +Exec=electrum +Icon=electrum +Terminal=false +Categories=Office;Finance; +DESK +else + step " no published Electrum AppImage for $DEB_ARCH; skipping" +fi + +# --- Liana ----------------------------------------------------------------- +step "Liana $LIANA_VERSION" +lbase="https://github.com/wizardsardine/liana/releases/download/v$LIANA_VERSION" +ldeb="liana-${LIANA_VERSION}-1_${DEB_ARCH}.deb" +lsums="liana-${LIANA_VERSION}-shasums.txt" +if fetch "$lbase/$ldeb" "$TMP/$ldeb" && fetch "$lbase/$lsums" "$TMP/$lsums"; then + # Liana signs a shasums file; the signature covers the manifest, the + # manifest covers the .deb. A keyserver that cannot be reached is not a + # reason to install something unchecked, so failure here fails the build. + fetch "$lbase/$lsums.asc" "$TMP/$lsums.asc" + import_key "$LIANA_FPR" + verify_detached_sig "$TMP/$lsums.asc" "$TMP/$lsums" "$LIANA_FPR" \ + || { echo " Liana shasums signature did not verify against $LIANA_FPR" >&2; exit 1; } + step " Liana shasums verified against $LIANA_FPR" + verify_from_manifest "$TMP/$lsums" "$ldeb" \ + || { echo " $ldeb does not match the signed shasums" >&2; exit 1; } + apt-get install -y "$TMP/$ldeb" + step " Liana verified and installed" +else + step " no Liana package for $DEB_ARCH at v$LIANA_VERSION; skipping" +fi + +# --- Cashu (nutshell wallet CLI) ------------------------------------------- +# --- Cashu ------------------------------------------------------------------ +step "Cashu wallet CLI" +# The mint is a container (compose.cashu.yml); this is the wallet CLI. +# +# Two upstream breakages have to be worked around, and both are pinned here +# rather than left to a resolver that would rediscover them differently on a +# different day: +# +# 1. cashu -> bip32 4.x -> `coincurve >=15,<21`. coincurve 21 is the FIRST +# release with a cp313 wheel, and that cap excludes it, so pip must build +# coincurve 20 from source. coincurve 20 in turn requires +# `scikit-build-core>=0.9.0` with no upper bound while using a config key +# (`cmake.verbose`) that scikit-build-core >= 0.10 rejects outright — so +# the sdist is unbuildable with a current toolchain. PIP_CONSTRAINT does +# NOT help: it is not consulted for pip's isolated build environment. +# The fix is to build that one wheel ourselves with a build tool that +# understands the source, then let normal resolution find it. The +# library itself is upstream's, unmodified; only the build tool is +# pinned. +# +# 2. cashu -> environs -> marshmallow. environs reads +# `marshmallow.__version_info__` at import; marshmallow 4 removed it. +# That one IS an ordinary runtime dependency, so a constraint fixes it. +# +# The build toolchain is installed for this and removed again: nothing here +# needs a compiler at runtime, and an appliance should not carry one. +apt_install pipx python3-venv +CASHU_BUILD_DEPS=(build-essential python3-dev libsecp256k1-dev pkg-config cmake ninja-build) +apt_install "${CASHU_BUILD_DEPS[@]}" + +CASHU_WHEELS=/tmp/cashu-wheels +CASHU_CONSTRAINTS=/tmp/cashu-constraints.txt +mkdir -p "$CASHU_WHEELS" +printf 'marshmallow<4\n' > "$CASHU_CONSTRAINTS" + +cashu_ok=0 +if python3 -m venv /tmp/cashu-build \ + && /tmp/cashu-build/bin/pip install -q "scikit-build-core<0.10" "hatchling>=1.24.2" \ + cffi setuptools wheel ninja \ + && /tmp/cashu-build/bin/pip wheel --no-build-isolation --no-deps \ + "coincurve==${COINCURVE_VERSION:-20.0.0}" -w "$CASHU_WHEELS"; then + step " built a coincurve wheel for this Python" + if PIP_CONSTRAINT="$CASHU_CONSTRAINTS" PIPX_HOME=/opt/pipx PIPX_BIN_DIR=/usr/local/bin \ + pipx install "cashu==${CASHU_VERSION:-0.20.2}" --pip-args="--find-links $CASHU_WHEELS"; then + cashu_ok=1 + fi +fi + +if [[ "$cashu_ok" == 1 ]] && /usr/local/bin/cashu --help > /dev/null 2>&1; then + step " cashu wallet installed and runs" +else + # Loud, and not fatal: the mint overlay is the part that matters, and a + # desktop image without one CLI is worth more than no image. + step " WARNING: cashu wallet did not install; the mint overlay is unaffected" +fi + +rm -rf /tmp/cashu-build "$CASHU_WHEELS" "$CASHU_CONSTRAINTS" +apt-get purge -y "${CASHU_BUILD_DEPS[@]}" > /dev/null 2>&1 || true +apt-get autoremove -y --purge > /dev/null 2>&1 || true + +step "wallet server presets" +# Pre-seeding the server URL is the difference between "a wallet is +# installed" and "a wallet is talking to this node". Both of these clients +# pin the server certificate on first use, so the user accepts it once. +install -d -m 0755 /etc/skel/.electrum +cat > /etc/skel/.electrum/config <<'ECONF' +{ + "auto_connect": false, + "oneserver": true, + "server": "localhost:50002:s", + "check_updates": false +} +ECONF +USER_HOME="$(getent passwd "$APPLIANCE_USER" | cut -d: -f6)" +install -d -o "$APPLIANCE_USER" -g "$APPLIANCE_USER" -m 0700 "$USER_HOME/.electrum" +install -o "$APPLIANCE_USER" -g "$APPLIANCE_USER" -m 0644 \ + /etc/skel/.electrum/config "$USER_HOME/.electrum/config" + +# Sparrow reads its server configuration from its own config file; point it +# at the appliance's Electrum TLS listener so the first launch connects +# instead of asking. +install -d -o "$APPLIANCE_USER" -g "$APPLIANCE_USER" -m 0700 "$USER_HOME/.sparrow" +cat > "$USER_HOME/.sparrow/config" </dev/null || true +done +[[ -f /usr/share/applications/electrum.desktop ]] && \ + cp /usr/share/applications/electrum.desktop "$USER_HOME/Desktop/" 2>/dev/null || true +chown -R "$APPLIANCE_USER:$APPLIANCE_USER" "$USER_HOME/Desktop" 2>/dev/null || true +chmod +x "$USER_HOME"/Desktop/*.desktop 2>/dev/null || true diff --git a/contrib/appliance/provision/50-containers.sh b/contrib/appliance/provision/50-containers.sh new file mode 100755 index 000000000..737fce089 --- /dev/null +++ b/contrib/appliance/provision/50-containers.sh @@ -0,0 +1,35 @@ +#!/bin/bash +# 50-containers.sh — Docker engine plus the reference stack, staged on disk. +# +# The overlays are not started here and no images are pulled at build time: +# a pulled image would age inside the download and would have to be +# refreshed anyway on first boot. `satd-appliance enable ` pulls on +# demand. +set -euo pipefail +. /provision/common.sh + +step "docker engine from Docker's apt repository" +install -m 0755 -d /etc/apt/keyrings +fetch "https://download.docker.com/linux/debian/gpg" /tmp/docker.asc +gpg --dearmor < /tmp/docker.asc > /etc/apt/keyrings/docker.gpg +chmod a+r /etc/apt/keyrings/docker.gpg +rm -f /tmp/docker.asc + +# `signed-by` pins this repository to that key alone, so it cannot sign for +# anything else in the sources list. +cat > /etc/apt/sources.list.d/docker.list < /dev/null 2>&1 || true + +usermod -aG docker "$APPLIANCE_USER" + +step "staging the reference stack in /opt/satd/stack" +install -d -m 0755 /opt/satd/stack +cp -a /provision/files/stack/. /opt/satd/stack/ diff --git a/contrib/appliance/provision/60-firstboot.sh b/contrib/appliance/provision/60-firstboot.sh new file mode 100755 index 000000000..d0807ee75 --- /dev/null +++ b/contrib/appliance/provision/60-firstboot.sh @@ -0,0 +1,37 @@ +#!/bin/bash +# 60-firstboot.sh — install the operator CLI and the first-boot unit. +set -euo pipefail +. /provision/common.sh + +step "operator CLI" +install -Dm755 /provision/files/satd-appliance /usr/local/bin/satd-appliance + +step "shared first-run scripts (the same ones the compose stack uses)" +install -Dm755 /provision/files/satd-init /usr/local/lib/satd-appliance/satd-init +install -Dm644 /provision/files/satd.conf.tmpl /usr/local/lib/satd-appliance/satd.conf.tmpl +install -Dm755 /provision/files/configure-network /usr/local/lib/satd-appliance/configure-network + +step "first-boot unit" +install -Dm755 /provision/files/firstboot /usr/local/lib/satd-appliance/firstboot +install -Dm644 /provision/files/firstboot.service \ + /etc/systemd/system/satd-appliance-firstboot.service +systemctl enable satd-appliance-firstboot.service > /dev/null + +step "default network" +install -d -m 0755 /var/lib/satd-appliance +echo "$SATD_NETWORK" > /var/lib/satd-appliance/network + +step "shell hint on login" +cat > /etc/profile.d/99-satd-appliance.sh <<'PROFILE' +# Printed on interactive login. Short on purpose: the one command that +# answers "what is this box doing" and the one that makes it reachable. +if [ -n "${PS1:-}" ] && [ -z "${SATD_APPLIANCE_MOTD_SHOWN:-}" ]; then + export SATD_APPLIANCE_MOTD_SHOWN=1 + echo + echo "satd appliance — try:" + echo " satd-appliance status what the node is doing" + echo " sat-tui -rpcport=8332 live dashboard" + echo " satd-appliance tls export-ca the certificate to trust on other machines" + echo +fi +PROFILE diff --git a/contrib/appliance/provision/90-cleanup.sh b/contrib/appliance/provision/90-cleanup.sh new file mode 100755 index 000000000..b0480ec6c --- /dev/null +++ b/contrib/appliance/provision/90-cleanup.sh @@ -0,0 +1,102 @@ +#!/bin/bash +# 90-cleanup.sh — strip everything that must not ship, then assert it is gone. +# +# The assertions at the end are the point. This image is downloaded by +# strangers; a private key, a machine-id or a live cookie baked into it +# would be shared by every one of them, and the failure would be silent. +set -euo pipefail +. /provision/common.sh + +step "apt caches" +apt-get autoremove -y > /dev/null +apt-get clean +rm -rf /var/lib/apt/lists/* + +step "logs and transient state" +find /var/log -type f -exec truncate -s 0 {} \; 2>/dev/null || true +rm -rf /tmp/* /var/tmp/* 2>/dev/null || true +rm -f /root/.bash_history "/home/$APPLIANCE_USER/.bash_history" 2>/dev/null || true + +step "machine identity" +# An empty (not missing) /etc/machine-id makes systemd generate a fresh one +# at first boot. A missing file makes some initramfs setups fail instead. +: > /etc/machine-id +rm -f /var/lib/dbus/machine-id +ln -sf /etc/machine-id /var/lib/dbus/machine-id + +step "host keys" +# sshd is off by default, but if the package pulled keys in, they must not +# be the same on every download. +rm -f /etc/ssh/ssh_host_* + +step "provisioning tree" +rm -rf /provision + +step "asserting the image carries no secrets" +fail=0 +check_absent() { + local desc="$1"; shift + local found + # No `| head`. Under `set -o pipefail` a truncating pipe makes the + # producer die of SIGPIPE and the assignment fail — which, in the one + # function whose job is to detect a leak, would abort the script instead + # of reporting the leak. The listing is trimmed afterwards, in the shell. + local all + all="$("$@" 2>/dev/null || true)" + if [[ -n "$all" ]]; then + local lines=() + mapfile -t lines <<< "$all" + found="$(printf '%s\n' "${lines[@]:0:5}")" + else + found="" + fi + if [[ -n "$found" ]]; then + echo " SECRET LEAK: $desc" >&2 + echo "$found" | sed 's/^/ /' >&2 + fail=1 + else + echo " ok: $desc" + fi +} + +check_absent "no TLS private keys" find / -xdev -name '*.key' -path '*satd*' +check_absent "no CA material in the datadir" find /var/lib/satd -mindepth 1 +check_absent "no RPC cookie" find / -xdev -name '.cookie' +check_absent "no authfile" find / -xdev -name 'authfile.toml' +check_absent "no MCP token" find / -xdev -name 'mcp-token' +check_absent "no ssh host keys" find /etc/ssh -name 'ssh_host_*' +check_absent "no first-boot marker" find /var/lib/satd-appliance -name 'firstboot-done' +check_absent "no saved initial password" find /var/lib/satd-appliance -name 'initial-password' + +# A non-empty machine-id would make every install of this image report the +# same identity to the network. +if [[ -s /etc/machine-id ]]; then + echo " SECRET LEAK: /etc/machine-id is not empty" >&2 + fail=1 +else + echo " ok: machine-id is empty" +fi + +# Locked, not blank. `!` in the password field accepts nothing; an empty +# field would let anyone in at the console. +for u in root "$APPLIANCE_USER"; do + hash="$(awk -F: -v u="$u" '$1==u{print $2}' /etc/shadow)" + if [[ "$hash" == "!" || "$hash" == "*" || "$hash" == "!"* ]]; then + echo " ok: $u has no usable password in the image" + else + echo " SECRET LEAK: $u ships with a password hash ('$hash')" >&2 + fail=1 + fi +done + +[[ "$fail" == 0 ]] || { echo "90-cleanup.sh: refusing to finish a leaky image" >&2; exit 1; } + +# No free-space zeroing here. This script runs against a debootstrap +# *directory*, not a mounted filesystem image, so writing a zero file would +# fill the build host's disk rather than the appliance's. It would also be +# pointless: build.sh copies this tree into a freshly created ext4, whose +# unallocated blocks are already zero, and qcow2 compression sees them as +# such. +sync + +step "done" diff --git a/contrib/appliance/provision/common.sh b/contrib/appliance/provision/common.sh new file mode 100644 index 000000000..7c4c60392 --- /dev/null +++ b/contrib/appliance/provision/common.sh @@ -0,0 +1,55 @@ +# shellcheck shell=bash +# Shared helpers for the provision scripts. Sourced, not executed — the +# directive above tells shellcheck which shell to check against, since +# there is no shebang to infer it from. +# +# Every variable read here is exported by build.sh; the defaults exist so a +# script can be run by hand against a chroot for debugging. + +export DEBIAN_FRONTEND=noninteractive + +SATD_FLAVOR="${SATD_FLAVOR:-core}" +SATD_NETWORK="${SATD_NETWORK:-signet}" +APPLIANCE_USER="${APPLIANCE_USER:-satd-user}" +APPLIANCE_HOSTNAME="${APPLIANCE_HOSTNAME:-satd}" +DEB_ARCH="${DEB_ARCH:-amd64}" + +case "$DEB_ARCH" in + amd64) GRUB_EFI_ARCH=amd64 ;; + arm64) GRUB_EFI_ARCH=arm64 ;; + *) echo "unsupported architecture: $DEB_ARCH" >&2; exit 1 ;; +esac + +export SATD_FLAVOR SATD_NETWORK APPLIANCE_USER APPLIANCE_HOSTNAME DEB_ARCH GRUB_EFI_ARCH + +step() { echo " [$(basename "$0")] $*"; } + +# apt-get with the flags that matter for a reproducible-ish image: no +# recommends (they pull half a desktop into a headless build), and no +# interactive prompts. +apt_install() { + apt-get install -y --no-install-recommends "$@" +} + +# Fetch a URL to a path, with retries. Every download in this tree is +# verified afterwards — by minisign, by GPG, or by SHA-256 — so this only +# has to be reliable, not trusted. +fetch() { + local url="$1" out="$2" + for attempt in 1 2 3; do + if curl -fsSL --retry 3 --retry-delay 2 -o "$out" "$url"; then + return 0 + fi + echo " fetch attempt $attempt failed: $url" >&2 + sleep $((attempt * 3)) + done + echo " giving up on $url" >&2 + return 1 +} + +# Install a systemd unit from a heredoc and enable it. +install_unit() { + local name="$1" + cat > "/etc/systemd/system/$name" + systemctl enable "$name" > /dev/null +} diff --git a/contrib/appliance/tests/boot-test.sh b/contrib/appliance/tests/boot-test.sh new file mode 100755 index 000000000..52bb9e5f0 --- /dev/null +++ b/contrib/appliance/tests/boot-test.sh @@ -0,0 +1,414 @@ +#!/bin/bash +# boot-test.sh — boot a built appliance image and check that it works. +# +# contrib/appliance/tests/boot-test.sh --image out/satd-appliance-...qcow2 +# contrib/appliance/tests/boot-test.sh --image ... --in-docker # no host qemu +# +# This is the "the image is not broken" gate. It boots the actual artifact — +# no test-only build, no injected hooks — and asserts what a person who +# downloaded it would find. +# +# Two channels, deliberately: +# +# * The QEMU guest agent, for looking inside: did first boot run, is satd +# up, did the certificates get generated, is anything that should not +# have shipped now present. +# * Forwarded ports from the host, for the TLS surfaces. Checking a +# certificate from inside the guest proves much less than connecting to +# it the way a client on the network will. The CA comes out over the +# guest agent and every external probe then verifies against it, so +# these are real verifications rather than handshake-completed checks. +# +# KVM is used when available and TCG when it is not, so this runs on a +# hosted CI runner and on a laptop with no virtualisation. + +set -euo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO="$(cd "$HERE/../../.." && pwd)" + +IMAGE="" +IN_DOCKER=0 +MEMORY=2560 +CPUS=2 +BOOT_TIMEOUT=900 +KEEP=0 +PORT_BASE="${SATD_BOOT_TEST_PORT_BASE:-22400}" + +while [[ $# -gt 0 ]]; do + case "$1" in + --image) IMAGE="$2"; shift 2 ;; + --in-docker) IN_DOCKER=1; shift ;; + --memory) MEMORY="$2"; shift 2 ;; + --timeout) BOOT_TIMEOUT="$2"; shift 2 ;; + --port-base) PORT_BASE="$2"; shift 2 ;; + --keep) KEEP=1; shift ;; + -h|--help) sed -n '2,25p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; + *) echo "boot-test.sh: unknown argument: $1" >&2; exit 2 ;; + esac +done + +[[ -n "$IMAGE" ]] || { echo "boot-test.sh: --image is required" >&2; exit 2; } +[[ -s "$IMAGE" ]] || { echo "boot-test.sh: no such image: $IMAGE" >&2; exit 2; } +IMAGE="$(readlink -f "$IMAGE")" + +if [[ "$IN_DOCKER" == 1 ]]; then + # qemu, python3 and openssl in a container, so the host needs none of + # them. Not privileged: /dev/kvm is passed through when it exists, and + # TCG needs no special access at all. + kvm_args=() + [[ -e /dev/kvm ]] && kvm_args=(--device /dev/kvm) + exec docker run --rm "${kvm_args[@]}" \ + -v "$REPO:/repo" -v "$(dirname "$IMAGE"):/image" \ + -w /repo \ + -e DEBIAN_FRONTEND=noninteractive \ + debian:trixie bash -c ' +set -euo pipefail +apt-get update -qq +apt-get install -y -qq --no-install-recommends \ + qemu-system-x86 qemu-utils python3 openssl curl ca-certificates > /dev/null +exec "$@" +' -- /repo/contrib/appliance/tests/boot-test.sh --image "/image/$(basename "$IMAGE")" \ + --memory "$MEMORY" --timeout "$BOOT_TIMEOUT" --port-base "$PORT_BASE" \ + $([[ "$KEEP" == 1 ]] && echo --keep) +fi + +for tool in qemu-system-x86_64 qemu-img openssl python3; do + command -v "$tool" > /dev/null || { echo "boot-test.sh: missing $tool (try --in-docker)" >&2; exit 1; } +done + +WORK="$(mktemp -d)" +QGA_SOCK="$WORK/qga.sock" +CONSOLE="$WORK/console.log" +QEMU_PID="" + +RPC_TLS=$((PORT_BASE + 0)) +ELECTRUM_TLS=$((PORT_BASE + 1)) +ESPLORA_TLS=$((PORT_BASE + 2)) +MCP_TLS=$((PORT_BASE + 3)) + +cleanup() { + if [[ -n "$QEMU_PID" ]] && kill -0 "$QEMU_PID" 2>/dev/null; then + kill -TERM "$QEMU_PID" 2>/dev/null || true + for _ in $(seq 1 20); do kill -0 "$QEMU_PID" 2>/dev/null || break; sleep 1; done + kill -KILL "$QEMU_PID" 2>/dev/null || true + fi + if [[ "$KEEP" == 1 ]]; then + echo "boot-test.sh: --keep; console log at $CONSOLE" + else + rm -rf "$WORK" + fi +} +trap cleanup EXIT + +FAILURES=0 +pass() { echo "ok — $1"; } +fail() { echo "FAIL — $1"; [[ $# -lt 2 ]] || sed 's/^/ /' <<< "$2"; FAILURES=$((FAILURES + 1)); } + +# --- the guest-agent client ------------------------------------------------- +cat > "$WORK/qga.py" <<'PY' +"""Minimal QEMU guest-agent client. + +Speaks newline-delimited JSON over the agent's unix socket. `exec` runs a +command in the guest and blocks until it exits, returning (rc, stdout, +stderr) — which is all this test needs and much less than a full QMP client. +""" +import base64 +import json +import socket +import sys +import time + + +class Agent: + def __init__(self, path, timeout=10): + self.path = path + self.timeout = timeout + + def _rpc(self, cmd, args=None): + s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + s.settimeout(self.timeout) + s.connect(self.path) + payload = {"execute": cmd} + if args: + payload["arguments"] = args + s.sendall((json.dumps(payload) + "\n").encode()) + buf = b"" + while b"\n" not in buf: + chunk = s.recv(65536) + if not chunk: + raise RuntimeError("guest agent closed the connection") + buf += chunk + s.close() + reply = json.loads(buf.split(b"\n")[0]) + if "error" in reply: + raise RuntimeError(reply["error"]) + return reply.get("return") + + def ping(self): + self._rpc("guest-ping") + + def exec(self, argv, timeout=300): + r = self._rpc("guest-exec", {"path": argv[0], "arg": argv[1:], + "capture-output": True}) + pid = r["pid"] + deadline = time.time() + timeout + while time.time() < deadline: + st = self._rpc("guest-exec-status", {"pid": pid}) + if st.get("exited"): + out = base64.b64decode(st.get("out-data", "")).decode(errors="replace") + err = base64.b64decode(st.get("err-data", "")).decode(errors="replace") + return st.get("exitcode", -1), out, err + time.sleep(0.5) + raise TimeoutError(f"guest command timed out: {argv}") + + +if __name__ == "__main__": + mode = sys.argv[1] + agent = Agent(sys.argv[2]) + if mode == "ping": + agent.ping() + elif mode == "exec": + rc, out, err = agent.exec(["/bin/sh", "-c", sys.argv[3]]) + sys.stdout.write(out) + sys.stderr.write(err) + sys.exit(rc) + else: + raise SystemExit(f"unknown mode {mode}") +PY + +guest() { python3 "$WORK/qga.py" exec "$QGA_SOCK" "$1"; } + +# --- boot ------------------------------------------------------------------- +# An overlay so the test never mutates the artifact it is checking; a rerun +# starts from the same bytes a downloader would get. +qemu-img create -q -f qcow2 -F qcow2 -b "$IMAGE" "$WORK/overlay.qcow2" + +echo "boot-test.sh: booting $(basename "$IMAGE")" +# `accel=kvm:tcg` is a fallback list and belongs on -machine; `-accel` takes +# one accelerator and rejects the list outright. The comment lives here +# rather than inside the invocation below: a `#` on a backslash-continued +# line comments out every argument after it, and qemu then starts with none +# — no serial file, no agent socket, and a boot that hangs until the test's +# own timeout rather than failing. +qemu-system-x86_64 \ + -machine q35,accel=kvm:tcg \ + -cpu max \ + -m "$MEMORY" -smp "$CPUS" \ + -drive "file=$WORK/overlay.qcow2,if=virtio,format=qcow2" \ + -netdev "user,id=n0,hostfwd=tcp:127.0.0.1:$RPC_TLS-:8336,hostfwd=tcp:127.0.0.1:$ELECTRUM_TLS-:50002,hostfwd=tcp:127.0.0.1:$ESPLORA_TLS-:3001,hostfwd=tcp:127.0.0.1:$MCP_TLS-:8339" \ + -device virtio-net-pci,netdev=n0 \ + -chardev "socket,path=$QGA_SOCK,server=on,wait=off,id=qga0" \ + -device virtio-serial \ + -device virtserialport,chardev=qga0,name=org.qemu.guest_agent.0 \ + -serial "file:$CONSOLE" \ + -display none \ + -no-reboot & +QEMU_PID=$! + +echo "boot-test.sh: waiting for the guest agent (up to ${BOOT_TIMEOUT}s)..." +deadline=$(($(date +%s) + BOOT_TIMEOUT)) +agent_up=0 +while [[ $(date +%s) -lt $deadline ]]; do + if ! kill -0 "$QEMU_PID" 2>/dev/null; then + fail "the VM stayed running" "$(tail -40 "$CONSOLE" 2>/dev/null)" + exit 1 + fi + if python3 "$WORK/qga.py" ping "$QGA_SOCK" 2>/dev/null; then agent_up=1; break; fi + sleep 5 +done +if [[ "$agent_up" != 1 ]]; then + fail "the guest booted and its agent answered" "$(tail -60 "$CONSOLE" 2>/dev/null)" + exit 1 +fi +pass "the image boots" + +# --- first boot ------------------------------------------------------------- +echo "boot-test.sh: waiting for first-boot setup..." +deadline=$(($(date +%s) + 600)) +done_marker=0 +while [[ $(date +%s) -lt $deadline ]]; do + if guest 'test -f /var/lib/satd-appliance/firstboot-done' > /dev/null 2>&1; then + done_marker=1; break + fi + sleep 5 +done +if [[ "$done_marker" == 1 ]]; then + pass "first boot completed" +else + fail "first boot completed" "$(guest 'journalctl -u satd-appliance-firstboot --no-pager | tail -40' 2>&1)" +fi + +# Everything unique to the install must now exist — and must have been +# created here rather than shipped, which 90-cleanup.sh asserted separately. +for path in /var/lib/satd/tls/ca.key /var/lib/satd/tls/ca.crt /var/lib/satd/tls/leaf.key \ + /var/lib/satd/tls/fullchain.crt /var/lib/satd/bitcoin.conf \ + /var/lib/satd/authfile.toml /var/lib/satd/secrets/mcp-token; do + if guest "test -s $path" > /dev/null 2>&1; then + pass "first boot created $path" + else + fail "first boot created $path" + fi +done + +# --- the node --------------------------------------------------------------- +if guest 'systemctl is-active --quiet satd' > /dev/null 2>&1; then + pass "satd is running under systemd" +else + fail "satd is running under systemd" "$(guest 'systemctl status satd --no-pager -l | tail -30' 2>&1)" +fi + +# Switch to regtest through the operator command. This tests set-network as +# well as giving the rest of the checks a chain that is at a usable tip +# immediately, rather than however far into signet IBD the VM has got. +echo "boot-test.sh: switching to regtest via satd-appliance..." +if out="$(guest 'satd-appliance set-network regtest 2>&1')"; then + pass "satd-appliance set-network regtest" +else + fail "satd-appliance set-network regtest" "$out" +fi + +deadline=$(($(date +%s) + 180)) +rpc_up=0 +while [[ $(date +%s) -lt $deadline ]]; do + if guest 'sat-cli --datadir=/var/lib/satd --rpcport=8332 --rpccookiefile=/var/lib/satd/rpc-cookie getblockcount' > /dev/null 2>&1; then + rpc_up=1; break + fi + sleep 5 +done +if [[ "$rpc_up" == 1 ]]; then + pass "sat-cli reaches the node over the loopback listener" +else + fail "sat-cli reaches the node over the loopback listener" \ + "$(guest 'journalctl -u satd --no-pager | tail -40' 2>&1)" +fi + +guest 'sat-cli --datadir=/var/lib/satd --rpcport=8332 --rpccookiefile=/var/lib/satd/rpc-cookie generatetoaddress 5 bcrt1ql3e9pgs3mmwuwrh95fecme0s0qtn2880hlwwpw' > /dev/null 2>&1 || true +HEIGHT="$(guest 'sat-cli --datadir=/var/lib/satd --rpcport=8332 --rpccookiefile=/var/lib/satd/rpc-cookie getblockcount' 2>/dev/null | tr -d '\r\n' || true)" +if [[ "$HEIGHT" == "5" ]]; then + pass "the node mines and reports height 5" +else + fail "the node mines and reports height 5" "height is '$HEIGHT'" +fi + +# --- TLS, from outside the guest ------------------------------------------- +CA="$WORK/ca.crt" +guest 'cat /var/lib/satd/tls/ca.crt' > "$CA" 2>/dev/null || true +if [[ -s "$CA" ]]; then + pass "the appliance CA can be exported" +else + fail "the appliance CA can be exported" +fi + +probe_tls() { + local name="$1" port="$2" + local out + out="$(timeout 25 openssl s_client -connect "127.0.0.1:$port" -servername satd \ + -CAfile "$CA" -verify_return_error -brief < /dev/null 2>&1 || true)" + if grep -q "Verification: OK" <<< "$out"; then + pass "$name is reachable over TLS and verifies against the appliance CA" + else + fail "$name is reachable over TLS and verifies against the appliance CA" "$out" + fi +} +probe_tls "JSON-RPC" "$RPC_TLS" +probe_tls "Electrum" "$ELECTRUM_TLS" +probe_tls "Esplora" "$ESPLORA_TLS" +probe_tls "MCP" "$MCP_TLS" + +# Negative control: without the CA the same handshake must fail. Otherwise +# the four checks above prove only that something is listening on the port. +out="$(timeout 25 openssl s_client -connect "127.0.0.1:$RPC_TLS" -servername satd \ + -verify_return_error -brief < /dev/null 2>&1 || true)" +if grep -q "Verification: OK" <<< "$out"; then + fail "an untrusted client is rejected" "the handshake verified without the CA" +else + pass "an untrusted client is rejected" +fi + +# The certificate has to name the appliance the way clients will reach it. +sans="$(timeout 25 openssl s_client -connect "127.0.0.1:$RPC_TLS" -servername satd \ + -CAfile "$CA" -showcerts < /dev/null 2>/dev/null \ + | openssl x509 -noout -ext subjectAltName 2>/dev/null | tail -n +2 | tr -d ' \n' || true)" +for want in "DNS:satd" "DNS:satd.local" "DNS:localhost" "IPAddress:127.0.0.1"; do + if [[ "$sans" == *"$want"* ]]; then + pass "the certificate covers $want" + else + fail "the certificate covers $want" "SANs: $sans" + fi +done + +# --- Esplora and MCP answer, not just handshake ----------------------------- +esplora_tip="$(curl -sS --cacert "$CA" --resolve "satd:$ESPLORA_TLS:127.0.0.1" \ + "https://satd:$ESPLORA_TLS/api/blocks/tip/height" 2>&1 || true)" +if [[ "$esplora_tip" == "$HEIGHT" ]]; then + pass "Esplora over TLS reports the node's tip" +else + fail "Esplora over TLS reports the node's tip" "got '$esplora_tip', expected '$HEIGHT'" +fi + +TOKEN="$(guest 'cat /var/lib/satd/secrets/mcp-token' 2>/dev/null | tr -d '\r\n' || true)" +if [[ -n "$TOKEN" ]]; then + # Unauthenticated first: a listener that answers without the token would + # mean the bearer gate is not installed, which no amount of TLS fixes. + anon_code="$(curl -sS --cacert "$CA" --resolve "satd:$MCP_TLS:127.0.0.1" \ + -o /dev/null -w '%{http_code}' -X POST \ + -H 'Content-Type: application/json' \ + --data '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}' \ + "https://satd:$MCP_TLS/" 2>&1 || true)" + if [[ "$anon_code" == "401" || "$anon_code" == "403" ]]; then + pass "MCP refuses an unauthenticated request ($anon_code)" + else + fail "MCP refuses an unauthenticated request" "http $anon_code" + fi +else + fail "the MCP token was generated" +fi + +# --- the firewall ----------------------------------------------------------- +if guest 'nft list ruleset | grep -q "policy drop"' > /dev/null 2>&1; then + pass "inbound traffic is default-deny" +else + fail "inbound traffic is default-deny" "$(guest 'nft list ruleset 2>&1 | head -30')" +fi + +# The plain RPC listener must not be reachable from the network. It is not +# forwarded, so this reads the ruleset's intent directly — and the intent is +# specifically "only the container subnet", not "closed": the overlays reach +# a natively-run satd through the docker gateway, which arrives on this same +# input chain. So an accept for 8332 is expected; an accept for 8332 that +# does not name a source address is the bug. +plain_rpc_rules="$(guest 'nft list ruleset | grep -E "dport[^\n]*8332"' 2>/dev/null || true)" +if [[ -z "$plain_rpc_rules" ]]; then + pass "the plain RPC port is not opened in the firewall" +elif grep -qv "ip saddr" <<< "$plain_rpc_rules"; then + fail "the plain RPC port is only opened to the container subnet" "$plain_rpc_rules" +else + pass "the plain RPC port is only opened to the container subnet" +fi + +# --- the container stack is staged but idle --------------------------------- +if guest 'test -f /opt/satd/stack/compose.appliance.yml && test -f /opt/satd/stack/compose.lightning.yml' > /dev/null 2>&1; then + pass "the compose overlays are staged on disk" +else + fail "the compose overlays are staged on disk" +fi +if guest 'systemctl is-active --quiet docker' > /dev/null 2>&1; then + fail "docker is idle until an overlay is enabled" +else + pass "docker is idle until an overlay is enabled" +fi + +# --- status, the command the README tells people to run --------------------- +if status_out="$(guest 'satd-appliance status 2>&1')" && grep -q "network:" <<< "$status_out"; then + pass "satd-appliance status reports the node's state" +else + fail "satd-appliance status reports the node's state" "$status_out" +fi + +echo +if [[ $FAILURES -ne 0 ]]; then + echo "$FAILURES boot check(s) failed" >&2 + exit 1 +fi +echo "all appliance boot checks passed" diff --git a/contrib/stack/caddy/Caddyfile b/contrib/stack/caddy/Caddyfile index a984e6d0f..ac556681d 100644 --- a/contrib/stack/caddy/Caddyfile +++ b/contrib/stack/caddy/Caddyfile @@ -29,5 +29,5 @@ # is why it is here; it stays on the compose network otherwise. :9443 { import satd_tls - reverse_proxy satd:9332 + reverse_proxy {$SATD_HOST:satd}:9332 } diff --git a/contrib/stack/compose.btcpay.yml b/contrib/stack/compose.btcpay.yml index 86999757e..f4fb7ce21 100644 --- a/contrib/stack/compose.btcpay.yml +++ b/contrib/stack/compose.btcpay.yml @@ -40,18 +40,21 @@ services: nbxplorer: image: nicolasdorier/nbxplorer:2.5.21@sha256:0eaa2b165873face1ac699297b45f5110d57b557558d68588896dc386c7eb3cb + # No `depends_on: satd`. The appliance runs satd natively under systemd + # and reaches it through `extra_hosts: satd:host-gateway`, so there is no + # satd service in that project to depend on. Nothing is lost: this + # service retries its connection and is `restart: unless-stopped`, so a + # satd that is still starting delays it rather than breaking it. depends_on: - satd: - condition: service_healthy btcpay-db: condition: service_healthy environment: NBXPLORER_NETWORK: ${NETWORK:-signet} NBXPLORER_BIND: 0.0.0.0:32838 NBXPLORER_CHAINS: btc - NBXPLORER_BTCRPCURL: http://satd:8332 + NBXPLORER_BTCRPCURL: http://${SATD_HOST:-satd}:8332 NBXPLORER_BTCRPCCOOKIEFILE: /satd/rpc-cookie - NBXPLORER_BTCNODEENDPOINT: satd:${SATD_P2P_PORT:-38333} + NBXPLORER_BTCNODEENDPOINT: ${SATD_HOST:-satd}:${SATD_P2P_PORT:-38333} NBXPLORER_POSTGRES: Host=btcpay-db;Port=5432;Database=nbxplorer;Username=postgres;Password=${POSTGRES_PASSWORD} # No auth on the compose network only; nothing publishes this port. NBXPLORER_NOAUTH: "1" diff --git a/contrib/stack/compose.cln.yml b/contrib/stack/compose.cln.yml index a00bbac9f..033398bc2 100644 --- a/contrib/stack/compose.cln.yml +++ b/contrib/stack/compose.cln.yml @@ -18,13 +18,15 @@ services: cln: image: elementsproject/lightningd:v24.11@sha256:30cc9802955cc640a057d65b0ace5cd1c0c8b719e9a28cf516d85f9d00531e1f - depends_on: - satd: - condition: service_healthy + # No `depends_on: satd`. The appliance runs satd natively under systemd + # and reaches it through `extra_hosts: satd:host-gateway`, so there is no + # satd service in that project to depend on. Nothing is lost: this + # service retries its connection and is `restart: unless-stopped`, so a + # satd that is still starting delays it rather than breaking it. entrypoint: ["lightningd"] command: - --network=${CLN_NETWORK:-signet} - - --bitcoin-rpcconnect=satd + - --bitcoin-rpcconnect=${SATD_HOST:-satd} - --bitcoin-rpcport=8332 - --bitcoin-rpccookiefile=/satd/rpc-cookie # satd answers RPC quickly, but a node still catching up can block a diff --git a/contrib/stack/compose.lightning.yml b/contrib/stack/compose.lightning.yml index a26a7328f..aa148a439 100644 --- a/contrib/stack/compose.lightning.yml +++ b/contrib/stack/compose.lightning.yml @@ -29,16 +29,18 @@ services: lnd: image: lightninglabs/lnd:v0.18.5-beta@sha256:2b560c9beb559c57ab2f2da1dfed80d286cf11a6dc6e4354cab84aafba79b6f6 - depends_on: - satd: - condition: service_healthy + # No `depends_on: satd`. The appliance runs satd natively under systemd + # and reaches it through `extra_hosts: satd:host-gateway`, so there is no + # satd service in that project to depend on. Nothing is lost: this + # service retries its connection and is `restart: unless-stopped`, so a + # satd that is still starting delays it rather than breaking it. command: - --bitcoin.active - --bitcoin.${NETWORK:-signet} - --bitcoin.node=neutrino # The only peer LND talks to. `--nobootstrap` keeps it that way, so a # green run is evidence about satd rather than about the network. - - --neutrino.connect=satd:${SATD_P2P_PORT:-38333} + - --neutrino.connect=${SATD_HOST:-satd}:${SATD_P2P_PORT:-38333} - --nobootstrap - --noseedbackup - --rpclisten=0.0.0.0:10009 diff --git a/contrib/stack/compose.proxy.yml b/contrib/stack/compose.proxy.yml index 1de6ab798..1b1c54e70 100644 --- a/contrib/stack/compose.proxy.yml +++ b/contrib/stack/compose.proxy.yml @@ -24,9 +24,20 @@ services: caddy: image: caddy:2.8-alpine@sha256:af32e97399febea808609119bb21544d0265c58a02836576e32a2d082c262c17 - depends_on: - satd-init: - condition: service_completed_successfully + # Caddy refuses to start without its certificate, and the certificate is + # written by whatever issued it — satd-init in the compose stack, first + # boot on the appliance. Waiting for the file rather than depending on a + # service keeps this overlay usable in both, where a `depends_on` would + # name a service that does not exist in one of them. + entrypoint: + - /bin/sh + - -c + - | + while [ ! -s /satd/tls/fullchain.crt ]; do + echo "waiting for /satd/tls/fullchain.crt ..." + sleep 2 + done + exec caddy run --config /etc/caddy/Caddyfile --adapter caddyfile volumes: - ./caddy/Caddyfile:/etc/caddy/Caddyfile:ro # Read-only, for the certificate and key only. Caddy runs as root in diff --git a/contrib/stack/compose.yml b/contrib/stack/compose.yml index a6abc60ce..6ab30088a 100644 --- a/contrib/stack/compose.yml +++ b/contrib/stack/compose.yml @@ -14,6 +14,11 @@ name: satd-stack +# Overlays address this node as ${SATD_HOST:-satd}. In this file that +# resolves to the service below. On the appliance, where satd runs natively +# under systemd and there is no satd container, it is set to the docker +# network's gateway — see contrib/appliance/files/compose.appliance.yml. + x-satd-image: &satd-image ${SATD_IMAGE:-ghcr.io/epochbtc/satd:latest} services: diff --git a/contrib/systemd/satd.service b/contrib/systemd/satd.service index 18e95ed0c..fb4aaa006 100644 --- a/contrib/systemd/satd.service +++ b/contrib/systemd/satd.service @@ -9,8 +9,9 @@ # sudo systemctl enable --now satd # # Cookie-auth access for non-root operators: add yourself to the -# `satd` group. The unit chmods $DATADIR/.cookie to 0640 on every -# start, so group members can run `sat-cli` / `sat-tui` without sudo: +# `satd` group. The unit chmods the cookie to 0640 on every start — +# both $DATADIR/.cookie (mainnet) and $DATADIR//.cookie — so +# group members can run `sat-cli` / `sat-tui` without sudo: # sudo usermod -aG satd # (group changes only take effect in new login sessions) # @@ -72,7 +73,19 @@ ExecStop=/bin/kill -SIGTERM $MAINPID # without sudo. `-` prefix: missing cookie (e.g., when --rpcuser is # set instead) is non-fatal. Cookie still rotates on every daemon # start, so this is no less secure than 0600 for outside-group users. -ExecStartPost=-/bin/chmod 0640 ${SATD_DATADIR}/.cookie +# +# The glob matters. satd writes the cookie under the network's +# subdirectory on every chain but mainnet — signet/.cookie, +# testnet4/.cookie, regtest/.cookie — so naming only ${SATD_DATADIR}/.cookie +# left group members unable to authenticate on any of them, which is +# precisely the case where an operator is most likely to be poking at the +# node by hand. /bin/sh is needed because systemd does not expand globs in +# ExecStartPost= arguments. +# ${SATD_DATADIR} rather than $SATD_DATADIR: systemd expands the braced form +# to a single word, and where it does not expand inside quotes the shell +# resolves it from the same Environment= value. Both readings give the same +# path, which is what makes this safe to write once. +ExecStartPost=-/bin/sh -c 'chmod 0640 ${SATD_DATADIR}/.cookie ${SATD_DATADIR}/*/.cookie 2>/dev/null; exit 0' # Reindex can take hours, but the heartbeat IS the liveness check # only when there's a finite budget to extend. EXTEND_TIMEOUT_USEC= From 8bf4de4d2a459918117e3fef52aa85efbfaa6a9c Mon Sep 17 00:00:00 2001 From: Benjamen Keroack Date: Tue, 8 Sep 2026 15:51:04 -0600 Subject: [PATCH 05/22] ci: gate the stack and the appliance image, and sign image manifests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three tiers, in increasing cost, each gated on the paths it covers: - the certificate script and the container healthcheck probe: seconds, no docker, plus shellcheck at error level over every shell file in this work; - the reference stack on regtest, with every TLS listener probed from outside the container against the CA the stack generated, LND syncing over Neutrino and RTL served through the proxy; - the appliance image, built and then booted under QEMU. Hosted runners only. satd is public, and a `pull_request` job on a self-hosted runner would let a fork PR execute arbitrary code on a maintainer machine. Path gating is per-job rather than a workflow-level `paths` filter: a path-skipped workflow never reports its contexts and leaves a PR waiting on a status that never arrives, where a skipped job reports "skipped" and satisfies branch protection. `sign-tarballs.sh --images ` signs appliance images. They are several GB each and exceed GitHub's 2 GB per-asset limit, so the images go to object storage and what lands on the release is a manifest of their SHA-256 sums with a minisign signature over it — which authenticates every image in the list just as well, next to the tarball signatures where anyone verifying already knows to look. The script deliberately does not upload the images themselves: it has no object-storage credentials and should not acquire any. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01K9Nb3m8HuhNbjkJYp9Frmt --- .github/workflows/appliance.yml | 224 +++++++++++++++++++++++++++++++ contrib/release/sign-tarballs.sh | 71 ++++++++++ 2 files changed, 295 insertions(+) create mode 100644 .github/workflows/appliance.yml diff --git a/.github/workflows/appliance.yml b/.github/workflows/appliance.yml new file mode 100644 index 000000000..8b24c1d19 --- /dev/null +++ b/.github/workflows/appliance.yml @@ -0,0 +1,224 @@ +# Appliance CI — the reference stack and the downloadable VM image. +# +# Three things are gated here, in increasing cost: +# +# 1. The certificate script and the container healthcheck probe. Seconds, +# no docker, run on any PR that touches them. Both are small pieces of +# shell that everything else depends on being right. +# 2. The reference stack (contrib/stack): brought up on regtest, with every +# TLS listener probed from outside the container against the CA the +# stack generated, plus LND syncing to the node over Neutrino and RTL +# served through the proxy. This is where a claim like "Sparrow can talk +# to this" is actually checked. +# 3. The appliance image: built with mmdebstrap, booted under QEMU, and +# inspected through the guest agent. This is the "the image is not +# broken" gate, and it boots the artifact itself rather than a +# test-only variant. +# +# Everything runs on GitHub-hosted runners. satd is public, and a +# `pull_request` job on a self-hosted runner would let a fork PR execute +# arbitrary code on a maintainer machine. +# +# On a tag the same build runs for every published format and uploads the +# artifacts; signing stays a manual post-tag step, as it is for tarballs. + +name: Appliance + +on: + pull_request: + types: [opened, synchronize, reopened] + push: + tags: + - 'v*' + workflow_dispatch: + inputs: + flavor: + description: "Which image flavour to build" + required: false + default: "core" + type: choice + options: ["core", "desktop", "both"] + +concurrency: + group: appliance-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +env: + RUST_TOOLCHAIN: '1.93.0' + +jobs: + # Job-level path gating rather than a workflow-level `paths` filter: a + # path-skipped workflow never reports its contexts, which leaves a PR + # waiting on a status that will never arrive. A skipped *job* reports + # "skipped", which satisfies branch protection. + changes: + name: detect appliance changes + runs-on: ubuntu-24.04 + timeout-minutes: 5 + outputs: + stack: ${{ steps.filter.outputs.stack }} + appliance: ${{ steps.filter.outputs.appliance }} + scripts: ${{ steps.filter.outputs.scripts }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - id: filter + run: | + set -euo pipefail + if [[ "${{ github.event_name }}" != "pull_request" ]]; then + echo "stack=true" >> "$GITHUB_OUTPUT" + echo "appliance=true" >> "$GITHUB_OUTPUT" + echo "scripts=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + base="${{ github.event.pull_request.base.sha }}" + files="$(git diff --name-only "$base"...HEAD)" + echo "changed files:"; echo "$files" | sed 's/^/ /' + match() { grep -qE "$1" <<< "$files" && echo true || echo false; } + # The stack job also covers the runtime image, since it ships + # mkca.sh, satd-init and the config template. + echo "stack=$(match '^(contrib/stack/|contrib/docker/|Dockerfile$)')" >> "$GITHUB_OUTPUT" + echo "appliance=$(match '^(contrib/appliance/|contrib/stack/|contrib/systemd/)')" >> "$GITHUB_OUTPUT" + echo "scripts=$(match '^contrib/(stack/tls/|stack/tests/|docker/)')" >> "$GITHUB_OUTPUT" + + # Cheap and always worth running when touched: no docker, no network. + scripts: + name: certificate + healthcheck scripts + needs: changes + if: needs.changes.outputs.scripts == 'true' + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + - name: mkca.sh + run: contrib/stack/tests/mkca-test.sh + - name: satd-healthcheck + run: contrib/docker/tests/healthcheck-test.sh + - name: shellcheck + run: | + sudo apt-get update -qq + sudo apt-get install -y -qq shellcheck + # `-S error` keeps this a correctness gate rather than a style one; + # the tree has its own conventions that shellcheck disagrees with. + shellcheck -S error \ + contrib/stack/tls/mkca.sh \ + contrib/stack/satd/satd-init \ + contrib/docker/satd-healthcheck \ + contrib/appliance/build.sh \ + contrib/appliance/bin/satd-appliance \ + contrib/appliance/firstboot/satd-appliance-firstboot \ + contrib/appliance/provision/*.sh + + stack: + name: reference stack (regtest, TLS probed) + needs: changes + if: needs.changes.outputs.stack == 'true' + runs-on: ubuntu-24.04 + # Building the runtime image compiles satd from scratch on a cold cache, + # and two stack bring-ups follow it. + timeout-minutes: 90 + steps: + - uses: actions/checkout@v4 + - name: Free disk space + run: | + set -euo pipefail + sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc /opt/hostedtoolcache/CodeQL + df -h / + - name: Build the runtime image + # The real Dockerfile, not a shortcut: the image under test has to be + # the one that ships mkca.sh, satd-init and the config template. + run: docker build -t satd:ci . + - name: Core stack + run: SATD_IMAGE=satd:ci contrib/stack/tests/smoke.sh + - name: With the Lightning and proxy overlays + run: | + SATD_IMAGE=satd:ci SATD_SMOKE_PORT_BASE=21600 \ + contrib/stack/tests/smoke.sh --with lightning --with proxy + - name: Compose files parse + run: | + set -euo pipefail + for overlay in lightning cln btcpay proxy; do + docker compose -f contrib/stack/compose.yml \ + -f "contrib/stack/compose.$overlay.yml" \ + --env-file contrib/stack/.env.example \ + config > /dev/null + echo "ok: $overlay" + done + # cashu extends the Lightning overlay, so it only parses with it. + MINT_PRIVATE_KEY=ci POSTGRES_PASSWORD=ci docker compose \ + -f contrib/stack/compose.yml \ + -f contrib/stack/compose.lightning.yml \ + -f contrib/stack/compose.cashu.yml \ + --env-file contrib/stack/.env.example config > /dev/null + echo "ok: cashu" + + image: + name: build and boot the appliance image + needs: changes + if: needs.changes.outputs.appliance == 'true' + runs-on: ubuntu-24.04 + timeout-minutes: 90 + strategy: + fail-fast: false + matrix: + flavor: ${{ github.event_name == 'pull_request' && fromJSON('["core"]') || fromJSON('["core","desktop"]') }} + steps: + - uses: actions/checkout@v4 + - name: Free disk space + run: | + set -euo pipefail + sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc /opt/hostedtoolcache/CodeQL + sudo docker image prune --all --force > /dev/null 2>&1 || true + df -h / + - name: Install build dependencies + # rocksdb-sys runs bindgen, which needs libclang; the native deps + # (rocksdb, zstd, lz4) need cmake and a compiler, and reqwest's TLS + # backend needs libssl. Every other Rust job in this repository + # installs the same set — without it the build dies in under a + # minute, long before anything interesting compiles. + run: | + set -euo pipefail + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + clang \ + cmake \ + libclang-dev \ + libssl-dev \ + pkg-config + - uses: dtolnay/rust-toolchain@master + with: + toolchain: ${{ env.RUST_TOOLCHAIN }} + - uses: Swatinem/rust-cache@v2 + - name: Build the binaries the image installs + run: cargo build --release --locked --bin satd --bin sat-cli --bin sat-tui + - name: Build the image + run: | + contrib/appliance/build-in-docker.sh \ + --flavor ${{ matrix.flavor }} \ + --out "$GITHUB_WORKSPACE/appliance-out" + ls -lh "$GITHUB_WORKSPACE/appliance-out" + - name: Boot it and check every surface + run: | + set -euo pipefail + # /dev/kvm is present on hosted Linux runners, so this boots with + # hardware acceleration; the test falls back to TCG where it is not. + ls -l /dev/kvm || echo "no /dev/kvm; the boot test will use TCG" + image="$(ls "$GITHUB_WORKSPACE"/appliance-out/*.qcow2 | head -1)" + contrib/appliance/tests/boot-test.sh --image "$image" --in-docker + - name: Upload + if: github.event_name != 'pull_request' + uses: actions/upload-artifact@v4 + with: + name: satd-appliance-${{ matrix.flavor }} + # The raw image is large and reconstructible from the qcow2; the + # checksums cover everything the build produced. + path: | + appliance-out/*.qcow2 + appliance-out/*.ova + appliance-out/*.SHA256SUMS + retention-days: 14 + compression-level: 0 diff --git a/contrib/release/sign-tarballs.sh b/contrib/release/sign-tarballs.sh index 6b017bff2..f56b16a58 100755 --- a/contrib/release/sign-tarballs.sh +++ b/contrib/release/sign-tarballs.sh @@ -11,11 +11,22 @@ # # Usage: # contrib/release/sign-tarballs.sh [--dry-run] +# contrib/release/sign-tarballs.sh --images [--dry-run] # # Flags: # --dry-run Sign locally and round-trip verify, but skip the # `gh release upload`. Useful before a real release to # validate the maintainer's local signing setup. +# --images +# Sign the appliance images in instead of the release's +# tarballs. Appliance images are several GB each and exceed +# GitHub's 2 GB per-asset limit, so they are published to +# object storage; what goes on the release is a manifest of +# their SHA-256 sums plus a minisign signature over it. That +# is what this mode produces and uploads. Upload the image +# files themselves to object storage separately — this script +# deliberately does not, since it has no credentials for it +# and should not acquire any. # # Optional env: # SATD_MINISIGN_KEY path to encrypted minisign secret key file @@ -26,9 +37,11 @@ set -euo pipefail DRY_RUN=0 +IMAGES_DIR="" while [[ $# -gt 0 ]]; do case "$1" in --dry-run) DRY_RUN=1; shift ;; + --images) IMAGES_DIR="${2:-}"; shift 2 ;; --help|-h) sed -n '1,/^set -e/p' "$0" | sed -n '/^# /p' | sed 's/^# \?//' exit 0 ;; @@ -57,6 +70,64 @@ work=$(mktemp -d -t satd-sign-XXXXXX) trap 'unset -v MINISIGN_PASSPHRASE 2>/dev/null; rm -rf "$work"' EXIT cd "$work" +if [[ -n "$IMAGES_DIR" ]]; then + # --- appliance images ------------------------------------------------- + # The manifest is the signed object, not the images: a signature over a + # list of SHA-256 sums authenticates every image in it just as well, and + # it is small enough to live on the release next to the tarball + # signatures where anyone verifying already knows to look. + [[ -d "$IMAGES_DIR" ]] || { echo "no such directory: $IMAGES_DIR" >&2; exit 1; } + IMAGES_DIR="$(cd "$IMAGES_DIR" && pwd)" + + shopt -s nullglob + images=( "$IMAGES_DIR"/*.qcow2 "$IMAGES_DIR"/*.raw "$IMAGES_DIR"/*.ova "$IMAGES_DIR"/*.iso "$IMAGES_DIR"/*.vmdk ) + shopt -u nullglob + if [[ ${#images[@]} -eq 0 ]]; then + echo "no appliance images (*.qcow2 / *.raw / *.ova / *.iso / *.vmdk) in $IMAGES_DIR" >&2 + exit 1 + fi + + manifest="SHA256SUMS-images" + echo ">> Hashing ${#images[@]} image(s) — this reads several GB" + : > "$manifest" + for img in "${images[@]}"; do + printf ' %s\n' "$(basename "$img")" + ( cd "$IMAGES_DIR" && sha256sum "$(basename "$img")" ) >> "$work/$manifest" + done + sort -k2 -o "$manifest" "$manifest" + echo ">> Manifest:" + sed 's/^/ /' "$manifest" + + echo ">> Signing $manifest" + read -rs -p " minisign passphrase for $KEY: " MINISIGN_PASSPHRASE + echo + if ! out=$(printf '%s\n' "$MINISIGN_PASSPHRASE" | minisign -S -s "$KEY" -m "$manifest" 2>&1); then + echo "$out" >&2 + echo "signing failed (wrong passphrase?)" >&2 + exit 1 + fi + unset -v MINISIGN_PASSPHRASE + minisign -Vm "$manifest" -P "$PUBKEY" > /dev/null + echo " ok: $manifest.minisig" + + if [[ "$DRY_RUN" -eq 1 ]]; then + echo + echo "[dry-run] Skipping upload. Generated:" + ls -1 "$manifest" "$manifest.minisig" + exit 0 + fi + + echo ">> Uploading the manifest and its signature to release $TAG" + gh release upload "$TAG" --repo epochbtc/satd --clobber \ + -- "$manifest" "$manifest.minisig" + + echo + echo "Done. Upload the image files to object storage, then operators verify with:" + echo " minisign -Vm SHA256SUMS-images -P '${PUBKEY}'" + echo " sha256sum -c SHA256SUMS-images" + exit 0 +fi + echo ">> Downloading release artifacts for $TAG" # Re-download every time. --skip-existing was considered but rejected: # if a tarball was tampered with after a previous sign-tarballs run, From a9358e3f6ae7eded59a3f89dc458678a24abac0d Mon Sep 17 00:00:00 2001 From: Benjamen Keroack Date: Tue, 8 Sep 2026 15:52:06 -0600 Subject: [PATCH 06/22] contrib/packaging: Umbrel app, and what a StartOS package needs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sources for the app-store packages, versioned with satd and published from their own repositories, which is how both stores consume them. Both contain satd and nothing else — the daemon, sat-cli, sat-tui and MCP. Umbrel and StartOS users compose Lightning, BTCPay and wallets from their own stores, and a package that bundled a second copy of software the store already offers would be worse than useless. The best-effort notice that applies to the appliance image therefore does not apply here: there is no third-party software to disclaim. The Umbrel app derives its satd service from contrib/stack/compose.yml and runs the same satd-init the reference stack does — which is why that script and mkca.sh are baked into the container image. A package that reimplemented first-run behaviour would drift from the stack within a release. exports.sh mirrors the official `bitcoin` app's variable names, so an app that already knows how to find Bitcoin Core finds satd: it speaks Core's JSON-RPC and cookie format, so nothing else has to change. startos/ is requirements, not a package. A StartOS package is a TypeScript project built with Start9's SDK, and that SDK's shape has changed across StartOS versions; written against a guessed API it would look right in review and not build. The README records what the package must declare — interfaces, health check, backup set, the config options that must NOT exist because txindex is not optional here — so writing it is mechanical once the target version is chosen. It also records the one open question: the certificate renews on container start there, with no scheduler to run the daily timer the appliance uses. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01K9Nb3m8HuhNbjkJYp9Frmt --- contrib/packaging/startos/README.md | 66 ++++++++++++++++++ contrib/packaging/umbrel/README.md | 59 ++++++++++++++++ .../packaging/umbrel/satd/docker-compose.yml | 68 +++++++++++++++++++ contrib/packaging/umbrel/satd/exports.sh | 28 ++++++++ contrib/packaging/umbrel/satd/umbrel-app.yml | 57 ++++++++++++++++ contrib/packaging/umbrel/umbrel-app-store.yml | 6 ++ 6 files changed, 284 insertions(+) create mode 100644 contrib/packaging/startos/README.md create mode 100644 contrib/packaging/umbrel/README.md create mode 100644 contrib/packaging/umbrel/satd/docker-compose.yml create mode 100644 contrib/packaging/umbrel/satd/exports.sh create mode 100644 contrib/packaging/umbrel/satd/umbrel-app.yml create mode 100644 contrib/packaging/umbrel/umbrel-app-store.yml diff --git a/contrib/packaging/startos/README.md b/contrib/packaging/startos/README.md new file mode 100644 index 000000000..93d4f4d2a --- /dev/null +++ b/contrib/packaging/startos/README.md @@ -0,0 +1,66 @@ +# StartOS package — not written yet + +A StartOS package is a TypeScript project built with Start9's SDK. The SDK's +API has changed shape across StartOS versions, and a package written against +a guessed API produces something that looks right in review and does not +build. + +So this directory holds the requirements rather than a package. Writing it +is mechanical once the target version is fixed: + +1. Choose the StartOS version to target, and install that SDK. +2. Copy the structure of `start9labs/bitcoind-startos` at the matching tag — + satd is a drop-in for Bitcoin Core's RPC, config file and cookie format, + so that package's shape is the right starting point rather than a blank + project. +3. Publish from its own repository (`epochbtc/satd-startos`); Start9's + registry expects one repository per package. + +## What the package must declare + +**Contents: satd only** — the daemon, `sat-cli`, `sat-tui` and the MCP +server. No Lightning, no BTCPay, no wallets: StartOS users compose those +from their own store, and a package that bundled a second copy of software +the store already offers would be worse than useless. + +**Image:** `ghcr.io/epochbtc/satd`, unmodified. It already carries +`satd-init` and `mkca.sh`, so the package's first run is the same one the +reference stack and the appliance perform, and cannot drift from them. + +**Interfaces:** + +| Interface | Port | Notes | +|---|---|---| +| JSON-RPC | 8332 | plain, internal to the StartOS network, cookie auth | +| JSON-RPC (TLS) | 8336 | LAN-facing | +| Electrum (TLS) | 50002 | LAN-facing; the plain 50001 stays internal | +| Esplora (TLS) | 3001 | LAN-facing, prefix `/api` | +| MCP (TLS) | 8339 | bearer token from the generated authfile | +| P2P | 8333 | mainnet | + +**Config options:** the network, and nothing else that changes indexing. +`txindex` and `addressindex` stay forced on — Electrum and Esplora both +require them — and there is therefore no prune option to offer. + +**Health check:** `/readyz` on the metrics listener (port 9332, internal). +It reports not-ready until the chainstate is loaded and every listener is +bound, which is what a dependent package needs it to mean. Sync progress +comes from `getblockchaininfo`. + +**Backups:** a wallet-less node has no irreplaceable state; exclude the +chain and index directories. Do include `/var/lib/satd/tls` if the +deployment wants its CA to survive a restore — restoring without it means +every client re-imports. + +**Actions to expose in the UI:** show the CA certificate (so a user can +import it), show the MCP token and connection snippet, and the Electrum / +Esplora connection strings. + +## Open question for the package + +The CA and certificate are reissued by `mkca.sh` when they near expiry or +the machine's addresses change. On the appliance a systemd timer runs that +daily. A StartOS package has no equivalent scheduler of its own, so it would +renew on container start — fine for a box that reboots, not fine for one +that runs for a year. Decide whether that is acceptable or whether the +package needs a scheduled action. diff --git a/contrib/packaging/umbrel/README.md b/contrib/packaging/umbrel/README.md new file mode 100644 index 000000000..2f69e0ef7 --- /dev/null +++ b/contrib/packaging/umbrel/README.md @@ -0,0 +1,59 @@ +# App-store packages + +Sources for the Umbrel and StartOS packages. They live here so they are +reviewed and versioned with satd; each is published from its own repository, +because that is how both stores consume packages. + +**These packages contain satd and nothing else** — the daemon, `sat-cli`, +`sat-tui` and the MCP server. No Lightning, no BTCPay, no wallets. Users of +those platforms compose the rest from their own app stores, and a package +that bundled a second copy of software the store already offers would be +worse than useless. The best-effort support notice that applies to the +appliance image therefore does not apply here: there is no third-party +software to disclaim. + +Both derive their satd service from `contrib/stack/compose.yml`, and both +run the same `satd-init` and `mkca.sh` that the reference stack does — they +are baked into the container image for exactly this reason. A package that +re-implemented first-run behaviour would drift from the stack within a +release. + +## Status + +`umbrel/` is complete and ready to publish to a community app store. + +`startos/` is **not written yet.** A StartOS package is a TypeScript project +built with Start9's SDK, and the SDK's shape has changed across StartOS +versions; writing one against a guessed API would produce something that +looks right and does not build. What it needs is: pick the StartOS version +to target, install that SDK, and copy the structure of +`start9labs/bitcoind-startos` at the matching tag. The interfaces to declare +are RPC (plain, app-internal), RPC-TLS, Electrum-TLS, Esplora-TLS and MCP; +the health check maps to `/readyz` and sync progress to +`getblockchaininfo`. Network is a config option; `txindex` is not — it stays +forced on, because Electrum and Esplora require it. + +## Publishing the Umbrel app + +Umbrel installs community stores from a git repository whose root holds +`umbrel-app-store.yml` and one directory per app: + +``` +epochbtc/umbrel-apps/ + umbrel-app-store.yml + satd/ + umbrel-app.yml + docker-compose.yml + exports.sh +``` + +Copy `umbrel/` to that repository's root. Before submitting upstream to +`getumbrel/umbrel-apps`, re-check two things against the current store: + +- the `manifestVersion` and the field set in `umbrel-app.yml`, which have + changed between store generations; +- whether apps can now declare satd as an alternative to the `bitcoin` + dependency — the mechanism added so Bitcoin Knots could satisfy it. If + they can, `exports.sh` should export the same variable names the official + `bitcoin` app does, so a dependent app is satisfied by either. If they + cannot, satd runs standalone and dependent apps keep using Core. diff --git a/contrib/packaging/umbrel/satd/docker-compose.yml b/contrib/packaging/umbrel/satd/docker-compose.yml new file mode 100644 index 000000000..d341d4194 --- /dev/null +++ b/contrib/packaging/umbrel/satd/docker-compose.yml @@ -0,0 +1,68 @@ +# Derived from contrib/stack/compose.yml. The satd service is defined there; +# what changes here is only what Umbrel owns: the app network, its proxy, +# and where the data lives. +# +# satd-init runs first and does exactly what it does in the reference stack +# — issues this install's CA and certificate, renders bitcoin.conf, mints +# the MCP token. It is baked into the image, so this file needs no bind +# mounts from a repository Umbrel has not cloned. +version: "3.7" + +services: + app_proxy: + environment: + APP_HOST: satd_server_1 + APP_PORT: 3001 + # Esplora is served over TLS by satd itself; the proxy has to speak + # TLS to it rather than plain HTTP. + PROXY_AUTH_ADD: "false" + + # The tag is bumped with each satd release the package is published for; + # v0.5.2 is the release this package was written against and does not + # exist until that release is cut. + init: + image: ghcr.io/epochbtc/satd:v0.5.2 + entrypoint: ["/usr/local/bin/satd-init"] + user: "2121:2121" + restart: on-failure + environment: + NETWORK: ${APP_SATD_NETWORK:-mainnet} + SATD_MCP: "1" + SATD_STACK_SUBNET: "10.21.0.0/16" + # The name clients reach this node by. Umbrel publishes .local + # over mDNS, and the certificate has to carry that name or every LAN + # client sees a mismatch. + SATD_TLS_HOSTNAME: ${DEVICE_HOSTNAME:-umbrel} + SATD_P2P_PORT: ${APP_SATD_P2P_PORT:-8333} + volumes: + - ${APP_DATA_DIR}/data:/var/lib/satd + + server: + image: ghcr.io/epochbtc/satd:v0.5.2 + depends_on: + init: + condition: service_completed_successfully + # The network is an argument, never a config-file line: satd accepts + # `signet=1` in a file and then ignores it, which silently starts a + # mainnet node. + command: + - --datadir=/var/lib/satd + - --${APP_SATD_NETWORK:-mainnet} + environment: + SATD_HEALTH_URL: http://127.0.0.1:9332/readyz + volumes: + - ${APP_DATA_DIR}/data:/var/lib/satd + ports: + - "${APP_SATD_P2P_PORT:-8333}:${APP_SATD_P2P_PORT:-8333}" + - "8336:8336" + - "50002:50002" + - "3001:3001" + - "8339:8339" + stop_grace_period: 10m + restart: on-failure + healthcheck: + test: ["CMD", "/usr/local/bin/satd-healthcheck"] + interval: 30s + timeout: 10s + start_period: 10m + retries: 3 diff --git a/contrib/packaging/umbrel/satd/exports.sh b/contrib/packaging/umbrel/satd/exports.sh new file mode 100644 index 000000000..d1efa6522 --- /dev/null +++ b/contrib/packaging/umbrel/satd/exports.sh @@ -0,0 +1,28 @@ +# Exported to other Umbrel apps that want to use this node. +# +# The names mirror the official `bitcoin` app's, so an app that already +# knows how to find Bitcoin Core can find satd — satd speaks Core's JSON-RPC +# and reads Core's cookie format, so nothing else has to change. +# +# The RPC endpoint here is the plain one on the app network. That is the +# same posture the official app has, and the reason satd's TLS listener +# exists separately: TLS is for what leaves the device, and a private CA is +# not something other store apps can be taught to trust. +# The container's DNS name on the app network, not an address: Umbrel +# resolves `__1`, and an address would be whatever the bridge +# happened to hand out. (`10.21.0.0` here would be a network address, not a +# host at all.) +export APP_SATD_HOST="satd_server_1" +export APP_SATD_IP="satd_server_1" +export APP_SATD_RPC_PORT="8332" +export APP_SATD_P2P_PORT="${APP_SATD_P2P_PORT:-8333}" +export APP_SATD_ELECTRUM_PORT="50001" +export APP_SATD_ELECTRUM_TLS_PORT="50002" +export APP_SATD_ESPLORA_PORT="3000" +export APP_SATD_NETWORK="${APP_SATD_NETWORK:-mainnet}" + +# Cookie authentication. satd writes the cookie under the network's +# subdirectory, and `rpc-cookie` is a stable symlink satd-init maintains to +# whichever path that is — so a dependent app needs one path rather than a +# per-network rule. +export APP_SATD_RPC_COOKIE_FILE="${APP_DATA_DIR}/data/rpc-cookie" diff --git a/contrib/packaging/umbrel/satd/umbrel-app.yml b/contrib/packaging/umbrel/satd/umbrel-app.yml new file mode 100644 index 000000000..59adbdc7c --- /dev/null +++ b/contrib/packaging/umbrel/satd/umbrel-app.yml @@ -0,0 +1,57 @@ +manifestVersion: 1 +id: satd +category: bitcoin +name: satd +version: "0.5.2" +tagline: A Bitcoin full node in Rust, with Electrum and Esplora built in +description: >- + satd is a Bitcoin Core-compatible full node written in Rust. It speaks + Core's JSON-RPC, config file and CLI, and serves an Electrum server and an + Esplora REST API from the same process — no second indexer to run, no + second copy of the chain to store. + + + This app runs satd fully indexed, with TLS on every surface it exposes to + your network. It generates a certificate authority for your install alone + on first start; export it from the app's data directory, import it once, + and Electrum wallets, REST clients and the MCP server are all trusted + together. + + + What it gives you: + + + - Electrum server on TLS, for Sparrow, Electrum, BlueWallet and Zeus. + + - Esplora REST API on TLS, for anything that speaks Blockstream's API. + + - Bitcoin Core-compatible JSON-RPC, plain on the app network and TLS on + your LAN. + + - An MCP server, so an AI assistant can query your own node instead of a + public explorer. + + - `sat-cli` and `sat-tui`, a terminal dashboard for the node. + + + This app contains satd and its own tools only. Lightning, BTCPay and + wallets come from your app store as separate apps. + + + Note on disk: satd here runs with the transaction and address indices on, + because Electrum and Esplora both require them, and pruning is not + compatible with that. Budget for the full chain plus roughly the same + again in indices. +developer: 20 Gauge Software +website: https://github.com/epochbtc/satd +dependencies: [] +repo: https://github.com/epochbtc/satd +support: https://github.com/epochbtc/satd/issues +# satd has no web UI. The Esplora REST API is what a browser can usefully +# open — its root returns JSON — and the app's description points at +# `sat-tui` for an actual dashboard. +port: 3001 +gallery: [] +path: "/api/blocks/tip/height" +submitter: 20 Gauge Software +submission: https://github.com/epochbtc/satd diff --git a/contrib/packaging/umbrel/umbrel-app-store.yml b/contrib/packaging/umbrel/umbrel-app-store.yml new file mode 100644 index 000000000..d88e911a9 --- /dev/null +++ b/contrib/packaging/umbrel/umbrel-app-store.yml @@ -0,0 +1,6 @@ +# Root manifest for the satd community app store. +# +# Umbrel adds a community store from a git URL; this file names it. Copy +# this directory to the root of the repository that serves it. +id: epochbtc +name: Epoch From 3c50a86b9c2880b12be7ac9e192e0ceb2a2ea5e6 Mon Sep 17 00:00:00 2001 From: Benjamen Keroack Date: Tue, 8 Sep 2026 15:52:06 -0600 Subject: [PATCH 07/22] docs: an Operator Manual chapter for the appliance and the stack A new chapter covering all three deliverables together, because they share one node configuration and one certificate scheme and are best understood that way: what each is, which ports are published and which deliberately are not, why there is no prune option anywhere, how the CA and certificate work and how to trust them from each kind of client, and what is actually checked in CI rather than asserted. The support policy appears verbatim here, in contrib/appliance/README.md, in contrib/stack/README.md and on the image's own welcome page, so that someone who meets the bundled software in any of those places meets the same statement about it. packaging.md gains a pointer to the chapter and an accurate description of what the container image now carries, since a packager building on that image inherits its first-run tooling. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01K9Nb3m8HuhNbjkJYp9Frmt --- CHANGELOG.md | 16 ++ docs/manual/src/SUMMARY.md | 1 + docs/manual/src/appliance.md | 255 ++++++++++++++++++++++++++++++++ docs/manual/src/ibd.md | 32 ++++ docs/manual/src/packaging.md | 23 +++ docs/release-notes/0.5.2-pre.md | 120 +++++++++++++++ 6 files changed, 447 insertions(+) create mode 100644 docs/manual/src/appliance.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 93808117f..a3fa16c5b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -58,6 +58,22 @@ item below is (or will be) written up in full in the in-development ### Added +- A **reference stack** (`contrib/stack/`): docker-compose running satd with + RPC, Electrum, Esplora, metrics and optional MCP, each TLS-terminated by a + certificate the install issues for itself, plus best-effort overlays for + LND (Neutrino), Core Lightning, Ride The Lightning, a Cashu mint and + BTCPay Server. +- A **downloadable appliance image** (`contrib/appliance/`): a bootable VM + with satd, its tooling and — in the desktop flavour — Sparrow, Electrum + and Liana already pointed at the node. Signet by default; + `satd-appliance set-network mainnet` switches. Built with `mmdebstrap`, + and gated in CI by booting the artifact under QEMU. +- `sat-cli` and `sat-tui` can reach a TLS-terminated RPC listener: + `-rpctls`, `-rpccacert`, and `-rpcclientcert` / `-rpcclientkey` for mTLS. + Previously an operator who enabled `-rpctlsbind` had to keep the plain + listener up for the project's own clients. +- The container image ships `sat-tui` and a `HEALTHCHECK`, so `docker exec + -it satd sat-tui` works and `depends_on: service_healthy` means something. - `addconnection`, Bitcoin Core's hidden regtest-only RPC for opening an outbound connection of a chosen type (`outbound-full-relay`, `block-relay-only`, `addr-fetch`, `feeler`). `getpeerinfo` now reports the diff --git a/docs/manual/src/SUMMARY.md b/docs/manual/src/SUMMARY.md index 5478d327c..b5225ad36 100644 --- a/docs/manual/src/SUMMARY.md +++ b/docs/manual/src/SUMMARY.md @@ -36,6 +36,7 @@ # Packaging & Deployment - [Packaging satd](packaging.md) +- [Appliance & Reference Stack](appliance.md) # Reference diff --git a/docs/manual/src/appliance.md b/docs/manual/src/appliance.md new file mode 100644 index 000000000..d100f5ce4 --- /dev/null +++ b/docs/manual/src/appliance.md @@ -0,0 +1,255 @@ +# Appliance & Reference Stack + +satd ships three ways to run it beyond a bare binary: a docker-compose +**reference stack**, a downloadable **appliance image**, and packages for +the **Umbrel** and **StartOS** app stores. They share one configuration and +one certificate scheme, so what you learn from any of them applies to the +others. + +| | What it is | Where it lives | Support | +|---|---|---|---| +| Reference stack | compose: satd plus optional third-party overlays | `contrib/stack/` | satd supported; overlays best-effort | +| Appliance image | a bootable VM with satd, wallets and Lightning | `contrib/appliance/` | satd supported; bundled software best-effort | +| Store packages | satd, `sat-cli`, `sat-tui` and MCP only | `contrib/packaging/` | supported | + +> **The appliance image and the stack's overlays bundle third-party software +> (wallets, Lightning, ecash, and others) so you can try satd end to end. +> That software is included on a best-effort basis for evaluation and +> testing. It is not a production deployment: we do not track its security +> advisories in real time, and a critical fix in a bundled component may not +> appear in an appliance image until the next scheduled build. satd itself +> in this image is the same supported release as our tarballs and container +> image. For production, run satd from a release artifact or an app store +> package and operate the other components yourself.** +> +> The Umbrel and StartOS packages carry no such notice: they contain only +> satd. + +## The reference stack + +```sh +cd contrib/stack +cp .env.example .env +docker compose up -d +``` + +That runs satd on signet with JSON-RPC, Electrum, Esplora and the metrics +endpoint all enabled, each TLS-terminated by a certificate the install +issues for itself on first start. + +Overlays add third-party software, combined with repeated `-f`: + +```sh +docker compose -f compose.yml -f compose.lightning.yml -f compose.proxy.yml up -d +``` + +| Overlay | Contents | +|---|---| +| `compose.lightning.yml` | LND in Neutrino mode, Ride The Lightning | +| `compose.cln.yml` | Core Lightning, as an alternative to LND | +| `compose.cashu.yml` | a Nutshell mint backed by that LND | +| `compose.btcpay.yml` | Postgres, NBXplorer, BTCPay Server | +| `compose.proxy.yml` | Caddy, terminating TLS for the web UIs and metrics | + +Overlays that need a secret have no default and refuse to start without one, +rather than shipping a value every deployment would share: + +```sh +echo "MINT_PRIVATE_KEY=$(openssl rand -hex 32)" >> .env +echo "POSTGRES_PASSWORD=$(openssl rand -hex 24)" >> .env +``` + +### Which ports are published + +Plain RPC, Electrum, Esplora and metrics listeners bind the compose network +and are **not** published. They exist because the overlay containers cannot +be taught to trust a private CA. What leaves the host is TLS only: + +| Published | Surface | +|---|---| +| 8336 | JSON-RPC over TLS | +| 50002 | Electrum over TLS | +| 3001 | Esplora over TLS | +| 8339 | MCP over TLS, when `SATD_MCP=1` | +| 38333 (signet) | Bitcoin P2P | +| 443 / 8443 / 9443 | web UIs and metrics, with `compose.proxy.yml` | + +The internal RPC port is 8332 on **every** network so that overlays, the +proxy and the store packages address one fixed port. The cost is that +`sat-cli` inside the container needs `-rpcport=8332` on any network but +mainnet, since it derives its default from the chain: + +```sh +docker compose exec satd sat-cli -rpcport=8332 getblockchaininfo +docker compose exec -it satd sat-tui -rpcport=8332 +``` + +### No pruning, anywhere + +Electrum and Esplora both require `txindex`, and satd rejects `txindex` +together with `prune`. So every deliverable here runs a fully indexed node. +On mainnet that is the whole chain plus the address, spend and transaction +indices — see [Disk Footprint & Indices](disk-footprint.md), and budget a +2 TB volume. [Initial Block Download & Fast Sync](ibd.md) covers loading an +AssumeUTXO snapshot so the node is usable in hours rather than days. + +signet is the default everywhere for this reason: it is the only network on +which the whole stack is a one-evening exercise. + +## TLS + +`contrib/stack/tls/mkca.sh` is the one certificate script. The compose +stack's `satd-init`, the appliance's first boot, and both store packages run +it, so all four produce the same material and the client instructions are +identical everywhere. + +It creates a **CA for that install only**, then issues **one server +certificate** that every satd surface presents. That is why there are two +certificates and not one self-signed: clients import the CA once, and every +later reissue — after a hostname change, a new address, or a year — is +signed by a CA they already trust, with nothing to accept again. + +The certificate covers `localhost`, `127.0.0.1`, `::1`, the hostname, +`.local`, and the machine's non-bridge addresses. **Prefer the +mDNS name.** A DHCP change invalidates an address in the SAN list; the name +survives it. + +Reissue happens automatically when the certificate expires within 30 days or +the machine's names or addresses have changed. The CA is never rotated +automatically — that would invalidate trust every client has established. +Rotating it is a deliberate act: delete the CA files and re-run. + +### Trusting it + +```sh +# compose +docker compose exec satd cat /var/lib/satd/tls/ca.crt > satd-ca.crt +# appliance +satd-appliance tls export-ca > satd-ca.crt +``` + +Then: + +| Client | How | +|---|---| +| `curl`, python, Go, anything using the OS store | import `satd-ca.crt` into the system trust store | +| `sat-cli` / `sat-tui` | `--rpctls --rpccacert=satd-ca.crt --rpcport=8336` | +| Firefox | already policy-configured on the appliance desktop; elsewhere, import it | +| Sparrow, Electrum, Liana | these pin the server certificate on first use; accept it once | + +`-rpccacert` wants the certificate that **issued** the one the server +presents. For a self-signed node certificate that is the certificate itself; +it is not the leaf of a chain, which cannot anchor its own path. + +### What TLS does not cover + +Bearer tokens from an [`authfile`](authentication.md) still gate MCP, +streaming and Esplora writes; the plain loopback RPC listener is +cookie-authenticated. The local CA authenticates the appliance to clients, +not clients to the appliance — every surface supports mTLS if you turn it +on, but none requires it by default. + +The metrics endpoint and the streaming WebSocket have no native TLS. They +stay on loopback or the container network, and `compose.proxy.yml` fronts +them. + +## The appliance image + +A bootable VM: `core` is headless, `desktop` adds XFCE with Sparrow, +Electrum and Liana already pointed at the node. Each bundled wallet is +installed from its project's own release, with the download checked against +a signature from a pinned key; the build fails rather than installing +anything that does not verify. + +```sh +contrib/appliance/build-in-docker.sh --flavor core --out out/ +``` + +No root, no KVM and no Packer: the image is built with `mmdebstrap` and a +GRUB install onto a loop device, which runs in a container and on a hosted +CI runner in minutes. `contrib/appliance/README.md` has the details. + +First boot creates everything that must be unique to an install — the disk +size, the console password, the CA and certificate, the MCP token — because +an image that shipped any of those would be an image where every download +shared them. The build asserts none of them exist in the artifact and +refuses to finish otherwise. + +Day-to-day operation goes through one command: + +```sh +satd-appliance status +satd-appliance tls export-ca +sudo satd-appliance set-network mainnet # refuses below 1.5 TB free +sudo satd-appliance enable lightning +satd-appliance logs satd +``` + +satd runs natively under systemd; the overlays run as containers from +`/opt/satd/stack`, which is `contrib/stack`'s overlay files unmodified. + +The firewall is default-deny inbound, and `sshd` is off until +`satd-appliance ssh enable`. + +## Why LND runs in Neutrino mode + +LND's `bitcoind` backend requires Bitcoin Core's raw ZMQ topics +(`zmqpubrawblock` / `zmqpubrawtx`). satd does not implement them and rejects +those settings; see [CORE_DIFFERENCES.md]. Neutrino needs no ZMQ — it pulls +BIP 157/158 filter headers and filters over P2P, which satd serves because +every deliverable here sets `peerblockfilters=1`. + +Core Lightning is unaffected: its `bcli` plugin polls JSON-RPC, so it runs +as an ordinary full-node client. + +### Ark + +`compose.ark.yml` runs an Ark server against satd. **Experimental** — Ark is +young, and every setting in that overlay was established by running the +binary rather than read from a specification, so expect it to need attention +on a version bump. + +The chain is: + +``` +satd -> NBXplorer -> arkd-wallet -> arkd +``` + +arkd v0.9 splits the wallet into its own service, and that wallet's chain +backend is **NBXplorer** — not Esplora, and not Core's ZMQ. Two things +follow. satd implements no raw ZMQ topics, so a backend that needed them +would have ruled Ark out entirely; and NBXplorer against satd is already a +PR-gating canary in this repository, so the single link in that chain which +touches satd is the link that is continuously tested. + +First run is two steps, because arkd will not start without a signer key and +its wallet must then be created and unlocked: + +```sh +docker compose -f compose.yml -f compose.ark.yml run --rm ark-init # prints the key +# add ARKD_SIGNER_KEY=... to .env +docker compose -f compose.yml -f compose.ark.yml up -d +docker compose -f compose.yml -f compose.ark.yml run --rm ark-init # creates the wallet +``` + +Both the signer key and the wallet password are generated per install into +the data volume. Neither is shipped. + +## What is checked, and how + +Each bundled application is a compatibility claim, so each is exercised +rather than asserted: + +- `contrib/stack/tests/mkca-test.sh` — the certificate script, including + that it does *not* reissue a healthy certificate or rotate the CA. +- `contrib/stack/tests/smoke.sh` — the stack on regtest, with every TLS + listener probed from outside the container against the generated CA, LND + syncing to the node's tip over Neutrino, and RTL served through the proxy. +- `contrib/appliance/tests/boot-test.sh` — the built image booted under + QEMU, checked through the guest agent and through forwarded ports. + +Every probe that verifies a certificate is paired with the negative control +that the same handshake without the CA must fail. A probe that would pass +unverified proves nothing about the certificate. + +[CORE_DIFFERENCES.md]: https://github.com/epochbtc/satd/blob/master/CORE_DIFFERENCES.md diff --git a/docs/manual/src/ibd.md b/docs/manual/src/ibd.md index d2633daf2..c0f09a586 100644 --- a/docs/manual/src/ibd.md +++ b/docs/manual/src/ibd.md @@ -78,6 +78,38 @@ does not skip validation. > download-verify-load flag and `--fast-start-sha256` are satd extensions. Core > requires a manual `loadtxoutset` against a file you fetched yourself. +### Where to get a snapshot + +satd hosts none, and does not name one for you. The anchors compiled into +the binary decide which snapshots are loadable at all — currently mainnet +heights 840,000, 880,000, 910,000 and 935,000, copied verbatim from Bitcoin +Core's `m_assumeutxo_data`. Signet, testnet and regtest have no anchors, so +fast-start is mainnet-only. + +Several people publish the `utxo-.dat` files Core's `dumptxoutset` +produces; Jameson Lopp's mirror and +are two that have been around a while. Any of them will do, because the host +is trusted for **availability only**: + +```sh +satd --fast-start=https:///utxo-880000.dat \ + --fast-start-sha256= +``` + +`--fast-start-sha256` pins what you downloaded, so a truncated or swapped +file fails before it is parsed. That check is a convenience; the one that +matters is the anchor comparison above, which satd performs against a hash +compiled into the binary and which no snapshot host can influence. A +snapshot from a hostile mirror is rejected at load. + +Pick the highest anchor height a published snapshot exists for: the higher +the base, the less history the background validation has left to walk. + +> **`--fast-start-sha256` is the file's SHA-256, not the anchor hash.** +> `hash_serialized_3` in the anchor table is a hash over the UTXO *set*, not +> over the file; `sha256sum utxo-880000.dat` does not produce it. Take the +> file digest from the publisher, or compute it after downloading once. + ## Script-verification skip: `assumevalid` `-assumevalid` controls how much script verification IBD performs. satd diff --git a/docs/manual/src/packaging.md b/docs/manual/src/packaging.md index d10318d1b..bc69d27a3 100644 --- a/docs/manual/src/packaging.md +++ b/docs/manual/src/packaging.md @@ -164,6 +164,19 @@ Reload](configuration.md). The container ships a mainnet-loopback default; every value can be overridden with `-e SATD_*` environment variables. See the Container section. +## Ready-made deployments + +Before packaging satd yourself, note that the repository ships three +finished ones, described in [Appliance & Reference +Stack](appliance.md): a docker-compose **reference stack** +(`contrib/stack/`), a bootable **appliance image** (`contrib/appliance/`), +and sources for **Umbrel and StartOS packages** (`contrib/packaging/`). + +They share one node configuration and one certificate scheme, and the +container image below carries the first-run tooling all three use +(`satd-init`, `satd-mkca`), so a package built on that image gets the same +behaviour without reimplementing it. + ## Container The repository ships a multi-stage `Dockerfile` at the repo root. @@ -178,6 +191,16 @@ Properties of the image: - Base: `debian:bookworm-slim`. - Runtime user: `satd`, UID/GID 2121. A non-1000 UID avoids a bind-mount clash with the usual host operator UID. +- Binaries: `satd`, `sat-cli` and `sat-tui`, so `docker exec -it satd + sat-tui` works against a running container. +- First-run tooling: `satd-mkca` (issues the install's CA and server + certificate) and `satd-init` (renders `bitcoin.conf`, mints the MCP + token), plus `openssl`. These are in the image so a deployment that + cannot mount repository files — an Umbrel app, a StartOS package — + behaves identically to `contrib/stack`. +- `HEALTHCHECK`: `satd-healthcheck`, which reports liveness by default and + readiness when `SATD_HEALTH_URL` points at `/readyz`. See + [Health and readiness](#health-and-readiness). - PID 1: `tini`, so SIGTERM forwards to satd cleanly. - Datadir: `/var/lib/satd`, declared as a `VOLUME`. - Exposed ports: `8333` (P2P) and `8332` (RPC). Map other ports with diff --git a/docs/release-notes/0.5.2-pre.md b/docs/release-notes/0.5.2-pre.md index 1edd7d1cd..c999d341a 100644 --- a/docs/release-notes/0.5.2-pre.md +++ b/docs/release-notes/0.5.2-pre.md @@ -20,6 +20,126 @@ This file accumulates entries as changes land; it is not yet a cut release. and `bip66`. - **`-connect=0` no longer dials the address zero**, and `getpeerinfo` reports each peer's real `connection_type`. +- **There is now a reference stack and a downloadable appliance image**, and + `sat-cli` can finally talk to satd's own TLS-terminated RPC listener. + +## Deployment: a reference stack and an appliance image + +Three ways to run satd beyond a bare binary, sharing one configuration and +one certificate scheme. The Operator Manual chapter +[Appliance & Reference Stack](https://epochbtc.github.io/satd/appliance.html) +is the full description; the short version follows. + +### The reference stack + +`contrib/stack/` is a docker-compose deployment running satd with JSON-RPC, +Electrum, Esplora, metrics and optionally MCP all enabled, each TLS- +terminated by a certificate the install issues for itself on first start. + +```sh +cd contrib/stack && cp .env.example .env && docker compose up -d +``` + +Overlays add third-party software: LND in Neutrino mode with Ride The +Lightning, Core Lightning as an alternative, a Cashu mint backed by that +LND, BTCPay Server, and a Caddy reverse proxy that terminates TLS for the +web UIs and for the metrics endpoint (which has no native TLS). + +Two decisions in that stack are worth knowing about because they show up in +what is published. The plain RPC, Electrum, Esplora and metrics listeners +bind the container network and are never published to the host — they exist +because the overlay containers cannot be taught to trust a private CA, and +nothing unencrypted leaves the host. And the internal RPC port is pinned to +8332 on every network, so the overlays and the app-store packages address +one fixed port; the cost is that in-container `sat-cli` needs `-rpcport=8332` +on any network but mainnet. + +There is no prune option anywhere in this work. Electrum and Esplora both +require `txindex`, which satd refuses to combine with `prune`, so every +deliverable runs a fully indexed node — and signet is the default +everywhere, because it is the only network on which the whole stack is a +one-evening exercise. + +### The appliance image + +`contrib/appliance/` builds a bootable VM. The `core` flavour is a headless +node; `desktop` adds XFCE with Sparrow, Electrum and Liana already pointed +at the node's Electrum server. Each is installed from its project's own +release and checked against a signature from a pinned key; the build fails +rather than installing anything that does not verify. Overlays are staged on disk +and started with `satd-appliance enable lightning`. + +Everything unique to an install is created on first boot — the console +password, the CA and server certificate, the MCP bearer token — because an +image that shipped any of them would be an image where every download shared +them. The build asserts none of them are present in the artifact and refuses +to finish otherwise. + +Each bundled application is a compatibility claim, so each is exercised +rather than asserted. CI brings the stack up on regtest and probes every TLS +listener from outside the container against the generated CA, has LND sync +to the node's tip over Neutrino and RTL served through the proxy, then +builds the appliance image and boots it under QEMU. Every certificate probe +is paired with the negative control that the same handshake without the CA +must fail; a probe that would pass unverified proves nothing. + +> The appliance image and the stack's overlays bundle third-party software +> on a best-effort basis, for evaluation and testing. We do not track its +> security advisories in real time. satd itself in the image is the same +> supported release as the tarballs and the container image. + +### TLS everywhere, from one script + +`contrib/stack/tls/mkca.sh` creates a CA for one install only and issues one +server certificate that every satd surface presents. Clients import the CA +once; later reissues — after a hostname change, a new address, or a year — +are signed by a CA they already trust, with nothing to accept again. The CA +is never rotated automatically, because that would invalidate trust every +client has established. + +The certificate names `.local` as well as the machine's addresses. +Connect by that name: a DHCP change invalidates an address in the SAN list, +and the name survives it. + +## `sat-cli` and `sat-tui` speak TLS + +satd has served TLS on `-rpctlsbind` for several releases, but both shipped +clients formatted `http://` URLs and had no CA option — so an operator who +turned RPC TLS on had to keep the plain listener bound purely so the +project's own tooling could reach the node. + +Both now accept `-rpctls`, `-rpccacert`, and `-rpcclientcert` / +`-rpcclientkey` for a listener started with `-rpcmtls`: + +```sh +sat-cli --rpctls --rpccacert=satd-ca.crt --rpcport=8336 getblockchaininfo +``` + +The flags are additive; an invocation that does not pass them behaves +exactly as before. Two failures are deliberately loud rather than quiet: +passing `-rpccacert` without `-rpctls` is an error, because ignoring it +would send the RPC credential over plain HTTP and look like success; and a +CA file containing no PEM certificates is an error, because `reqwest` parses +such a file into an empty list, so pointing the flag at a private key or the +wrong path would add no trust anchor and fail later as a generic handshake +error. + +Point `-rpccacert` at the certificate that *issued* the one the server +presents. For a self-signed node certificate that is the certificate itself; +it is not the leaf of a chain, which cannot anchor its own path. + +## The container image ships `sat-tui` and a healthcheck + +`docker exec -it satd sat-tui` now works without a second install, and the +image has a `HEALTHCHECK`, so `depends_on: condition: service_healthy` means +something. + +The probe reports liveness by default: it cannot see the daemon's +credentials or its network flags, so it treats any HTTP status line from the +RPC listener — a 401 included — as healthy, which is the strongest claim it +can honestly make. Set `SATD_HEALTH_URL=http://127.0.0.1:9332/readyz` (with +`-metricsport=9332`) for a real readiness gate, which is what the reference +stack does. Non-mainnet containers set `SATD_RPCPORT`. ## Bitcoin Core compatibility From 30335559242502599e99bfbb7e1f5d2eb3c030ec Mon Sep 17 00:00:00 2001 From: Benjamen Keroack Date: Wed, 9 Sep 2026 09:57:30 -0600 Subject: [PATCH 08/22] appliance: fix five defects found in review of the stack and image Review of the appliance/stack branch turned up five problems, each verified against the artifact rather than the source before being fixed. RTL was reachable with a well-known password. RTL v0.15.x reads APP_PASSWORD and nothing else; the overlay passed RTL_PASSWORD, which the container ignores, so RTL fell back to the password in the config it generates for itself -- the literal string "password" -- in front of LND's admin macaroon, on a port the proxy publishes. Confirmed by grepping the pinned image (RTL_PASSWORD appears nowhere in it) and then by logging in. Now passed as APP_PASSWORD and required rather than defaulted. Installing to disk destroyed the target. rsync excludes /dev, /proc and /sys, so those directories do not exist on the new root; the chroot bind mounts that follow failed on the first one, under `set -e`, after the disk had been formatted and before GRUB ran. The mountpoints are now created (with /run, /tmp, /mnt and /media, which the installed system needs regardless), and the mounts are unwound by a trap so a later failure does not leave the target's filesystems held. Mainnet did not start at all. `NETWORK=mainnet` rendered `--mainnet`, and satd has bare flags for the test networks but none for mainnet, so the daemon exited on an unknown argument -- including in the Umbrel package, where mainnet is the default. Both now use `--chain=`, which accepts every name. Overlay lifecycle commands could not parse their own compose files. The overlays declare secrets as ${VAR:?}, a hard parse error rather than an empty string, and only `enable` sourced overlay.env: `set-network` left overlays on the old chain, and `disable` swallowed a failed teardown and then removed the marker, orphaning containers nothing could name again. Secrets are now loaded inside run_compose, the one place that runs compose, and a failed teardown keeps the marker. `enable ark` and `enable lightning` also generated no secret at all and so could never have started. BTCPay published its login and payment configuration in clear text on every interface, which the proxy neither replaced nor protected -- and a published container port is DNAT'd through the forward chain, so the appliance's default-deny input chain never saw it. It now binds loopback, and the proxy serves it over TLS on 49393. Tests: contrib/stack/tests/compose-test.sh asserts each of these statically, and smoke.sh now asks RTL to reject its upstream default password as well as accept the generated one. Both were perturbation-checked: reverting each fix fails a named test, and reverting the RTL fix specifically shows "password" logging in with http 200. Also fixes the Compose-parse CI step, which exported POSTGRES_PASSWORD only for the cashu invocation and so failed on the btcpay overlay. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01K9Nb3m8HuhNbjkJYp9Frmt --- .github/workflows/appliance.yml | 34 +++++- contrib/appliance/bin/satd-appliance | 78 +++++++++++-- contrib/appliance/provision/00-base.sh | 4 +- .../packaging/umbrel/satd/docker-compose.yml | 5 +- contrib/stack/.env.example | 13 +++ contrib/stack/README.md | 9 ++ contrib/stack/caddy/Caddyfile | 11 ++ contrib/stack/compose.btcpay.yml | 14 ++- contrib/stack/compose.lightning.yml | 10 +- contrib/stack/compose.proxy.yml | 8 +- contrib/stack/compose.yml | 13 ++- contrib/stack/tests/compose-test.sh | 99 ++++++++++++++++ contrib/stack/tests/published-ports.py | 110 ++++++++++++++++++ contrib/stack/tests/smoke.sh | 60 ++++++++++ docs/manual/src/appliance.md | 17 ++- docs/release-notes/0.5.2-pre.md | 5 +- 16 files changed, 455 insertions(+), 35 deletions(-) create mode 100755 contrib/stack/tests/compose-test.sh create mode 100755 contrib/stack/tests/published-ports.py diff --git a/.github/workflows/appliance.yml b/.github/workflows/appliance.yml index 8b24c1d19..df9b9d5aa 100644 --- a/.github/workflows/appliance.yml +++ b/.github/workflows/appliance.yml @@ -83,11 +83,14 @@ jobs: # mkca.sh, satd-init and the config template. echo "stack=$(match '^(contrib/stack/|contrib/docker/|Dockerfile$)')" >> "$GITHUB_OUTPUT" echo "appliance=$(match '^(contrib/appliance/|contrib/stack/|contrib/systemd/)')" >> "$GITHUB_OUTPUT" - echo "scripts=$(match '^contrib/(stack/tls/|stack/tests/|docker/)')" >> "$GITHUB_OUTPUT" + # Widened past tls/tests: compose-test.sh asserts invariants over the + # compose files, the appliance CLI and the Umbrel package, so it has + # to run when any of those change. + echo "scripts=$(match '^contrib/(stack/|docker/|appliance/bin/|packaging/umbrel/)')" >> "$GITHUB_OUTPUT" # Cheap and always worth running when touched: no docker, no network. scripts: - name: certificate + healthcheck scripts + name: stack + appliance scripts needs: changes if: needs.changes.outputs.scripts == 'true' runs-on: ubuntu-24.04 @@ -98,6 +101,8 @@ jobs: run: contrib/stack/tests/mkca-test.sh - name: satd-healthcheck run: contrib/docker/tests/healthcheck-test.sh + - name: compose + appliance invariants + run: contrib/stack/tests/compose-test.sh - name: shellcheck run: | sudo apt-get update -qq @@ -111,7 +116,8 @@ jobs: contrib/appliance/build.sh \ contrib/appliance/bin/satd-appliance \ contrib/appliance/firstboot/satd-appliance-firstboot \ - contrib/appliance/provision/*.sh + contrib/appliance/provision/*.sh \ + contrib/stack/tests/compose-test.sh stack: name: reference stack (regtest, TLS probed) @@ -141,7 +147,15 @@ jobs: - name: Compose files parse run: | set -euo pipefail - for overlay in lightning cln btcpay proxy; do + # Every secret an overlay declares as ${VAR:?} has to be in the + # environment for the whole loop, not just for the overlay that + # prompted it: a missing one is a parse error, so the step fails + # on the first overlay that needs one it was not given. + export RTL_PASSWORD=ci + export MINT_PRIVATE_KEY=ci + export POSTGRES_PASSWORD=ci + export ARK_POSTGRES_PASSWORD=ci + for overlay in lightning cln btcpay ark proxy; do docker compose -f contrib/stack/compose.yml \ -f "contrib/stack/compose.$overlay.yml" \ --env-file contrib/stack/.env.example \ @@ -149,12 +163,22 @@ jobs: echo "ok: $overlay" done # cashu extends the Lightning overlay, so it only parses with it. - MINT_PRIVATE_KEY=ci POSTGRES_PASSWORD=ci docker compose \ + docker compose \ -f contrib/stack/compose.yml \ -f contrib/stack/compose.lightning.yml \ -f contrib/stack/compose.cashu.yml \ --env-file contrib/stack/.env.example config > /dev/null echo "ok: cashu" + # And all of them together, which is what the appliance can end up + # running and what no single-overlay parse would catch. + docker compose -f contrib/stack/compose.yml \ + -f contrib/stack/compose.lightning.yml \ + -f contrib/stack/compose.cashu.yml \ + -f contrib/stack/compose.btcpay.yml \ + -f contrib/stack/compose.ark.yml \ + -f contrib/stack/compose.proxy.yml \ + --env-file contrib/stack/.env.example config > /dev/null + echo "ok: all overlays together" image: name: build and boot the appliance image diff --git a/contrib/appliance/bin/satd-appliance b/contrib/appliance/bin/satd-appliance index a06569371..11f5d3a81 100755 --- a/contrib/appliance/bin/satd-appliance +++ b/contrib/appliance/bin/satd-appliance @@ -82,15 +82,27 @@ enabled_overlays() { find "$ENABLED_DIR" -maxdepth 1 -type f -printf '%f\n' 2>/dev/null | sort } +# Every overlay secret is loaded here, in the one place that runs compose, +# rather than at each call site. The overlays declare their secrets with +# compose's `${VAR:?}` form, so a missing one is a hard parse error, not an +# empty string: any caller that forgot to source overlay.env could not bring +# the stack up, tear it down, or move it to another network. Sourcing happens +# in the subshell so the secrets never reach the rest of this script. run_compose() { local args=() mapfile -t args < <(compose_args) - ( cd "$STACK" && SATD_HOST="$(stack_gateway)" \ + ( + cd "$STACK" + if [[ -s "$STATE/overlay.env" ]]; then + set -a; . "$STATE/overlay.env"; set +a + fi + SATD_HOST="$(stack_gateway)" \ SATD_STACK_SUBNET="$(stack_subnet)" \ NETWORK="$(current_network)" \ SATD_P2P_PORT="$(p2p_port_for "$(current_network)")" \ SATD_TLS_HOSTNAME="$(hostname)" \ - docker compose "${args[@]}" "$@" ) + docker compose "${args[@]}" "$@" + ) } # --- render the node configuration ------------------------------------------ @@ -253,6 +265,8 @@ available: $(cd "$STACK" && ls compose.*.yml | sed 's/compose\.\(.*\)\.yml/\1/' # shipping one that every image would share. mkdir -p "$STATE" touch "$STATE/overlay.env"; chmod 0600 "$STATE/overlay.env" + # Every overlay that declares a `${VAR:?}` secret needs a branch here, or + # `enable` fails on compose interpolation before it starts anything. case "$overlay" in cashu) grep -q '^MINT_PRIVATE_KEY=' "$STATE/overlay.env" || \ @@ -262,13 +276,22 @@ available: $(cd "$STACK" && ls compose.*.yml | sed 's/compose\.\(.*\)\.yml/\1/' grep -q '^POSTGRES_PASSWORD=' "$STATE/overlay.env" || \ echo "POSTGRES_PASSWORD=$(openssl rand -hex 24)" >> "$STATE/overlay.env" ;; + ark) + grep -q '^ARK_POSTGRES_PASSWORD=' "$STATE/overlay.env" || \ + echo "ARK_POSTGRES_PASSWORD=$(openssl rand -hex 24)" >> "$STATE/overlay.env" + ;; + lightning) + # RTL's login. Without it RTL serves its own default, "password", + # in front of LND's admin macaroon. + grep -q '^RTL_PASSWORD=' "$STATE/overlay.env" || \ + echo "RTL_PASSWORD=$(openssl rand -hex 24)" >> "$STATE/overlay.env" + ;; esac systemctl enable --now docker > /dev/null 2>&1 || true mkdir -p "$ENABLED_DIR" touch "$ENABLED_DIR/$overlay" echo "satd-appliance: pulling images for $overlay (this is the first network fetch for it)" - set -a; . "$STATE/overlay.env"; set +a run_compose pull --quiet 2>/dev/null || run_compose pull run_compose up -d echo "satd-appliance: $overlay enabled" @@ -283,15 +306,15 @@ cmd_disable() { # bring back what remains: `compose down` only knows about services in # the files it is given, so removing the file first would strand the # overlay's containers with nothing able to name them. - run_compose down --remove-orphans || true + # Not `|| true`: a teardown that failed left the overlay's containers + # running, and removing the marker then makes them invisible to every + # later command -- nothing would name them again to stop them. + if ! run_compose down --remove-orphans; then + die "could not bring the stack down; $overlay is still enabled and its +containers are still running. Fix the error above and retry." + fi rm -f "$ENABLED_DIR/$overlay" if [[ -n "$(enabled_overlays)" ]]; then - # An `if`, not `[[ -s f ]] && . f`: under `set -e` that AND-list - # returns non-zero when the file is absent and aborts the command - # half-way through, leaving the remaining overlays down. - if [[ -s "$STATE/overlay.env" ]]; then - set -a; . "$STATE/overlay.env"; set +a - fi run_compose up -d else systemctl disable --now docker > /dev/null 2>&1 || true @@ -326,6 +349,15 @@ cmd_ssh() { # Only reachable from the live ISO: a running installed system copying # itself over a disk is not something to make easy by accident. +# Set by cmd_install_to_disk before it mounts anything; read only by the trap. +INSTALL_MNT="" +install_cleanup() { + [[ -n "$INSTALL_MNT" ]] || return 0 + umount -R "$INSTALL_MNT/dev" "$INSTALL_MNT/proc" "$INSTALL_MNT/sys" 2>/dev/null || true + umount -R "$INSTALL_MNT" 2>/dev/null || true + rmdir "$INSTALL_MNT" 2>/dev/null || true +} + cmd_install_to_disk() { need_root install-to-disk "$@" local target="${1:-}" @@ -368,7 +400,18 @@ cmd_install_to_disk() { mkfs.vfat -F32 -n ESP "${target}${p}2" > /dev/null mkfs.ext4 -q -L satd-root "${target}${p}3" - local mnt; mnt="$(mktemp -d)" + # Global, not local: the EXIT trap below runs while the shell is being + # torn down, and a `local` is not something to rely on still being in + # scope there. + INSTALL_MNT="$(mktemp -d)" + local mnt="$INSTALL_MNT" + # Everything from here on is unwound on the way out however it goes. A + # failure between the first mount and the last (a full disk, a GRUB that + # will not install) otherwise leaves the target's filesystems held by this + # shell, so the obvious next move -- run the installer again -- fails on a + # busy device instead. + trap install_cleanup EXIT + mount "${target}${p}3" "$mnt" mkdir -p "$mnt/boot/efi" mount "${target}${p}2" "$mnt/boot/efi" @@ -381,6 +424,16 @@ cmd_install_to_disk() { --exclude=/tmp --exclude=/mnt --exclude=/media --exclude=/lib/live \ / "$mnt/" + # rsync excluded these, so none of them exist on the new filesystem. The + # bind mounts below would fail on the first one -- after the disk has been + # formatted, under `set -e` and before GRUB runs, which leaves the target + # erased and unbootable. The installed system needs them regardless: /run + # and /tmp are mountpoints systemd fills at boot, and /tmp carries the + # sticky bit. + mkdir -p "$mnt"/{dev,proc,sys,run,tmp,mnt,media} + chmod 0755 "$mnt"/{dev,proc,sys,run,mnt,media} + chmod 1777 "$mnt/tmp" + local root_uuid esp_uuid root_uuid="$(blkid -s UUID -o value "${target}${p}3")" esp_uuid="$(blkid -s UUID -o value "${target}${p}2")" @@ -410,6 +463,9 @@ FSTAB # live-config's session setup does not belong on an installed system. rm -f "$mnt/etc/sudoers.d/live" 2>/dev/null || true + # Unmount now rather than leaving it to the trap, so a failure to flush + # the new filesystem is reported instead of being swallowed by cleanup. + trap - EXIT umount -R "$mnt/dev" "$mnt/proc" "$mnt/sys" 2>/dev/null || true umount -R "$mnt" rmdir "$mnt" diff --git a/contrib/appliance/provision/00-base.sh b/contrib/appliance/provision/00-base.sh index 51929b519..c08a8c06e 100755 --- a/contrib/appliance/provision/00-base.sh +++ b/contrib/appliance/provision/00-base.sh @@ -123,7 +123,9 @@ table inet filter { tcp dport { 8336, 50002, 3001, 8339 } accept # Reverse proxy: web UIs and metrics, TLS with the same cert. - tcp dport { 443, 8443, 9443 } accept + # 49393 is BTCPay; its own HTTP port binds loopback in + # compose.btcpay.yml and is deliberately not opened here. + tcp dport { 443, 8443, 9443, 49393 } accept # Lightning P2P (LND / CLN), when an overlay is enabled. tcp dport { 9735, 9736 } accept diff --git a/contrib/packaging/umbrel/satd/docker-compose.yml b/contrib/packaging/umbrel/satd/docker-compose.yml index d341d4194..01a193b33 100644 --- a/contrib/packaging/umbrel/satd/docker-compose.yml +++ b/contrib/packaging/umbrel/satd/docker-compose.yml @@ -44,10 +44,11 @@ services: condition: service_completed_successfully # The network is an argument, never a config-file line: satd accepts # `signet=1` in a file and then ignores it, which silently starts a - # mainnet node. + # mainnet node. `--chain=` because there is no bare `--mainnet` flag, + # and mainnet is this package's default. command: - --datadir=/var/lib/satd - - --${APP_SATD_NETWORK:-mainnet} + - --chain=${APP_SATD_NETWORK:-mainnet} environment: SATD_HEALTH_URL: http://127.0.0.1:9332/readyz volumes: diff --git a/contrib/stack/.env.example b/contrib/stack/.env.example index 70629934c..dea53607a 100644 --- a/contrib/stack/.env.example +++ b/contrib/stack/.env.example @@ -42,6 +42,19 @@ SATD_TLS_HOSTNAME=satd # secrets/mcp-token and publishes MCP over TLS on 8339. SATD_MCP=0 +# --- overlay secrets -------------------------------------------------------- +# No defaults: an overlay that needs one refuses to start rather than share a +# value with every other deployment. Only needed for the overlays you enable. +# +# RTL_PASSWORD compose.lightning.yml -- the RTL login, which +# fronts LND's admin macaroon. Without it RTL serves +# its own default, the literal string "password". +# MINT_PRIVATE_KEY compose.cashu.yml +# POSTGRES_PASSWORD compose.btcpay.yml +# ARK_POSTGRES_PASSWORD compose.ark.yml +# +# echo "RTL_PASSWORD=$(openssl rand -hex 24)" >> .env + # --- internals -------------------------------------------------------------- # The compose network. Change only if it collides with an existing network; # bitcoin.conf's rpcallowip is derived from it. diff --git a/contrib/stack/README.md b/contrib/stack/README.md index 72cce3154..e22050dfe 100644 --- a/contrib/stack/README.md +++ b/contrib/stack/README.md @@ -104,6 +104,10 @@ compose.cashu.yml Nutshell mint, backed by the LND above. compose.btcpay.yml Postgres + NBXplorer + BTCPay Server. compose.ark.yml An Ark server (arkd), via NBXplorer. Experimental. compose.proxy.yml Caddy, terminating TLS for the web UIs and metrics. + 443 RTL, 8443 Cashu mint, 49393 BTCPay, 9443 metrics. + RTL and the mint are not published at all and BTCPay + binds loopback, so these are the only ways to reach a + web UI from another machine. satd/satd.conf.tmpl The node configuration, with @NAME@ substitutions. satd/satd-init Renders it, issues the certificates, mints the MCP token. tls/mkca.sh The one CA/certificate script, shared by all three deliverables. @@ -121,11 +125,16 @@ Some overlays require a secret with no default, and refuse to start without it rather than shipping one everybody shares: ```sh +echo "RTL_PASSWORD=$(openssl rand -hex 24)" >> .env # compose.lightning.yml echo "MINT_PRIVATE_KEY=$(openssl rand -hex 32)" >> .env # compose.cashu.yml echo "POSTGRES_PASSWORD=$(openssl rand -hex 24)" >> .env # compose.btcpay.yml echo "ARK_POSTGRES_PASSWORD=$(openssl rand -hex 24)" >> .env # compose.ark.yml ``` +`RTL_PASSWORD` is the login for Ride The Lightning, which fronts LND's admin +macaroon. RTL has no default worth keeping: with nothing set it generates a +config whose password is the literal string `password`. + ## Why LND runs in Neutrino mode LND's `bitcoind` backend needs Bitcoin Core's raw ZMQ topics diff --git a/contrib/stack/caddy/Caddyfile b/contrib/stack/caddy/Caddyfile index ac556681d..1559a582c 100644 --- a/contrib/stack/caddy/Caddyfile +++ b/contrib/stack/caddy/Caddyfile @@ -25,6 +25,17 @@ reverse_proxy mint:3338 } +# BTCPay Server — compose.btcpay.yml. BTCPay builds absolute URLs and decides +# whether to mark its cookies Secure from the forwarded headers, so the scheme +# and host have to be passed through or it redirects a logged-in browser back +# to http:// on its own port. +:49393 { + import satd_tls + reverse_proxy btcpay:49392 { + header_up X-Forwarded-Proto https + } +} + # satd's metrics, /healthz and /readyz. No native TLS on this listener, which # is why it is here; it stays on the compose network otherwise. :9443 { diff --git a/contrib/stack/compose.btcpay.yml b/contrib/stack/compose.btcpay.yml index f4fb7ce21..eae750a59 100644 --- a/contrib/stack/compose.btcpay.yml +++ b/contrib/stack/compose.btcpay.yml @@ -90,11 +90,17 @@ services: - satd restart: unless-stopped # BTCPay speaks plain HTTP and builds absolute URLs from its own root, - # so it gets a dedicated proxy port rather than a path. Published - # directly here for stacks not running compose.proxy.yml; behind the - # proxy, prefer the TLS port. + # so it gets a dedicated proxy port rather than a path: compose.proxy.yml + # serves it over TLS on 49393. + # + # Bound to loopback, not 0.0.0.0. This endpoint takes a login and a store + # configuration, and a published port bypasses the appliance's inbound + # nftables chain, so binding every interface put credentials on the LAN in + # clear -- including for anyone who added compose.proxy.yml believing that + # was the TLS-only setup. Override BTCPAY_BIND_ADDR only for a stack that + # terminates TLS somewhere else. ports: - - "${BTCPAY_PORT:-49392}:49392" + - "${BTCPAY_BIND_ADDR:-127.0.0.1}:${BTCPAY_PORT:-49392}:49392" volumes: btcpay-db: diff --git a/contrib/stack/compose.lightning.yml b/contrib/stack/compose.lightning.yml index aa148a439..ad161644a 100644 --- a/contrib/stack/compose.lightning.yml +++ b/contrib/stack/compose.lightning.yml @@ -85,9 +85,13 @@ services: CONFIG_PATH: "" RTL_CONFIG_PATH: /RTL/config CHANNEL_BACKUP_PATH: /RTL/database/backup - # RTL's own login. The appliance replaces this on first boot; set it - # in .env for any other deployment. - RTL_PASSWORD: ${RTL_PASSWORD:-satd-stack} + # RTL's own login. The variable RTL reads is APP_PASSWORD -- it has no + # entrypoint that translates anything else, and with APP_PASSWORD unset + # it falls back to the `multiPass` in the config it generates, which is + # the literal string "password". Required rather than defaulted, so a + # stack cannot come up on a well-known credential that fronts LND's + # admin macaroon. + APP_PASSWORD: ${RTL_PASSWORD:?generate one with `openssl rand -hex 24` and put RTL_PASSWORD in .env} PORT: "3000" DEFAULT_NODE_INDEX: "1" volumes: diff --git a/contrib/stack/compose.proxy.yml b/contrib/stack/compose.proxy.yml index 1b1c54e70..3090d6912 100644 --- a/contrib/stack/compose.proxy.yml +++ b/contrib/stack/compose.proxy.yml @@ -14,9 +14,10 @@ # /btcpay means rewriting HTML and breaking on the next release. Distinct # ports are uglier to type and keep working. # -# 443 Ride The Lightning (compose.lightning.yml) -# 8443 Cashu mint (compose.cashu.yml) -# 9443 satd metrics / healthz / readyz +# 443 Ride The Lightning (compose.lightning.yml) +# 8443 Cashu mint (compose.cashu.yml) +# 49393 BTCPay Server (compose.btcpay.yml) +# 9443 satd metrics / healthz / readyz # # An overlay that is not enabled leaves its port answering 502, because # Caddy resolves upstreams per request rather than at start. @@ -49,6 +50,7 @@ services: ports: - "${PROXY_RTL_PORT:-443}:443" - "${PROXY_MINT_PORT:-8443}:8443" + - "${PROXY_BTCPAY_PORT:-49393}:49393" - "${PROXY_METRICS_PORT:-9443}:9443" networks: - satd diff --git a/contrib/stack/compose.yml b/contrib/stack/compose.yml index 6ab30088a..a899090ff 100644 --- a/contrib/stack/compose.yml +++ b/contrib/stack/compose.yml @@ -44,12 +44,17 @@ services: depends_on: satd-init: condition: service_completed_successfully - # The network flag has to be an argument. satd accepts `signet=1` in a - # config file and then ignores it, which silently starts a mainnet node, - # so the stack never expresses the chain that way. + # The network has to be an argument. satd accepts `signet=1` in a config + # file and then ignores it, which silently starts a mainnet node, so the + # stack never expresses the chain that way. + # + # `--chain=` rather than a bare `--`: there are bare flags for + # the test networks but none for mainnet, so `NETWORK=mainnet` rendered + # `--mainnet` and satd exited on an unknown argument. --chain takes every + # name this file accepts, mainnet included. command: - --datadir=/var/lib/satd - - --${NETWORK:-signet} + - --chain=${NETWORK:-signet} environment: # Readiness, not liveness: /readyz stays negative until the chainstate # is loaded and every listener is bound, which is what the overlays' diff --git a/contrib/stack/tests/compose-test.sh b/contrib/stack/tests/compose-test.sh new file mode 100755 index 000000000..bfda87bc6 --- /dev/null +++ b/contrib/stack/tests/compose-test.sh @@ -0,0 +1,99 @@ +#!/usr/bin/env bash +# Static checks on the compose definitions and the appliance CLI. +# +# No docker, no network: these are the invariants that a round of review +# found broken by inspection, and each one is cheap enough to assert on +# every push. Anything needing a running stack belongs in smoke.sh. +set -euo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +STACK="$(cd "$HERE/.." && pwd)" +ROOT="$(cd "$STACK/../.." && pwd)" +APPLIANCE="$ROOT/contrib/appliance" +UMBREL="$ROOT/contrib/packaging/umbrel/satd" + +fail=0 +ok() { printf ' ok %s\n' "$1"; } +bad() { printf ' FAIL %s\n' "$1"; fail=1; } +check() { if eval "$2"; then ok "$1"; else bad "$1"; fi; } + +echo "== chain selector ==" +# satd has bare --signet/--regtest/--testnet4 but no bare --mainnet, so a +# `--${NETWORK}` render exits on an unknown argument for the one network +# most deployments actually want. --chain= takes every name. +for f in "$STACK/compose.yml" "$UMBREL/docker-compose.yml"; do + n="$(basename "$(dirname "$f")")/$(basename "$f")" + if grep -qE '^\s+- --\$\{[A-Z_]*NETWORK' "$f"; then + bad "$n renders a bare --\${NETWORK} flag (no such flag for mainnet)" + else + ok "$n does not render a bare --\${NETWORK} flag" + fi + check "$n selects the chain with --chain=" \ + "grep -qE '^\s+- --chain=\\\$\{[A-Z_]*NETWORK' '$f'" +done + +echo "== RTL credentials ==" +# RTL v0.15.x reads APP_PASSWORD and nothing else; with it unset it serves +# the config it generates, whose password is the literal "password", in +# front of LND's admin macaroon. +check "compose.lightning.yml sets APP_PASSWORD" \ + "grep -q 'APP_PASSWORD:' '$STACK/compose.lightning.yml'" +check "compose.lightning.yml does not pass RTL_PASSWORD to the container" \ + "! grep -qE '^\s+RTL_PASSWORD:' '$STACK/compose.lightning.yml'" +check "the RTL password is required, not defaulted" \ + "grep -qE 'APP_PASSWORD: \\\$\{RTL_PASSWORD:\?' '$STACK/compose.lightning.yml'" + +echo "== no plaintext web UI on a public interface ==" +# A published port also bypasses the appliance's inbound nftables chain, so +# "it is only on the LAN" is the whole exposure. Every host-published port +# must be either loopback-bound or on the allow-list below, which is the +# point: a new public port fails here until someone says why it is safe. +if ! python3 "$HERE/published-ports.py" "$STACK" "$APPLIANCE/files" "$UMBREL"; then + fail=1 +fi + +check "the proxy serves BTCPay over TLS" \ + "grep -q 'btcpay:49392' '$STACK/caddy/Caddyfile'" +check "the proxy publishes the BTCPay TLS port" \ + "grep -q 'PROXY_BTCPAY_PORT' '$STACK/compose.proxy.yml'" + +echo "== every required secret has a generator ==" +# Overlays declare secrets as ${VAR:?...}, which is a hard compose parse +# error rather than an empty string. An overlay the appliance can enable but +# has no branch for cannot be started, stopped, or moved between networks. +CLI="$APPLIANCE/bin/satd-appliance" +for f in "$STACK"/compose.*.yml; do + overlay="$(basename "$f" | sed 's/^compose\.//; s/\.yml$//')" + [[ "$overlay" == "yml" || "$overlay" == "proxy" ]] && continue + while read -r var; do + if grep -q "^\s*grep -q '\^${var}=' " "$CLI"; then + ok "$overlay: satd-appliance generates $var" + else + bad "$overlay declares \${$var:?} but satd-appliance never generates it" + fi + done < <(grep -oE '\$\{[A-Z_]+:\?' "$f" | sed 's/\${//; s/:?//' | sort -u) +done + +echo "== the appliance loads secrets where it runs compose ==" +# Sourcing at each call site is what went wrong: `set-network` and the +# teardown in `disable` did not, so they failed to parse the overlay files. +check "run_compose sources overlay.env itself" \ + "awk '/^run_compose\(\)/,/^}/' '$CLI' | grep -q 'overlay.env'" +check "disable does not drop the marker after a failed teardown" \ + "! awk '/^cmd_disable\(\)/,/^}/' '$CLI' | grep -q 'run_compose down --remove-orphans || true'" + +echo "== the installer creates what it mounts on ==" +# rsync excludes the pseudo-filesystems, so the chroot mountpoints do not +# exist on the new root. The first bind mount then fails under `set -e`, +# after the disk is formatted and before GRUB runs. +check "install creates the chroot mountpoints" \ + "awk '/^cmd_install_to_disk\(\)/,/^}/' '$CLI' | grep -q 'mkdir -p \"\$mnt\"/{dev,proc,sys'" +check "install unwinds its mounts on failure" \ + "awk '/^cmd_install_to_disk\(\)/,/^}/' '$CLI' | grep -q 'trap install_cleanup EXIT'" + +echo +if [[ "$fail" -ne 0 ]]; then + echo "compose-test.sh: FAILED" + exit 1 +fi +echo "compose-test.sh: all checks passed" diff --git a/contrib/stack/tests/published-ports.py b/contrib/stack/tests/published-ports.py new file mode 100755 index 000000000..c4f1ff352 --- /dev/null +++ b/contrib/stack/tests/published-ports.py @@ -0,0 +1,110 @@ +#!/usr/bin/env python3 +"""Check which host ports the compose overlays publish. + +Ports published by docker are reachable from off the host and bypass the +appliance's inbound nftables chain, so each one is a deliberate decision. +Anything not bound to loopback has to appear in ALLOWED with a reason. +""" +import glob +import os +import re +import sys + +# port -> why it is allowed to face the network +ALLOWED = { + "9735": "Lightning P2P (LND) -- useless unless publicly reachable", + "9736": "Lightning P2P (CLN) -- useless unless publicly reachable", + "8080": "LND REST -- TLS with LND's own certificate, macaroon-gated", + "443": "proxy: RTL over TLS", + "8443": "proxy: Cashu mint over TLS", + "49393": "proxy: BTCPay over TLS", + "9443": "proxy: satd metrics over TLS", + # satd's own published ports are all natively TLS or Bitcoin P2P; they + # live in compose.yml and are checked by the stack smoke test. + "8336": "satd JSON-RPC, native TLS", + "50002": "satd Electrum, native TLS", + "3001": "satd Esplora, native TLS", + "8333": "Bitcoin P2P (mainnet)", + "18333": "Bitcoin P2P (testnet3)", + "18444": "Bitcoin P2P (regtest)", + "38333": "Bitcoin P2P (signet)", + "48333": "Bitcoin P2P (testnet4)", + "8339": "satd MCP, native TLS plus a bearer token", +} + +# "${VAR:-default}" -> "default"; "${VAR}" -> "" (unknown at rest) +VAR = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\}") + + +def resolve(text): + return VAR.sub(lambda m: m.group(2) or "", text) + + +def published(path): + """Yield the raw port specs under each `ports:` key. + + Comments and blank lines inside the block are skipped rather than ending + it -- every `ports:` in this tree opens with an explanatory comment, and + treating that as the end of the block silently checked nothing. + """ + out, in_ports, blocks = [], False, 0 + for line in open(path): + if re.match(r"^ ports:\s*$", line): + in_ports = True + blocks += 1 + continue + if not in_ports: + continue + if not line.strip() or line.strip().startswith("#"): + continue + m = re.match(r'^ - "?([^"\n]+)"?\s*$', line) + if m: + out.append(m.group(1)) + else: + in_ports = False + if blocks and not out: + raise SystemExit( + f"published-ports.py: {path} has {blocks} ports: block(s) but " + "none parsed -- the parser is out of step with the file" + ) + return out + + +def main(*roots): + # `compose*.yml`, not `compose.*.yml`: the latter does not match + # compose.yml itself, so the base stack's own ports went unchecked. + paths = [] + for root in roots: + if os.path.isdir(root): + paths += glob.glob(os.path.join(root, "compose*.yml")) + paths += glob.glob(os.path.join(root, "docker-compose.yml")) + elif os.path.exists(root): + paths.append(root) + else: + raise SystemExit(f"published-ports.py: no such path: {root}") + if not paths: + raise SystemExit(f"published-ports.py: nothing to check under {roots}") + + failed = False + for path in sorted(paths): + name = os.path.basename(path) + for spec in published(path): + parts = resolve(spec).split(":") + # ADDR:HOST:CONTAINER, or HOST:CONTAINER + addr = parts[0] if len(parts) == 3 else None + host = parts[-2] if len(parts) >= 2 else parts[0] + if addr and addr not in ("0.0.0.0", "::"): + print(f" ok {name} publishes {spec} on {addr}") + elif host in ALLOWED: + print(f" ok {name} publishes {host} ({ALLOWED[host]})") + else: + print( + f" FAIL {name} publishes {spec} on every interface; " + f"bind it to 127.0.0.1 or add {host} to ALLOWED with a reason" + ) + failed = True + return 1 if failed else 0 + + +if __name__ == "__main__": + sys.exit(main(*sys.argv[1:])) diff --git a/contrib/stack/tests/smoke.sh b/contrib/stack/tests/smoke.sh index 285a756d5..99bcc3605 100755 --- a/contrib/stack/tests/smoke.sh +++ b/contrib/stack/tests/smoke.sh @@ -47,6 +47,10 @@ export PROXY_MINT_PORT=$((PORT_BASE + 4)) export PROXY_METRICS_PORT=$((PORT_BASE + 5)) export LND_P2P_PORT=$((PORT_BASE + 6)) export LND_REST_PORT=$((PORT_BASE + 7)) +export PROXY_BTCPAY_PORT=$((PORT_BASE + 8)) +# Required by compose.lightning.yml, and asserted on below: RTL falls back to +# the literal password "password" if this does not reach it. +export RTL_PASSWORD="smoke-$(head -c 16 /dev/urandom | od -An -tx1 | tr -d ' \n')" export NETWORK=regtest export SATD_IMAGE="${SATD_IMAGE:-ghcr.io/epochbtc/satd:latest}" export SATD_TLS_HOSTNAME=satd @@ -300,6 +304,62 @@ sys.exit(0 if d.get('synced_to_chain') and d.get('block_height')==$HEIGHT else 1 "http $rtl_code $(compose logs --no-color --tail 30 rtl 2>&1)" fi + + # RTL reads APP_PASSWORD and nothing else. Passing it under + # any other name leaves the password RTL writes into the + # config it generates -- the literal string "password" -- in + # front of LND's admin macaroon, on a port the proxy + # publishes. Serving a login page is not evidence that the + # login is ours, so both directions are checked. + # + # RTL mounts csurf on every route, so the POST needs the + # token from a prior GET; without it the answer is 403 and + # both assertions below would fail for the wrong reason. + # + # The API lives under RTL's baseHref, /rtl -- express serves + # the frontend as a static catch-all, so posting to /api/... + # returns 200 and index.html no matter what the credentials + # were. Both directions are asserted precisely because a + # wrong path answers 200 to anything. + rtl_curl() { curl -sS --cacert "$CA" \ + --resolve "localhost:$PROXY_RTL_PORT:127.0.0.1" "$@"; } + # /rtl/login, not /rtl/: the XSRF-TOKEN cookie is set by the + # single-page catch-all, and express.static answers /rtl/ with + # index.html before that middleware ever runs. + rtl_jar="$WORK/rtl-cookies" + rtl_curl -c "$rtl_jar" -o /dev/null \ + "https://localhost:$PROXY_RTL_PORT/rtl/login" || true + # Netscape jar: name is field 6, value is field 7. + rtl_xsrf="$(awk '$6=="XSRF-TOKEN"{print $7}' "$rtl_jar" 2>/dev/null || true)" + rtl_login() { + local hash + hash="$(printf '%s' "$1" | sha256sum | cut -d' ' -f1)" + rtl_curl -b "$rtl_jar" -c "$rtl_jar" \ + -H "X-XSRF-TOKEN: $rtl_xsrf" \ + -H 'Content-Type: application/json' \ + -o /dev/null -w '%{http_code}' \ + -d "{\"authenticateWith\":\"PASSWORD\",\"authenticationValue\":\"$hash\"}" \ + "https://localhost:$PROXY_RTL_PORT/rtl/api/authenticate" 2>&1 || true + } + if [[ -z "$rtl_xsrf" ]]; then + fail "RTL rejects its upstream default password" \ + "no XSRF-TOKEN cookie from RTL; the login check could not run" + else + default_code="$(rtl_login password)" + if [[ "$default_code" == "401" ]]; then + pass "RTL rejects its upstream default password" + else + fail "RTL rejects its upstream default password" \ + "expected http 401, got $default_code -- APP_PASSWORD did not reach RTL" + fi + ours_code="$(rtl_login "$RTL_PASSWORD")" + if [[ "$ours_code" == "200" ]]; then + pass "RTL accepts the password the stack configured" + else + fail "RTL accepts the password the stack configured" \ + "expected http 200, got $ours_code" + fi + fi fi ;; *) diff --git a/docs/manual/src/appliance.md b/docs/manual/src/appliance.md index d100f5ce4..1b868046f 100644 --- a/docs/manual/src/appliance.md +++ b/docs/manual/src/appliance.md @@ -55,10 +55,20 @@ Overlays that need a secret have no default and refuse to start without one, rather than shipping a value every deployment would share: ```sh +echo "RTL_PASSWORD=$(openssl rand -hex 24)" >> .env echo "MINT_PRIVATE_KEY=$(openssl rand -hex 32)" >> .env echo "POSTGRES_PASSWORD=$(openssl rand -hex 24)" >> .env +echo "ARK_POSTGRES_PASSWORD=$(openssl rand -hex 24)" >> .env ``` +`RTL_PASSWORD` is the login for Ride The Lightning, which fronts LND's admin +macaroon. Left unset, RTL generates a configuration whose password is the +literal string `password`, so this one is required rather than defaulted. + +`satd-appliance enable ` generates each of these into +`/var/lib/satd-appliance/overlay.env` on first use, so the appliance needs +none of this by hand. + ### Which ports are published Plain RPC, Electrum, Esplora and metrics listeners bind the compose network @@ -72,7 +82,12 @@ be taught to trust a private CA. What leaves the host is TLS only: | 3001 | Esplora over TLS | | 8339 | MCP over TLS, when `SATD_MCP=1` | | 38333 (signet) | Bitcoin P2P | -| 443 / 8443 / 9443 | web UIs and metrics, with `compose.proxy.yml` | +| 443 / 8443 / 49393 / 9443 | RTL, Cashu mint, BTCPay and metrics, with `compose.proxy.yml` | + +BTCPay's own HTTP port binds `127.0.0.1` and RTL and the mint are not +published at all, so the proxy is the only route to a web UI from another +machine. A docker-published port is also not filtered by the appliance's +inbound firewall chain, which is the second reason those bindings matter. The internal RPC port is 8332 on **every** network so that overlays, the proxy and the store packages address one fixed port. The cost is that diff --git a/docs/release-notes/0.5.2-pre.md b/docs/release-notes/0.5.2-pre.md index c999d341a..b4ac0bd87 100644 --- a/docs/release-notes/0.5.2-pre.md +++ b/docs/release-notes/0.5.2-pre.md @@ -81,7 +81,10 @@ listener from outside the container against the generated CA, has LND sync to the node's tip over Neutrino and RTL served through the proxy, then builds the appliance image and boots it under QEMU. Every certificate probe is paired with the negative control that the same handshake without the CA -must fail; a probe that would pass unverified proves nothing. +must fail; a probe that would pass unverified proves nothing. The same rule +applies to the bundled applications' own credentials: RTL is asked to reject +its upstream default password as well as to accept the generated one, since +a login page that answers is not evidence that the login is yours. > The appliance image and the stack's overlays bundle third-party software > on a best-effort basis, for evaluation and testing. We do not track its From d59dd2163789a3bc922df712c5289c0b9e6ff247 Mon Sep 17 00:00:00 2001 From: Benjamen Keroack Date: Wed, 9 Sep 2026 10:22:05 -0600 Subject: [PATCH 09/22] ci: make the appliance stack and image builds release gates The reference-stack bring-up and the appliance image build each compile satd from scratch and take about twenty minutes apiece, so between them they cost roughly forty minutes of runner time on every pull request that touches contrib/stack or contrib/appliance. That is the wrong place to spend it: both are artifact-shaped gates whose result matters at release, not per commit. Both now run on `v*` tags -- the same trigger the release workflow uses -- and on workflow_dispatch. They are skipped, not removed, on pull requests, so branch protection still sees a reported context. The escape hatch is the `appliance-ci` label. `labeled` is in the trigger list, so applying it to an open PR starts the run without needing another push; a PR that genuinely changes this surface can still be gated on the full thing. On a labelled PR the image matrix stays core-only, as it already did. Unchanged: the static checks over the certificate script, the container healthcheck and the compose/appliance invariants. Those are seconds, need no docker or network, and keep running on every PR that touches them. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01K9Nb3m8HuhNbjkJYp9Frmt --- .github/workflows/appliance.yml | 33 ++++++++++++++++++++++++--------- CONTRIBUTING.md | 9 +++++++++ docs/release-notes/0.5.2-pre.md | 6 ++++++ 3 files changed, 39 insertions(+), 9 deletions(-) diff --git a/.github/workflows/appliance.yml b/.github/workflows/appliance.yml index df9b9d5aa..a3eceb76a 100644 --- a/.github/workflows/appliance.yml +++ b/.github/workflows/appliance.yml @@ -1,19 +1,28 @@ # Appliance CI — the reference stack and the downloadable VM image. # -# Three things are gated here, in increasing cost: +# Three things are gated here, in increasing cost. Only the first runs on an +# ordinary pull request: # -# 1. The certificate script and the container healthcheck probe. Seconds, -# no docker, run on any PR that touches them. Both are small pieces of -# shell that everything else depends on being right. +# 1. The static checks: the certificate script, the container healthcheck +# probe, and the compose/appliance invariants. Seconds, no docker, no +# network. Runs on any PR that touches them. # 2. The reference stack (contrib/stack): brought up on regtest, with every # TLS listener probed from outside the container against the CA the # stack generated, plus LND syncing to the node over Neutrino and RTL # served through the proxy. This is where a claim like "Sparrow can talk -# to this" is actually checked. +# to this" is actually checked. ~20 minutes. # 3. The appliance image: built with mmdebstrap, booted under QEMU, and # inspected through the guest agent. This is the "the image is not # broken" gate, and it boots the artifact itself rather than a -# test-only variant. +# test-only variant. ~20 minutes. +# +# 2 and 3 each compile satd from scratch, so together they cost around forty +# minutes of runner time. They run on `v*` tags — the same trigger the release +# workflow uses — and on workflow_dispatch, not on every pull request. +# +# To run them on a PR that actually changes this surface, add the +# `appliance-ci` label. `labeled` is in the trigger list below, so applying it +# starts the run; no push is needed. # # Everything runs on GitHub-hosted runners. satd is public, and a # `pull_request` job on a self-hosted runner would let a fork PR execute @@ -26,7 +35,9 @@ name: Appliance on: pull_request: - types: [opened, synchronize, reopened] + # `labeled` so that adding `appliance-ci` to an open PR starts the heavy + # jobs without needing a fresh push. + types: [opened, synchronize, reopened, labeled] push: tags: - 'v*' @@ -122,7 +133,10 @@ jobs: stack: name: reference stack (regtest, TLS probed) needs: changes - if: needs.changes.outputs.stack == 'true' + # Release trigger, dispatch, or an explicitly labelled PR. Roughly twenty + # minutes of runner time, most of it compiling satd, which is not worth + # spending on every push to every branch. + if: needs.changes.outputs.stack == 'true' && (github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'appliance-ci')) runs-on: ubuntu-24.04 # Building the runtime image compiles satd from scratch on a cold cache, # and two stack bring-ups follow it. @@ -183,7 +197,8 @@ jobs: image: name: build and boot the appliance image needs: changes - if: needs.changes.outputs.appliance == 'true' + # Same gate as `stack`: tags, dispatch, or the `appliance-ci` label. + if: needs.changes.outputs.appliance == 'true' && (github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'appliance-ci')) runs-on: ubuntu-24.04 timeout-minutes: 90 strategy: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 88be5f0fd..e3d80afb5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -31,6 +31,15 @@ means in practice for contributors. 5. CI must be green for a PR to be merged. CI runs the same checks listed below plus `cargo-deny` on dep-graph-touching PRs. +Two appliance jobs are release gates rather than per-PR ones, because each +compiles satd from scratch and they cost around forty minutes of runner time +between them: the reference-stack bring-up and the appliance image build. +They run on release tags and on demand. If your change touches +`contrib/stack/` or `contrib/appliance/`, add the **`appliance-ci`** label to +your PR to run them there — applying the label starts the run, so no extra +push is needed. The cheap static checks over those directories run on every +PR regardless. + Stacked PRs are fine. State the merge order in each PR description and land them in that order. diff --git a/docs/release-notes/0.5.2-pre.md b/docs/release-notes/0.5.2-pre.md index b4ac0bd87..33afb473d 100644 --- a/docs/release-notes/0.5.2-pre.md +++ b/docs/release-notes/0.5.2-pre.md @@ -86,6 +86,12 @@ applies to the bundled applications' own credentials: RTL is asked to reject its upstream default password as well as to accept the generated one, since a login page that answers is not evidence that the login is yours. +Those two runs each compile satd from scratch, so they are release gates +rather than per-commit ones: they run on release tags, on demand, and on any +pull request labelled `appliance-ci`. The static checks over the certificate +script, the healthcheck and the compose definitions run on every pull request +that touches them. + > The appliance image and the stack's overlays bundle third-party software > on a best-effort basis, for evaluation and testing. We do not track its > security advisories in real time. satd itself in the image is the same From 65ba48b550f4364eb25f26e9bc3ff8f79e526071 Mon Sep 17 00:00:00 2001 From: Benjamen Keroack Date: Wed, 9 Sep 2026 19:55:53 -0600 Subject: [PATCH 10/22] umbrel: pin the satd image to a tag that exists, by digest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The package named `ghcr.io/epochbtc/satd:v0.5.2`. Two things were wrong with that, and either one alone makes the app fail at image pull: - The registry has never published a `v`-prefixed tag. Every tag it holds is bare (`0.5.1`, `0.5`, `latest`), so `v0.5.2` resolved to nothing. - `0.5.2` is unreleased. The comment above the line said so, but a package that cannot pull is not shippable in the meantime. Pin to 0.5.1, the newest release that exists, and bump at release instead. Pinned by digest as well, which the Umbrel app store requires and which `umbrel lint` rejects the absence of: a tag is mutable, so a store that resolved it at install time could hand two users different software from one manifest. The digest names the OCI index, so it still resolves per-arch (linux/amd64 + linux/arm64). Both services now take the image from one anchor. They must be the same build, and two literals drift — which is how one of them could have been wrong on its own. Verified against the live registry: the tag resolves (HTTP 200), the digest is the index digest, and `umbrel lint` passes with no errors. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01K9Nb3m8HuhNbjkJYp9Frmt --- .../packaging/umbrel/satd/docker-compose.yml | 20 ++++++++++++++----- contrib/packaging/umbrel/satd/umbrel-app.yml | 2 +- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/contrib/packaging/umbrel/satd/docker-compose.yml b/contrib/packaging/umbrel/satd/docker-compose.yml index 01a193b33..636e630af 100644 --- a/contrib/packaging/umbrel/satd/docker-compose.yml +++ b/contrib/packaging/umbrel/satd/docker-compose.yml @@ -8,6 +8,19 @@ # mounts from a repository Umbrel has not cloned. version: "3.7" +# One definition for both services: they must be the same build, and two +# literals drift. +# +# Pinned by digest as well as tag, which the Umbrel app store requires: a tag +# is mutable, and an app store that resolved it at install time would give two +# users different software from the same manifest. The digest is the OCI index +# (linux/amd64 + linux/arm64), so it still resolves per-architecture. +# +# The tag is bare. The registry has never published a `v`-prefixed tag, so the +# `v0.5.2` this used to name resolved to nothing on every architecture. +# Bumping both halves is a step in the release checklist. +x-satd-image: &satd-image ghcr.io/epochbtc/satd:0.5.1@sha256:bcbde256a0d5191d124f36d01df3d6dbdc70d4690aa9cccdb109eebf3df1dd1f + services: app_proxy: environment: @@ -17,11 +30,8 @@ services: # TLS to it rather than plain HTTP. PROXY_AUTH_ADD: "false" - # The tag is bumped with each satd release the package is published for; - # v0.5.2 is the release this package was written against and does not - # exist until that release is cut. init: - image: ghcr.io/epochbtc/satd:v0.5.2 + image: *satd-image entrypoint: ["/usr/local/bin/satd-init"] user: "2121:2121" restart: on-failure @@ -38,7 +48,7 @@ services: - ${APP_DATA_DIR}/data:/var/lib/satd server: - image: ghcr.io/epochbtc/satd:v0.5.2 + image: *satd-image depends_on: init: condition: service_completed_successfully diff --git a/contrib/packaging/umbrel/satd/umbrel-app.yml b/contrib/packaging/umbrel/satd/umbrel-app.yml index 59adbdc7c..975a0f438 100644 --- a/contrib/packaging/umbrel/satd/umbrel-app.yml +++ b/contrib/packaging/umbrel/satd/umbrel-app.yml @@ -2,7 +2,7 @@ manifestVersion: 1 id: satd category: bitcoin name: satd -version: "0.5.2" +version: "0.5.1" tagline: A Bitcoin full node in Rust, with Electrum and Esplora built in description: >- satd is a Bitcoin Core-compatible full node written in Rust. It speaks From c5ed19631e28ebfdb01ebaa559aadf24e64b149b Mon Sep 17 00:00:00 2001 From: Benjamen Keroack Date: Wed, 9 Sep 2026 20:08:55 -0600 Subject: [PATCH 11/22] startos: write the package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the requirements document with a StartOS package built against @start9labs/start-sdk 2.0.9 — the version `Start9Labs/bitcoin-core-startos` pins on its default branch, read rather than recalled. The old README argued that a package written against a guessed SDK "looks right in review and does not build"; the fix for that is to pin a version and read it, not to skip the package. (That repo's `master` branch is still the 0.3.x shape — manifest.yaml plus a Rust manager — so the hazard was real, but it is one branch name deep.) StartOS terminates TLS, so satd should not. The requirements table this replaces specified satd's own TLS ports (8336 / 50002 / 3001) as the LAN-facing surface. That was written without reference to how StartOS works: the OS reverse-proxies every exported interface with a certificate chaining to the server's root CA, which the user's clients already trust. Exporting satd's private CA instead would make every user import a second one for a single service. So the plain listeners are bound and the OS wraps them. satd's TLS listeners still run, unexported — which leaves them on lo and lxcbr0, off the LAN — and satd-init is used unmodified, which is what keeps this package from drifting away from the reference stack. MCP is the exception: satd refuses to start with MCP bound off-loopback unless TLS and auth are configured, so that listener speaks TLS from satd's own certificate and the OS re-wraps it with upstreamCertValidation disabled. The inward leg is a hop across lxcbr0 to a certificate the OS cannot be taught. Other things settled by reading rather than assuming: - rpcallowip must admit 10.0.3.0/24. StartOS puts every service on one bridge with the OS itself at a fixed 10.0.3.1; narrower and the reverse proxy is refused at the RPC surface. - sat-cli needs an explicit -rpccookiefile. It infers the cookie's per-network subdirectory by reading `regtest=1`/`testnet=1` from bitcoin.conf — lines this stack deliberately never writes, because satd accepts a network in a config file and then ignores it. satd-init keeps `rpc-cookie` pointing at the live path, so naming it sidesteps the inference on every network. - The image publishes linux/amd64 and linux/arm64 only, so there is no riscv64 arch and nothing to emulate it from. test/networks.test.ts parses satd-init's own case statement and compares the network list and every P2P port against it. Two restatements of one fact do not drift into a compile error — they drift into a service that fails at first start on the one network nobody tried. Verified locally: `tsc --noEmit` clean over 21 files, both tests pass, and `make` gets through typecheck, tests, SDK lint and the ncc bundle. Perturbed to confirm the checks bite — an invalid protocol literal and a dropped `secure` field are both type errors, and a wrong signet port or a missing network fails a named test. Not verified: installing on a StartOS server. Nothing here has run on one, and the README says so rather than implying otherwise. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01K9Nb3m8HuhNbjkJYp9Frmt --- contrib/packaging/.gitignore | 4 + contrib/packaging/startos/.gitignore | 5 + contrib/packaging/startos/LICENSE | 28 + contrib/packaging/startos/Makefile | 4 + contrib/packaging/startos/README.md | 163 +- contrib/packaging/startos/assets/.gitkeep | 0 contrib/packaging/startos/icon.png | Bin 0 -> 199668 bytes contrib/packaging/startos/instructions.md | 47 + contrib/packaging/startos/package-lock.json | 1670 +++++++++++++++++ contrib/packaging/startos/package.json | 24 + .../startos/startos/actions/caCertificate.ts | 67 + .../startos/startos/actions/index.ts | 9 + .../startos/startos/actions/mcpToken.ts | 67 + .../startos/startos/actions/network.ts | 46 + contrib/packaging/startos/startos/backups.ts | 32 + .../packaging/startos/startos/dependencies.ts | 8 + .../startos/startos/fileModels/store.json.ts | 25 + .../startos/i18n/dictionaries/default.ts | 48 + .../startos/i18n/dictionaries/translations.ts | 9 + .../packaging/startos/startos/i18n/index.ts | 8 + contrib/packaging/startos/startos/index.ts | 11 + .../packaging/startos/startos/init/index.ts | 18 + .../startos/startos/init/seedFiles.ts | 13 + .../packaging/startos/startos/interfaces.ts | 197 ++ contrib/packaging/startos/startos/main.ts | 187 ++ .../startos/startos/manifest/i18n.ts | 8 + .../startos/startos/manifest/index.ts | 35 + contrib/packaging/startos/startos/networks.ts | 35 + contrib/packaging/startos/startos/sdk.ts | 7 + contrib/packaging/startos/startos/utils.ts | 87 + .../startos/startos/versions/current.ts | 9 + .../startos/startos/versions/index.ts | 7 + .../packaging/startos/test/networks.test.ts | 46 + contrib/packaging/startos/tsconfig.json | 5 + contrib/packaging/umbrel/README.md | 28 +- 35 files changed, 2879 insertions(+), 78 deletions(-) create mode 100644 contrib/packaging/.gitignore create mode 100644 contrib/packaging/startos/.gitignore create mode 100644 contrib/packaging/startos/LICENSE create mode 100644 contrib/packaging/startos/Makefile create mode 100644 contrib/packaging/startos/assets/.gitkeep create mode 100644 contrib/packaging/startos/icon.png create mode 100644 contrib/packaging/startos/instructions.md create mode 100644 contrib/packaging/startos/package-lock.json create mode 100644 contrib/packaging/startos/package.json create mode 100644 contrib/packaging/startos/startos/actions/caCertificate.ts create mode 100644 contrib/packaging/startos/startos/actions/index.ts create mode 100644 contrib/packaging/startos/startos/actions/mcpToken.ts create mode 100644 contrib/packaging/startos/startos/actions/network.ts create mode 100644 contrib/packaging/startos/startos/backups.ts create mode 100644 contrib/packaging/startos/startos/dependencies.ts create mode 100644 contrib/packaging/startos/startos/fileModels/store.json.ts create mode 100644 contrib/packaging/startos/startos/i18n/dictionaries/default.ts create mode 100644 contrib/packaging/startos/startos/i18n/dictionaries/translations.ts create mode 100644 contrib/packaging/startos/startos/i18n/index.ts create mode 100644 contrib/packaging/startos/startos/index.ts create mode 100644 contrib/packaging/startos/startos/init/index.ts create mode 100644 contrib/packaging/startos/startos/init/seedFiles.ts create mode 100644 contrib/packaging/startos/startos/interfaces.ts create mode 100644 contrib/packaging/startos/startos/main.ts create mode 100644 contrib/packaging/startos/startos/manifest/i18n.ts create mode 100644 contrib/packaging/startos/startos/manifest/index.ts create mode 100644 contrib/packaging/startos/startos/networks.ts create mode 100644 contrib/packaging/startos/startos/sdk.ts create mode 100644 contrib/packaging/startos/startos/utils.ts create mode 100644 contrib/packaging/startos/startos/versions/current.ts create mode 100644 contrib/packaging/startos/startos/versions/index.ts create mode 100644 contrib/packaging/startos/test/networks.test.ts create mode 100644 contrib/packaging/startos/tsconfig.json diff --git a/contrib/packaging/.gitignore b/contrib/packaging/.gitignore new file mode 100644 index 000000000..68bdde08a --- /dev/null +++ b/contrib/packaging/.gitignore @@ -0,0 +1,4 @@ +# start-cli's packaging workspace marker. It holds a local build signing key +# and the registry list, is created per developer machine, and is required by +# `start-cli s9pk pack` in the parent of a package repo — see startos/README.md. +.startos/ diff --git a/contrib/packaging/startos/.gitignore b/contrib/packaging/startos/.gitignore new file mode 100644 index 000000000..46174c25f --- /dev/null +++ b/contrib/packaging/startos/.gitignore @@ -0,0 +1,5 @@ +*.s9pk +startos/*.js +node_modules/ +javascript +ncc-cache diff --git a/contrib/packaging/startos/LICENSE b/contrib/packaging/startos/LICENSE new file mode 100644 index 000000000..00a656056 --- /dev/null +++ b/contrib/packaging/startos/LICENSE @@ -0,0 +1,28 @@ +MIT License + +Copyright (c) 2026 20 Gauge Software, Inc. and the satd developers + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +This repository vendors source code from third parties under their own +licenses; see the per-file headers and any accompanying LICENSE files +in the relevant subdirectories. In particular: + + - electrum-proto/vendor/electrs.MIT — original LICENSE text from + romanz/electrs (MIT). diff --git a/contrib/packaging/startos/Makefile b/contrib/packaging/startos/Makefile new file mode 100644 index 000000000..dad38ff72 --- /dev/null +++ b/contrib/packaging/startos/Makefile @@ -0,0 +1,4 @@ +# The SDK ships the whole build: `make` packs the .s9pk, `make install` +# sideloads it to the server in ~/.startos/config.yaml. Overrides, if this +# package ever needs any, go above the include. +include node_modules/@start9labs/start-sdk/s9pk.mk diff --git a/contrib/packaging/startos/README.md b/contrib/packaging/startos/README.md index 93d4f4d2a..f52d16dfd 100644 --- a/contrib/packaging/startos/README.md +++ b/contrib/packaging/startos/README.md @@ -1,66 +1,97 @@ -# StartOS package — not written yet - -A StartOS package is a TypeScript project built with Start9's SDK. The SDK's -API has changed shape across StartOS versions, and a package written against -a guessed API produces something that looks right in review and does not -build. - -So this directory holds the requirements rather than a package. Writing it -is mechanical once the target version is fixed: - -1. Choose the StartOS version to target, and install that SDK. -2. Copy the structure of `start9labs/bitcoind-startos` at the matching tag — - satd is a drop-in for Bitcoin Core's RPC, config file and cookie format, - so that package's shape is the right starting point rather than a blank - project. -3. Publish from its own repository (`epochbtc/satd-startos`); Start9's - registry expects one repository per package. - -## What the package must declare - -**Contents: satd only** — the daemon, `sat-cli`, `sat-tui` and the MCP -server. No Lightning, no BTCPay, no wallets: StartOS users compose those -from their own store, and a package that bundled a second copy of software -the store already offers would be worse than useless. - -**Image:** `ghcr.io/epochbtc/satd`, unmodified. It already carries -`satd-init` and `mkca.sh`, so the package's first run is the same one the -reference stack and the appliance perform, and cannot drift from them. - -**Interfaces:** - -| Interface | Port | Notes | -|---|---|---| -| JSON-RPC | 8332 | plain, internal to the StartOS network, cookie auth | -| JSON-RPC (TLS) | 8336 | LAN-facing | -| Electrum (TLS) | 50002 | LAN-facing; the plain 50001 stays internal | -| Esplora (TLS) | 3001 | LAN-facing, prefix `/api` | -| MCP (TLS) | 8339 | bearer token from the generated authfile | -| P2P | 8333 | mainnet | - -**Config options:** the network, and nothing else that changes indexing. -`txindex` and `addressindex` stay forced on — Electrum and Esplora both -require them — and there is therefore no prune option to offer. - -**Health check:** `/readyz` on the metrics listener (port 9332, internal). -It reports not-ready until the chainstate is loaded and every listener is -bound, which is what a dependent package needs it to mean. Sync progress -comes from `getblockchaininfo`. - -**Backups:** a wallet-less node has no irreplaceable state; exclude the -chain and index directories. Do include `/var/lib/satd/tls` if the -deployment wants its CA to survive a restore — restoring without it means -every client re-imports. - -**Actions to expose in the UI:** show the CA certificate (so a user can -import it), show the MCP token and connection snippet, and the Electrum / -Esplora connection strings. - -## Open question for the package - -The CA and certificate are reissued by `mkca.sh` when they near expiry or -the machine's addresses change. On the appliance a systemd timer runs that -daily. A StartOS package has no equivalent scheduler of its own, so it would -renew on container start — fine for a box that reboots, not fine for one -that runs for a year. Decide whether that is acceptable or whether the -package needs a scheduled action. +# StartOS package + +A StartOS package for satd, built with Start9's TypeScript SDK. It is +published from its own repository (`epochbtc/satd-startos`) — Start9's +registry expects one repository per package — and lives here so it is +reviewed and versioned with satd. + +**Contents: satd only** — the daemon, `sat-cli`, `sat-tui` and the MCP server. +No Lightning, no BTCPay, no wallets: StartOS users compose those from their +own marketplace, and a package that bundled a second copy of software the +store already offers would be worse than useless. + +**Image:** `ghcr.io/epochbtc/satd`, unmodified. It already carries `satd-init` +and `mkca.sh`, so this package's first run is the same one the reference stack +and the appliance perform and cannot drift from them. + +## Who terminates TLS + +satd serves TLS itself on 8336 / 50002 / 3001, from a CA it generates per +install. That is the right answer for the reference stack and the appliance, +where nothing else can issue a certificate. It is the wrong answer here. + +StartOS already terminates TLS at its reverse proxy, with a certificate +chaining to the server's root CA — the one the user's browser trusts on that +box. Exporting satd's own listeners would ask every user to import a second +certificate authority for a single service. + +So this package binds satd's **plain** listeners and lets the OS wrap them. +satd's TLS listeners still run, unexported, which leaves them on `lo` and +`lxcbr0` and off the LAN. satd-init is used unmodified. + +MCP is the exception: satd refuses to start with MCP bound off-loopback unless +TLS and auth are both configured, so that listener speaks TLS from satd's own +certificate. The OS re-wraps it — terminating the client's connection with the +server's certificate and opening a fresh one inward — with +`upstreamCertValidation: 'disable'`, because the inward leg presents a +certificate from satd's per-install CA that the OS has no way to be taught. + +This is a deliberate departure from the interface table this file used to +carry, which specified satd's own TLS ports. That table was written without +reference to how StartOS handles TLS. + +## Building + +Requires Node 22+, Docker, `jq`, and: + +- **`start-cli` 2.0+** — from + [`Start9Labs/start-technologies` releases](https://github.com/Start9Labs/start-technologies/releases) + (`start-cli_x86_64-linux`). Note that `Start9Labs/shared-workflows` is the + *legacy* build line and pins start-cli `v0.4.0-beta.9`; the SDK 2.0 line + this package targets uses `start-technologies` instead. +- **`squashfs-tools-ng`** — `pack` shells out to `tar2sqfs` to turn each image + layer set into the squashfs the `.s9pk` carries. +- **A packaging workspace in the parent directory.** `start-cli` looks for a + `.startos/` marker in the directory *containing* the package repo, so + `contrib/packaging/.startos/` has to exist. Create it with + `cd contrib/packaging && start-cli s9pk init-workspace` — note that also + clones the whole `start-technologies` monorepo beside it, which is not + wanted here; only `.startos/` is required, and it is gitignored because it + holds a per-machine signing key. + +Then: + +```sh +make # typecheck, test, lint, bundle, and pack every arch +make x86 # just x86_64 +make install # sideload to the server in ~/.startos/config.yaml +``` + +`make` runs `tsc --noEmit`, the tests, the SDK's lint pass and `ncc` before it +packs, so a type error or a failing test stops the build. + +The SDK ships the entire build as `s9pk.mk`; the `Makefile` here is one +`include` line. + +## What is checked, and what is not + +Checked locally: the package typechecks against `@start9labs/start-sdk` +2.0.9, `test/networks.test.ts` verifies the network list and every P2P port +against `contrib/stack/satd/satd-init` itself (so the two cannot drift), and +`make` produces a `.s9pk` that `start-cli s9pk inspect` reads back. + +**Not checked: installing on a real StartOS server.** Nothing here has been +run on one. Until it has, treat the interface bindings, the health checks and +the `rpcallowip` bridge range as reasoned-but-unverified — in particular +`bridgeSubnet`, which assumes StartOS's documented fixed `10.0.3.1` gateway on +`lxcbr0`. + +## Before publishing + +1. Bump the image tag in `startos/manifest/index.ts` and the version in + `startos/versions/current.ts` to the release being published. +2. Install it on a StartOS box and confirm every interface answers. +3. Push to `epochbtc/satd-startos` and call + `Start9Labs/start-technologies/.github/workflows/build.yml@master` from its + CI, which builds the `.s9pk` with no secrets (it generates a temporary + signing key when `DEV_KEY` is absent). diff --git a/contrib/packaging/startos/assets/.gitkeep b/contrib/packaging/startos/assets/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/contrib/packaging/startos/icon.png b/contrib/packaging/startos/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..58de9bea9b27704c8e267b48cf55568c536c8472 GIT binary patch literal 199668 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

R^jAEX1kRs09 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�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" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@nodable/entities": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.2.0.tgz", + "integrity": "sha512-9uGyhaQavEUMC8AIddIjau4NsnsXhou+j5sBAGojCM1oxmQpVKTWR/9JxABD6UAv12vpIms55fPZKFQEhG6uBg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/nodable" + } + ], + "license": "MIT" + }, + "node_modules/@start9labs/start-sdk": { + "version": "2.0.9", + "resolved": "https://registry.npmjs.org/@start9labs/start-sdk/-/start-sdk-2.0.9.tgz", + "integrity": "sha512-HuSYrS10Bb+f6OyAMlCzd5Qyr+rIauiI0dF8MVh5Ny+eU0gF8/DeFAXlHFgL+9wkSv4UAP5q5yOzlBpXzsNbWQ==", + "bundleDependencies": [ + "@start9labs/start-core", + "eslint", + "typescript-eslint" + ], + "license": "MIT", + "dependencies": { + "@iarna/toml": "^3.0.0", + "@noble/curves": "^1.9.7", + "@noble/hashes": "^1.8.0", + "@start9labs/start-core": "file:./node_modules/@start9labs/start-core", + "@types/ini": "^4.1.1", + "deep-equality-data-structures": "^2.0.0", + "eslint": "^9.39.4", + "fast-xml-parser": "~5.7.0", + "ini": "^5.0.0", + "isomorphic-fetch": "^3.0.0", + "mime": "^4.1.0", + "typescript-eslint": "^8.61.0", + "yaml": "^2.8.3", + "zod": "4.4.3", + "zod-deep-partial": "^1.2.0" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "inBundle": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "inBundle": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "inBundle": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/@eslint/config-array": { + "version": "0.21.2", + "inBundle": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/@eslint/config-array/node_modules/brace-expansion": { + "version": "1.1.15", + "inBundle": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/@eslint/config-array/node_modules/minimatch": { + "version": "3.1.5", + "inBundle": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "inBundle": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/@eslint/core": { + "version": "0.17.0", + "inBundle": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/@eslint/eslintrc": { + "version": "3.3.5", + "inBundle": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/@eslint/eslintrc/node_modules/argparse": { + "version": "2.0.1", + "inBundle": true, + "license": "Python-2.0" + }, + "node_modules/@start9labs/start-sdk/node_modules/@eslint/eslintrc/node_modules/brace-expansion": { + "version": "1.1.15", + "inBundle": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/@eslint/eslintrc/node_modules/js-yaml": { + "version": "4.2.0", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "inBundle": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/@eslint/eslintrc/node_modules/minimatch": { + "version": "3.1.5", + "inBundle": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/@eslint/js": { + "version": "9.39.4", + "inBundle": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/@eslint/object-schema": { + "version": "2.1.7", + "inBundle": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "inBundle": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/@humanfs/core": { + "version": "0.19.2", + "inBundle": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/@humanfs/node": { + "version": "0.16.8", + "inBundle": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/@humanfs/types": { + "version": "0.15.0", + "inBundle": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "inBundle": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "inBundle": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/@types/estree": { + "version": "1.0.9", + "inBundle": true, + "license": "MIT" + }, + "node_modules/@start9labs/start-sdk/node_modules/@types/json-schema": { + "version": "7.0.15", + "inBundle": true, + "license": "MIT" + }, + "node_modules/@start9labs/start-sdk/node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.61.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.61.0", + "@typescript-eslint/type-utils": "8.61.0", + "@typescript-eslint/utils": "8.61.0", + "@typescript-eslint/visitor-keys": "8.61.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.61.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/@typescript-eslint/parser": { + "version": "8.61.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.61.0", + "@typescript-eslint/types": "8.61.0", + "@typescript-eslint/typescript-estree": "8.61.0", + "@typescript-eslint/visitor-keys": "8.61.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/@typescript-eslint/project-service": { + "version": "8.61.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.61.0", + "@typescript-eslint/types": "^8.61.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/@typescript-eslint/scope-manager": { + "version": "8.61.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.61.0", + "@typescript-eslint/visitor-keys": "8.61.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.61.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/@typescript-eslint/type-utils": { + "version": "8.61.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.61.0", + "@typescript-eslint/typescript-estree": "8.61.0", + "@typescript-eslint/utils": "8.61.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/@typescript-eslint/types": { + "version": "8.61.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/@typescript-eslint/typescript-estree": { + "version": "8.61.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.61.0", + "@typescript-eslint/tsconfig-utils": "8.61.0", + "@typescript-eslint/types": "8.61.0", + "@typescript-eslint/visitor-keys": "8.61.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "inBundle": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "5.0.6", + "inBundle": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.5", + "inBundle": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.8.3", + "inBundle": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/@typescript-eslint/utils": { + "version": "8.61.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.61.0", + "@typescript-eslint/types": "8.61.0", + "@typescript-eslint/typescript-estree": "8.61.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/@typescript-eslint/visitor-keys": { + "version": "8.61.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.61.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "inBundle": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/acorn": { + "version": "8.16.0", + "inBundle": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/acorn-jsx": { + "version": "5.3.2", + "inBundle": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/ajv": { + "version": "6.15.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/ansi-styles": { + "version": "4.3.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/balanced-match": { + "version": "1.0.2", + "inBundle": true, + "license": "MIT" + }, + "node_modules/@start9labs/start-sdk/node_modules/callsites": { + "version": "3.1.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/chalk": { + "version": "4.1.2", + "inBundle": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/color-convert": { + "version": "2.0.1", + "inBundle": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/color-name": { + "version": "1.1.4", + "inBundle": true, + "license": "MIT" + }, + "node_modules/@start9labs/start-sdk/node_modules/concat-map": { + "version": "0.0.1", + "inBundle": true, + "license": "MIT" + }, + "node_modules/@start9labs/start-sdk/node_modules/cross-spawn": { + "version": "7.0.6", + "inBundle": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/debug": { + "version": "4.4.3", + "inBundle": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@start9labs/start-sdk/node_modules/deep-is": { + "version": "0.1.4", + "inBundle": true, + "license": "MIT" + }, + "node_modules/@start9labs/start-sdk/node_modules/eslint": { + "version": "9.39.4", + "inBundle": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.5", + "@eslint/js": "9.39.4", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/@start9labs/start-sdk/node_modules/eslint-scope": { + "version": "8.4.0", + "inBundle": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "inBundle": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/eslint/node_modules/brace-expansion": { + "version": "1.1.15", + "inBundle": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/eslint/node_modules/escape-string-regexp": { + "version": "4.0.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/eslint/node_modules/find-up": { + "version": "5.0.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/eslint/node_modules/glob-parent": { + "version": "6.0.2", + "inBundle": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/eslint/node_modules/locate-path": { + "version": "6.0.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/eslint/node_modules/minimatch": { + "version": "3.1.5", + "inBundle": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/eslint/node_modules/p-locate": { + "version": "5.0.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/espree": { + "version": "10.4.0", + "inBundle": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/esquery": { + "version": "1.7.0", + "inBundle": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/esrecurse": { + "version": "4.3.0", + "inBundle": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/estraverse": { + "version": "5.3.0", + "inBundle": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/esutils": { + "version": "2.0.3", + "inBundle": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/fast-deep-equal": { + "version": "3.1.3", + "inBundle": true, + "license": "MIT" + }, + "node_modules/@start9labs/start-sdk/node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "inBundle": true, + "license": "MIT" + }, + "node_modules/@start9labs/start-sdk/node_modules/fast-levenshtein": { + "version": "2.0.6", + "inBundle": true, + "license": "MIT" + }, + "node_modules/@start9labs/start-sdk/node_modules/fdir": { + "version": "6.5.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/@start9labs/start-sdk/node_modules/file-entry-cache": { + "version": "8.0.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/flat-cache": { + "version": "4.0.1", + "inBundle": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/flatted": { + "version": "3.4.2", + "inBundle": true, + "license": "ISC" + }, + "node_modules/@start9labs/start-sdk/node_modules/globals": { + "version": "14.0.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/has-flag": { + "version": "4.0.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/ignore": { + "version": "5.3.2", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/import-fresh": { + "version": "3.3.1", + "inBundle": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/import-fresh/node_modules/resolve-from": { + "version": "4.0.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/imurmurhash": { + "version": "0.1.4", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/is-extglob": { + "version": "2.1.1", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/is-glob": { + "version": "4.0.3", + "inBundle": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/isexe": { + "version": "2.0.0", + "inBundle": true, + "license": "ISC" + }, + "node_modules/@start9labs/start-sdk/node_modules/json-buffer": { + "version": "3.0.1", + "inBundle": true, + "license": "MIT" + }, + "node_modules/@start9labs/start-sdk/node_modules/json-schema-traverse": { + "version": "0.4.1", + "inBundle": true, + "license": "MIT" + }, + "node_modules/@start9labs/start-sdk/node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "inBundle": true, + "license": "MIT" + }, + "node_modules/@start9labs/start-sdk/node_modules/keyv": { + "version": "4.5.4", + "inBundle": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/levn": { + "version": "0.4.1", + "inBundle": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/lodash.merge": { + "version": "4.6.2", + "inBundle": true, + "license": "MIT" + }, + "node_modules/@start9labs/start-sdk/node_modules/ms": { + "version": "2.1.3", + "inBundle": true, + "license": "MIT" + }, + "node_modules/@start9labs/start-sdk/node_modules/natural-compare": { + "version": "1.4.0", + "inBundle": true, + "license": "MIT" + }, + "node_modules/@start9labs/start-sdk/node_modules/optionator": { + "version": "0.9.4", + "inBundle": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/p-limit": { + "version": "3.1.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/parent-module": { + "version": "1.0.1", + "inBundle": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/path-exists": { + "version": "4.0.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/path-key": { + "version": "3.1.1", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/picomatch": { + "version": "4.0.4", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/prelude-ls": { + "version": "1.2.1", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/punycode": { + "version": "2.3.1", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/shebang-command": { + "version": "2.0.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/shebang-regex": { + "version": "3.0.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/strip-json-comments": { + "version": "3.1.1", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/supports-color": { + "version": "7.2.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/tinyglobby": { + "version": "0.2.17", + "inBundle": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/ts-api-utils": { + "version": "2.5.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/type-check": { + "version": "0.4.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/typescript-eslint": { + "version": "8.61.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.61.0", + "@typescript-eslint/parser": "8.61.0", + "@typescript-eslint/typescript-estree": "8.61.0", + "@typescript-eslint/utils": "8.61.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/uri-js": { + "version": "4.4.1", + "inBundle": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/which": { + "version": "2.0.2", + "inBundle": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/word-wrap": { + "version": "1.2.5", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/yocto-queue": { + "version": "0.1.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@types/ini": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@types/ini/-/ini-4.1.1.tgz", + "integrity": "sha512-MIyNUZipBTbyUNnhvuXJTY7B6qNI78meck9Jbv3wk0OgNwRyOOVEKDutAkOs1snB/tx0FafyR6/SN4Ps0hZPeg==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.20.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.2.tgz", + "integrity": "sha512-xlvWf4Vs9n1PEVYwP1n4vvG07M6y8WgvJ2t0vbrWTmijsIHp1cS+uJ2kMIRdY3nHZK0nCYKrPeD171+SzF4/zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@vercel/ncc": { + "version": "0.38.4", + "resolved": "https://registry.npmjs.org/@vercel/ncc/-/ncc-0.38.4.tgz", + "integrity": "sha512-8LwjnlP39s08C08J5NstzriPvW1SP8Zfpp1BvC2sI35kPeZnHfxVkCwu4/+Wodgnd60UtT1n8K8zw+Mp7J9JmQ==", + "dev": true, + "license": "MIT", + "bin": { + "ncc": "dist/ncc/cli.js" + } + }, + "node_modules/anynum": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/anynum/-/anynum-1.0.1.tgz", + "integrity": "sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, + "node_modules/deep-equality-data-structures": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/deep-equality-data-structures/-/deep-equality-data-structures-2.0.0.tgz", + "integrity": "sha512-qgrUr7MKXq7VRN+WUpQ48QlXVGL0KdibAoTX8KRg18lgOgqbEKMAW1WZsVCtakY4+XX42pbAJzTz/DlXEFM2Fg==", + "license": "MIT", + "dependencies": { + "object-hash": "^3.0.0" + } + }, + "node_modules/fast-xml-builder": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.3.1.tgz", + "integrity": "sha512-pIM/1n3ntFXKYrUZwW7QCK0gAW7XY+wzj1YMIV3tLDvPj/V+zTGJK5e3/4WJfwj0qWw2ElNXiTixda/R+3YSug==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "path-expression-matcher": "^1.6.2", + "xml-naming": "^0.3.0" + } + }, + "node_modules/fast-xml-parser": { + "version": "5.7.3", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.7.3.tgz", + "integrity": "sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "@nodable/entities": "^2.1.0", + "fast-xml-builder": "^1.1.7", + "path-expression-matcher": "^1.5.0", + "strnum": "^2.2.3" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, + "node_modules/ini": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/ini/-/ini-5.0.0.tgz", + "integrity": "sha512-+N0ngpO3e7cRUWOJAS7qw0IZIVc6XPrW4MlFBdD066F2L4k1L6ker3hLqSq7iXxU5tgS4WGkIUElWn5vogAEnw==", + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/isomorphic-fetch": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/isomorphic-fetch/-/isomorphic-fetch-3.0.0.tgz", + "integrity": "sha512-qvUtwJ3j6qwsF3jLxkZ72qCgjMysPzDfeV240JHiGZsANBYd+EEuu35v7dfrJ9Up0Ak07D7GGSkGhCHTqg/5wA==", + "license": "MIT", + "dependencies": { + "node-fetch": "^2.6.1", + "whatwg-fetch": "^3.4.1" + } + }, + "node_modules/mime": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-4.1.0.tgz", + "integrity": "sha512-X5ju04+cAzsojXKes0B/S4tcYtFAJ6tTMuSPBEn9CPGlrWr8Fiw7qYeLT0XyH80HSoAoqWCaz+MWKh22P7G1cw==", + "funding": [ + "https://github.com/sponsors/broofa" + ], + "license": "MIT", + "bin": { + "mime": "bin/cli.js" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/path-expression-matcher": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.6.2.tgz", + "integrity": "sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/prettier": { + "version": "3.9.6", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz", + "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/strnum": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.4.2.tgz", + "integrity": "sha512-rDG3Ah4TV0k1hWvLSzkZtMmLN9+eS+h3knq4MP6A42Y3Yh5qGNnOUs1jJkoSr8FG5dsL28c7KgkIBzSEykqtuw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "anynum": "^1.0.1" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-fetch": { + "version": "3.6.20", + "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.6.20.tgz", + "integrity": "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==", + "license": "MIT" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/xml-naming": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.3.0.tgz", + "integrity": "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-deep-partial": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/zod-deep-partial/-/zod-deep-partial-1.4.4.tgz", + "integrity": "sha512-aWkPl7hVStgE01WzbbSxCgX4O+sSpgt8JOjvFUtMTF75VgL6MhWQbiZi+AWGN85SfSTtI9gsOtL1vInoqfDVaA==", + "license": "MIT", + "peerDependencies": { + "zod": "^4.1.13" + } + } + } +} diff --git a/contrib/packaging/startos/package.json b/contrib/packaging/startos/package.json new file mode 100644 index 000000000..e1e3e06e8 --- /dev/null +++ b/contrib/packaging/startos/package.json @@ -0,0 +1,24 @@ +{ + "name": "satd-startos", + "scripts": { + "build": "rm -rf ./javascript && ncc build startos/index.ts -o ./javascript", + "prettier": "prettier --write startos test", + "check": "tsc --noEmit && npm run test", + "test": "node --experimental-strip-types --test test/*.test.ts" + }, + "dependencies": { + "@start9labs/start-sdk": "2.0.9" + }, + "devDependencies": { + "@types/node": "^22.19.7", + "@vercel/ncc": "^0.38.4", + "prettier": "^3.8.1", + "typescript": "^6.0.3" + }, + "prettier": { + "trailingComma": "all", + "tabWidth": 2, + "semi": false, + "singleQuote": true + } +} diff --git a/contrib/packaging/startos/startos/actions/caCertificate.ts b/contrib/packaging/startos/startos/actions/caCertificate.ts new file mode 100644 index 000000000..e98bafd8d --- /dev/null +++ b/contrib/packaging/startos/startos/actions/caCertificate.ts @@ -0,0 +1,67 @@ +import { readFile } from 'fs/promises' +import { i18n } from '../i18n' +import { sdk } from '../sdk' +import { rootDir, satdMounts } from '../utils' + +/** + * StartOS terminates TLS for everything this package exports, so a user does + * not need this to reach the node from the LAN — that certificate is the + * server's own and is already trusted. + * + * It is here for the two places satd's own certificate is what gets + * presented: a client on the container bridge dialling satd's TLS listeners + * directly, and the MCP surface, whose inward leg the OS re-wraps without + * validating. + */ +export const caCertificate = sdk.Action.withoutInput( + 'ca-certificate', + + async () => ({ + name: i18n('CA Certificate'), + description: i18n( + "This install's certificate authority, for clients that reach satd's own TLS listeners directly", + ), + warning: null, + allowedStatuses: 'any', + group: null, + visibility: 'enabled', + }), + + async ({ effects }) => { + const cert = await sdk.SubContainer.withTemp( + effects, + { imageId: 'satd' }, + satdMounts, + 'ca-certificate', + async (subc) => + readFile(`${subc.rootfs}${rootDir}/tls/ca.crt`, 'utf8').catch( + () => null, + ), + ) + + if (!cert) + return { + version: '1' as const, + title: i18n('Not generated yet'), + message: i18n( + 'satd-init writes the CA on the first start. Start the service once, then run this action again.', + ), + result: null, + } + + return { + version: '1' as const, + title: i18n('CA Certificate'), + message: i18n('Import this certificate to trust this node directly.'), + result: { + type: 'single' as const, + name: i18n('CA Certificate'), + description: i18n('PEM-encoded certificate authority'), + value: cert, + copyable: true, + qr: false, + masked: false, + }, + } + }, +) diff --git a/contrib/packaging/startos/startos/actions/index.ts b/contrib/packaging/startos/startos/actions/index.ts new file mode 100644 index 000000000..5ba6e7e5f --- /dev/null +++ b/contrib/packaging/startos/startos/actions/index.ts @@ -0,0 +1,9 @@ +import { sdk } from '../sdk' +import { caCertificate } from './caCertificate' +import { mcpToken } from './mcpToken' +import { network } from './network' + +export const actions = sdk.Actions.of() + .addAction(network) + .addAction(caCertificate) + .addAction(mcpToken) diff --git a/contrib/packaging/startos/startos/actions/mcpToken.ts b/contrib/packaging/startos/startos/actions/mcpToken.ts new file mode 100644 index 000000000..f7e5d2b0d --- /dev/null +++ b/contrib/packaging/startos/startos/actions/mcpToken.ts @@ -0,0 +1,67 @@ +import { readFile } from 'fs/promises' +import { i18n } from '../i18n' +import { sdk } from '../sdk' +import { rootDir, satdMounts } from '../utils' + +/** + * The bearer token satd-init mints on first start. Only its hash is written + * to the authfile, so this file is the only copy — regenerating it would + * break every client already configured with it, which is why satd-init + * never does. + */ +export const mcpToken = sdk.Action.withoutInput( + 'mcp-token', + + async () => ({ + name: i18n('MCP Token'), + description: i18n( + 'The bearer token an AI assistant needs to query this node', + ), + warning: i18n( + 'Anyone holding this token can query this node through the MCP surface. Treat it as a password.', + ), + allowedStatuses: 'any', + group: null, + visibility: 'enabled', + }), + + async ({ effects }) => { + const token = await sdk.SubContainer.withTemp( + effects, + { imageId: 'satd' }, + satdMounts, + 'mcp-token', + async (subc) => + readFile(`${subc.rootfs}${rootDir}/secrets/mcp-token`, 'utf8') + .then((t) => t.trim()) + .catch(() => null), + ) + + if (!token) + return { + version: '1' as const, + title: i18n('Not generated yet'), + message: i18n( + 'satd-init mints the token on the first start. Start the service once, then run this action again.', + ), + result: null, + } + + return { + version: '1' as const, + title: i18n('MCP Token'), + message: i18n( + 'Send this as `Authorization: Bearer ` to the MCP interface.', + ), + result: { + type: 'single' as const, + name: i18n('MCP Token'), + description: i18n('Bearer token'), + value: token, + copyable: true, + qr: false, + masked: true, + }, + } + }, +) diff --git a/contrib/packaging/startos/startos/actions/network.ts b/contrib/packaging/startos/startos/actions/network.ts new file mode 100644 index 000000000..65fda7ea8 --- /dev/null +++ b/contrib/packaging/startos/startos/actions/network.ts @@ -0,0 +1,46 @@ +import { ISB } from '@start9labs/start-sdk' +import { storeJson } from '../fileModels/store.json' +import { i18n } from '../i18n' +import { sdk } from '../sdk' +import { networks } from '../utils' + +/** + * The only setting this package offers. + * + * `txindex` and `addressindex` are deliberately not options: Electrum and + * Esplora both require them, so turning either off would break the two + * surfaces that are the reason to run satd rather than Bitcoin Core. Pruning + * is incompatible with txindex for the same reason, so there is no prune + * option to offer either. + */ +export const network = sdk.Action.withInput( + 'network', + + async () => ({ + name: i18n('Network'), + description: i18n('Which Bitcoin network this node runs on'), + warning: i18n( + 'Changing the network restarts the node on a different chain. The existing chain data is kept — each network has its own directory — but the node re-syncs the new network from scratch, and the P2P port changes with it.', + ), + allowedStatuses: 'any', + group: null, + visibility: 'enabled', + }), + + ISB.InputSpec.of({ + network: ISB.Value.select({ + name: i18n('Network'), + description: i18n( + 'Mainnet is the Bitcoin network. The others are test networks whose coins have no value.', + ), + default: 'mainnet', + values: networks, + }), + }), + + async ({ effects }) => storeJson.read().once(), + + async ({ effects, input }) => { + await storeJson.merge(effects, { network: input.network }) + }, +) diff --git a/contrib/packaging/startos/startos/backups.ts b/contrib/packaging/startos/startos/backups.ts new file mode 100644 index 000000000..ec9bf240c --- /dev/null +++ b/contrib/packaging/startos/startos/backups.ts @@ -0,0 +1,32 @@ +import { sdk } from './sdk' + +/** + * A wallet-less node holds nothing irreplaceable: the chain, the chainstate + * and every index rebuild from the network, and backing them up would move + * hundreds of gigabytes to reproduce something the node fetches on its own. + * + * What is worth keeping is small and cannot be re-derived: the per-install CA + * and certificate, the MCP bearer token, and the network selection. Restoring + * without the CA means every client that imported it has to import a new one + * — which on StartOS matters less than it does elsewhere, since the OS + * terminates TLS with its own certificate, but the CA is still what a + * container-to-container client and the MCP surface present. + */ +export const { createBackup, restoreInit } = sdk.setupBackups(async () => + sdk.Backups.ofVolumes('main').setOptions({ + exclude: [ + 'blocks/', + 'chainstate/', + 'indexes/', + // Per-network subdirectories hold the same three, plus the cookie. + '*/blocks/', + '*/chainstate/', + '*/indexes/', + '.cookie', + '*/.cookie', + 'rpc-cookie', + 'debug.log', + '*/debug.log', + ], + }), +) diff --git a/contrib/packaging/startos/startos/dependencies.ts b/contrib/packaging/startos/startos/dependencies.ts new file mode 100644 index 000000000..710c4091c --- /dev/null +++ b/contrib/packaging/startos/startos/dependencies.ts @@ -0,0 +1,8 @@ +import { sdk } from './sdk' + +/** + * None. satd serves its own Electrum and Esplora surfaces from one process, + * so there is no indexer to depend on, and it needs nothing else on the box + * to start or to stay healthy. + */ +export const setDependencies = sdk.setupDependencies(async () => ({})) diff --git a/contrib/packaging/startos/startos/fileModels/store.json.ts b/contrib/packaging/startos/startos/fileModels/store.json.ts new file mode 100644 index 000000000..b3f6e1617 --- /dev/null +++ b/contrib/packaging/startos/startos/fileModels/store.json.ts @@ -0,0 +1,25 @@ +import { FileHelper, z } from '@start9labs/start-sdk' +import { sdk } from '../sdk' + +/** + * StartOS-level state, which is why it lives here rather than in + * bitcoin.conf: the network is passed to satd as a command-line argument on + * every start, and satd accepts a `signet=1` line in a config file and then + * ignores it — silently running mainnet. Writing it to the config file would + * therefore look like it worked. + */ +export const shape = z + .object({ + network: z + .enum(['mainnet', 'signet', 'testnet4', 'testnet', 'regtest']) + .catch('mainnet'), + }) + .strip() + +export const storeJson = FileHelper.json( + { + base: sdk.volumes.main, + subpath: '/startos-store.json', + }, + shape, +) diff --git a/contrib/packaging/startos/startos/i18n/dictionaries/default.ts b/contrib/packaging/startos/startos/i18n/dictionaries/default.ts new file mode 100644 index 000000000..23957f429 --- /dev/null +++ b/contrib/packaging/startos/startos/i18n/dictionaries/default.ts @@ -0,0 +1,48 @@ +export const DEFAULT_LANG = 'en_US' + +const dict = { + // startos/actions/caCertificate.ts + 'CA Certificate': 1, + 'This install\'s certificate authority, for clients that reach satd\'s own TLS listeners directly': 2, + 'Not generated yet': 3, + 'satd-init writes the CA on the first start. Start the service once, then run this action again.': 4, + 'Import this certificate to trust this node directly.': 5, + 'PEM-encoded certificate authority': 6, + // startos/actions/mcpToken.ts + 'MCP Token': 7, + 'The bearer token an AI assistant needs to query this node': 8, + 'Anyone holding this token can query this node through the MCP surface. Treat it as a password.': 9, + 'satd-init mints the token on the first start. Start the service once, then run this action again.': 10, + 'Send this as `Authorization: Bearer ` to the MCP interface.': 11, + 'Bearer token': 12, + // startos/actions/network.ts + 'Network': 13, + 'Which Bitcoin network this node runs on': 14, + 'Changing the network restarts the node on a different chain. The existing chain data is kept — each network has its own directory — but the node re-syncs the new network from scratch, and the P2P port changes with it.': 15, + 'Mainnet is the Bitcoin network. The others are test networks whose coins have no value.': 16, + // startos/interfaces.ts + 'RPC': 17, + 'Bitcoin Core-compatible JSON-RPC': 18, + 'Electrum': 19, + 'Electrum server, for Sparrow, Electrum, BlueWallet and Zeus': 20, + 'Esplora': 21, + 'Esplora REST API, compatible with Blockstream\'s': 22, + 'MCP': 23, + 'Model Context Protocol server, so an AI assistant can query this node': 24, + 'Peer': 25, + 'Listens for connections from other Bitcoin nodes': 26, + // startos/main.ts + 'satd is starting…': 27, + 'Could not read ${cmd} from satd: ${error}': 28, + 'Node': 29, + 'satd is ready': 30, + 'Blockchain Sync': 31, + 'satd is fully synced': 32, + 'Syncing block headers: ${count}': 33, + 'Syncing block headers…': 34, + 'Syncing blocks: ${percentage}%': 35, +} as const + +export type LangDict = typeof dict + +export default dict diff --git a/contrib/packaging/startos/startos/i18n/dictionaries/translations.ts b/contrib/packaging/startos/startos/i18n/dictionaries/translations.ts new file mode 100644 index 000000000..2bc621696 --- /dev/null +++ b/contrib/packaging/startos/startos/i18n/dictionaries/translations.ts @@ -0,0 +1,9 @@ +/** + * No translations yet. English-only is honest; machine-translated operator + * text that nobody who speaks the language has read is not, and a wrong + * string here is one a user acts on. + * + * To add one, map the numeric ids from ./default.ts — the ids are the + * contract, so the English text can be reworded without touching this file. + */ +export default {} as const diff --git a/contrib/packaging/startos/startos/i18n/index.ts b/contrib/packaging/startos/startos/i18n/index.ts new file mode 100644 index 000000000..04cea200e --- /dev/null +++ b/contrib/packaging/startos/startos/i18n/index.ts @@ -0,0 +1,8 @@ +/** + * Plumbing. DO NOT EDIT this file. + */ +import { setupI18n } from '@start9labs/start-sdk' +import defaultDict, { DEFAULT_LANG } from './dictionaries/default' +import translations from './dictionaries/translations' + +export const i18n = setupI18n(defaultDict, translations, DEFAULT_LANG) diff --git a/contrib/packaging/startos/startos/index.ts b/contrib/packaging/startos/startos/index.ts new file mode 100644 index 000000000..7af589b81 --- /dev/null +++ b/contrib/packaging/startos/startos/index.ts @@ -0,0 +1,11 @@ +/** + * Plumbing. DO NOT EDIT. + */ +export { createBackup } from './backups' +export { main } from './main' +export { init, uninit } from './init' +export { actions } from './actions' +import { buildManifest } from '@start9labs/start-sdk' +import { manifest as sdkManifest } from './manifest' +import { versionGraph } from './versions' +export const manifest = buildManifest(versionGraph, sdkManifest) diff --git a/contrib/packaging/startos/startos/init/index.ts b/contrib/packaging/startos/startos/init/index.ts new file mode 100644 index 000000000..0f6cb49c4 --- /dev/null +++ b/contrib/packaging/startos/startos/init/index.ts @@ -0,0 +1,18 @@ +import { actions } from '../actions' +import { restoreInit } from '../backups' +import { setDependencies } from '../dependencies' +import { setInterfaces } from '../interfaces' +import { sdk } from '../sdk' +import { versionGraph } from '../versions' +import { seedFiles } from './seedFiles' + +export const init = sdk.setupInit( + restoreInit, + versionGraph, + seedFiles, + setInterfaces, + setDependencies, + actions, +) + +export const uninit = sdk.setupUninit(versionGraph) diff --git a/contrib/packaging/startos/startos/init/seedFiles.ts b/contrib/packaging/startos/startos/init/seedFiles.ts new file mode 100644 index 000000000..3d1187641 --- /dev/null +++ b/contrib/packaging/startos/startos/init/seedFiles.ts @@ -0,0 +1,13 @@ +import { storeJson } from '../fileModels/store.json' +import { sdk } from '../sdk' + +/** + * Only the network selection. Everything else satd needs on disk — + * bitcoin.conf, the CA, the authfile — is written by satd-init at every + * start, from the template baked into the image, so seeding a copy here + * would be a second source of truth that goes stale. + */ +export const seedFiles = sdk.setupOnInit(async (effects, kind) => { + if (!kind) return + await storeJson.merge(effects, {}) +}) diff --git a/contrib/packaging/startos/startos/interfaces.ts b/contrib/packaging/startos/startos/interfaces.ts new file mode 100644 index 000000000..43815388f --- /dev/null +++ b/contrib/packaging/startos/startos/interfaces.ts @@ -0,0 +1,197 @@ +import { storeJson } from './fileModels/store.json' +import { i18n } from './i18n' +import { sdk } from './sdk' +import { + electrumHostId, + electrumInterfaceId, + electrumPort, + electrumTlsPort, + esploraHostId, + esploraInterfaceId, + esploraPort, + esploraTlsPort, + mcpHostId, + mcpInterfaceId, + mcpPort, + p2pPorts, + peerHostId, + peerInterfaceId, + rpcHostId, + rpcInterfaceId, + rpcPort, +} from './utils' + +/** + * Who terminates TLS. + * + * satd serves TLS itself on 8336 / 50002 / 3001 from a CA it generates per + * install. That is the right answer for the reference stack and the + * appliance, where nothing else can issue a certificate. It is the wrong + * answer here: StartOS already terminates TLS at its reverse proxy with a + * certificate chaining to this server's root CA, which every client the user + * has set up already trusts. Exporting satd's own listeners instead would ask + * each user to import a second CA for one service. + * + * So the plain listeners are what get bound, and the OS wraps them. satd's + * TLS listeners still run — satd-init is used unmodified, which is what keeps + * this package from drifting away from the stack — they simply are not + * exported, which leaves them on `lo` and `lxcbr0` and off the LAN. + * + * MCP is the one exception, below. + */ +export const setInterfaces = sdk.setupInterfaces(async ({ effects }) => { + const network = (await storeJson.read((s) => s.network).const(effects)) ?? 'mainnet' + + // --- JSON-RPC ----------------------------------------------------------- + // `http` rather than a raw binding: it publishes both a plaintext bridge + // address for other packages on lxcbr0 and a TLS-terminated one for the + // LAN, which is the split Core-compatible clients expect. Cookie auth is + // unchanged and is still satd's. + const rpcOrigin = await sdk.MultiHost.of(effects, rpcHostId).bindPort( + rpcPort, + { protocol: 'http', preferredExternalPort: rpcPort }, + ) + const rpc = sdk.createInterface(effects, { + name: i18n('RPC'), + id: rpcInterfaceId, + description: i18n('Bitcoin Core-compatible JSON-RPC'), + type: 'api', + masked: false, + schemeOverride: null, + username: null, + path: '', + query: {}, + }) + + // --- Electrum ----------------------------------------------------------- + // Not HTTP: the Electrum protocol is line-delimited JSON over a raw TCP + // socket, so the OS adds TLS in front of the plaintext listener rather than + // proxying requests. No X-Forwarded headers — there is no request to put + // them on — and no ALPN, which is an HTTP/2 negotiation Electrum clients do + // not speak. + const electrumOrigin = await sdk.MultiHost.of( + effects, + electrumHostId, + ).bindPort(electrumPort, { + protocol: null, + preferredExternalPort: electrumPort, + secure: { ssl: false }, + addSsl: { + preferredExternalPort: electrumTlsPort, + addXForwardedHeaders: false, + alpn: null, + auth: null, + }, + }) + const electrum = sdk.createInterface(effects, { + name: i18n('Electrum'), + id: electrumInterfaceId, + description: i18n( + 'Electrum server, for Sparrow, Electrum, BlueWallet and Zeus', + ), + type: 'api', + masked: false, + schemeOverride: null, + username: null, + path: '', + query: {}, + }) + + // --- Esplora ------------------------------------------------------------ + // Unauthenticated, as every public Esplora deployment is: it serves public + // chain data. The prefix is satd's `esploraprefix`. + const esploraOrigin = await sdk.MultiHost.of(effects, esploraHostId).bindPort( + esploraPort, + { + protocol: 'http', + preferredExternalPort: esploraPort, + addSsl: { preferredExternalPort: esploraTlsPort }, + }, + ) + const esplora = sdk.createInterface(effects, { + name: i18n('Esplora'), + id: esploraInterfaceId, + description: i18n("Esplora REST API, compatible with Blockstream's"), + type: 'api', + masked: false, + schemeOverride: null, + username: null, + path: '/api', + query: {}, + }) + + // --- MCP ---------------------------------------------------------------- + // The exception. satd refuses to start with MCP bound off-loopback unless + // TLS and auth are both configured, so this listener speaks TLS from satd's + // own certificate and cannot be handed over as plaintext. + // + // `secure.ssl` says the container's port is already TLS; `addSsl` makes the + // OS terminate the client's connection with the server's own certificate + // and open a fresh one inward. That inward leg is what + // `upstreamCertValidation: 'disable'` covers: it is a hop across lxcbr0 to + // a certificate from satd's per-install CA, which the OS has no reason to + // trust and no way to be taught. Without it the OS validates against the + // StartOS root CA and every MCP request fails. + const mcpOrigin = await sdk.MultiHost.of(effects, mcpHostId).bindPort( + mcpPort, + { + protocol: null, + preferredExternalPort: mcpPort, + secure: { ssl: true }, + addSsl: { + preferredExternalPort: mcpPort, + addXForwardedHeaders: true, + alpn: null, + auth: null, + upstreamCertValidation: 'disable', + }, + }, + ) + const mcp = sdk.createInterface(effects, { + name: i18n('MCP'), + id: mcpInterfaceId, + description: i18n( + 'Model Context Protocol server, so an AI assistant can query this node', + ), + type: 'api', + masked: true, + schemeOverride: null, + username: null, + path: '', + query: {}, + }) + + // --- P2P ---------------------------------------------------------------- + // The port follows the chain, because other nodes rely on the convention. + // No TLS in either direction: the Bitcoin P2P protocol has its own + // encrypted transport (BIP 324) and wrapping it in TLS would make this node + // unreachable to every peer. + const peerOrigin = await sdk.MultiHost.of(effects, peerHostId).bindPort( + p2pPorts[network], + { + protocol: null, + preferredExternalPort: p2pPorts[network], + secure: { ssl: false }, + addSsl: null, + }, + ) + const peer = sdk.createInterface(effects, { + name: i18n('Peer'), + id: peerInterfaceId, + description: i18n('Listens for connections from other Bitcoin nodes'), + type: 'p2p', + masked: false, + schemeOverride: { ssl: null, noSsl: null }, + username: null, + path: '', + query: {}, + }) + + return [ + await rpcOrigin.export([rpc]), + await electrumOrigin.export([electrum]), + await esploraOrigin.export([esplora]), + await mcpOrigin.export([mcp]), + await peerOrigin.export([peer]), + ] +}) diff --git a/contrib/packaging/startos/startos/main.ts b/contrib/packaging/startos/startos/main.ts new file mode 100644 index 000000000..7697d3e1e --- /dev/null +++ b/contrib/packaging/startos/startos/main.ts @@ -0,0 +1,187 @@ +import { healthFns } from '@start9labs/start-sdk' +import { storeJson } from './fileModels/store.json' +import { i18n } from './i18n' +import { sdk } from './sdk' +import { + bridgeSubnet, + GetBlockchainInfo, + metricsPort, + p2pPorts, + rootDir, + satCliArgs, + satdMounts, +} from './utils' + +export const main = sdk.setupMain(async ({ effects }) => { + const store = await storeJson.read().once() + if (!store) throw new Error('No store') + const { network } = store + + const satdSub = await sdk.SubContainer.eager( + effects, + { imageId: 'satd' }, + satdMounts, + 'satd-sub', + ) + + /** + * One read-only sat-cli call, parsed. Every outcome is a value: a node not + * answering yet reads as `starting`, and a call that cannot be run or whose + * reply cannot be parsed reads as `failure` — neither is a state satd + * reaches while running normally. `exec` rather than `execFail` because a + * non-zero exit is the expected signal here, not an error. + */ + const probe = async ( + ...cmd: string[] + ): Promise<{ value: T } | { health: healthFns.HealthCheckResult }> => { + try { + const res = await satdSub.exec([...satCliArgs, ...cmd]) + if ( + res.exitCode !== 0 || + typeof res.stdout !== 'string' || + res.stdout === '' + ) { + return { + health: { result: 'starting' as const, message: i18n('satd is starting…') }, + } + } + return { value: JSON.parse(res.stdout) as T } + } catch (e) { + return { + health: { + result: 'failure' as const, + message: i18n('Could not read ${cmd} from satd: ${error}', { + cmd: cmd[0], + error: String(e), + }), + }, + } + } + } + + return sdk.Daemons.of(effects) + /** + * StartOS creates the volume owned by root; the image runs as `satd` + * (uid 2121) and satd-init writes the CA, the config and the token into + * it. Cheap and idempotent, and without it the first start fails on the + * first write rather than on anything that names the cause. + */ + .addOneshot('own-volume', { + subcontainer: satdSub, + exec: { + command: ['chown', '-R', 'satd:satd', rootDir], + user: 'root', + }, + requires: [], + }) + /** + * The same satd-init the reference stack and the appliance run, from the + * image, unmodified — it issues this install's CA and certificate, + * renders bitcoin.conf for the selected network, mints the MCP token and + * points `rpc-cookie` at the network's cookie. A package that + * re-implemented any of that would drift from the stack within a release. + * + * SATD_STACK_SUBNET becomes satd's `rpcallowip`. On StartOS every service + * shares one bridge with the OS at 10.0.3.1, so this range is what admits + * the OS reverse proxy and other packages; narrower and the RPC interface + * answers nothing. + */ + .addOneshot('satd-init', { + subcontainer: satdSub, + exec: { + command: ['/usr/local/bin/satd-init'], + user: 'satd', + env: { + NETWORK: network, + SATD_MCP: '1', + SATD_STACK_SUBNET: bridgeSubnet, + // The name clients reach this server by. StartOS terminates TLS + // itself, so this only labels satd's own certificate — the one used + // on the bridge and for MCP. + SATD_TLS_HOSTNAME: 'satd.startos', + SATD_P2P_PORT: String(p2pPorts[network]), + SATD_CA_EXPORT_HINT: + 'the CA certificate is shown by this service’s "CA Certificate" action', + }, + }, + requires: ['own-volume'], + }) + .addDaemon('satd', { + subcontainer: satdSub, + exec: { + // The network is an argument, never a config-file line: satd accepts + // `signet=1` in a file and then ignores it, silently running mainnet. + // `--chain=` because there are bare flags for the test networks but + // none for mainnet. + command: ['satd', `--datadir=${rootDir}`, `--chain=${network}`], + user: 'satd', + // A node writing out its chainstate should not be killed mid-flush. + sigtermTimeout: 600_000, + }, + ready: { + display: i18n('Node'), + /** + * satd's own readiness gate rather than a port check: /readyz reports + * not-ready until the chainstate is loaded and every configured + * listener is bound, which is what a dependent package needs "ready" + * to mean. The probe is in the image and speaks HTTP over bash's + * /dev/tcp, so it needs no curl in this thin image. + */ + fn: async () => { + const res = await satdSub.exec(['/usr/local/bin/satd-healthcheck'], { + env: { + SATD_HEALTH_URL: `http://127.0.0.1:${metricsPort}/readyz`, + }, + }) + return res.exitCode === 0 + ? { result: 'success' as const, message: i18n('satd is ready') } + : { + result: 'starting' as const, + message: i18n('satd is starting…'), + } + }, + }, + requires: ['satd-init'], + }) + .addHealthCheck('sync-progress', { + ready: { + display: i18n('Blockchain Sync'), + trigger: sdk.trigger.statusTrigger(30_000, { + starting: 5_000, + failure: 5_000, + }), + fn: async () => { + const res = await probe('getblockchaininfo') + if ('health' in res) return res.health + const info = res.value + + if (!info.initialblockdownload) + return { + result: 'success' as const, + message: i18n('satd is fully synced'), + } + + // At genesis nothing sits above the tip yet and + // verificationprogress is still 0 — the header chain is the only + // thing moving, so reporting a percentage there reads as stuck. + if (info.blocks === 0) + return { + result: 'loading' as const, + message: info.headers + ? i18n('Syncing block headers: ${count}', { + count: info.headers, + }) + : i18n('Syncing block headers…'), + } + + return { + result: 'loading' as const, + message: i18n('Syncing blocks: ${percentage}%', { + percentage: (info.verificationprogress * 100).toFixed(2), + }), + } + }, + }, + requires: ['satd'], + }) +}) diff --git a/contrib/packaging/startos/startos/manifest/i18n.ts b/contrib/packaging/startos/startos/manifest/i18n.ts new file mode 100644 index 000000000..2a78e99d4 --- /dev/null +++ b/contrib/packaging/startos/startos/manifest/i18n.ts @@ -0,0 +1,8 @@ +export const short = { + en_US: 'A Bitcoin full node in Rust, with Electrum and Esplora built in', +} + +export const long = { + en_US: + "satd is a Bitcoin Core-compatible full node written in Rust. It speaks Core's JSON-RPC, config file and CLI, and serves an Electrum server and an Esplora REST API from the same process — no second indexer to run and no second copy of the chain to store. This package contains satd and its own tools only; Lightning, BTCPay and wallets come from the marketplace as separate services. It runs fully indexed, because Electrum and Esplora both require the transaction and address indices, so pruning is not offered and the disk budget is the full chain plus roughly the same again.", +} diff --git a/contrib/packaging/startos/startos/manifest/index.ts b/contrib/packaging/startos/startos/manifest/index.ts new file mode 100644 index 000000000..07f5f7806 --- /dev/null +++ b/contrib/packaging/startos/startos/manifest/index.ts @@ -0,0 +1,35 @@ +import { setupManifest } from '@start9labs/start-sdk' +import { long, short } from './i18n' + +export const manifest = setupManifest({ + id: 'satd', + title: 'satd', + license: 'MIT', + donationUrl: null, + packageRepo: 'https://github.com/epochbtc/satd/tree/master/contrib/packaging/startos', + upstreamRepo: 'https://github.com/epochbtc/satd', + marketingUrl: 'https://epochbtc.github.io/satd/', + description: { short, long }, + volumes: ['main'], + images: { + satd: { + source: { + // The published runtime image, unmodified. It already carries + // satd-init and mkca.sh, so this package's first run is the same one + // the reference stack and the appliance perform and cannot drift from + // them. + // + // Pinned to a release that exists. The registry publishes bare tags + // only — there has never been a `v`-prefixed one — and the tag is + // bumped as a step in the release checklist. + dockerTag: 'ghcr.io/epochbtc/satd:0.5.1', + }, + // The image publishes linux/amd64 and linux/arm64 and nothing else, so + // there is no riscv64 here and nothing to emulate it from. + arch: ['x86_64', 'aarch64'], + }, + }, + // satd is standalone: it needs no other service on the box, and nothing it + // serves is contingent on one being installed. + dependencies: {}, +}) diff --git a/contrib/packaging/startos/startos/networks.ts b/contrib/packaging/startos/startos/networks.ts new file mode 100644 index 000000000..ce418729d --- /dev/null +++ b/contrib/packaging/startos/startos/networks.ts @@ -0,0 +1,35 @@ +/** + * The network tables, kept free of any SDK import. + * + * That is deliberate rather than tidiness: test/networks.test.ts checks these + * against satd-init, and node's type stripping resolves a transitive + * extensionless import of the SDK at runtime, so a test that reached these + * through a module importing `./sdk` could not run at all. + */ + +/** + * The networks satd-init accepts. It exits 2 on anything else, so this list + * and its list have to agree. + */ +export const networks = { + mainnet: 'Mainnet', + signet: 'Signet', + testnet4: 'Testnet4', + testnet: 'Testnet3', + regtest: 'Regtest', +} as const + +export type Network = keyof typeof networks + +/** + * P2P ports, from satd-init's own case statement. satd-init refuses to start + * when SATD_P2P_PORT disagrees with the network's standard port, which is + * what makes a mismatch here a startup failure rather than a silent one. + */ +export const p2pPorts: Record = { + mainnet: 8333, + signet: 38333, + testnet4: 48333, + testnet: 18333, + regtest: 18444, +} diff --git a/contrib/packaging/startos/startos/sdk.ts b/contrib/packaging/startos/startos/sdk.ts new file mode 100644 index 000000000..a2969a421 --- /dev/null +++ b/contrib/packaging/startos/startos/sdk.ts @@ -0,0 +1,7 @@ +import { StartSdk } from '@start9labs/start-sdk' +import { manifest } from './manifest' + +/** + * Plumbing. DO NOT EDIT. + */ +export const sdk = StartSdk.of().withManifest(manifest).build(true) diff --git a/contrib/packaging/startos/startos/utils.ts b/contrib/packaging/startos/startos/utils.ts new file mode 100644 index 000000000..1a64b7e75 --- /dev/null +++ b/contrib/packaging/startos/startos/utils.ts @@ -0,0 +1,87 @@ +import { sdk } from './sdk' + +export { networks, p2pPorts } from './networks' +export type { Network } from './networks' + +/** + * Where the volume lands, matching `--datadir` in the reference stack. + */ +export const rootDir = '/var/lib/satd' + +/** + * Ports, mirroring contrib/stack/satd/satd.conf.tmpl. satd-init renders that + * template inside the image, so these are read from it rather than chosen + * here — changing one on this side alone would bind nothing. + * + * The plain listeners are what this package exports. satd also serves TLS on + * 8336 / 50002 / 3001 from its own per-install CA, and on StartOS those go + * unexported: StartOS terminates TLS at its reverse proxy with a certificate + * chaining to the server's root CA, which every client on the box already + * trusts. Exporting satd's CA instead would ask each user to import a second + * one for a single service. MCP is the exception — see interfaces.ts. + */ +export const rpcPort = 8332 +export const electrumPort = 50001 +export const esploraPort = 3000 +export const metricsPort = 9332 +export const mcpPort = 8339 + +/** Conventional external ports, requested via `preferredExternalPort`. */ +export const electrumTlsPort = 50002 +export const esploraTlsPort = 3001 + +export const rpcHostId = 'rpc' +export const electrumHostId = 'electrum' +export const esploraHostId = 'esplora' +export const mcpHostId = 'mcp' +export const peerHostId = 'peer' + +export const rpcInterfaceId = 'rpc' +export const electrumInterfaceId = 'electrum' +export const esploraInterfaceId = 'esplora' +export const mcpInterfaceId = 'mcp' +export const peerInterfaceId = 'peer' + +/** + * StartOS puts every service container on one bridge, `lxcbr0`, with the OS + * itself at a fixed 10.0.3.1. satd's `rpcallowip` has to admit that range or + * the OS reverse proxy — and every other package on the box — is refused at + * the RPC surface. + */ +export const bridgeSubnet = '10.0.3.0/24' + +export const satdMounts = sdk.Mounts.of().mountVolume({ + volumeId: 'main', + mountpoint: rootDir, + subpath: null, + readonly: false, + type: 'directory', +}) + +/** + * `sat-cli` invocation for a node in this container. + * + * Both flags matter. `-rpcport` because the stack fixes RPC at 8332 on every + * network while sat-cli derives its default from the chain, and + * `-rpccookiefile` because sat-cli works out the cookie's per-network + * subdirectory by reading `regtest=1`/`testnet=1` from bitcoin.conf — lines + * this stack deliberately never writes, since satd accepts a network in a + * config file and then ignores it. satd-init maintains `rpc-cookie` as a + * symlink to whichever path is live, so naming it outright sidesteps the + * detection entirely and is correct on every network. + */ +export const satCliArgs = [ + 'sat-cli', + `-datadir=${rootDir}`, + `-rpcport=${rpcPort}`, + `-rpccookiefile=${rootDir}/rpc-cookie`, + '-rpcconnect=127.0.0.1', +] + +export type GetBlockchainInfo = { + chain: string + blocks: number + headers: number + verificationprogress: number + initialblockdownload: boolean +} diff --git a/contrib/packaging/startos/startos/versions/current.ts b/contrib/packaging/startos/startos/versions/current.ts new file mode 100644 index 000000000..a00998fa4 --- /dev/null +++ b/contrib/packaging/startos/startos/versions/current.ts @@ -0,0 +1,9 @@ +import { VersionInfo } from '@start9labs/start-sdk' + +export const current = VersionInfo.of({ + version: '0.5.1:0', + releaseNotes: { + en_US: 'First StartOS release of satd.', + }, + migrations: {}, +}) diff --git a/contrib/packaging/startos/startos/versions/index.ts b/contrib/packaging/startos/startos/versions/index.ts new file mode 100644 index 000000000..e596b0cd1 --- /dev/null +++ b/contrib/packaging/startos/startos/versions/index.ts @@ -0,0 +1,7 @@ +import { VersionGraph } from '@start9labs/start-sdk' +import { current } from './current' + +export const versionGraph = VersionGraph.of({ + current, + other: [], +}) diff --git a/contrib/packaging/startos/test/networks.test.ts b/contrib/packaging/startos/test/networks.test.ts new file mode 100644 index 000000000..6041a2582 --- /dev/null +++ b/contrib/packaging/startos/test/networks.test.ts @@ -0,0 +1,46 @@ +import { deepStrictEqual } from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { test } from 'node:test' +import { networks, p2pPorts } from '../startos/networks.ts' + +/** + * satd-init owns both of these facts: which network names it accepts (it + * exits 2 on anything else) and which P2P port each one uses (it refuses to + * start when SATD_P2P_PORT disagrees). This package restates both, so it can + * offer the networks in a dropdown and bind the right peer port. + * + * A restatement that drifts is not a compile error and not a lint error — it + * is a service that fails at first start, on the one network nobody tried. + * So read satd-init and compare, rather than trusting two lists to stay + * equal by inspection. + */ +const initScript = readFileSync( + new URL('../../../stack/satd/satd-init', import.meta.url), + 'utf8', +) + +/** The `case "$NETWORK" in` arm that assigns P2P_PORT, as satd-init writes it. */ +const parsePorts = (src: string): Record => { + const block = src.match( + /case "\$NETWORK" in\n([\s\S]*?)\n\s*\*\)\n[\s\S]*?esac/, + ) + if (!block) throw new Error('could not find satd-init\'s NETWORK case block') + const found: Record = {} + for (const m of block[1].matchAll(/^\s*(\w+)\)\s*P2P_PORT=(\d+)\s*;;/gm)) { + found[m[1]] = Number(m[2]) + } + if (!Object.keys(found).length) + throw new Error('parsed satd-init\'s case block but found no arms') + return found +} + +test('the offered networks are the ones satd-init accepts', () => { + deepStrictEqual( + Object.keys(networks).sort(), + Object.keys(parsePorts(initScript)).sort(), + ) +}) + +test('every P2P port matches satd-init', () => { + deepStrictEqual({ ...p2pPorts }, parsePorts(initScript)) +}) diff --git a/contrib/packaging/startos/tsconfig.json b/contrib/packaging/startos/tsconfig.json new file mode 100644 index 000000000..c3ee77379 --- /dev/null +++ b/contrib/packaging/startos/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "@start9labs/start-sdk/tsconfig.base.json", + "compilerOptions": { "rewriteRelativeImportExtensions": true }, + "include": ["startos/**/*.ts", "test/**/*.ts", "node_modules/**/startos"] +} diff --git a/contrib/packaging/umbrel/README.md b/contrib/packaging/umbrel/README.md index 2f69e0ef7..61ae6b8c4 100644 --- a/contrib/packaging/umbrel/README.md +++ b/contrib/packaging/umbrel/README.md @@ -20,18 +20,22 @@ release. ## Status -`umbrel/` is complete and ready to publish to a community app store. - -`startos/` is **not written yet.** A StartOS package is a TypeScript project -built with Start9's SDK, and the SDK's shape has changed across StartOS -versions; writing one against a guessed API would produce something that -looks right and does not build. What it needs is: pick the StartOS version -to target, install that SDK, and copy the structure of -`start9labs/bitcoind-startos` at the matching tag. The interfaces to declare -are RPC (plain, app-internal), RPC-TLS, Electrum-TLS, Esplora-TLS and MCP; -the health check maps to `/readyz` and sync progress to -`getblockchaininfo`. Network is a config option; `txindex` is not — it stays -forced on, because Electrum and Esplora require it. +Both packages are written. **Neither has been installed on a real Umbrel or +StartOS instance**, which is the gap that matters: everything below is +statically checked, and static checks did not stop this Umbrel package from +shipping a `--mainnet` flag satd does not have or an image tag the registry +has never held. + +What is checked: + +- `umbrel/` — `umbrel lint` (from `npm i -g umbrel-cli`) validates the store + manifest, each app manifest, the compose file and `exports.sh`. It is what + caught the missing image digest pin, which the Umbrel app store requires. +- `startos/` — typechecks against the SDK, tests its network table against + `satd-init`, and packs to a `.s9pk`. See `startos/README.md`. + +Neither validator understands satd's own flags, so the checks that cover +those live in `contrib/stack/tests/compose-test.sh`. ## Publishing the Umbrel app From 4951b99dded13ab1d29b3fad011399d630c46552 Mon Sep 17 00:00:00 2001 From: Benjamen Keroack Date: Thu, 10 Sep 2026 10:17:46 -0600 Subject: [PATCH 12/22] umbrel: manifestVersion 1.1, and record why satd must not implement `bitcoin` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two things the README left as "re-check before submitting upstream", checked. manifestVersion is 1.1. Every app in getumbrel/umbrel-apps uses it, `bitcoin` and `bitcoin-knots` included. This package was on 1. The alternatives mechanism exists, and satd must not use it. A top-level `implements:` array is what Bitcoin Knots declares to satisfy another app's `dependencies: [bitcoin]`, and the contract behind it is exports.sh: Knots ends with a loop aliasing every APP_BITCOIN_KNOTS_ to APP_BITCOIN_, and that variable set is what a substitute owes its dependents. satd cannot honour that set. RPC_USER/RPC_PASS it could — it accepts Core-format rpcauth — but ZMQ_RAWBLOCK_PORT and ZMQ_RAWTX_PORT name topics satd does not publish at all. It serves Core-compatible hashblock/hashtx plus its own JSON topics, which is why the reference stack runs LND in Neutrino mode rather than bitcoind mode. There is no partial form of the declaration. Declaring it would mean dependents that need the raw topics install cleanly and then fail at runtime, while the ones that don't happen to work — the failure mode this package has been producing all along. Left undeclared, with the reason written down and the condition to revisit it. `umbrel lint` still passes. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013YuASGsfZnzWGdiqjqxmuJ --- contrib/packaging/umbrel/README.md | 38 ++++++++++++++------ contrib/packaging/umbrel/satd/umbrel-app.yml | 2 +- 2 files changed, 29 insertions(+), 11 deletions(-) diff --git a/contrib/packaging/umbrel/README.md b/contrib/packaging/umbrel/README.md index 61ae6b8c4..18d808f6d 100644 --- a/contrib/packaging/umbrel/README.md +++ b/contrib/packaging/umbrel/README.md @@ -51,13 +51,31 @@ epochbtc/umbrel-apps/ exports.sh ``` -Copy `umbrel/` to that repository's root. Before submitting upstream to -`getumbrel/umbrel-apps`, re-check two things against the current store: - -- the `manifestVersion` and the field set in `umbrel-app.yml`, which have - changed between store generations; -- whether apps can now declare satd as an alternative to the `bitcoin` - dependency — the mechanism added so Bitcoin Knots could satisfy it. If - they can, `exports.sh` should export the same variable names the official - `bitcoin` app does, so a dependent app is satisfied by either. If they - cannot, satd runs standalone and dependent apps keep using Core. +Copy `umbrel/` to that repository's root. + +Both questions this section used to leave open have been checked against the +current store. + +**`manifestVersion` is `1.1`.** Every app in `getumbrel/umbrel-apps` uses it, +including `bitcoin` and `bitcoin-knots`. This package was on `1`. + +**satd must not declare `implements: bitcoin`.** The mechanism does exist — +it is a top-level `implements:` array in the manifest, `bitcoin-knots` +declares `implements: [bitcoin]`, and a dependent like `electrs` declares +`dependencies: [bitcoin]` and is satisfied by either. The contract is +`exports.sh`: Knots ends with a loop aliasing every `APP_BITCOIN_KNOTS_` +to `APP_BITCOIN_`, and that variable set is what a substitute owes its +dependents. + +satd cannot honour it. Two of those exports it can — it accepts Core-format +`rpcauth`, so `RPC_USER`/`RPC_PASS` are reachable — but +`ZMQ_RAWBLOCK_PORT` and `ZMQ_RAWTX_PORT` name topics satd does not publish. +It serves Core-compatible `hashblock`/`hashtx` and its own JSON topics, which +is exactly why the reference stack runs LND in Neutrino mode rather than +bitcoind mode. There is no way to declare "implements `bitcoin`, except the +raw topics": a dependent that needs them would install cleanly against satd +and then fail at runtime, and the ones that do not need them would work. +Shipping that is worse than not offering the substitution at all. + +Revisit this if satd grows raw block and transaction ZMQ topics. Until then +satd runs standalone and dependent apps keep using Core. diff --git a/contrib/packaging/umbrel/satd/umbrel-app.yml b/contrib/packaging/umbrel/satd/umbrel-app.yml index 975a0f438..39d2bb3da 100644 --- a/contrib/packaging/umbrel/satd/umbrel-app.yml +++ b/contrib/packaging/umbrel/satd/umbrel-app.yml @@ -1,4 +1,4 @@ -manifestVersion: 1 +manifestVersion: 1.1 id: satd category: bitcoin name: satd From f3fb48fde56b8da86ee2119d6b2c97aa6b5e8284 Mon Sep 17 00:00:00 2001 From: Benjamen Keroack Date: Thu, 10 Sep 2026 10:38:54 -0600 Subject: [PATCH 13/22] umbrel: two install-blocking bugs, both found by installing on umbrelOS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Neither is visible to `umbrel lint`, which passes clean before and after. 1. The app id must carry the store id as a prefix. umbreld filters a community store's registry to apps whose id starts with the store id (apps/app-repository.ts): only the official `umbrel-app-store` is exempt. Store `epochbtc` plus app `satd` therefore produced a store that added successfully, reported no error, and listed no apps at all. The official template is the same shape — store `sparkles`, app `sparkles-hello-world`. Renamed to `epochbtc-satd`. The container DNS names follow the app id, so exports.sh and the app_proxy host had to move with it; `satd_server_1` would have resolved to nothing. 2. exports.sh named a variable that does not exist in its scope. exports.sh: line 33: APP_DATA_DIR: unbound variable umbrel sources exports.sh from `app-script`, which runs under `set -euo pipefail` and exports `EXPORTS_APP_DIR` for the app being sourced. `APP_DATA_DIR` is exported later and only for the compose environment. Naming it here is not an empty string and not a warning — it aborts the install, three retries, then fails. Both resolve to ${UMBREL_ROOT}/app-data/, so the fix is a rename. Found by adding the store to a real umbrelOS 1.7.4 VM over umbreld's tRPC API and reading the failure out of `system.logs`. The store is now listed with `apps = epochbtc-satd`, and the install proceeds past both points. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013YuASGsfZnzWGdiqjqxmuJ --- .../docker-compose.yml | 2 +- .../umbrel/{satd => epochbtc-satd}/exports.sh | 23 +++++++++++++++---- .../{satd => epochbtc-satd}/umbrel-app.yml | 2 +- 3 files changed, 21 insertions(+), 6 deletions(-) rename contrib/packaging/umbrel/{satd => epochbtc-satd}/docker-compose.yml (98%) rename contrib/packaging/umbrel/{satd => epochbtc-satd}/exports.sh (52%) rename contrib/packaging/umbrel/{satd => epochbtc-satd}/umbrel-app.yml (99%) diff --git a/contrib/packaging/umbrel/satd/docker-compose.yml b/contrib/packaging/umbrel/epochbtc-satd/docker-compose.yml similarity index 98% rename from contrib/packaging/umbrel/satd/docker-compose.yml rename to contrib/packaging/umbrel/epochbtc-satd/docker-compose.yml index 636e630af..8916aa72b 100644 --- a/contrib/packaging/umbrel/satd/docker-compose.yml +++ b/contrib/packaging/umbrel/epochbtc-satd/docker-compose.yml @@ -24,7 +24,7 @@ x-satd-image: &satd-image ghcr.io/epochbtc/satd:0.5.1@sha256:bcbde256a0d5191d124 services: app_proxy: environment: - APP_HOST: satd_server_1 + APP_HOST: epochbtc-satd_server_1 APP_PORT: 3001 # Esplora is served over TLS by satd itself; the proxy has to speak # TLS to it rather than plain HTTP. diff --git a/contrib/packaging/umbrel/satd/exports.sh b/contrib/packaging/umbrel/epochbtc-satd/exports.sh similarity index 52% rename from contrib/packaging/umbrel/satd/exports.sh rename to contrib/packaging/umbrel/epochbtc-satd/exports.sh index d1efa6522..096caed7c 100644 --- a/contrib/packaging/umbrel/satd/exports.sh +++ b/contrib/packaging/umbrel/epochbtc-satd/exports.sh @@ -9,11 +9,16 @@ # exists separately: TLS is for what leaves the device, and a private CA is # not something other store apps can be taught to trust. # The container's DNS name on the app network, not an address: Umbrel -# resolves `__1`, and an address would be whatever the bridge +# resolves `__1`, and an address would be whatever the bridge # happened to hand out. (`10.21.0.0` here would be a network address, not a # host at all.) -export APP_SATD_HOST="satd_server_1" -export APP_SATD_IP="satd_server_1" +# +# The app id carries the store's `epochbtc-` prefix, so the container name +# does too. umbreld only surfaces apps from a community store whose id starts +# with the store id, so this prefix is not cosmetic — without it the app is +# filtered out of the registry and never appears at all. +export APP_SATD_HOST="epochbtc-satd_server_1" +export APP_SATD_IP="epochbtc-satd_server_1" export APP_SATD_RPC_PORT="8332" export APP_SATD_P2P_PORT="${APP_SATD_P2P_PORT:-8333}" export APP_SATD_ELECTRUM_PORT="50001" @@ -25,4 +30,14 @@ export APP_SATD_NETWORK="${APP_SATD_NETWORK:-mainnet}" # subdirectory, and `rpc-cookie` is a stable symlink satd-init maintains to # whichever path that is — so a dependent app needs one path rather than a # per-network rule. -export APP_SATD_RPC_COOKIE_FILE="${APP_DATA_DIR}/data/rpc-cookie" +# `EXPORTS_APP_DIR`, not `APP_DATA_DIR`. Umbrel sources this file from +# `app-script`, which runs under `set -euo pipefail` and defines +# `EXPORTS_APP_DIR` (and `EXPORTS_APP_DATA_DIR`) for the app being sourced. +# `APP_DATA_DIR` is exported later, for the compose environment only, so +# naming it here is an unbound variable that aborts the whole install — +# not an empty string, and not a warning. +# +# Both resolve to `${UMBREL_ROOT}/app-data/`, and the compose file +# mounts `${APP_DATA_DIR}/data`, so this is the same path the container sees +# at /var/lib/satd. +export APP_SATD_RPC_COOKIE_FILE="${EXPORTS_APP_DIR}/data/rpc-cookie" diff --git a/contrib/packaging/umbrel/satd/umbrel-app.yml b/contrib/packaging/umbrel/epochbtc-satd/umbrel-app.yml similarity index 99% rename from contrib/packaging/umbrel/satd/umbrel-app.yml rename to contrib/packaging/umbrel/epochbtc-satd/umbrel-app.yml index 39d2bb3da..d350d6abb 100644 --- a/contrib/packaging/umbrel/satd/umbrel-app.yml +++ b/contrib/packaging/umbrel/epochbtc-satd/umbrel-app.yml @@ -1,5 +1,5 @@ manifestVersion: 1.1 -id: satd +id: epochbtc-satd category: bitcoin name: satd version: "0.5.1" From f46e05dbe2a6e93d813b1e0f71429af1cdc6d6ee Mon Sep 17 00:00:00 2001 From: Benjamen Keroack Date: Thu, 10 Sep 2026 11:17:12 -0600 Subject: [PATCH 14/22] docker: strip the shipped binaries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The runtime image has been shipping half a gigabyte of debuginfo since line tables were enabled for the release binaries. `[profile.release]` sets `debug = "line-tables-only"`, and release.yml strips what it ships, splitting a `.debug` sidecar out first — but the Dockerfile's build stage just installs `target/release/*` and never strips, so only the tarball half of that plan was ever in effect. Measured on the published image: ghcr.io/epochbtc/satd:0.5.1 670 MB satd 544 MB (506 MB of it DWARF) sat-cli 41 MB ghcr.io/epochbtc/satd:0.2.0 133 MB (before line tables) After this change the runtime image is 136 MB, with satd at 37.8 MB, sat-cli at 7.3 MB and sat-tui at 5.9 MB. No sidecar is emitted here, because it would not be interchangeable with the tarball's: release.yml builds with `-Cforce-frame-pointers=yes` and a `--remap-path-prefix`, so those binaries are not these binaries. `--build-arg STRIP_BINARIES=0` rebuilds the image unstripped for anyone who needs to debug the container itself. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013YuASGsfZnzWGdiqjqxmuJ --- Dockerfile | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 5a699c6bb..9ca0ebf8b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -140,10 +140,29 @@ RUN cargo chef cook --release --locked --bin satd --bin sat-cli --bin sat-tui -- # Compile first-party crates on top of the cooked dependency artifacts already # sitting in target/. COPY . . +# Strip unless asked not to. +# +# `[profile.release]` sets `debug = "line-tables-only"` (#388), which on +# binaries this size is not a small addition: unstripped, satd is 544 MB of +# which 506 MB is DWARF, and the runtime image comes out at 670 MB against +# 133 MB for the same image before line tables were turned on. +# +# release.yml already strips what it ships, splitting a `.debug` sidecar out +# first, so the tarball download was lean and only this path was still +# shipping half a gigabyte of debuginfo to every `docker pull`. +# +# No sidecar is emitted here. It would not be interchangeable with the +# tarball's: release.yml builds with `-Cforce-frame-pointers=yes` and a +# `--remap-path-prefix`, so those binaries are not these binaries. To debug +# the container image itself, rebuild it with `--build-arg STRIP_BINARIES=0`. +ARG STRIP_BINARIES=1 RUN cargo build --release --locked --bin satd --bin sat-cli --bin sat-tui \ && install -Dm755 target/release/satd /out/satd \ && install -Dm755 target/release/sat-cli /out/sat-cli \ - && install -Dm755 target/release/sat-tui /out/sat-tui + && install -Dm755 target/release/sat-tui /out/sat-tui \ + && if [ "${STRIP_BINARIES}" = "1" ]; then \ + strip /out/satd /out/sat-cli /out/sat-tui; \ + fi FROM docker.io/library/debian:${DEBIAN_VERSION}-slim AS runtime From 472f25834bcd514473c682092caf86aa3a08a3e8 Mon Sep 17 00:00:00 2001 From: Benjamen Keroack Date: Thu, 10 Sep 2026 11:17:26 -0600 Subject: [PATCH 15/22] umbrel: three more defects, all found by installing on umbrelOS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit None of these are visible to `umbrel lint`, and none reproduce in contrib/stack — each one is a property of how Umbrel deploys an app rather than of the compose file in isolation. 1. satd-init could not write the data volume. Umbrel gives an app a bind mount, and Docker creates the host side of a bind mount owned by root, mode 0755; the init service ran as uid 2121, so its first mkdir failed with EACCES. It ran under `restart: on-failure`, so instead of failing the install it looped, `apps.install` never returned, and the app sat at "installing" until the caller timed out. contrib/stack never sees this because it uses a named volume, whose ownership Docker seeds from the image's /var/lib/satd. The StartOS package already solved the same problem with its own-volume oneshot; init now does the same thing, chowning as root and dropping to satd with setpriv for the work. 2. The server published 3001, which app_proxy already binds. umbrel-app.yml sets `port: 3001`, that is the host port the proxy takes, and the proxy starts first — so this was a guaranteed "port is already allocated" on every install, not a race. Dropped; the proxy reaches Esplora over the app network, which is what APP_HOST/APP_PORT already say. 3. app_proxy was pointed at Esplora's TLS port. It always dials its upstream as `http://` and has no option to do otherwise, so every request 502'd with `Parse Error: Expected HTTP/ [HPE_INVALID_CONSTANT]` — node's HTTP parser reading a TLS ServerHello. It now dials the plaintext listener on 3000, which is container-to-container on the app network and is where satd.conf.tmpl already puts the plain listeners. The TLS listener on 3001 is unchanged; it is for LAN clients reaching satd directly. Verified on umbrelOS 1.7.4: apps.install returns true, satd-init exits 0, the server container reports healthy, and RPC (cookie auth via the rpc-cookie symlink), Esplora over TLS, Electrum over TLS and MCP all answer with a certificate that verifies against the install's own CA. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013YuASGsfZnzWGdiqjqxmuJ --- .../umbrel/epochbtc-satd/docker-compose.yml | 55 +++++++++++++++++-- 1 file changed, 49 insertions(+), 6 deletions(-) diff --git a/contrib/packaging/umbrel/epochbtc-satd/docker-compose.yml b/contrib/packaging/umbrel/epochbtc-satd/docker-compose.yml index 8916aa72b..c183e23ae 100644 --- a/contrib/packaging/umbrel/epochbtc-satd/docker-compose.yml +++ b/contrib/packaging/umbrel/epochbtc-satd/docker-compose.yml @@ -25,15 +25,50 @@ services: app_proxy: environment: APP_HOST: epochbtc-satd_server_1 - APP_PORT: 3001 - # Esplora is served over TLS by satd itself; the proxy has to speak - # TLS to it rather than plain HTTP. + # 3000, Esplora's plaintext listener — not 3001, its TLS one. Umbrel's + # app_proxy always dials its upstream as `http://`, with no option to do + # otherwise, so pointing it at a TLS port gets a ServerHello where node's + # HTTP parser wants a status line: every request 502s with + # `Parse Error: Expected HTTP/ [HPE_INVALID_CONSTANT]`. + # + # Plaintext is correct here rather than merely tolerable. This hop is + # container-to-container on the app network, which is the same place + # satd.conf.tmpl already puts the other plain listeners, and the proxy is + # what terminates TLS for the browser. The TLS listener on 3001 is for + # LAN clients reaching satd directly, and it stays exactly as it was. + APP_PORT: 3000 + # Esplora serves public chain data and is unauthenticated by design, like + # every public Esplora deployment — putting Umbrel's login in front of it + # would break API clients rather than protect anything. PROXY_AUTH_ADD: "false" init: image: *satd-image - entrypoint: ["/usr/local/bin/satd-init"] - user: "2121:2121" + # This one service starts as root and drops to satd itself, which the + # reference stack does not have to do. + # + # Umbrel gives an app a bind mount, and Docker creates the host side of a + # bind mount owned by root, mode 0755. uid 2121 cannot write into it, so + # satd-init's very first mkdir fails. contrib/stack/compose.yml never sees + # this because it uses a named volume, and Docker seeds a named volume's + # ownership from the image's /var/lib/satd (satd:satd 0750). + # + # `chown` therefore has to happen inside the container, as root, before + # anything else — and then the work itself runs unprivileged, because + # everything satd-init writes (the CA key, the MCP token) has to be owned + # by the user the server runs as. The StartOS package solves the same + # problem the same way, with its own-volume oneshot. + # + # setpriv rather than su/runuser: this is debian-slim, which ships + # util-linux but not the login utilities. + user: "0:0" + entrypoint: + - /bin/sh + - -c + - >- + chown -R 2121:2121 /var/lib/satd && + exec setpriv --reuid=2121 --regid=2121 --clear-groups + /usr/local/bin/satd-init restart: on-failure environment: NETWORK: ${APP_SATD_NETWORK:-mainnet} @@ -63,11 +98,19 @@ services: SATD_HEALTH_URL: http://127.0.0.1:9332/readyz volumes: - ${APP_DATA_DIR}/data:/var/lib/satd + # Esplora's 3001 is deliberately NOT here. `umbrel-app.yml` sets + # `port: 3001`, which is the host port app_proxy binds, and app_proxy + # starts first — so publishing 3001 from this service too is a guaranteed + # "port is already allocated" on every install, not a race. The proxy + # reaches Esplora over the app network as epochbtc-satd_server_1:3001, + # which is exactly what APP_HOST/APP_PORT above already say. + # + # The rest are published on purpose: they are the LAN-facing surfaces, + # each TLS-terminated by satd itself, and app_proxy fronts only one port. ports: - "${APP_SATD_P2P_PORT:-8333}:${APP_SATD_P2P_PORT:-8333}" - "8336:8336" - "50002:50002" - - "3001:3001" - "8339:8339" stop_grace_period: 10m restart: on-failure From 64ef39fcdbff8f4414f4b0a15528a4bd11179dff Mon Sep 17 00:00:00 2001 From: Benjamen Keroack Date: Thu, 10 Sep 2026 11:18:07 -0600 Subject: [PATCH 16/22] umbrel: record what installing on umbrelOS actually proved Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013YuASGsfZnzWGdiqjqxmuJ --- contrib/packaging/umbrel/README.md | 27 ++++++++++++++++++++------- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/contrib/packaging/umbrel/README.md b/contrib/packaging/umbrel/README.md index 18d808f6d..1e93f136f 100644 --- a/contrib/packaging/umbrel/README.md +++ b/contrib/packaging/umbrel/README.md @@ -20,13 +20,26 @@ release. ## Status -Both packages are written. **Neither has been installed on a real Umbrel or -StartOS instance**, which is the gap that matters: everything below is -statically checked, and static checks did not stop this Umbrel package from -shipping a `--mainnet` flag satd does not have or an image tag the registry -has never held. - -What is checked: +The Umbrel package has been installed and run on umbrelOS 1.7.4. The StartOS +package is written, typechecked and packs to a `.s9pk`, but **has not been +installed on a StartOS server**. + +That distinction is the whole point of this section. Five of the eight defects +found in the Umbrel package were invisible to every static check — `umbrel +lint` passes clean both before and after each of them: + +- The app id must be prefixed with the store id, or the store adds + successfully, reports no error, and lists zero apps. +- `exports.sh` is sourced under `set -euo pipefail` with `EXPORTS_APP_DIR` + defined and `APP_DATA_DIR` not yet defined; naming the wrong one aborts the + install. +- No published image contained `satd-init`, because this branch adds it. +- Umbrel bind-mounts the data directory, and Docker creates the host side of a + bind mount root-owned, so an unprivileged init service cannot write to it. +- `app_proxy` dials its upstream as `http://` with no TLS option, so pointing + it at a TLS listener 502s every request. + +What is checked statically: - `umbrel/` — `umbrel lint` (from `npm i -g umbrel-cli`) validates the store manifest, each app manifest, the compose file and `exports.sh`. It is what From 4f2584b69a9c3c7d94f7601afba8c983501c0407 Mon Sep 17 00:00:00 2001 From: Benjamen Keroack Date: Thu, 10 Sep 2026 11:44:17 -0600 Subject: [PATCH 17/22] ci(docker): publish a per-commit sha- tag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Between releases no satd image existed. This workflow built one on every PR and threw it away — "This image is never pushed" — and release.yml published only on `v*` tags. That gap has a concrete cost. The app-store packages under contrib/packaging reference satd as `tag@sha256:...`, because the Umbrel store requires a digest pin, and a digest cannot be written for an image that does not exist. It is also how a package shipped referencing `satd-init`, `satd-mkca` and `satd-healthcheck` — three programs added by this branch's Dockerfile that no published image contained. Nothing in CI could have caught that, because CI never published anything to install from. The image that passes the existing per-arch smoke test is now pushed by digest and merged into a two-arch manifest list tagged `sha-`. The final step prints `@` to the job summary, which is the exact string a package manifest pins; one pin covers both arches, since the digest names the index. Deliberately narrow: - `sha-` only. `latest` and the version aliases stay with release.yml, so nothing here can change what an operator pulls. - Unsigned. cosign keyless signing stays a release-grade guarantee; a development reference should not appear to carry one. - Skipped for fork PRs, whose GITHUB_TOKEN is read-only. The `if` says so rather than letting the push fail. - Publishing needs both arches green, since a manifest list naming a broken arch fails only for the users on that architecture. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013YuASGsfZnzWGdiqjqxmuJ --- .github/workflows/docker.yml | 150 +++++++++++++++++++++++++++++++++-- 1 file changed, 142 insertions(+), 8 deletions(-) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 4f665bd6b..aa68460c4 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -13,13 +13,18 @@ # runner — same split release.yml uses, which keeps arm64 off slow QEMU # emulation (rocksdb's C++ build is ~2h under emulation, 5-15 min native). # -# Each arch does load + run, not push: we pull the built image into the -# local daemon and actually execute both binaries (`--version`). A plain -# `build` proves the *builder* stage compiles but says nothing about the -# *runtime* stage — a missing libssl3, a broken tini/ENTRYPOINT, or wrong -# datadir perms all build green and only fail at `docker run`. Running on a -# native per-arch runner means the smoke test exercises the real arch -# binary, not an emulated one. +# Each arch loads and RUNS the image before anything is published: we pull +# the built image into the local daemon and actually execute both binaries +# (`--version`). A plain `build` proves the *builder* stage compiles but says +# nothing about the *runtime* stage — a missing libssl3, a broken +# tini/ENTRYPOINT, or wrong datadir perms all build green and only fail at +# `docker run`. Running on a native per-arch runner means the smoke test +# exercises the real arch binary, not an emulated one. +# +# What passes the smoke test is then pushed as `sha-` and merged into +# a two-arch manifest list. That tag is a development reference for pinning +# the app-store packages between releases — it is unsigned, and it never +# touches `latest` or a version alias, both of which stay with release.yml. # # Runs in its own jobs, parallel to ci.yml, so it adds no wall-clock time # there. Hosted-runner minutes are free for public repos. @@ -34,6 +39,10 @@ on: permissions: {} +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + jobs: build-smoke: name: build + smoke (${{ matrix.slug }}) @@ -51,6 +60,7 @@ jobs: slug: arm64 permissions: contents: read + packages: write steps: - name: Checkout uses: actions/checkout@v7 @@ -73,7 +83,9 @@ jobs: cache-to: type=gha,mode=max,scope=docker-ci-${{ matrix.slug }} # We sign + attest provenance in release.yml's merge-docker via # cosign keyless; a partial buildkit attestation here would be - # noise. This image is never pushed, so it needs neither. + # noise. The sha- tag pushed below is a development reference, not + # a distribution artifact, and is deliberately unsigned: signing it + # would imply a release-grade guarantee it does not carry. provenance: false - name: Smoke test — both binaries run in the runtime image @@ -87,3 +99,125 @@ jobs: # sat-cli isn't the entrypoint — override it. echo "sat-cli:" docker run --rm --entrypoint /usr/local/bin/sat-cli satd:ci --version + + # --- publish a per-commit reference ---------------------------------- + # + # Until now no image existed between releases: this workflow built one + # and threw it away, and release.yml published only on `v*`. That gap + # has a concrete cost — the app-store packages under contrib/packaging + # reference satd as `tag@sha256:...`, which cannot be written for an + # image that does not exist, and a package shipped referencing + # `satd-init`, a program no published image contained. + # + # What is pushed is the image that just passed the smoke test above, + # under `sha-` only. `latest` and the version aliases stay with + # release.yml, so nothing here can advance what an operator pulls. + # + # Fork PRs are skipped: their GITHUB_TOKEN is read-only, and saying so + # in the `if` is better than letting the push fail. + - name: Log in to GHCR + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + uses: docker/login-action@v4.6.0 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Push by digest + id: push + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + uses: docker/build-push-action@v7 + with: + context: . + file: ./Dockerfile + platforms: ${{ matrix.platform }} + outputs: | + type=image,name=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true + # Same cache scope as the build above, so this re-exports that + # build from cache rather than compiling anything a second time. + # No cache-to: the build step already populated the scope, and a + # second writer would just race it. + cache-from: type=gha,scope=docker-ci-${{ matrix.slug }} + provenance: false + + - name: Export digest + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + run: | + mkdir -p /tmp/digests + digest='${{ steps.push.outputs.digest }}' + touch "/tmp/digests/${digest#sha256:}" + + - name: Upload digest + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + uses: actions/upload-artifact@v7 + with: + name: digests-${{ matrix.slug }} + path: /tmp/digests/* + if-no-files-found: error + retention-days: 7 + + + publish-sha: + name: publish sha- tag + # Both arches must have smoked clean before either is reachable under a + # tag: a manifest list naming a broken arch is worse than no tag, because + # it fails only for the users on that architecture. + needs: [build-smoke] + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-24.04 + timeout-minutes: 20 + permissions: + contents: read + packages: write + steps: + - name: Download digests + uses: actions/download-artifact@v8 + with: + path: /tmp/digests + pattern: digests-* + merge-multiple: true + + - name: Set up Docker buildx + uses: docker/setup-buildx-action@v4 + + - name: Log in to GHCR + uses: docker/login-action@v4.6.0 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Compute the sha- tag + id: meta + uses: docker/metadata-action@v6 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + # `type=sha` alone. On a pull_request the action resolves this to + # the PR head commit rather than the synthetic merge commit, which + # is the sha a developer can actually check out and rebuild. + tags: type=sha + + - name: Create + push manifest list + working-directory: /tmp/digests + run: | + set -euo pipefail + tags=$(jq -cr '.tags | map("--tag " + .) | join(" ")' <<< '${{ steps.meta.outputs.json }}') + digests=$(printf '${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@sha256:%s ' *) + docker buildx imagetools create $tags $digests + + - name: Report the pinnable reference + run: | + set -euo pipefail + tag=$(jq -cr '.tags[0]' <<< '${{ steps.meta.outputs.json }}') + # The manifest-list digest, which is what a package manifest pins: + # it resolves per-architecture, so one pin covers both arches. + digest=$(docker buildx imagetools inspect "$tag" --format '{{json .Manifest.Digest}}' | tr -d '"') + echo "Pin this in contrib/packaging as:" + echo " ${tag}@${digest}" + { + echo "### Pinnable image reference" + echo + echo '```' + echo "${tag}@${digest}" + echo '```' + } >> "$GITHUB_STEP_SUMMARY" From 4a533511bb349770d8d2e24d31d136086ccbc4fc Mon Sep 17 00:00:00 2001 From: Benjamen Keroack Date: Thu, 10 Sep 2026 13:10:18 -0600 Subject: [PATCH 18/22] ci(docker): tag the PR head commit, not the merge commit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `type=sha` names `github.sha`, which on a `pull_request` event is the synthetic `refs/pull/N/merge` commit GitHub creates for the run. The first image this workflow published proved it: the tag came out `sha-c243f0c`, and `git cat-file -t c243f0c` answers "Not a valid object name" — the commit exists nowhere outside that run, so the tag names something no one can check out, bisect, or rebuild from. `DOCKER_METADATA_PR_HEAD_SHA` switches the action to the PR head sha, which is a real commit on the branch and the one whose tree the image was built from. That is the whole point of a per-commit tag: a package manifest pins `sha-@sha256:`, and the reviewer has to be able to get from that tag back to the source. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013YuASGsfZnzWGdiqjqxmuJ --- .github/workflows/docker.yml | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index aa68460c4..53cc8a9d6 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -192,10 +192,17 @@ jobs: uses: docker/metadata-action@v6 with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} - # `type=sha` alone. On a pull_request the action resolves this to - # the PR head commit rather than the synthetic merge commit, which - # is the sha a developer can actually check out and rebuild. tags: type=sha + env: + # Without this, `type=sha` on a pull_request names `github.sha` — + # the synthetic `refs/pull/N/merge` commit, which exists only inside + # the run. The first image this workflow ever published came out as + # `sha-c243f0c`, and `git cat-file -t c243f0c` in the repo answers + # "Not a valid object name": nothing a developer can check out, + # bisect, or rebuild from. This switches the action to the PR head + # sha, which is a commit on the branch and the one whose tree the + # image was actually built from. + DOCKER_METADATA_PR_HEAD_SHA: true - name: Create + push manifest list working-directory: /tmp/digests From 05c1c808ca162c51fe094630205bc688639f386c Mon Sep 17 00:00:00 2001 From: Benjamen Keroack Date: Thu, 10 Sep 2026 13:46:18 -0600 Subject: [PATCH 19/22] packaging: health-check liveness, not readiness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every package pointed its container health check at `/readyz`, which is 503 until the tip is within six blocks of the headers tip. On a node doing a multi-day initial sync that is a permanent red mark on a service that is working perfectly. Observed, not reasoned about. The Umbrel install that this branch's earlier commits validated reads `Up 2 hours (unhealthy)` with a failing streak of 254 while satd answers RPC, Electrum, Esplora and MCP normally. It looked healthy during the first check only because the node was seconds old and its header chain had not yet outrun its block chain — a window a few minutes wide. The stated reason for `/readyz` was that it is "what the overlays' `depends_on` needs to mean". No overlay depends on satd's health: cln, lightning and btcpay each carry a comment saying they deliberately do not, because the appliance runs satd under systemd and they reach it through `extra_hosts`. So the reading bought nothing and cost the red mark. Dropping SATD_HEALTH_URL falls through to the probe's second mode, which sends a getblockchaininfo and accepts any HTTP status line: proof the RPC listener is bound and serving, which is what "is this container alive" means. /readyz stays exactly as it is — it is the right probe for a client deciding whether this node's chain is current, and the healthcheck's own header now says so instead of recommending it here. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013YuASGsfZnzWGdiqjqxmuJ --- contrib/docker/satd-healthcheck | 19 ++++++++++--------- .../umbrel/epochbtc-satd/docker-compose.yml | 8 ++++++-- contrib/stack/compose.yml | 18 +++++++++++++----- 3 files changed, 29 insertions(+), 16 deletions(-) diff --git a/contrib/docker/satd-healthcheck b/contrib/docker/satd-healthcheck index 2fcde4091..ab25c1787 100755 --- a/contrib/docker/satd-healthcheck +++ b/contrib/docker/satd-healthcheck @@ -4,14 +4,15 @@ # "Healthy" means the node is answering on a surface a client would use. # Two probe modes, in order: # -# 1. SATD_HEALTH_URL — an http:// URL to GET. Set this to satd's -# readiness endpoint when the metrics listener is on, which is what -# contrib/stack does: -# SATD_HEALTH_URL=http://127.0.0.1:9332/readyz -# /readyz is satd's real readiness gate: it reports not-ready until -# the chainstate is loaded and every configured listener is bound. -# Only a 2xx counts, so a node that is still starting reads as -# unhealthy rather than ready. +# 1. SATD_HEALTH_URL — an http:// URL to GET. Only a 2xx counts. +# +# Do NOT point this at /readyz for a container health gate, however +# natural that reads. /readyz answers 503 until the tip is within six +# blocks of the headers tip, which on a fresh mainnet node is days +# away: the packaging used to do exactly this and every container went +# `unhealthy` for the whole initial sync while satd served RPC, +# Electrum and Esplora normally. /readyz is for a client deciding +# whether this node's chain is current — not for "is it alive". # # 2. JSON-RPC liveness on SATD_RPCPORT (default 8332). The probe sends a # getblockchaininfo and treats ANY HTTP status line as healthy — @@ -20,7 +21,7 @@ # cookie may be unreadable), so authenticating is not something it can # do reliably. A 401 still proves the RPC listener is bound and # serving, which is the strongest claim this mode can honestly make. -# Use mode 1 when you want readiness rather than liveness. +# This is the mode every package here uses. # # Non-mainnet containers must set SATD_RPCPORT (18332 testnet, 38332 # signet, 18443 regtest) or SATD_HEALTH_URL, since the probe cannot see diff --git a/contrib/packaging/umbrel/epochbtc-satd/docker-compose.yml b/contrib/packaging/umbrel/epochbtc-satd/docker-compose.yml index c183e23ae..5636dfb4e 100644 --- a/contrib/packaging/umbrel/epochbtc-satd/docker-compose.yml +++ b/contrib/packaging/umbrel/epochbtc-satd/docker-compose.yml @@ -94,8 +94,12 @@ services: command: - --datadir=/var/lib/satd - --chain=${APP_SATD_NETWORK:-mainnet} - environment: - SATD_HEALTH_URL: http://127.0.0.1:9332/readyz + # No SATD_HEALTH_URL, so the image's own HEALTHCHECK probes JSON-RPC + # liveness rather than readiness. /readyz is 503 until the tip is within + # six blocks of the headers tip; on this install the container read + # `Up 2 hours (unhealthy)` with a failing streak of 254 while satd was + # answering RPC, Electrum, Esplora and MCP normally. Umbrel surfaces that + # state to the user, so the whole initial sync would look like a fault. volumes: - ${APP_DATA_DIR}/data:/var/lib/satd # Esplora's 3001 is deliberately NOT here. `umbrel-app.yml` sets diff --git a/contrib/stack/compose.yml b/contrib/stack/compose.yml index a899090ff..9d588a7a3 100644 --- a/contrib/stack/compose.yml +++ b/contrib/stack/compose.yml @@ -55,11 +55,19 @@ services: command: - --datadir=/var/lib/satd - --chain=${NETWORK:-signet} - environment: - # Readiness, not liveness: /readyz stays negative until the chainstate - # is loaded and every listener is bound, which is what the overlays' - # `depends_on` needs to mean. - SATD_HEALTH_URL: http://127.0.0.1:9332/readyz + # No SATD_HEALTH_URL, so the image's own HEALTHCHECK probes JSON-RPC + # liveness: the RPC listener is bound and answering. + # + # This deliberately does NOT use /readyz. That endpoint is 503 until the + # tip is within six blocks of the headers tip, which on a fresh mainnet + # node is days away — the container went `unhealthy` on a real install + # after the header chain outran the block chain, with a failing streak in + # the hundreds, while satd was serving RPC, Electrum and Esplora + # perfectly. Nothing in this stack depends on satd's health either, so + # the readiness reading bought nothing and cost a permanent red mark. + # + # /readyz remains the right probe for a client that must not read a + # stale chain. It is the wrong probe for "is this container alive". volumes: - satd-data:/var/lib/satd ports: From 7f3e46a2c9fbdb91f513a2190efda9ff4237ee16 Mon Sep 17 00:00:00 2001 From: Benjamen Keroack Date: Thu, 10 Sep 2026 13:46:31 -0600 Subject: [PATCH 20/22] startos: two defects, both found by installing on StartOS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Installed the package on a StartOS 0.4.0.1 server for the first time. It runs — satd-init issues the CA, mints the MCP token and renders the config; satd syncs; Esplora, Electrum and MCP all answer through the OS reverse proxy with certificates chaining to the server's own root CA. Two things were wrong, and neither was visible to a typecheck. **The service never finished starting.** The daemon's ready gate probed `/readyz`, so it read "starting" for the whole initial sync, and `sync-progress` — which requires it — sat at "waiting" behind it. The instructions tell the user to watch Blockchain Sync during exactly that period; it showed nothing. The probe is now liveness, alongside the same fix in the other two packages, and the two checks report "satd is ready" and "Syncing blocks: 69.15%" within seconds of start. **The Network action did nothing.** It wrote the store and returned. main read the store with `.once()`, which by the SDK's own definition does not re-run the caller when the value changes, so satd-init never re-ran and satd kept the `--chain=` argument it started with: the store said signet while the node went on syncing mainnet, indefinitely. `.const()` is the SDK's mechanism for this and interfaces.ts already used it — which is why switching networks bound the new P2P port while the daemon stayed where it was, a split that should have been the tell. Verified live after the fix: the action moved a running node mainnet → signet → testnet4, each time re-rendering the config and re-syncing on the new chain, with StartOS disabling the old peer binding and enabling the new one. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013YuASGsfZnzWGdiqjqxmuJ --- contrib/packaging/startos/startos/main.ts | 39 ++++++++++++++++------- 1 file changed, 27 insertions(+), 12 deletions(-) diff --git a/contrib/packaging/startos/startos/main.ts b/contrib/packaging/startos/startos/main.ts index 7697d3e1e..39b4ec8d3 100644 --- a/contrib/packaging/startos/startos/main.ts +++ b/contrib/packaging/startos/startos/main.ts @@ -5,7 +5,6 @@ import { sdk } from './sdk' import { bridgeSubnet, GetBlockchainInfo, - metricsPort, p2pPorts, rootDir, satCliArgs, @@ -13,7 +12,19 @@ import { } from './utils' export const main = sdk.setupMain(async ({ effects }) => { - const store = await storeJson.read().once() + /** + * `.const`, not `.once`: it re-runs main when the store changes, which is + * what makes the Network action take effect. With `.once` the action wrote + * `signet` to the store and nothing else happened — satd-init never re-ran, + * satd kept its `--chain=mainnet` argument, and the node went on syncing + * mainnet while the service page said signet, indefinitely. The action's own + * warning promises "changing the network restarts the node on a different + * chain"; this is what keeps that promise. + * + * interfaces.ts already reads the store this way, which is why the signet + * P2P port appeared on the switch while the daemon did not move. + */ + const store = await storeJson.read().const(effects) if (!store) throw new Error('No store') const { network } = store @@ -121,18 +132,22 @@ export const main = sdk.setupMain(async ({ effects }) => { ready: { display: i18n('Node'), /** - * satd's own readiness gate rather than a port check: /readyz reports - * not-ready until the chainstate is loaded and every configured - * listener is bound, which is what a dependent package needs "ready" - * to mean. The probe is in the image and speaks HTTP over bash's - * /dev/tcp, so it needs no curl in this thin image. + * Liveness, not readiness. The probe is in the image and speaks HTTP + * over bash's /dev/tcp, so it needs no curl in this thin image; with + * no SATD_HEALTH_URL it sends a getblockchaininfo to the RPC port and + * counts any HTTP status line, which proves the listener is bound and + * serving. + * + * This deliberately does NOT probe /readyz. That endpoint is 503 + * until the tip is within six blocks of the headers tip — days away + * on a fresh mainnet node — and this daemon's readiness is what + * `sync-progress` waits on. Gating it on a synced chain left the + * service reading "starting" and Blockchain Sync reading "waiting" + * for the entire initial sync, which is exactly the period the + * instructions tell the user to watch Blockchain Sync. */ fn: async () => { - const res = await satdSub.exec(['/usr/local/bin/satd-healthcheck'], { - env: { - SATD_HEALTH_URL: `http://127.0.0.1:${metricsPort}/readyz`, - }, - }) + const res = await satdSub.exec(['/usr/local/bin/satd-healthcheck']) return res.exitCode === 0 ? { result: 'success' as const, message: i18n('satd is ready') } : { From 8246eb990353a81ba5c957c201b39be15285cb36 Mon Sep 17 00:00:00 2001 From: Benjamen Keroack Date: Thu, 10 Sep 2026 13:48:32 -0600 Subject: [PATCH 21/22] startos: record what installing on a server proved, and guard both fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The README claimed nothing here had been run on a StartOS box. It has now — 0.4.0.1, x86_64, sideloaded — so the section says what that showed instead of what it assumed, including the interfaces that answer through the OS proxy with certificates chaining to the server's own root CA, and the two things still unverified: aarch64 and backup/restore. The tests are the part worth keeping. Both defects were single-token mistakes that typechecked, linted and packed: `.once` where `.const` was meant, and a URL that made a readiness endpoint into a liveness gate. Deleting either fix now fails a named test — verified by making each change and watching exactly one test go red — and the comments say what the failure means rather than restating the assertion. `read()` strips comments before matching, because main.ts names `/readyz` precisely to say it does not use it, and a grep cannot tell the difference. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013YuASGsfZnzWGdiqjqxmuJ --- contrib/packaging/startos/README.md | 47 +++++++++++----- .../packaging/startos/test/reactivity.test.ts | 53 +++++++++++++++++++ 2 files changed, 88 insertions(+), 12 deletions(-) create mode 100644 contrib/packaging/startos/test/reactivity.test.ts diff --git a/contrib/packaging/startos/README.md b/contrib/packaging/startos/README.md index f52d16dfd..d065f3286 100644 --- a/contrib/packaging/startos/README.md +++ b/contrib/packaging/startos/README.md @@ -73,18 +73,41 @@ packs, so a type error or a failing test stops the build. The SDK ships the entire build as `s9pk.mk`; the `Makefile` here is one `include` line. -## What is checked, and what is not - -Checked locally: the package typechecks against `@start9labs/start-sdk` -2.0.9, `test/networks.test.ts` verifies the network list and every P2P port -against `contrib/stack/satd/satd-init` itself (so the two cannot drift), and -`make` produces a `.s9pk` that `start-cli s9pk inspect` reads back. - -**Not checked: installing on a real StartOS server.** Nothing here has been -run on one. Until it has, treat the interface bindings, the health checks and -the `rpcallowip` bridge range as reasoned-but-unverified — in particular -`bridgeSubnet`, which assumes StartOS's documented fixed `10.0.3.1` gateway on -`lxcbr0`. +## Status + +Installed and run on **StartOS 0.4.0.1** (x86_64), sideloaded with +`start-cli package install -s`. What that proved: + +- satd-init runs unmodified from the image and produces this install's CA, + certificate, MCP token, `authfile.toml` and `bitcoin.conf`, all owned by + `satd` with the right modes. +- The node syncs, and both health checks report as documented — **Node** + "satd is ready", **Blockchain Sync** "Syncing blocks: …%". +- Every exported interface answers through StartOS's reverse proxy with a + certificate chaining to the server's root CA: Esplora + `GET /api/blocks/tip/height` → 200, Electrum `server.version` → + `satd-electrs-compatible`, both verifying against that CA with + `Verify return code: 0 (ok)`. MCP is 401 without a token and returns a + full `initialize` result with the token the **MCP Token** action prints. +- The **Network** action moves a running node between chains, re-rendering + the config and rebinding the P2P port each time. + +Three defects came out of it, none of them visible to a typecheck: the ready +gate probed `/readyz` and so never went green during a sync; the **Network** +action wrote the store without restarting the node; and the manifest pinned +an image tag that predates `satd-init`, so the package as first written could +not have started at all. + +`bridgeSubnet` is now checked rather than assumed — the `rpcallowip` range it +feeds is what admits the OS proxy on the real bridge, and the RPC interface +answers. + +Also checked, locally: the package typechecks against `@start9labs/start-sdk` +2.0.9, and `test/networks.test.ts` verifies the network list and every P2P +port against `contrib/stack/satd/satd-init` itself, so the two cannot drift. + +Still unverified: `aarch64` — only the x86_64 `.s9pk` has been installed — +and backup/restore. ## Before publishing diff --git a/contrib/packaging/startos/test/reactivity.test.ts b/contrib/packaging/startos/test/reactivity.test.ts new file mode 100644 index 000000000..af39e8895 --- /dev/null +++ b/contrib/packaging/startos/test/reactivity.test.ts @@ -0,0 +1,53 @@ +import { match, doesNotMatch } from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { test } from 'node:test' + +/** + * The Network action's only effect is a write to the store. Nothing restarts + * the node on its own: main re-runs — and so re-runs satd-init and satd with + * the new `--chain=` — only because it reads the store with `.const`, which + * the SDK defines as "reruns the context from which it has been called if the + * underlying value changes". `.once` is the same call with that behaviour + * removed. + * + * Swapping one for the other is not a type error, and every other test still + * passes: the store updates, the UI shows the new network, the P2P port even + * rebinds because interfaces.ts reads it reactively. Only the daemon stays on + * the old chain, silently, until something else restarts it. That is what + * shipped, and it took installing on a server to see. + */ +/** + * Read a source file with its comments stripped. These assertions are about + * what the code does, and the comments here discuss the very constructs being + * asserted against — `/readyz` is named in main.ts precisely to say it is not + * used, and a naive grep reads that as the defect. + */ +const read = (p: string) => + readFileSync(new URL(p, import.meta.url), 'utf8') + .replace(/\/\*[\s\S]*?\*\//g, '') + .replace(/(^|[^:])\/\/.*$/gm, '$1') + +test('main reads the store reactively, so the Network action takes effect', () => { + const src = read('../startos/main.ts') + match(src, /storeJson\.read\(\)\.const\(effects\)/) + doesNotMatch(src, /storeJson\.read\([^)]*\)\.once\(\)/) +}) + +test('interfaces reads the store reactively, so the P2P port follows', () => { + const src = read('../startos/interfaces.ts') + match(src, /storeJson\.read\([\s\S]*?\)\.const\(effects\)/) +}) + +/** + * `/readyz` is 503 until the tip is within six blocks of the headers tip. + * As the daemon's ready gate that means the service never finishes starting + * during an initial sync, and `sync-progress`, which requires it, never runs + * — so the one screen the instructions tell the user to watch shows nothing + * for the days it matters. + */ +test('the ready gate probes liveness, not readiness', () => { + const src = read('../startos/main.ts') + match(src, /satdSub\.exec\(\['\/usr\/local\/bin\/satd-healthcheck'\]\)/) + doesNotMatch(src, /readyz/) + doesNotMatch(src, /SATD_HEALTH_URL/) +}) From 6699583cc7dc6b0932c6b670bf6f482e4329d260 Mon Sep 17 00:00:00 2001 From: Benjamen Keroack Date: Thu, 10 Sep 2026 14:59:43 -0600 Subject: [PATCH 22/22] packaging: pin both packages to a published image, and stop a test reading nothing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **The pins.** StartOS named `0.5.1` and Umbrel `0.5.1@sha256:bcbde256…`. Neither could work: no release contains `satd-init`, which this branch adds. Both now name `sha-8246eb9@sha256:88b7489a…` — a per-commit tag from `docker.yml`, whose short sha resolves to a real commit on this branch, and whose digest is the OCI index, so one pin covers amd64 and arm64. The image was pulled and checked: `satd` stripped to 38,001,648 bytes, and `satd-init`, `satd-mkca` and `satd-healthcheck` all present. The `.s9pk` installed on the StartOS server was packed from this digest. Bumping both to the release tag stays a release-checklist step. **The test.** `compose-test.sh` had `UMBREL=…/umbrel/satd`, a path that stopped existing when the app directory was renamed to carry its store prefix. A stale path does not announce itself here: every check is a grep, so the positive assertions failed with a message about the compose file's contents while the negative ones — "does not render a bare --${NETWORK} flag" — passed, because a file that is not there contains nothing. Two checks had been reporting ok on a file nobody was reading, and `published-ports.py` printed "no such path" and was counted as a pass. Fixed, and the paths are now asserted before any check runs, so the next rename fails with the path in the message instead of quietly halving the suite. Verified by re-breaking the path and watching it exit. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013YuASGsfZnzWGdiqjqxmuJ --- .../startos/startos/manifest/index.ts | 17 +++++++++++---- contrib/packaging/umbrel/README.md | 21 ++++++++++++------- .../umbrel/epochbtc-satd/docker-compose.yml | 13 +++++++++++- contrib/stack/tests/compose-test.sh | 12 ++++++++++- 4 files changed, 50 insertions(+), 13 deletions(-) diff --git a/contrib/packaging/startos/startos/manifest/index.ts b/contrib/packaging/startos/startos/manifest/index.ts index 07f5f7806..c225d941d 100644 --- a/contrib/packaging/startos/startos/manifest/index.ts +++ b/contrib/packaging/startos/startos/manifest/index.ts @@ -19,10 +19,19 @@ export const manifest = setupManifest({ // the reference stack and the appliance perform and cannot drift from // them. // - // Pinned to a release that exists. The registry publishes bare tags - // only — there has never been a `v`-prefixed one — and the tag is - // bumped as a step in the release checklist. - dockerTag: 'ghcr.io/epochbtc/satd:0.5.1', + // A per-commit tag, not a release tag: no release contains satd-init, + // which this branch adds, so `0.5.1` — what this line used to say — + // could not have started. `docker.yml` publishes `sha-` on + // every build, the short sha names a real commit on the branch, and + // the digest pins the manifest list, which resolves per architecture + // so one pin covers amd64 and arm64. + // + // The `.s9pk` that was installed on a StartOS server was packed from + // this digest — `pack` resolves the tag and embeds the layers, so the + // server itself never contacts a registry. + // + // Bumping it to the release tag is a step in the release checklist. + dockerTag: 'ghcr.io/epochbtc/satd:sha-8246eb9@sha256:88b7489a76a11aebf57b805f6fa97b002141fa712b530083bfa081a7dc4ec4b6', }, // The image publishes linux/amd64 and linux/arm64 and nothing else, so // there is no riscv64 here and nothing to emulate it from. diff --git a/contrib/packaging/umbrel/README.md b/contrib/packaging/umbrel/README.md index 1e93f136f..8675894b5 100644 --- a/contrib/packaging/umbrel/README.md +++ b/contrib/packaging/umbrel/README.md @@ -20,13 +20,12 @@ release. ## Status -The Umbrel package has been installed and run on umbrelOS 1.7.4. The StartOS -package is written, typechecked and packs to a `.s9pk`, but **has not been -installed on a StartOS server**. +Both packages have now been installed and run on a real server: the Umbrel +package on umbrelOS 1.7.4, the StartOS package on StartOS 0.4.0.1. Neither +had been, and installing them is what found almost everything below. -That distinction is the whole point of this section. Five of the eight defects -found in the Umbrel package were invisible to every static check — `umbrel -lint` passes clean both before and after each of them: +Six of the nine defects in the Umbrel package were invisible to every static +check — `umbrel lint` passes clean both before and after each of them: - The app id must be prefixed with the store id, or the store adds successfully, reports no error, and lists zero apps. @@ -38,6 +37,13 @@ lint` passes clean both before and after each of them: bind mount root-owned, so an unprivileged init service cannot write to it. - `app_proxy` dials its upstream as `http://` with no TLS option, so pointing it at a TLS listener 502s every request. +- The health check probed `/readyz`, which is 503 until the node is within six + blocks of its headers tip. The container reported `Up 2 hours (unhealthy)` + with a failing streak of 254 while serving RPC, Electrum, Esplora and MCP + normally, and would have done so for the whole multi-day initial sync. It + read healthy on the first check only because the node was minutes old and + its header chain had not yet outrun its blocks — a window narrow enough to + pass a spot check and nothing else. What is checked statically: @@ -45,7 +51,8 @@ What is checked statically: manifest, each app manifest, the compose file and `exports.sh`. It is what caught the missing image digest pin, which the Umbrel app store requires. - `startos/` — typechecks against the SDK, tests its network table against - `satd-init`, and packs to a `.s9pk`. See `startos/README.md`. + `satd-init`, guards the two defects the install found, and packs to a + `.s9pk`. See `startos/README.md` for what running it on a server showed. Neither validator understands satd's own flags, so the checks that cover those live in `contrib/stack/tests/compose-test.sh`. diff --git a/contrib/packaging/umbrel/epochbtc-satd/docker-compose.yml b/contrib/packaging/umbrel/epochbtc-satd/docker-compose.yml index 5636dfb4e..a34e00909 100644 --- a/contrib/packaging/umbrel/epochbtc-satd/docker-compose.yml +++ b/contrib/packaging/umbrel/epochbtc-satd/docker-compose.yml @@ -18,8 +18,19 @@ version: "3.7" # # The tag is bare. The registry has never published a `v`-prefixed tag, so the # `v0.5.2` this used to name resolved to nothing on every architecture. +# +# It is also a per-commit tag rather than a release: no release contains +# satd-init, which this branch adds, so the `0.5.1` this used to name could +# not have started. `docker.yml` publishes `sha-` on every build and +# the short sha names a real commit on the branch. +# +# The umbrelOS install was run against a locally built copy of this image +# served from a registry on the host, not against ghcr itself — the VM's +# outbound path is QEMU's NAT and the package is not public yet. Same +# Dockerfile, same tree; the registry hop is what is untested. +# # Bumping both halves is a step in the release checklist. -x-satd-image: &satd-image ghcr.io/epochbtc/satd:0.5.1@sha256:bcbde256a0d5191d124f36d01df3d6dbdc70d4690aa9cccdb109eebf3df1dd1f +x-satd-image: &satd-image ghcr.io/epochbtc/satd:sha-8246eb9@sha256:88b7489a76a11aebf57b805f6fa97b002141fa712b530083bfa081a7dc4ec4b6 services: app_proxy: diff --git a/contrib/stack/tests/compose-test.sh b/contrib/stack/tests/compose-test.sh index bfda87bc6..b3e4b9da6 100755 --- a/contrib/stack/tests/compose-test.sh +++ b/contrib/stack/tests/compose-test.sh @@ -10,9 +10,19 @@ HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" STACK="$(cd "$HERE/.." && pwd)" ROOT="$(cd "$STACK/../.." && pwd)" APPLIANCE="$ROOT/contrib/appliance" -UMBREL="$ROOT/contrib/packaging/umbrel/satd" +UMBREL="$ROOT/contrib/packaging/umbrel/epochbtc-satd" fail=0 + +# Every check below is a grep against a path. A stale path does not announce +# itself: `grep -q` on a missing file just returns non-zero, so the positive +# assertions fail with a message about the compose file's contents and the +# negative ones ("does not contain X") pass, because nothing contains anything. +# This directory was renamed to carry the store prefix and these checks spent +# that time reading a file that was not there. +for p in "$STACK/compose.yml" "$UMBREL/docker-compose.yml" "$APPLIANCE/files"; do + [ -e "$p" ] || { echo "compose-test.sh: no such path: $p" >&2; exit 1; } +done ok() { printf ' ok %s\n' "$1"; } bad() { printf ' FAIL %s\n' "$1"; fail=1; } check() { if eval "$2"; then ok "$1"; else bad "$1"; fi; }