Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions ADMIN_API_ENDPOINTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -3764,6 +3764,28 @@ carries `fee_paid` (whether the one-off listing fee has settled),
reached) and `host_id` (the backing host, `null` until approval creates it).
Node tokens are never returned — LNVPS keeps no copy of them.

Each node carries `host_enabled` and `probe`. Approval alone does not enable a
node: the health gate does, so a node reading `approved` with `host_enabled:
false` is waiting on (or has failed) its gate, and `probe.detail` says which
step. That pair answers "why is my node approved and empty?".

```json
{
"host_enabled": false,
"probe": {
"status": "failed",
// running|passed|failed
"ip": null,
// The address held while the gate runs; released when it finishes
"ip_range_id": 4,
// null when the run was refused before an address was worth taking
"detail": "The route server could not reach 203.0.113.9 on this node, so a customer's VM would not be reachable either",
"created": "2026-08-10T09:00:00Z",
"finished": "2026-08-10T09:00:12Z"
}
}
```

#### Get Node

```
Expand All @@ -3772,6 +3794,32 @@ GET /api/admin/v1/marketplace/nodes/{id}

Required Permission: `marketplace_node::view`

#### Run the Health Gate

```
POST /api/admin/v1/marketplace/nodes/{id}/health_check
```

Required Permission: `marketplace_node::update`

Queues a health-gate run. The gate takes an address from a real customer range
in the node's region, sends it to the node as a guest, and pings it **from the
route server** — which is the path a customer's traffic takes: the address is
statically routed from the core network to the node's tunnel address, forwarded
across the guest bridge, and answered back out the node's default route. On a
pass the backing host is **enabled**; on a failure it is left approved and
disabled, with the failing step recorded.

Queued rather than run inline: it waits on a route server and a tunnel, which is
not work to hold an HTTP request open for. The verdict lands on the node's
`probe` field, returned by `GET /marketplace/nodes/{id}`.

The gate also runs automatically when a node's tunnel is allocated, which is the
first moment it can run at all. This endpoint is for the cases after that — an
operator who has fixed their firewall, a node whose route server was down.

Errors: `400` when the node has no host yet (not approved).

#### Get Live Node Status

```
Expand Down
2 changes: 2 additions & 0 deletions API_CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).

- **The route server now routes its tunnel pool's own blocks.** An address on a point-to-point interface does not route the rest of its prefix, so a route server holding `10.66.0.1/16` answered "network is unreachable" for every node in that pool — the peers were configured correctly and unreachable. Found by an end-to-end harness that builds both ends of a tunnel and sends real packets, not by review.

- **A marketplace node has to prove it can carry a customer before it takes one** — approving a node no longer enables it. A health gate takes an address from a real customer range in the node's region, sends it to the node as a guest, and pings it from the route server; only then is the backing host enabled. That is the production path exactly, which is the point: a VM's address is statically routed from the core network to the node's tunnel address, forwarded across the guest bridge, and answered back out the node's default route, while the guest routes to the core router's address that the node answers for by proxy ARP. Asking the node how it thinks it is doing tests none of that, and the failure worth catching is the node that believes it is fine. It runs automatically when a node's tunnel is allocated — the first moment it can — and on demand via `POST /api/admin/v1/marketplace/nodes/{id}/health_check`. A failure leaves the node approved and unusable with the failing step named, which is the safe direction: a node that never carries a customer is a support conversation, and one that carries a customer badly is an outage. `GET /api/admin/v1/marketplace/nodes/{id}` gains `host_enabled` and `probe`, which together answer "why is my node approved and empty?".

- **LNVPS can call a node** — `GET /api/admin/v1/marketplace/nodes/{id}/status` (admin, `marketplace_node::view`) returns what the node says about itself right now: daemon version, tunnel state and handshake age, bridge, forwarding, routed guest count, and the packet filter. Signed with LNVPS's existing nostr identity (`nostr.nsec` — the account customers DM for support, `npub1lnvps32qq2nvg75cqwflq4y6cmnzn55d26ypzjakpkp3khqcx2ns7t7vjj`), which the admin API now also reads; without it the endpoint answers that this deployment runs no marketplace rather than failing obscurely. One identity rather than a control key of its own: a separate secret would have to be generated, distributed to whoever builds the node binaries and kept in step with the value compiled into them, whereas this one is already published — an operator can check the key their node obeys against an account that publicly answers. Both ends of the call are authenticated — the request is a NIP-98 event signed with LNVPS's control key, which the node checks against a public key compiled into its binary, and the node's TLS certificate is checked against the fingerprint it registered. There is no CA: the node is self-signed and its name is an address inside a tunnel, so what is verified is the pin. 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.

- **A node's status now reports the packet filter around its guests** — `GET /status` on the node control API gains `dataplane.firewall`: whether the machine has a working nftables, whether LNVPS's ruleset is loaded, whether guests are isolated from each other at layer 2, how many guest bindings are enforced, which ruleset the *kernel* is running (a tag read back out of a rule comment, not remembered by the daemon), and how many packets have been dropped for claiming an address the guest was not assigned. That last number is the only one here that says something about a customer rather than a node: a guest that is spoofing is either compromised or hostile, and LNVPS would rather learn it from a counter than from an upstream abuse report. A node whose filter is not loaded now reports itself **unhealthy** — an unfiltered node is one where any guest can be any other, which is worse than a node carrying nobody.
Expand Down
18 changes: 18 additions & 0 deletions lnvps_api/src/api/marketplace.rs
Original file line number Diff line number Diff line change
Expand Up @@ -594,6 +594,24 @@ async fn v1_node_request_tunnel(
auth.node.id
);
}

// A node with a tunnel is a node that can finally be tested end to end.
// Queued here rather than at approval, because at approval there was no
// tunnel and nothing to test — this is the first moment the gate can run,
// and a node whose hardware works should not wait for an admin to notice.
if let Err(e) = this
.work_sender
.send(WorkJob::HealthCheckNode {
node_id: auth.node.id,
})
.await
{
log::error!(
"Allocated tunnel {} for node {} but could not queue its health check: {e}",
allocation.tunnel.id,
auth.node.id
);
}
ApiData::ok(allocation.into())
}

Expand Down
30 changes: 29 additions & 1 deletion lnvps_api/src/mocks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ use payments_rs::onchain::{
ChainPaymentUpdate, NewAddressRequest, NewAddressResponse, OnChainProvider, PaymentCursor,
SendCoinsRequest, SendCoinsResponse,
};
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use std::ops::Add;
use std::pin::Pin;
use std::sync::Arc;
Expand All @@ -56,6 +56,11 @@ pub struct MockRouter {
addresses: Arc<Mutex<HashMap<String, Vec<String>>>>,
/// Routes pointing down each tunnel interface
routes: Arc<Mutex<HashMap<String, Vec<String>>>>,
/// Whether this route server can reach anything at all. A caller cannot
/// know in advance which address the code under test will pick, so the
/// switch is all-or-nothing.
reach_all: Arc<Mutex<bool>>,
probed: Arc<Mutex<Vec<String>>>,
}

impl Default for MockRouter {
Expand Down Expand Up @@ -83,6 +88,8 @@ impl MockRouter {
Arc::new(Mutex::new(HashMap::new()));
static TL_ROUTES: Arc<Mutex<HashMap<String, Vec<String>>>> =
Arc::new(Mutex::new(HashMap::new()));
static TL_PROBED: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
static TL_REACH_ALL: Arc<Mutex<bool>> = Arc::new(Mutex::new(false));
static TL_DEFAULT_ROUTE: Arc<Mutex<Option<BgpRoute>>> =
Arc::new(Mutex::new(Some(BgpRoute {
prefix: "0.0.0.0/0".to_string(),
Expand All @@ -97,6 +104,8 @@ impl MockRouter {
default_route: TL_DEFAULT_ROUTE.with(|d| d.clone()),
addresses: TL_ADDRESSES.with(|a| a.clone()),
routes: TL_ROUTES.with(|r| r.clone()),
reach_all: TL_REACH_ALL.with(|r| r.clone()),
probed: TL_PROBED.with(|p| p.clone()),
}
}

Expand All @@ -112,6 +121,8 @@ impl MockRouter {
addresses.clear();
let mut routes = self.routes.lock().await;
routes.clear();
*self.reach_all.lock().await = false;
self.probed.lock().await.clear();
}

/// Addresses configured on a tunnel interface
Expand All @@ -121,6 +132,16 @@ impl MockRouter {
}

/// Routes pointing down a tunnel interface
/// Make this route server able to reach every address it is asked about.
pub async fn set_reach_all(&self, reach: bool) {
*self.reach_all.lock().await = reach;
}

/// Every address this route server has been asked to probe.
pub async fn probed(&self) -> Vec<String> {
self.probed.lock().await.clone()
}

pub async fn interface_routes(&self, interface: &str) -> Vec<String> {
let routes = self.routes.lock().await;
routes.get(interface).cloned().unwrap_or_default()
Expand Down Expand Up @@ -328,6 +349,13 @@ impl TunnelRouter for MockRouter {
Ok(())
}

async fn probe_address(&self, address: &str) -> OpResult<bool> {
self.probed.lock().await.push(address.to_string());
// A route server with nothing configured reaches nothing, which is the
// honest default and the one that makes a test say what it means.
Ok(*self.reach_all.lock().await)
}

async fn sync_tunnel_routes(&self, interface: &str, prefixes: &[String]) -> OpResult<()> {
let mut map = self.routes.lock().await;
map.insert(interface.to_string(), prefixes.to_vec());
Expand Down
41 changes: 41 additions & 0 deletions lnvps_api/src/provisioner/tunnel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -519,6 +519,36 @@ pub async fn node_guests(
});
}
}
// Any address a health gate is holding against this node. It is sent as a
// guest because that is the whole point: the gate proves the *guest* path —
// the route server's route and AllowedIPs, the node's bridge route, the
// packet filter's binding, proxy ARP for the range's gateway. A probe the
// node treated specially would prove a path no customer takes.
for ip in db.list_marketplace_probe_ips_for_node(node.id).await? {
let Some(probe) = db.get_marketplace_node_probe(node.id).await? else {
continue;
};
let Some(address) = host_address(Some(&ip)) else {
continue;
};
let Some(range_id) = probe.ip_range_id else {
continue;
};
let range = db.get_ip_range(range_id).await?;
let gateway = lnvps_api_common::parse_gateway(&range.gateway)
.map(|g| g.ip().to_string())
.unwrap_or(range.gateway);
out.push(GuestAddress {
address,
gateway,
// No MAC: nothing owns this address but the node itself, which
// holds it on the bridge rather than handing it to a guest. The
// filter admits it on address alone, which is the weaker check and
// visibly so.
mac: None,
});
}

out.sort_by(|a, b| a.address.cmp(&b.address));
Ok(out)
}
Expand Down Expand Up @@ -553,6 +583,17 @@ async fn guest_addresses(db: &Arc<dyn LNVpsDb>, tunnel: &Tunnel) -> Result<Vec<S
}
}
}

// Any address a health gate is holding against this node. The route server
// has to route it and admit it from this peer, exactly as it will for the
// customer who gets that address next — a probe the route server treated
// differently would prove a path nobody uses.
for ip in db.list_marketplace_probe_ips_for_node(node.id).await? {
if let Some(addr) = host_address(Some(&ip)) {
out.push(addr);
}
}

out.sort();
out.dedup();
Ok(out)
Expand Down
87 changes: 87 additions & 0 deletions lnvps_api/src/router/linux_ssh.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@ use crate::ssh_client::SshClient;
/// Connection details are encoded in the router config:
/// - `url`: `ssh://<user>@<host>[:<port>]/<interface>` (port defaults to 22)
/// - `token`: the SSH private key in PEM format
/// What a successful probe prints.
const PROBE_REACHED: &str = "lnvps-probe-reached";
/// ...and what an unsuccessful one prints, so a silent command is neither.
const PROBE_UNREACHED: &str = "lnvps-probe-unreached";

pub struct LinuxSshRouter {
host: String,
username: String,
Expand Down Expand Up @@ -695,6 +700,33 @@ impl TunnelRouter for LinuxSshRouter {
Ok(())
}

async fn probe_address(&self, address: &str) -> OpResult<bool> {
let parsed: std::net::IpAddr = match address.parse() {
Ok(ip) => ip,
// Not a transient failure: something upstream built a probe out of
// a value that is not an address, and retrying cannot fix it.
Err(e) => op_fatal!("{address} is not an address: {e}"),
};
// `ping` for v4 and v6 alike on any current iproute2/iputils; the flag
// is what selects the family, so a v6 address is never sent to a v4
// resolver.
let family = if parsed.is_ipv6() { "-6" } else { "-4" };
// Three attempts, two seconds each: a node that answers at all answers
// in milliseconds — it is one hop away — and a gate that waited longer
// would only be waiting on a node that is not going to answer.
// The verdict is echoed rather than taken from ping's exit code: to
// this transport a non-zero exit means "the command failed", which is
// the right contract for every other call and the wrong one here. "No
// reply" is the answer the gate asked for, not a fault, and it must
// stay distinguishable from a route server that cannot be reached at
// all — those two results need different people.
let command = format!(
"ping {family} -c 3 -W 2 -q {} >/dev/null 2>&1 && echo {PROBE_REACHED} || echo {PROBE_UNREACHED}",
shq(address)
);
Ok(self.exec_checked(&command).await?.contains(PROBE_REACHED))
}

async fn sync_tunnel_routes(&self, interface: &str, prefixes: &[String]) -> OpResult<()> {
// Both families have to be asked for separately: `ip route show` is
// IPv4 only, and a v6 guest prefix would otherwise look like a route
Expand Down Expand Up @@ -1410,6 +1442,61 @@ mod tests {
assert!(ran[2].contains("wgln1"), "{}", ran[2]);
}

/// The health gate asks the route server whether it can reach an address,
/// and both answers have to come back as answers. "No reply" is a verdict
/// about the node; a command that could not be run is a fault on the route
/// server, and the two need different people.
#[tokio::test]
async fn test_probe_address_reports_both_answers() {
// A shell that answers as the real one does: the command itself echoes
// the verdict, so the transport's "non-zero means failed" contract is
// left intact for every other call.
let log = std::sync::Arc::new(Mutex::new(Vec::new()));
let sink = log.clone();
let r = LinuxSshRouter::with_exec(std::sync::Arc::new(move |cmd: &str| {
sink.lock().unwrap().push(cmd.to_string());
Ok("lnvps-probe-reached\n".to_string())
}));

assert!(r.probe_address("203.0.113.9").await.unwrap());
let ran = log.lock().unwrap().clone();
assert!(ran[0].contains("ping -4 -c 3"), "{}", ran[0]);
assert!(ran[0].contains("203.0.113.9"), "{}", ran[0]);

// A v6 address is asked about with the v6 flag, or the command resolves
// the wrong family and answers about nothing.
let log6 = std::sync::Arc::new(Mutex::new(Vec::new()));
let sink6 = log6.clone();
let r6 = LinuxSshRouter::with_exec(std::sync::Arc::new(move |cmd: &str| {
sink6.lock().unwrap().push(cmd.to_string());
Ok(String::new())
}));
let _ = r6.probe_address("2001:db8::9").await;
assert!(log6.lock().unwrap()[0].contains("ping -6"), "{log6:?}");
}

/// A silent route server is not a reachable node. The verdict is echoed by
/// the command, so output that says nothing must not be read as success.
#[tokio::test]
async fn test_probe_address_does_not_assume_reachable() {
let r = LinuxSshRouter::with_exec(std::sync::Arc::new(|_cmd: &str| {
Ok("lnvps-probe-unreached".to_string())
}));
assert!(!r.probe_address("203.0.113.9").await.unwrap());

let quiet = LinuxSshRouter::with_exec(std::sync::Arc::new(|_cmd: &str| Ok(String::new())));
assert!(!quiet.probe_address("203.0.113.9").await.unwrap());
}

/// A value that is not an address is a fault upstream, not a node that
/// failed: retrying it forever would never make it an address.
#[tokio::test]
async fn test_probe_address_refuses_a_non_address() {
let r = LinuxSshRouter::with_exec(std::sync::Arc::new(|_cmd: &str| Ok(String::new())));
let err = r.probe_address("not-an-address").await.unwrap_err();
assert!(err.to_string().contains("not an address"), "{err}");
}

/// The link route the kernel installs for the interface's own /31 is the
/// link itself. Removing it to tidy a list would break the tunnel.
#[tokio::test]
Expand Down
Loading
Loading