Skip to content

feat(marketplace): LNVPS can call a node - #368

Merged
v0l merged 2 commits into
masterfrom
feat/marketplace-node-control
Aug 7, 2026
Merged

feat(marketplace): LNVPS can call a node#368
v0l merged 2 commits into
masterfrom
feat/marketplace-node-control

Conversation

@v0l

@v0l v0l commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

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_client has no arm for VmHostKind::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.md records 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_at has 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

  • Not depending on lnvps_node to 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.
  • 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 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 the control secret key, and a derived Debug is how a secret reaches a log line nobody meant to write.

Surface

GET /api/admin/v1/marketplace/nodes/{id}/status — live, not remembered. Stored last_seen says 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-key in config (API and admin). Without it the endpoint says this deployment runs no marketplace, rather than failing obscurely.

Testing

lnvps_e2e/tests/node_control.rs runs 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.

v0l added 2 commits August 7, 2026 11:27
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.
@v0l

v0l commented Aug 7, 2026

Copy link
Copy Markdown
Contributor Author

Switched to LNVPS's existing nostr identity — npub1lnvps32qq2nvg75cqwflq4y6cmnzn55d26ypzjakpkp3khqcx2ns7t7vjj, the account customers DM for support and the one legal agreements are already signed with. No separate control key.

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:

  • NostrConfig moves to lnvps_api_common; the admin API reads the same identity to call nodes. relays now defaults, since signing a control request needs no relay and the admin API talks to none.
  • marketplace.control-key is gone from both configs — it is nostr.nsec, as it already was for the main API.
  • The npub is recorded in lnvps_api_common::node_control::LNVPS_NPUB and in the node's control_auth docs, with a test pinning it to the hex compiled into node binaries as LNVPS_CONTROL_PUBKEY. A typo there would be a documented value no node trusts, discovered by an operator whose node refuses every command.

Workspace suite green, interop tests green.

@v0l
v0l merged commit bb5b136 into master Aug 7, 2026
11 checks passed
@v0l
v0l deleted the feat/marketplace-node-control branch August 7, 2026 11:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant