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
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:
@ -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
|
||||
|
||||
@ -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| {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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),
|
||||
}
|
||||
},
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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>)> {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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");
|
||||
|
||||
Reference in New Issue
Block a user