diff --git a/.github/workflows/appliance.yml b/.github/workflows/appliance.yml new file mode 100644 index 000000000..a3eceb76a --- /dev/null +++ b/.github/workflows/appliance.yml @@ -0,0 +1,263 @@ +# Appliance CI — the reference stack and the downloadable VM image. +# +# Three things are gated here, in increasing cost. Only the first runs on an +# ordinary pull request: +# +# 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. ~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. ~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 +# 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: + # `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*' + 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" + # 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: stack + appliance 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: compose + appliance invariants + run: contrib/stack/tests/compose-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 \ + contrib/stack/tests/compose-test.sh + + stack: + name: reference stack (regtest, TLS probed) + needs: changes + # 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. + 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 + # 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 \ + config > /dev/null + echo "ok: $overlay" + done + # cashu extends the Lightning overlay, so it only parses with it. + 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 + needs: changes + # 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: + 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/.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/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/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/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/Dockerfile b/Dockerfile index 9ed0628bc..5a699c6bb 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 @@ -153,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/* @@ -171,6 +179,20 @@ 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 + +# 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 @@ -181,5 +203,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/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..11f5d3a81 --- /dev/null +++ b/contrib/appliance/bin/satd-appliance @@ -0,0 +1,502 @@ +#!/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 +} + +# 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" + 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[@]}" "$@" + ) +} + +# --- 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" + # 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" || \ + 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" + ;; + 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)" + 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. + # 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 + 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. +# 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:-}" + [[ -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" + + # 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" + + 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/" + + # 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")" + 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 + + # 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" + + 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..c08a8c06e --- /dev/null +++ b/contrib/appliance/provision/00-base.sh @@ -0,0 +1,164 @@ +#!/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. + # 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 + + # 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/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" 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..01a193b33 --- /dev/null +++ b/contrib/packaging/umbrel/satd/docker-compose.yml @@ -0,0 +1,69 @@ +# 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. `--chain=` because there is no bare `--mainnet` flag, + # and mainnet is this package's default. + command: + - --datadir=/var/lib/satd + - --chain=${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 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, diff --git a/contrib/stack/.env.example b/contrib/stack/.env.example new file mode 100644 index 000000000..dea53607a --- /dev/null +++ b/contrib/stack/.env.example @@ -0,0 +1,61 @@ +# 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 + +# --- 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. +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..e22050dfe --- /dev/null +++ b/contrib/stack/README.md @@ -0,0 +1,190 @@ +# 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. + 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. +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 "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 +(`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..1559a582c --- /dev/null +++ b/contrib/stack/caddy/Caddyfile @@ -0,0 +1,44 @@ +# 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 +} + +# 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 { + import satd_tls + reverse_proxy {$SATD_HOST: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..eae750a59 --- /dev/null +++ b/contrib/stack/compose.btcpay.yml @@ -0,0 +1,108 @@ +# 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 + # 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: + btcpay-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=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: 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_BIND_ADDR:-127.0.0.1}:${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..033398bc2 --- /dev/null +++ b/contrib/stack/compose.cln.yml @@ -0,0 +1,54 @@ +# 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 + # 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_HOST:-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..ad161644a --- /dev/null +++ b/contrib/stack/compose.lightning.yml @@ -0,0 +1,113 @@ +# 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 + # 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_HOST:-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 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: + # 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..3090d6912 --- /dev/null +++ b/contrib/stack/compose.proxy.yml @@ -0,0 +1,61 @@ +# 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) +# 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. + +services: + caddy: + image: caddy:2.8-alpine@sha256:af32e97399febea808609119bb21544d0265c58a02836576e32a2d082c262c17 + # 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 + # 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_BTCPAY_PORT:-49393}:49393" + - "${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..a899090ff --- /dev/null +++ b/contrib/stack/compose.yml @@ -0,0 +1,99 @@ +# 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 + +# 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: + # 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 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 + - --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 + 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/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/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/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 new file mode 100755 index 000000000..99bcc3605 --- /dev/null +++ b/contrib/stack/tests/smoke.sh @@ -0,0 +1,377 @@ +#!/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 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 +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 + + # 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 + ;; + *) + 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 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= 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..1b868046f --- /dev/null +++ b/docs/manual/src/appliance.md @@ -0,0 +1,270 @@ +# 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 "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 +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 / 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 +`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..33afb473d 100644 --- a/docs/release-notes/0.5.2-pre.md +++ b/docs/release-notes/0.5.2-pre.md @@ -20,6 +20,135 @@ 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 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. + +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 +> 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 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;