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
4 changes: 4 additions & 0 deletions API_CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).

### Added

- **Import existing host VMs (admin)** — new admin endpoints to adopt VMs that exist on a host but aren't tracked in the database (issue #166).
- `GET /api/admin/v1/hosts/{id}/vms/unmanaged` lists VMs present on the host with no matching database record (returns host vmid, mapped database id, name, CPU/memory/disk specs, storage, MAC, running state). The admin service dispatches a discovery job to the worker and reads the reply over a temporary Redis pub/sub channel.
- `POST /api/admin/v1/hosts/{id}/vms/import` (`{ host_vm_id, user_id, reason? }`) imports one VM: it is assigned to `user_id` and billed via the region's **custom pricing** (required — import fails if the region has none), capturing the VM's current CPU/memory/disk specs into a custom template. Currently supports Proxmox hosts. Returns a `job_id`.

- **Per-request OAuth return URL** — `GET /api/v1/oauth/{provider}/login` now accepts an optional `?redirect=<url>` to override the configured `success-redirect` for that login only (e.g. `http://localhost:3000/oauth/complete` in dev). The URL is validated against a new `allowed-redirects` allowlist (`localhost` host always allowed; `success-redirect` always implicitly allowed) and rejected with `400` otherwise, then round-tripped through the signed `state`. See the OAuth login flow in API_DOCUMENTATION.md for details and a `config.yaml` example.

- **2026-07-18** - Passwordless WebAuthn / passkey login
Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

19 changes: 18 additions & 1 deletion lnvps_api/src/host/dummy_host.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ use crate::host::{
use async_trait::async_trait;
use chrono::Utc;
use lnvps_api_common::retry::OpResult;
use lnvps_api_common::{GB, PB, TB, VmRunningState, VmRunningStates, op_fatal};
use lnvps_api_common::{GB, HostVmSpec, PB, TB, VmRunningState, VmRunningStates, op_fatal};
use lnvps_db::{Vm, VmOsImage};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
Expand Down Expand Up @@ -152,10 +152,27 @@ impl DummyVmHost {
let _ = std::fs::write(STATE_FILE, json);
}
}

/// Set the list of host VMs reported by [`list_host_vms`].
///
/// Backed by a process-wide registry so the value survives the fresh
/// [`DummyVmHost`] instances that `get_host_client` constructs. Intended for
/// exercising VM import/discovery flows.
pub async fn set_host_vms(vms: Vec<HostVmSpec>) {
*DUMMY_HOST_VMS.lock().await = vms;
}
}

/// Process-wide registry of "host" VMs reported by [`DummyVmHost::list_host_vms`].
static DUMMY_HOST_VMS: LazyLock<Arc<Mutex<Vec<HostVmSpec>>>> =
LazyLock::new(|| Arc::new(Mutex::new(Vec::new())));

#[async_trait]
impl VmHostClient for DummyVmHost {
async fn list_host_vms(&self) -> OpResult<Vec<HostVmSpec>> {
Ok(DUMMY_HOST_VMS.lock().await.clone())
}

async fn get_info(&self) -> OpResult<VmHostInfo> {
Ok(VmHostInfo {
cpu: 100,
Expand Down
13 changes: 13 additions & 0 deletions lnvps_api/src/host/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ use crate::settings::ProvisionerConfig;
use anyhow::{Result, anyhow, bail};
use async_trait::async_trait;
use futures::future::join_all;
use lnvps_api_common::HostVmSpec;
use lnvps_api_common::VmRunningState;
use lnvps_api_common::retry::OpResult;
use lnvps_db::{
Expand Down Expand Up @@ -30,6 +31,18 @@ pub struct TerminalStream {
pub trait VmHostClient: Send + Sync {
async fn get_info(&self) -> OpResult<VmHostInfo>;

/// List all VMs present on the host, described in host-native terms.
///
/// Used for discovering/importing VMs that exist on the host but aren't
/// tracked in the database. Defaults to unsupported for hosts that don't
/// implement discovery.
async fn list_host_vms(&self) -> OpResult<Vec<HostVmSpec>> {
use lnvps_api_common::retry::OpError;
Err(OpError::Fatal(anyhow!(
"VM discovery is not supported on this host type"
)))
}

/// Download OS image to the host
async fn download_os_image(&self, image: &VmOsImage) -> OpResult<()>;

Expand Down
119 changes: 119 additions & 0 deletions lnvps_api/src/host/proxmox.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ use anyhow::Result;
use async_trait::async_trait;
use chrono::Utc;
use ipnetwork::IpNetwork;
use lnvps_api_common::HostVmSpec;
use lnvps_api_common::JsonApi;
use lnvps_api_common::retry::{OpError, OpResult, Pipeline, RetryPolicy};
use lnvps_api_common::{VmRunningState, VmRunningStates, op_fatal, parse_gateway};
Expand Down Expand Up @@ -1279,6 +1280,51 @@ impl VmHostClient for ProxmoxClient {
}
}

async fn list_host_vms(&self) -> OpResult<Vec<HostVmSpec>> {
let vms = self.list_vms(&self.node).await?;
let mut out = Vec::with_capacity(vms.len());
for vm in vms {
// Map to the LNVPS db id (vmid = db_id + 100). VMs with vmid < 100
// fall outside the managed range and can't be imported.
let mapped_vm_id = if vm.vm_id >= 100 {
let id: ProxmoxVmId = vm.vm_id.into();
Some(id.inner())
} else {
None
};

// Pull the live config for MAC + backing storage; tolerate failures
// so a single unreadable VM doesn't abort discovery.
let (mac_address, disk_storage) =
match self.get_vm_config(&self.node, vm.vm_id.into()).await {
Ok(cfg) => (
cfg.config.net.as_deref().and_then(parse_mac_from_net),
cfg.config
.scsi_0
.as_deref()
.and_then(parse_storage_from_disk),
),
Err(e) => {
warn!("Failed to read config for vm {}: {}", vm.vm_id, e);
(None, None)
}
};

out.push(HostVmSpec {
host_vm_id: vm.vm_id as i64,
mapped_vm_id,
name: vm.name.clone(),
cpu: vm.cpus.unwrap_or(0),
memory: vm.max_mem.unwrap_or(0),
disk_size: vm.max_disk.unwrap_or(0),
disk_storage,
mac_address,
running: matches!(vm.status, VmStatus::Running),
});
}
Ok(out)
}

async fn download_os_image(&self, image: &VmOsImage) -> OpResult<()> {
let iso_storage = self.get_iso_storage(&self.node).await?;
let files = self.list_storage_files(&self.node, &iso_storage).await?;
Expand Down Expand Up @@ -1973,6 +2019,39 @@ impl ProxmoxClient {
#[derive(Debug, Copy, Clone, Default)]
pub struct ProxmoxVmId(u64);

impl ProxmoxVmId {
/// The underlying LNVPS database id (host vmid minus the +100 offset).
pub fn inner(&self) -> u64 {
self.0
}
}

/// Extract the MAC address from a Proxmox `netN` config string, e.g.
/// `virtio=BC:24:11:00:11:22,bridge=vmbr0,firewall=1` -> `BC:24:11:00:11:22`.
fn parse_mac_from_net(net: &str) -> Option<String> {
net.split(',').find_map(|kv| {
let (k, v) = kv.split_once('=')?;
// The NIC model key (virtio/e1000/...) holds the MAC as its value.
if v.split(':').count() == 6 && k != "bridge" {
Some(v.to_string())
} else {
None
}
})
}

/// Extract the storage pool from a Proxmox disk config string, e.g.
/// `local-lvm:vm-1566-disk-0,size=32G` -> `local-lvm`.
fn parse_storage_from_disk(disk: &str) -> Option<String> {
let first = disk.split(',').next()?;
let (storage, _) = first.split_once(':')?;
if storage.is_empty() {
None
} else {
Some(storage.to_string())
}
}

impl From<ProxmoxVmId> for i32 {
fn from(val: ProxmoxVmId) -> Self {
val.0 as i32 + 100
Expand Down Expand Up @@ -2865,6 +2944,46 @@ mod tests {
assert!(!json.contains("proto"), "json: {json}");
}

#[test]
fn test_parse_mac_from_net() {
assert_eq!(
parse_mac_from_net("virtio=BC:24:11:00:11:22,bridge=vmbr0,firewall=1").as_deref(),
Some("BC:24:11:00:11:22")
);
assert_eq!(
parse_mac_from_net("e1000=00:15:5D:01:02:03,bridge=vmbr1").as_deref(),
Some("00:15:5D:01:02:03")
);
// No MAC present
assert_eq!(parse_mac_from_net("bridge=vmbr0,firewall=1"), None);
assert_eq!(parse_mac_from_net(""), None);
}

#[test]
fn test_parse_storage_from_disk() {
assert_eq!(
parse_storage_from_disk("local-lvm:vm-1566-disk-0,size=32G").as_deref(),
Some("local-lvm")
);
assert_eq!(
parse_storage_from_disk("ceph:vm-100-disk-0").as_deref(),
Some("ceph")
);
// No storage separator
assert_eq!(parse_storage_from_disk("none"), None);
assert_eq!(parse_storage_from_disk(""), None);
}

#[test]
fn test_proxmox_vm_id_inner_maps_to_db_id() {
// Host vmid 1566 -> db id 1466
let id: ProxmoxVmId = 1566i32.into();
assert_eq!(id.inner(), 1466);
// Round trip back to host vmid
let host_id: i32 = id.into();
assert_eq!(host_id, 1566);
}

#[test]
fn test_to_pve_firewall_rule_reject_action() {
let rule = lnvps_db::VmFirewallRule {
Expand Down
Loading
Loading