Private
Public Access
1
0

feat: add host editing endpoint and frontend UI
All checks were successful
CI Pipeline / Rust Format Check (push) Successful in 3s
CI Pipeline / Clippy Lints (push) Successful in 53s
CI Pipeline / Rust Unit Tests (push) Successful in 1m12s
CI Pipeline / Security Audit (push) Successful in 4s
CI Pipeline / Frontend Lint & Type Check (push) Successful in 14s
CI Pipeline / Build .deb & Release (push) Successful in 3m51s

This commit is contained in:
2026-05-18 21:52:00 +00:00
parent b3ae42215b
commit f70c5e53f9
26 changed files with 254 additions and 65 deletions

View File

@ -174,7 +174,7 @@ async fn probe_and_store(pool: sqlx::PgPool, scan_id: Uuid, ip: IpAddr, port: u1
/// Simple reverse DNS lookup.
fn dns_lookup_for_ip(ip: IpAddr) -> Option<String> {
use std::net::{SocketAddr, ToSocketAddrs};
let addr = SocketAddr::new(ip, 0);
let _addr = SocketAddr::new(ip, 0);
// Standard library doesn't have reverse lookup; use getaddrinfo via format
let host = format!("{ip}");
// Best-effort: try to resolve numeric address to hostname

View File

@ -1,6 +1,6 @@
use crate::AppState;
use axum::{
extract::{ConnectInfo, Path, State},
extract::{Path, State},
http::{HeaderMap, StatusCode},
response::{IntoResponse, Response},
routing::{delete, get, post},
@ -16,7 +16,7 @@ use pm_core::{
};
use rand::{distributions::Alphanumeric, Rng};
use serde::Serialize;
use std::net::{IpAddr, SocketAddr};
use std::net::IpAddr;
use std::time::Instant;
#[derive(Debug, Clone, Serialize)]
@ -167,7 +167,7 @@ async fn list_admin_enrollments(
db::list_enrollment_requests(&state.db)
.await
.map(|requests| Json(requests))
.map(Json)
.map_err(|e| {
tracing::error!(error = %e, "Failed to list enrollment requests");
(
@ -212,7 +212,7 @@ async fn approve_enrollment(
"SELECT id, fqdn, ip_address::text, display_name, os_family, os_name, arch, agent_version, health_status, last_health_at, last_patch_at, agent_port, notes, registered_at, updated_at FROM hosts WHERE fqdn = $1 OR ip_address = $2::inet"
)
.bind(&enrollment_request.fqdn)
.bind(&enrollment_request.ip_address.to_string())
.bind(enrollment_request.ip_address.to_string())
.fetch_optional(&state.db)
.await
.map_err(|e| {
@ -231,16 +231,21 @@ async fn approve_enrollment(
.get("name")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let display_name = enrollment_request
.hostname
.clone()
.unwrap_or_else(|| enrollment_request.fqdn.clone());
sqlx::query(
r#"
INSERT INTO hosts (id, fqdn, ip_address, os_name, registered_at, updated_at)
VALUES ($1, $2, $3::inet, $4, NOW(), NOW())
INSERT INTO hosts (id, fqdn, ip_address, os_name, display_name, registered_at, updated_at)
VALUES ($1, $2, $3::inet, $4, $5, NOW(), NOW())
"#,
)
.bind(enrollment_request.id)
.bind(&enrollment_request.fqdn)
.bind(&enrollment_request.ip_address.to_string())
.bind(enrollment_request.ip_address.to_string())
.bind(os_name)
.bind(&display_name)
.execute(&state.db)
.await
.map_err(|e| {

View File

@ -12,7 +12,7 @@ use axum::{
extract::{Path, State},
http::StatusCode,
response::Json,
routing::{delete, get, post, put},
routing::{get, post},
Router,
};
use pm_auth::rbac::AuthUser;

View File

@ -11,7 +11,7 @@ use axum::{
extract::{Path, State},
http::StatusCode,
response::Json,
routing::{delete, get, post, put},
routing::{get, post},
Router,
};
use pm_auth::rbac::AuthUser;
@ -24,7 +24,7 @@ use pm_core::{
},
};
use reqwest::tls::Version;
use serde::{Deserialize, Serialize};
use serde::Serialize;
use serde_json::{json, Value};
use std::path::PathBuf;
use uuid::Uuid;
@ -631,7 +631,6 @@ async fn update_health_check(
set_clauses.push(format!("basic_auth_pass_encrypted = ${}", param_idx));
param_idx += 1;
set_clauses.push(format!("basic_auth_pass_nonce = ${}", param_idx));
param_idx += 1;
}
if set_clauses.is_empty() {
@ -644,7 +643,7 @@ async fn update_health_check(
}
// Always update updated_at
set_clauses.push(format!("updated_at = NOW()"));
set_clauses.push("updated_at = NOW()".to_string());
// Use a simpler approach: query the current row, apply changes, update
// This avoids complex dynamic SQL binding issues
@ -945,7 +944,7 @@ async fn run_service_check(check: &HealthCheck, state: &AppState) -> CheckResult
}
CheckResult {
healthy: false,
detail: format!("Failed to parse agent response"),
detail: "Failed to parse agent response".to_string(),
latency_ms: Some(latency),
}
},

View File

@ -4,6 +4,7 @@
//! POST /api/v1/hosts — register new host (admin only)
//! GET /api/v1/hosts/{id} — get host detail
//! DELETE /api/v1/hosts/{id} — remove host (admin only)
//! PUT /api/v1/hosts/{id} — update host (write access)
//! GET /api/v1/hosts/{id}/groups — list groups for host
//! POST /api/v1/hosts/{id}/groups — assign host to group
//! DELETE /api/v1/hosts/{id}/groups/{group_id} — remove host from group
@ -19,7 +20,7 @@ use axum::{
use pm_auth::rbac::AuthUser;
use pm_core::{
audit::{log_event, AuditAction},
models::{CreateHostRequest, Group, HostSummary},
models::{CreateHostRequest, Group, HostSummary, UpdateHostRequest},
};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
@ -30,7 +31,7 @@ use crate::AppState;
pub fn router() -> Router<AppState> {
Router::new()
.route("/", get(list_hosts).post(register_host))
.route("/{id}", get(get_host).delete(remove_host))
.route("/{id}", get(get_host).put(update_host).delete(remove_host))
.route(
"/{id}/groups",
get(list_host_groups).post(add_host_to_group),
@ -42,6 +43,7 @@ pub fn router() -> Router<AppState> {
// ── Query params ─────────────────────────────────────────────────────────────
#[derive(Debug, Deserialize)]
#[allow(dead_code)]
pub struct HostListQuery {
pub group_id: Option<Uuid>,
pub health_status: Option<String>,
@ -398,6 +400,69 @@ async fn remove_host(
Ok(Json(json!({ "message": "Host removed" })))
}
// ── PUT /api/v1/hosts/:id ─────────────────────────────────────────────────────
async fn update_host(
State(state): State<AppState>,
auth: AuthUser,
Path(id): Path<Uuid>,
Json(req): Json<UpdateHostRequest>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
if !auth.role.can_write() {
return Err((
StatusCode::FORBIDDEN,
Json(json!({ "error": { "code": "forbidden", "message": "Write access required" } })),
));
}
// Update only fields that were provided; COALESCE preserves existing values.
let host = sqlx::query_scalar(
r#"
WITH updated AS (
UPDATE hosts SET
fqdn = COALESCE($1, fqdn),
ip_address = COALESCE($2::inet, ip_address),
display_name = COALESCE($3, display_name),
updated_at = NOW()
WHERE id = $4
RETURNING id
)
SELECT row_to_json(h) FROM (
SELECT id, fqdn, host(ip_address)::text AS ip_address, display_name,
os_family, os_name, arch, agent_version, health_status,
last_health_at, last_patch_at, agent_port, notes,
registered_at, updated_at
FROM hosts WHERE id = (SELECT id FROM updated)
) h
"#,
)
.bind(&req.fqdn)
.bind(&req.ip_address)
.bind(&req.display_name)
.bind(id)
.fetch_optional(&state.db)
.await
.map_err(|e| {
tracing::error!(error = %e, host_id = %id, "Failed to update host");
let msg = if e.to_string().contains("unique") {
"A host with this FQDN and IP already exists".to_string()
} else {
"Database error".to_string()
};
(
StatusCode::CONFLICT,
Json(json!({ "error": { "code": "conflict", "message": msg } })),
)
})?;
host.map(Json).ok_or_else(|| {
(
StatusCode::NOT_FOUND,
Json(json!({ "error": { "code": "not_found", "message": "Host not found" } })),
)
})
}
// ── GET /api/v1/hosts/:id/groups ──────────────────────────────────────────────
async fn list_host_groups(

View File

@ -438,7 +438,7 @@ async fn cancel_job(
// Only admin or the job creator may cancel.
if !auth.role.can_write() {
let is_creator = creator_id.map_or(false, |cid| cid == auth.user_id);
let is_creator = creator_id == Some(auth.user_id);
if !is_creator {
return Err(err(
StatusCode::FORBIDDEN,

View File

@ -115,6 +115,7 @@ pub struct OidcDiscoveryRequest {
}
#[derive(Debug, Serialize)]
#[allow(dead_code)]
pub struct OidcDiscoveryResult {
pub issuer: String,
pub authorization_endpoint: String,
@ -558,7 +559,7 @@ async fn update_settings(
// ============================================================
async fn discover_oidc(
State(state): State<AppState>,
State(_state): State<AppState>,
auth: AuthUser,
Json(req): Json<OidcDiscoveryRequest>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {

View File

@ -95,22 +95,13 @@ pub struct OidcDiscovery {
}
/// Cache for OIDC discovery documents and JWKS with TTL-based refresh.
#[derive(Default)]
pub struct OidcCache {
pub discovery: Option<OidcDiscovery>,
pub jwks: Option<serde_json::Value>,
pub jwks_fetched_at: Option<chrono::DateTime<Utc>>,
}
impl Default for OidcCache {
fn default() -> Self {
Self {
discovery: None,
jwks: None,
jwks_fetched_at: None,
}
}
}
/// JWKS cache TTL in seconds (1 hour).
const JWKS_CACHE_TTL_SECS: i64 = 3600;
/// Discovery cache TTL in seconds (1 hour).
@ -492,10 +483,11 @@ async fn sso_callback(
DbUserForSso {
id: existing.id,
username: existing.username.clone(),
display_name: name
.is_empty()
.then(|| existing.display_name.clone())
.unwrap_or(name),
display_name: if name.is_empty() {
existing.display_name.clone()
} else {
name
},
role: existing.role.clone(),
is_active: existing.is_active,
mfa_enabled: existing.mfa_enabled,

View File

@ -13,7 +13,7 @@ use axum::{
};
use chrono::{Duration, Utc};
use pm_auth::rbac::AuthUser;
use serde::{Deserialize, Serialize};
use serde::Deserialize;
use serde_json::{json, Value};
use sqlx::postgres::PgListener;
use ulid::Ulid;
@ -188,10 +188,8 @@ async fn handle_browser_ws(mut socket: WebSocket, db: sqlx::PgPool, ticket: WsTi
tracing::info!(user_id = %ticket.user_id, "Browser WS closed by client");
break;
}
Some(Ok(Message::Ping(data))) => {
if socket.send(Message::Pong(data)).await.is_err() {
break;
}
Some(Ok(Message::Ping(data))) if socket.send(Message::Pong(data.clone())).await.is_err() => {
break;
}
Some(Err(e)) => {
tracing::debug!(error = %e, user_id = %ticket.user_id, "Browser WS recv error");