Skip to content
Merged
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
60 changes: 59 additions & 1 deletion ADMIN_API_ENDPOINTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ Admin API request/response format reference for LLM consumption.
`"redhatenterprise"`
**IpRangeAllocationMode**: `"random"`, `"sequential"`, `"slaac_eui64"`
**NetworkAccessPolicyKind**: `"static_arp"`
**RouterKind**: `"mikrotik"`, `"ovh_additional_ip"`
**RouterKind**: `"mikrotik"`, `"ovh_additional_ip"`, `"linux_ssh"`
**AdminUserRole**: `"super_admin"`, `"admin"`, `"read_only"`
**AdminUserStatus**: `"active"`, `"suspended"`, `"deleted"`
**SubscriptionPaymentType**: `"purchase"`, `"renewal"`, `"upgrade"`
Expand Down Expand Up @@ -2320,6 +2320,64 @@ Required Permission: `router::delete`
Note: Routers that are used by access policies cannot be deleted. You must first remove the router from all access
policies before deleting it.

#### List Router Tunnels

```
GET /api/admin/v1/routers/{router_id}/tunnels
```

Required Permission: `router::view`

Returns the cached tunnel inventory discovered on the router (GRE/VXLAN/WireGuard). Each entry: `id`, `router_id`,
`name`, `kind` (`"gre"` | `"vxlan"` | `"wireguard"`), `local_addr`, `remote_addr`, `enabled`, `last_seen`. Refreshed
by a background sampler (~60s).

#### Get Tunnel Traffic History

```
GET /api/admin/v1/routers/{router_id}/tunnels/{tunnel_name}/traffic
```

Required Permission: `router::view`

Query parameters (optional): `from`, `to` (RFC3339 timestamps; default: last 24 hours).

Returns per-tunnel traffic samples: `tunnel_name`, `rx_bytes`, `tx_bytes`, `sampled_at`. Tunnel interface counters
are the canonical per-session traffic source — BGP sessions expose no byte counters.

#### List BGP Sessions

```
GET /api/admin/v1/routers/{router_id}/bgp/sessions
```

Required Permission: `router::view`

Returns cached BGP sessions: `id`, `router_id`, `name`, `peer_ip`, `peer_asn`, `local_asn`, `state`,
`prefixes_received`, `prefixes_sent`, `enabled`, `direction` (`"upstream"` | `"downstream"` | `"peer"` |
`"unknown"`), `last_seen`.

#### Toggle BGP Session

```
POST /api/admin/v1/routers/{router_id}/bgp/sessions/toggle
```

Required Permission: `router::update`

Body:

```json
{
"session_id": "string",
// Backend session id from the sessions listing (protocol name on BIRD, .id on Mikrotik)
"enabled": boolean
}
```

Returns a `JobResponse` (`{ "job_id": "string" }`). The enable/disable is applied asynchronously by the worker and
the session cache is refreshed afterwards.

### VM IP Assignment Management

VM IP assignments bind specific IP addresses from IP ranges to virtual machines. These endpoints provide comprehensive
Expand Down
8 changes: 8 additions & 0 deletions API_CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).

### Added

- **2026-06-21** - Route server management: BGP session and tunnel visibility/control (admin)
- New `RouterKind` value `linux_ssh` — a Linux router managed over SSH (BIRD/Pathvector routing, iproute2/WireGuard tunnels). Configure with `url = ssh://<user>@<host>[:<port>]/<interface>` and `token` = the SSH private key (PEM). Selectable via `kind` on `POST/PATCH /api/admin/v1/routers`.
- `GET /api/admin/v1/routers/{id}/tunnels` — list cached tunnels discovered on the router. Each `AdminRouterTunnel`: `id`, `router_id`, `name`, `kind` (`gre`|`vxlan`|`wireguard`), `local_addr`, `remote_addr`, `enabled`, `last_seen`. Requires `router::view`.
- `GET /api/admin/v1/routers/{id}/tunnels/{name}/traffic` — per-tunnel traffic history (`AdminRouterTunnelTraffic`: `tunnel_name`, `rx_bytes`, `tx_bytes`, `sampled_at`). Optional `from`/`to` RFC3339 query params (default: last 24h). Tunnel interface counters are the canonical per-session traffic source — BGP sessions have no byte counters. Requires `router::view`.
- `GET /api/admin/v1/routers/{id}/bgp/sessions` — list cached BGP sessions (`AdminRouterBgpSession`: `id`, `router_id`, `name`, `peer_ip`, `peer_asn`, `local_asn`, `state`, `prefixes_received`, `prefixes_sent`, `enabled`, `direction` (`upstream`|`downstream`|`peer`|`unknown`), `last_seen`). Requires `router::view`.
- `POST /api/admin/v1/routers/{id}/bgp/sessions/toggle` — enable/disable a BGP session. Body: `{ "session_id": string, "enabled": boolean }` (`session_id` is the backend id from the sessions listing — protocol name on BIRD, `.id` on Mikrotik). Returns a `JobResponse` (`{ "job_id": string }`); the action is applied asynchronously and the session cache refreshed. Requires `router::update`.
- Tunnel inventory, per-tunnel traffic, and BGP session state are refreshed by a background sampler (~60s). All router queries are bounded and safe on routers carrying a full DFZ table.

- **2026-06-18** - Subscription line items now expose a typed `resource` reference
- `SubscriptionLineItem` (public `GET /api/v1/subscriptions/{id}`) and `AdminSubscriptionLineItemInfo` (admin subscription + line-item endpoints) gain a `resource` field: a tagged union resolved server-side from the line item's subscription type. Shapes: `{ "type": "vps", "vm_id": number }`, `{ "type": "ip_range", "ip_range_subscription_id": number }`, or `null` when there is no linked resource.
- `AdminSubscriptionLineItemInfo` now also includes the `subscription_type` discriminant.
Expand Down
3 changes: 3 additions & 0 deletions lnvps_api/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ default = [
"nostr-nwc",
"nostr-domain",
"proxmox",
"linux-ssh",
"lnd",
"cloudflare",
"revolut",
Expand All @@ -24,6 +25,8 @@ default = [
]
openapi = ["dep:openapi-build-gen"]
mikrotik = []
# Linux router managed over SSH (shares the ssh2-based SshClient with proxmox)
linux-ssh = ["dep:ssh2"]
nostr-dm = ["dep:nostr-sdk", "nostr-sdk/nip44", "nostr-sdk/nip59"]
nostr-dvm = ["dep:nostr-sdk"]
nostr-domain = ["lnvps_db/nostr-domain", "dep:uuid"]
Expand Down
3 changes: 3 additions & 0 deletions lnvps_api/src/bin/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,9 @@ async fn main() -> Result<(), Error> {

tasks.push(worker.spawn_job_interval(WorkJob::CheckVms, Duration::from_secs(30)));
tasks.push(worker.spawn_job_interval(WorkJob::CheckSubscriptions, Duration::from_secs(30)));
// Sample router tunnel traffic + refresh BGP/tunnel state every 60s
tasks
.push(worker.spawn_job_interval(WorkJob::SampleRouterTraffic, Duration::from_secs(60)));
tasks.push(worker.spawn_handler_loop());

// check all nostr domains every 10 minutes for CNAME entries (enable/disable as needed)
Expand Down
2 changes: 1 addition & 1 deletion lnvps_api/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ pub mod payments;
pub mod provisioner;
pub mod router;
pub mod settings;
#[cfg(feature = "proxmox")]
#[cfg(any(feature = "proxmox", feature = "linux-ssh"))]
pub mod ssh_client;
pub mod subscription;
pub mod worker;
Expand Down
135 changes: 133 additions & 2 deletions lnvps_api/src/mocks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ use crate::host::dummy_host::DummyVmHost;
use crate::host::{
FullVmInfo, TerminalStream, TimeSeries, TimeSeriesData, VmHostClient, VmHostInfo,
};
use crate::router::{ArpEntry, Router};
use crate::router::{
ArpEntry, BgpPeer, BgpRoute, BgpRouter, BgpSession, Router, Tunnel, TunnelRouter, TunnelTraffic,
};

/// Type alias so tests can refer to the in-memory VM host as `MockVmHost`.
pub type MockVmHost = DummyVmHost;
Expand Down Expand Up @@ -44,6 +46,8 @@ use tokio::sync::Mutex;
#[derive(Debug, Clone)]
pub struct MockRouter {
arp: Arc<Mutex<HashMap<u64, ArpEntry>>>,
tunnels: Arc<Mutex<HashMap<String, Tunnel>>>,
sessions: Arc<Mutex<HashMap<String, BgpSession>>>,
}

impl Default for MockRouter {
Expand All @@ -56,16 +60,32 @@ impl MockRouter {
pub fn new() -> Self {
static LAZY_ARP: LazyLock<Arc<Mutex<HashMap<u64, ArpEntry>>>> =
LazyLock::new(|| Arc::new(Mutex::new(HashMap::new())));
static LAZY_TUNNELS: LazyLock<Arc<Mutex<HashMap<String, Tunnel>>>> =
LazyLock::new(|| Arc::new(Mutex::new(HashMap::new())));
static LAZY_SESSIONS: LazyLock<Arc<Mutex<HashMap<String, BgpSession>>>> =
LazyLock::new(|| Arc::new(Mutex::new(HashMap::new())));

Self {
arp: LAZY_ARP.clone(),
tunnels: LAZY_TUNNELS.clone(),
sessions: LAZY_SESSIONS.clone(),
}
}

/// Clear all ARP entries - useful for test isolation
/// Clear all ARP entries, tunnels and BGP sessions - useful for test isolation
pub async fn clear(&self) {
let mut arp = self.arp.lock().await;
arp.clear();
let mut tunnels = self.tunnels.lock().await;
tunnels.clear();
let mut sessions = self.sessions.lock().await;
sessions.clear();
}

/// Seed a BGP session for tests
pub async fn add_session(&self, session: BgpSession) {
let mut sessions = self.sessions.lock().await;
sessions.insert(session.id.clone(), session);
}
}

Expand Down Expand Up @@ -160,6 +180,117 @@ impl Router for MockRouter {
}
Ok(entry.clone())
}

fn tunnel(&self) -> Option<&dyn TunnelRouter> {
Some(self)
}

fn bgp(&self) -> Option<&dyn BgpRouter> {
Some(self)
}
}

#[async_trait]
impl TunnelRouter for MockRouter {
async fn list_tunnels(&self) -> OpResult<Vec<Tunnel>> {
let tunnels = self.tunnels.lock().await;
Ok(tunnels.values().cloned().collect())
}

async fn add_tunnel(&self, tunnel: &Tunnel) -> OpResult<Tunnel> {
let mut tunnels = self.tunnels.lock().await;
if tunnels.contains_key(&tunnel.name) {
return Err(OpError::Fatal(anyhow::anyhow!(
"Tunnel already exists: {}",
tunnel.name
)));
}
let stored = Tunnel {
id: Some(tunnel.name.clone()),
enabled: true,
..tunnel.clone()
};
tunnels.insert(tunnel.name.clone(), stored.clone());
Ok(stored)
}

async fn remove_tunnel(&self, id: &str) -> OpResult<()> {
let mut tunnels = self.tunnels.lock().await;
tunnels.remove(id);
Ok(())
}

async fn update_tunnel(&self, tunnel: &Tunnel) -> OpResult<Tunnel> {
let mut tunnels = self.tunnels.lock().await;
let stored = Tunnel {
id: Some(tunnel.name.clone()),
..tunnel.clone()
};
tunnels.insert(tunnel.name.clone(), stored.clone());
Ok(stored)
}

async fn tunnel_traffic(&self) -> OpResult<Vec<TunnelTraffic>> {
let tunnels = self.tunnels.lock().await;
Ok(tunnels
.values()
.map(|t| TunnelTraffic {
name: t.name.clone(),
rx_bytes: 0,
tx_bytes: 0,
})
.collect())
}
}

#[async_trait]
impl BgpRouter for MockRouter {
async fn list_sessions(&self) -> OpResult<Vec<BgpSession>> {
let sessions = self.sessions.lock().await;
Ok(sessions.values().cloned().collect())
}

async fn originated_routes(&self, candidates: &[String]) -> OpResult<Vec<BgpRoute>> {
let all = vec![BgpRoute {
prefix: "203.0.113.0/24".to_string(),
next_hop: None,
}];
if candidates.is_empty() {
Ok(all)
} else {
Ok(all
.into_iter()
.filter(|r| candidates.contains(&r.prefix))
.collect())
}
}

async fn default_route(&self) -> OpResult<Option<BgpRoute>> {
Ok(Some(BgpRoute {
prefix: "0.0.0.0/0".to_string(),
next_hop: Some("192.0.2.1".to_string()),
}))
}

async fn discover_peers(&self) -> OpResult<Vec<BgpPeer>> {
let sessions = self.sessions.lock().await;
Ok(sessions
.values()
.map(|s| BgpPeer {
peer_ip: s.peer_ip.clone(),
asn: s.peer_asn,
direction: s.direction,
})
.collect())
}

async fn set_session_enabled(&self, id: &str, enabled: bool) -> OpResult<()> {
let mut sessions = self.sessions.lock().await;
if let Some(s) = sessions.get_mut(id) {
s.enabled = enabled;
}
Ok(())
}
}

#[derive(Clone, Debug, Default)]
Expand Down
Loading
Loading