appliance: a reference stack, a bootable image, and app-store packages - #708
Open
bkeroack wants to merge 9 commits into
Open
appliance: a reference stack, a bootable image, and app-store packages#708bkeroack wants to merge 9 commits into
bkeroack wants to merge 9 commits into
Conversation
bkeroack
force-pushed
the
feature/appliance-reference-stack
branch
from
September 9, 2026 14:21
15aa6c0 to
63a6ba5
Compare
The runtime image carried satd and sat-cli only, so `docker exec -it satd sat-tui` — the obvious way to watch a containerised node — did not work, and the Umbrel/StartOS packages that expose sat-tui as their terminal would each have had to bolt it on themselves. Cook and build now select the same three bins, which the cargo-chef cache requires. The HEALTHCHECK reports liveness by default rather than readiness. The probe cannot see the daemon's credentials (the CMD may set -rpcuser, and the cookie may be unreadable) or its network flags, so it treats any HTTP status line from the RPC listener — 401 included — as healthy, which is the strongest claim it can honestly make. Operators who want a real readiness gate, which is what `depends_on: condition: service_healthy` needs, point SATD_HEALTH_URL at /readyz; contrib/stack does exactly that. No curl or wget: the runtime image is deliberately thin, so the probe speaks HTTP over bash's /dev/tcp. That transport is easy to get subtly wrong, so it has a test — a bodyless request assembled with command substitution loses the blank line that terminates it, and a healthy node then reads as down until the probe's own timeout fires. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K9Nb3m8HuhNbjkJYp9Frmt
satd has served TLS on -rpctlsbind for several releases, but both shipped
clients formatted `http://{host}:{port}/` and had no CA option. An
operator who turned RPC TLS on therefore had to keep the plain listener
bound purely so the project's own tooling could reach the node — which
means the deployment most in need of TLS was the one that could not have
it end to end.
Four flags on each binary, spelled the same and accepted in Core's
single-dash form: -rpctls, -rpccacert, -rpcclientcert, -rpcclientkey.
They are additive; nothing changes for an invocation that does not pass
them.
The shared implementation lives in tls-config beside the acceptor the
same operator configured, behind a `client` feature so satd does not
build it. Two behaviours are deliberate:
- TLS material without -rpctls is an error, not a warning. Ignoring it
would send the RPC credential over plain HTTP and look like success.
- A CA file containing no PEM certificates is an error. reqwest parses
such a file into an empty list, so pointing -rpccacert at a private
key or the wrong path would add no anchor and surface later as a
generic handshake failure, indistinguishable from the node being down.
The tests run a real handshake against an acceptor built by this crate's
server half, because loading a PEM proves nothing about verification: the
positive case is paired with an untrusted server, the wrong CA, and a name
mismatch, all of which must fail.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K9Nb3m8HuhNbjkJYp9Frmt
A docker-compose stack that runs satd with RPC, Electrum, Esplora, metrics and (optionally) MCP all on, each TLS-terminated by a certificate the install issues for itself. It is the shared substrate the rest of the appliance work builds on: the image runs this stack, and the Umbrel and StartOS packages are derived from compose.yml, so the satd service is defined once and cannot drift between them. tls/mkca.sh is the single certificate script all three use. It creates a CA for one install only and issues one leaf that every surface presents, so a client imports one thing and trusts everything. Reissue happens on expiry, on a change to the machine's names or addresses, or on --force; the CA is never rotated automatically, because that invalidates trust every client already established. Nothing key-like is in any image — the smoke test asserts that, and it is what makes the appliance image redistributable. Two decisions worth stating. Plain RPC/Electrum/Esplora/metrics listeners bind the compose network and are never published: they exist because the overlay containers cannot be taught to trust a private CA, and nothing unencrypted leaves the host. And the internal RPC port is pinned to 8332 on every network so overlays, the proxy and the app-store packages address one fixed port — the cost, documented, is that in-container sat-cli needs -rpcport on a non-mainnet stack. LND runs in Neutrino mode because satd implements no raw ZMQ topics and rejects them by design; Neutrino needs none, pulling BIP157/158 filters over P2P from the peerblockfilters listener the stack turns on. Core Lightning is unaffected and runs as a full-node client. The image now also carries openssl, mkca.sh, satd-init and the config template, so a deployment that cannot mount repository files gets identical first-run behaviour. smoke.sh brings the stack up on regtest and probes every TLS listener from outside the container against the generated CA with -verify_return_error, paired with the negative control that the same handshake without the CA must fail — a probe that would pass unverified proves nothing about the certificate. Verified locally: core stack, LND syncing to tip over Neutrino, and RTL served through the proxy. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K9Nb3m8HuhNbjkJYp9Frmt
A bootable image with satd, its tooling and — in the desktop flavour —
Sparrow, Electrum, Liana and a Cashu wallet already pointed at the node's
Electrum server. Signet by default, because it is the only network on which
the whole thing is a one-evening exercise; `satd-appliance set-network
mainnet` switches and refuses below 1.5 TB free.
Built with mmdebstrap and a GRUB install onto a loop device rather than
Packer. Packer's QEMU builder drives Debian's installer through a preseed
inside a running VM: it needs KVM to finish in sensible time, needs a
700 MB ISO, and fails in ways you diagnose by watching a VNC console. This
runs in a container with no root and no KVM on the host, takes minutes, and
every failure is a shell command that exited non-zero. That is what made
the boot gate practical rather than aspirational.
Everything unique to an install is created on first boot — the console
password, the CA and server certificate, the MCP token, the disk's real
size — because an image that shipped any of them would be an image where
every download shared them. 90-cleanup.sh asserts none of them are present
and refuses to finish a build that would ship one.
satd runs natively under systemd; the overlays run as containers from
contrib/stack's files, used unmodified. Making that sharing real took two
changes there, included here: the overlays address the node as
${SATD_HOST:-satd} so the same file works against a compose service and
against the host, and they no longer depend on a satd service that does not
exist in the appliance's project.
Four defects found by booting the artifact rather than reasoning about it:
- grub-mkconfig's UUID probe comes back empty in a build container, so
10_linux fell back to the BUILD HOST's loop device. The image booted,
the kernel started, and the initramfs waited forever for a /dev/loopNpM
that exists on no machine but the builder. Now rewritten to the UUID
and asserted.
- `tr -dc < /dev/urandom | head -c 18` killed first boot outright: head
closes the pipe, tr dies of SIGPIPE, and pipefail aborts the unit.
- `systemctl enable --now satd` from inside first boot deadlocked against
its own Before= ordering — satd waited for first boot, first boot waited
for satd. `--no-block` queues the job instead.
- sat-cli has no signet selector and derives the cookie path from the
chain, so satd-appliance could not authenticate on any network but
mainnet. It now uses the stable rpc-cookie symlink satd-init maintains.
The last of those exposed a real bug in the shipped systemd unit, fixed
here: ExecStartPost relaxed only ${SATD_DATADIR}/.cookie, so on signet,
testnet4 and regtest — where the cookie lives under the network's
subdirectory — members of the satd group could not run sat-cli at all.
boot-test.sh boots the artifact under QEMU and checks it through two
channels: the guest agent for what is inside, and forwarded ports for the
TLS surfaces, verified from outside against the CA the agent hands out.
Checking a certificate from inside the guest proves much less than
connecting to it the way a client will. KVM where there is one, TCG where
there is not.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K9Nb3m8HuhNbjkJYp9Frmt
Three tiers, in increasing cost, each gated on the paths it covers:
- the certificate script and the container healthcheck probe: seconds,
no docker, plus shellcheck at error level over every shell file in
this work;
- the reference stack on regtest, with every TLS listener probed from
outside the container against the CA the stack generated, LND syncing
over Neutrino and RTL served through the proxy;
- the appliance image, built and then booted under QEMU.
Hosted runners only. satd is public, and a `pull_request` job on a
self-hosted runner would let a fork PR execute arbitrary code on a
maintainer machine.
Path gating is per-job rather than a workflow-level `paths` filter: a
path-skipped workflow never reports its contexts and leaves a PR waiting on
a status that never arrives, where a skipped job reports "skipped" and
satisfies branch protection.
`sign-tarballs.sh --images <dir>` signs appliance images. They are several
GB each and exceed GitHub's 2 GB per-asset limit, so the images go to object
storage and what lands on the release is a manifest of their SHA-256 sums
with a minisign signature over it — which authenticates every image in the
list just as well, next to the tarball signatures where anyone verifying
already knows to look. The script deliberately does not upload the images
themselves: it has no object-storage credentials and should not acquire any.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K9Nb3m8HuhNbjkJYp9Frmt
Sources for the app-store packages, versioned with satd and published from their own repositories, which is how both stores consume them. Both contain satd and nothing else — the daemon, sat-cli, sat-tui and MCP. Umbrel and StartOS users compose Lightning, BTCPay and wallets from their own stores, and a package that bundled a second copy of software the store already offers would be worse than useless. The best-effort notice that applies to the appliance image therefore does not apply here: there is no third-party software to disclaim. The Umbrel app derives its satd service from contrib/stack/compose.yml and runs the same satd-init the reference stack does — which is why that script and mkca.sh are baked into the container image. A package that reimplemented first-run behaviour would drift from the stack within a release. exports.sh mirrors the official `bitcoin` app's variable names, so an app that already knows how to find Bitcoin Core finds satd: it speaks Core's JSON-RPC and cookie format, so nothing else has to change. startos/ is requirements, not a package. A StartOS package is a TypeScript project built with Start9's SDK, and that SDK's shape has changed across StartOS versions; written against a guessed API it would look right in review and not build. The README records what the package must declare — interfaces, health check, backup set, the config options that must NOT exist because txindex is not optional here — so writing it is mechanical once the target version is chosen. It also records the one open question: the certificate renews on container start there, with no scheduler to run the daily timer the appliance uses. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K9Nb3m8HuhNbjkJYp9Frmt
A new chapter covering all three deliverables together, because they share one node configuration and one certificate scheme and are best understood that way: what each is, which ports are published and which deliberately are not, why there is no prune option anywhere, how the CA and certificate work and how to trust them from each kind of client, and what is actually checked in CI rather than asserted. The support policy appears verbatim here, in contrib/appliance/README.md, in contrib/stack/README.md and on the image's own welcome page, so that someone who meets the bundled software in any of those places meets the same statement about it. packaging.md gains a pointer to the chapter and an accurate description of what the container image now carries, since a packager building on that image inherits its first-run tooling. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K9Nb3m8HuhNbjkJYp9Frmt
bkeroack
force-pushed
the
feature/appliance-reference-stack
branch
from
September 9, 2026 15:21
63a6ba5 to
3c50a86
Compare
Review of the appliance/stack branch turned up five problems, each verified
against the artifact rather than the source before being fixed.
RTL was reachable with a well-known password. RTL v0.15.x reads APP_PASSWORD
and nothing else; the overlay passed RTL_PASSWORD, which the container
ignores, so RTL fell back to the password in the config it generates for
itself -- the literal string "password" -- in front of LND's admin macaroon,
on a port the proxy publishes. Confirmed by grepping the pinned image
(RTL_PASSWORD appears nowhere in it) and then by logging in. Now passed as
APP_PASSWORD and required rather than defaulted.
Installing to disk destroyed the target. rsync excludes /dev, /proc and /sys,
so those directories do not exist on the new root; the chroot bind mounts
that follow failed on the first one, under `set -e`, after the disk had been
formatted and before GRUB ran. The mountpoints are now created (with /run,
/tmp, /mnt and /media, which the installed system needs regardless), and the
mounts are unwound by a trap so a later failure does not leave the target's
filesystems held.
Mainnet did not start at all. `NETWORK=mainnet` rendered `--mainnet`, and
satd has bare flags for the test networks but none for mainnet, so the daemon
exited on an unknown argument -- including in the Umbrel package, where
mainnet is the default. Both now use `--chain=`, which accepts every name.
Overlay lifecycle commands could not parse their own compose files. The
overlays declare secrets as ${VAR:?}, a hard parse error rather than an empty
string, and only `enable` sourced overlay.env: `set-network` left overlays on
the old chain, and `disable` swallowed a failed teardown and then removed the
marker, orphaning containers nothing could name again. Secrets are now loaded
inside run_compose, the one place that runs compose, and a failed teardown
keeps the marker. `enable ark` and `enable lightning` also generated no
secret at all and so could never have started.
BTCPay published its login and payment configuration in clear text on every
interface, which the proxy neither replaced nor protected -- and a published
container port is DNAT'd through the forward chain, so the appliance's
default-deny input chain never saw it. It now binds loopback, and the proxy
serves it over TLS on 49393.
Tests: contrib/stack/tests/compose-test.sh asserts each of these statically,
and smoke.sh now asks RTL to reject its upstream default password as well as
accept the generated one. Both were perturbation-checked: reverting each fix
fails a named test, and reverting the RTL fix specifically shows "password"
logging in with http 200.
Also fixes the Compose-parse CI step, which exported POSTGRES_PASSWORD only
for the cashu invocation and so failed on the btcpay overlay.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K9Nb3m8HuhNbjkJYp9Frmt
| treating that as the end of the block silently checked nothing. | ||
| """ | ||
| out, in_ports, blocks = [], False, 0 | ||
| for line in open(path): |
The reference-stack bring-up and the appliance image build each compile satd from scratch and take about twenty minutes apiece, so between them they cost roughly forty minutes of runner time on every pull request that touches contrib/stack or contrib/appliance. That is the wrong place to spend it: both are artifact-shaped gates whose result matters at release, not per commit. Both now run on `v*` tags -- the same trigger the release workflow uses -- and on workflow_dispatch. They are skipped, not removed, on pull requests, so branch protection still sees a reported context. The escape hatch is the `appliance-ci` label. `labeled` is in the trigger list, so applying it to an open PR starts the run without needing another push; a PR that genuinely changes this surface can still be gated on the full thing. On a labelled PR the image matrix stays core-only, as it already did. Unchanged: the static checks over the certificate script, the container healthcheck and the compose/appliance invariants. Those are seconds, need no docker or network, and keep running on every PR that touches them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K9Nb3m8HuhNbjkJYp9Frmt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Three ways to run satd beyond a bare binary — a docker-compose reference
stack, a downloadable appliance image, and app-store package sources — plus
the two satd-side changes they needed.
Every bundled application here is a compatibility claim, so each one is
exercised rather than asserted. Everything below was verified by building
and booting the real artifacts, not by inspection.
What lands
contrib/stack/contrib/appliance/coreheadless,desktopwith Sparrow/Electrum/Liana) and a live ISO, built from one provisioning tree.contrib/packaging/sat-cli/sat-tui-rpctls,-rpccacert,-rpcclientcert/-rpcclientkey.sat-tui, aHEALTHCHECK, and the stack's first-run tooling.signet is the default everywhere. There is no prune option anywhere:
Electrum and Esplora both require
txindex, which excludes pruning.Verification
under QEMU, checked through the guest agent and through forwarded ports,
with every TLS surface verified from outside the guest against the CA the
install generated. Each certificate probe is paired with the negative
control that the same handshake without the CA must fail.
synced to tip over Neutrino, RTL served through the proxy.
synced: true, with areal signer pubkey and forfeit address.
parse, mdbook builds.
Two satd-side changes worth reviewing on their own
sat-cli/sat-tuicould not speak to satd's own TLS listener. satd hasserved TLS on
-rpctlsbindfor releases, but both clients formattedhttp://and had no CA option — so the deployment most in need of TLS wasthe one that could not have it end to end. Two failures are deliberately
loud: TLS material without
-rpctlsis an error (ignoring it would send theRPC credential in the clear and look like success), and a CA file containing
no certificates is an error (reqwest parses such a file into an empty list,
so a wrong path would add no anchor and fail later as a generic handshake
error). The tests run real handshakes against an acceptor built by this
crate's own server half; the positive case is paired with an untrusted
server, the wrong CA, and a name mismatch.
contrib/systemd/satd.servicehad a real bug, unrelated to this workbut found by it:
ExecStartPostrelaxed only${SATD_DATADIR}/.cookie, soon signet, testnet4 and regtest — where the cookie lives under the network's
subdirectory — members of the
satdgroup could not runsat-cliat all.Worth cherry-picking ahead of the rest if you'd rather not wait for this.
Design decisions that are mine, not the plan's
Built with
mmdebstrap, not Packer. Packer's QEMU builder drivesDebian's installer through a preseed inside a VM: it needs KVM to finish in
sensible time, needs the netinst ISO, and fails in ways you diagnose from a
VNC console. Building the filesystem directly and installing GRUB onto a
loop device runs with no KVM, inside a container, in minutes — which is what
made the CI boot gate practical rather than aspirational. Reversible: only
build.shwould change.satd runs natively on the appliance, with the overlays reaching it
through the docker network's gateway. That needed two changes in
contrib/stack: overlays address${SATD_HOST:-satd}, and they no longerdepends_ona satd service that does not exist in the appliance's project.The reverse proxy uses one port per app, not one path per app. RTL and
BTCPay build absolute URLs from their own root, so path-mounting them breaks
on their next release.
The internal RPC port is pinned to 8332 on every network, so overlays,
the proxy and the store packages address one fixed port. The cost, which is
documented: in-container
sat-clineeds-rpcporton non-mainnet.Not shipped, deliberately
The Cashu wallet CLI is included, but the Ark overlay is experimental
— every arkd setting in it was established by running the binary rather than
read from a specification, so expect it to need attention on a version bump.
The StartOS package is a requirements document, not code. A StartOS
package is a TypeScript project built with Start9's SDK, and the SDK's shape
has changed across versions; written against a guessed API it would review
well and not build.
Known gaps
pip; the appliance path goes throughpipx --pip-args, which has not been exercised in a full image build.sign-tarballs.sh --imagessigns a checksum manifest, but nothing publishes the images yet.Review round 1
Five defects found and fixed in
30335559. Each was verified against thebuilt artifact before being fixed, not just read out of the source.
RTL_PASSWORDappears nowhere in the pinned image; logging in withpasswordreturned http 200install-to-diskaborted after formatting the target, leaving it unbootable/dev,/proc,/sys; the chroot bind mounts then fail underset -eforward(policy accept), so the default-deny input chain never saw it--mainnetis not a flag satd has, and it is the Umbrel defaultsatd --mainnetexits with "unexpected argument";--chain=mainstarts a mainnet nodeset-networkanddisablecould not parse the overlay files, orphaning containers${VAR:?}, a hard parse error; onlyenablesourced themBoth fixes with a security consequence are covered by tests that were
perturbation-checked — reverting the fix fails a named check:
contrib/stack/tests/compose-test.sh(new, no docker or network) assertsall five statically; all ten perturbations are caught.
smoke.shnow asks RTL to reject its upstream default password as well asaccept the generated one. Reverting the RTL fix makes it report
expected http 401, got 200.Two of these were found only because the checks were run against the running
system: the first version of the RTL login test passed for the wrong reason
(wrong path, and express's static catch-all answers 200 to anything), and the
port checker silently checked nothing on
compose.ymlbecausecompose.*.ymldoes not match it.Also fixed the
Compose files parseCI step, which exportedPOSTGRES_PASSWORDonly for the cashu invocation and so failed on btcpay.CI cost
The reference-stack bring-up and the appliance image build each compile satd
from scratch, about twenty minutes apiece, so they no longer run on every
pull request. Both are release gates now:
v*tags (the release workflow'sown trigger) and
workflow_dispatch. They are skipped rather than removed onPRs, so branch protection still gets a reported context.
To run them on a PR that changes this surface, add the
appliance-cilabel —
labeledis in the trigger list, so applying it starts the run withno extra push. The static checks over the certificate script, the healthcheck
and the compose/appliance invariants still run on every PR that touches them,
and cost seconds.
Both heavy jobs passed on
30335559before this gating landed, so the fixesabove are verified against a real stack bring-up and a real QEMU boot.
Supersedes
The paused local branch
feature/umbrel-app(contrib/umbrel/). Note itsDockerfile change added
--bin sat-tuito the build but not to thecargo-chef
cookline, which would have silently busted the dependencycache on every image build; this PR does both.
🤖 Generated with Claude Code
https://claude.ai/code/session_01K9Nb3m8HuhNbjkJYp9Frmt