The half that landed correctly (LND, clean 401) made the half that did not harder to notice, because the first check an operator would run returns a pass. Records the probes, the three-fact root cause, and the pass condition for re-probing a node. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
90 lines
4.5 KiB
Markdown
90 lines
4.5 KiB
Markdown
# The Bitcoin RPC proxy that stayed open after it was fixed
|
|
|
|
**Status:** code fix committed (`f6b5245b`); on-node verification recorded below.
|
|
**Found:** 2026-08-02, archi-dev-box, while verifying `a05956c4` instead of assuming it.
|
|
**Severity:** critical on any affected node — unauthenticated control of Bitcoin Core RPC
|
|
through a proxy that injects the node's own credentials.
|
|
|
|
## Why this document exists
|
|
|
|
`a05956c4` closed two unauthenticated endpoints on the wallet UI ports. Its commit message
|
|
stated:
|
|
|
|
> The nginx template is `include_str!`'d and re-rendered on every reconcile pass, so this
|
|
> ships atomically with the binary.
|
|
|
|
That is true for most nodes and false for a specific, silent, and not-rare state. The half
|
|
that landed correctly (LND) made the half that did not (Bitcoin RPC) *harder* to notice,
|
|
because a spot check of the LND endpoint returns a clean `401` and reads as "patched".
|
|
|
|
## What was observed
|
|
|
|
Node running the fixed binary (installed 17:21, contains the new template — `auth_request`
|
|
present in the binary at 4 occurrences). All probes from the node's own LAN address, no
|
|
cookies, no credentials:
|
|
|
|
| Probe | Result |
|
|
|---|---|
|
|
| `GET http://192.168.63.240:18083/lnd-connect-info` | `401`, 24 bytes, `{"error":"Unauthorized"}` — **closed** |
|
|
| `POST http://192.168.63.240:8334/bitcoin-rpc/` (`getblockcount`) | `200` — `{"result":960774,"error":null}` — **OPEN** |
|
|
| `OPTIONS http://192.168.63.240:8334/bitcoin-rpc/` | `204` with `Access-Control-Allow-Origin: *` — **OPEN** |
|
|
|
|
The rendered config on disk, `/var/lib/archipelago/bitcoin-ui/nginx.conf`, was dated
|
|
**2026-06-30** — the pre-fix version, with no `auth_request` and with the wildcard CORS
|
|
header the fix removes.
|
|
|
|
## Root cause
|
|
|
|
Three facts have to be true at once, and on this node they were:
|
|
|
|
1. `bitcoin-ui` is listed in the node's durable `user-uninstalled` marker
|
|
(`/var/lib/archipelago/user-uninstalled.json`).
|
|
2. `reconcile_app` returns on that marker (`prod_orchestrator.rs:1956`) **before** reaching
|
|
`run_pre_start_hooks`, which is the only thing that renders the nginx config.
|
|
3. The container keeps running anyway, because it is owned by **systemd via a Quadlet
|
|
unit** — `archy-bitcoin-ui.service`, `active`, restarted 17:25 after the daemon restart —
|
|
not by the reconciler that is refusing to touch it.
|
|
|
|
So: *a container systemd keeps alive, that the orchestrator has stopped reconciling, never
|
|
receives a config fix shipped inside the binary.* The marker means "must stay removed", but
|
|
nothing enforces removal against systemd, and the orchestrator treats the marker as
|
|
permission to stop looking.
|
|
|
|
This is not a one-app accident. On the same node `archy-electrs-ui` is in the identical
|
|
state (uninstalled marker + active Quadlet unit + `Up 10 days`). It serves only a static
|
|
page with no credential-injecting proxy, so its exposure is low — but it would miss any
|
|
future config fix the same way.
|
|
|
|
## Why it matters beyond this node
|
|
|
|
An OTA carrying `a05956c4` would have closed the LND leak everywhere and silently failed to
|
|
close the Bitcoin RPC proxy on every node in this state — while making those nodes *look*
|
|
patched to exactly the check an operator would run first. That is the most misleading
|
|
possible outcome of shipping a security fix.
|
|
|
|
## The fix
|
|
|
|
`f6b5245b`: a container that is actually running is a live attack surface whatever a marker
|
|
says about it, so its security-relevant config is reconciled even behind the marker, and the
|
|
container is restarted so nginx loads it.
|
|
|
|
Deliberately narrow:
|
|
|
|
- Nothing is created, pulled, built, started or resurrected. The "must stay removed"
|
|
contract can only weaken for a container that is **already running**, which by definition
|
|
means it was never removed.
|
|
- A hook error is swallowed, not propagated — an app the user uninstalled must not be able
|
|
to fail the reconcile pass for every app after it.
|
|
- The pre-existing marker test passes unchanged; that is what proves the removal contract
|
|
survived. A new regression test pins the whole chain: stale conf in, gate present out,
|
|
container restarted, nothing created.
|
|
|
|
## Operator note
|
|
|
|
Deploying the fix rewrites the config and restarts `archy-bitcoin-ui` (a brief Bitcoin UI
|
|
interruption, nothing else). Any node that ever had `bitcoin-ui` uninstalled while its
|
|
Quadlet unit stayed active should be re-probed with the `POST /bitcoin-rpc/` check above —
|
|
a `401` is the pass condition. Treat the Bitcoin RPC password on any node that answered
|
|
`200` as known to anyone who could reach that port, and rotate it **after** the fix is
|
|
deployed, never before.
|