feat(marketplace): LNVPS can call a node - #368
Conversation
Increment 4c3a. Until now the control channel ran one way: a node calls LNVPS,
and LNVPS has never once called a node. There was no client, no signing key in
settings, and no way to check that the machine answering is the node that
registered.
The call is authenticated at both ends, and neither end trusts the tunnel to
establish who is at the other side of it:
- **Outbound**, the request is a NIP-98 event signed with LNVPS's control key,
which the node verifies against a public key compiled into its binary. The
event is bound to the method and the full URL, so a signature that authorised
reading status cannot be replayed against an endpoint that stops a guest, and
it carries a nonce — a nostr event id is the hash of its own contents and
`created_at` has one-second resolution, so without one a second poll in the
same second would be rejected by the node as a replay of the first.
- **Inbound**, the node's TLS certificate is checked against the fingerprint it
registered. No CA: the node is self-signed and its name is an address inside a
tunnel, so a public CA would only add a third party able to issue for a name
we already control out of band. Without server authentication, anything able
to answer on that address — a guest that grabbed the IP, a mistake on the
route server — could report that a VM is running when it is not.
Three smaller decisions:
- **The node's status is not modelled by depending on `lnvps_node`.** That would
pull netlink, nftables and WireGuard into the API binary to describe a JSON
document. The wire format is the contract; unknown fields are ignored so a
node running a newer daemon stays readable and the fleet need not be upgraded
in lockstep.
- **The control port is not stored per node.** The control API exists only
inside the tunnel, where every node has an address to itself and nothing
competes for a port. An operator who changes it makes their own node
unreachable, which the health gate reports as unreachable — self-correcting,
and cheaper than a column that can disagree with the node's own config.
- **`NodeControl` has no `Debug`.** It holds LNVPS's control secret key, and a
derived `Debug` is how a secret reaches a log line nobody meant to write.
Surfaced as `GET /api/admin/v1/marketplace/nodes/{id}/status`, live rather than
remembered: the stored `last_seen` says a node *was* there, which is not the
question anyone is asking when a customer's VM is unreachable. It is also the
only way to see a node's data plane before the node has been enabled, which is
what debugging a failed approval needs. Failures are returned verbatim, because
"connection refused", "certificate does not match the pin" and "clock is 400s
out" each send an operator somewhere different.
The client lives in `lnvps_api_common` because both the worker and the admin API
need it, and it is proved end to end in `lnvps_e2e/tests/node_control.rs`: the
real node server, the real client, a real handshake. Each half being unit-tested
against its own idea of the other is exactly the arrangement in which two
correct-looking halves fail to interoperate.
The control key was a secret of its own. It is now the key LNVPS already has: the account customers DM for support, the one legal agreements are signed with, `npub1lnvps32qq2nvg75cqwflq4y6cmnzn55d26ypzjakpkp3khqcx2ns7t7vjj`. A control key of its own would have to be generated, handed to whoever builds the node binaries, and kept in step with the value compiled into them — three places for it to drift, and a secret to look after in each. This one is already published, which turns the operator's side of the arrangement from trust into a check: the key their node was built to obey is an account that publicly answers, and they can compare the two without asking LNVPS for anything. `NostrConfig` moves to `lnvps_api_common`, since the admin API now reads the same identity to call nodes; its `relays` field defaults, because signing a control request needs no relay and the admin API speaks to none. The npub is recorded in `lnvps_api_common::node_control::LNVPS_NPUB` and in the node's own documentation, with a test asserting it is the key whose hex is compiled into node binaries. A typo there would be a documented value no node trusts, found by an operator whose node refuses every command.
|
Switched to LNVPS's existing nostr identity — Better than the shape I had, and not only for the obvious reason. A key of its own would have to be generated, handed to whoever builds the node binaries, and kept in step with the value compiled into them — three places to drift and a secret to look after in each. Reusing the published one turns the operator's side from trust into a check: the key their node was built to obey is an account that publicly answers, so they can compare the two without asking LNVPS for anything. That is a materially stronger position for someone being asked to run a daemon that takes remote commands. Changes:
Workspace suite green, interop tests green. |
Increment 4c3a. Starting the health gate turned up something worth splitting out first: the control channel only ran one way. A node calls LNVPS; LNVPS has never once called a node. No client, no signing key in settings, no way to check that the machine answering is the node that registered.
Also worth flagging: the plan said 4c3 provisions "a probe guest through the ordinary path". There is no ordinary path yet —
get_host_clienthas no arm forVmHostKind::MarketplaceNode, so a node cannot start a VM until its hypervisor backend lands. 4c3b's probe stands in for the guest, on the address it would have had;work/marketplace.mdrecords the re-scope.Both ends authenticated
Neither end trusts the tunnel to establish who is at the other side of it.
Outbound — a NIP-98 event signed with LNVPS's control key, which the node verifies against a public key compiled into its binary. Bound to the method and full URL, so a signature that authorised reading status cannot be replayed against an endpoint that stops a guest. It carries a nonce, because a nostr event id is the hash of its own contents and
created_athas one-second resolution: without one, a second poll in the same second is rejected by the node as a replay of the first — which is exactly what a health gate that polls does.Inbound — the node's certificate is checked against the fingerprint it registered. No CA: the node is self-signed and its name is an address inside a tunnel, so a public CA would only add a third party able to issue for a name we already control out of band. Without server authentication, anything able to answer on that address (a guest that grabbed the IP, a mistake on the route server) could report that a VM is running when it is not.
Smaller decisions
lnvps_nodeto model the status. That would pull netlink, nftables and WireGuard into the API binary to describe a JSON document. The wire format is the contract; unknown fields are ignored so a node on a newer daemon stays readable and the fleet needn't be upgraded in lockstep.NodeControlhas noDebug— it holds the control secret key, and a derivedDebugis how a secret reaches a log line nobody meant to write.Surface
GET /api/admin/v1/marketplace/nodes/{id}/status— live, not remembered. Storedlast_seensays a node was there, which is not the question anyone is asking when a customer's VM is unreachable, and this is the only way to see a node's data plane before it has been enabled. Failures come back verbatim: "connection refused", "certificate does not match the pin" and "clock is 400s out" each send an operator somewhere different.Needs
marketplace.control-keyin config (API and admin). Without it the endpoint says this deployment runs no marketplace, rather than failing obscurely.Testing
lnvps_e2e/tests/node_control.rsruns the real node server against the real client — signing, pinning, a real handshake, plus the impostor key and the unpinned certificate. Each half being unit-tested against its own idea of the other is the exact arrangement in which two correct-looking halves fail to interoperate. No root; runs in the ordinary suite.13 unit tests in
lnvps_api_common::node_control; workspace suite green. The TLS verifier's signature callbacks are covered by the interop test rather than the unit run, since they only execute in a real handshake.