From da3dffd81f5b587651ec6f473e7f3c785b4bb591 Mon Sep 17 00:00:00 2001 From: Echo Date: Sat, 16 May 2026 16:58:00 +0000 Subject: [PATCH] feat: add host self-enrollment workflow v0.1.7 --- Cargo.lock | 1 + Cargo.toml | 2 +- SPEC.md | 31 +++ crates/pm-core/src/db.rs | 49 ++++ crates/pm-core/src/models.rs | 45 ++++ crates/pm-web/Cargo.toml | 1 + crates/pm-web/src/main.rs | 47 +++- crates/pm-web/src/routes/enrollment.rs | 318 +++++++++++++++++++++++++ crates/pm-web/src/routes/mod.rs | 1 + crates/pm-worker/src/main.rs | 34 ++- debian/changelog | 11 + frontend/package.json | 2 +- frontend/src/api/client.ts | 23 ++ frontend/src/pages/HostsPage.tsx | 266 +++++++++++++++++---- frontend/src/types/index.ts | 20 ++ migrations/016_enrollment_requests.sql | 16 ++ tasks/todo.md | 29 +++ 17 files changed, 841 insertions(+), 55 deletions(-) create mode 100644 crates/pm-web/src/routes/enrollment.rs create mode 100644 migrations/016_enrollment_requests.sql diff --git a/Cargo.lock b/Cargo.lock index 7da162d..d363391 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2501,6 +2501,7 @@ dependencies = [ "base64", "chrono", "dashmap", + "hex", "ipnet", "jsonwebtoken", "lettre", diff --git a/Cargo.toml b/Cargo.toml index aff63a2..8e2e891 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,7 +11,7 @@ members = [ ] [workspace.package] -version = "0.1.6" +version = "0.1.7" edition = "2021" authors = ["Echo "] license = "MIT" diff --git a/SPEC.md b/SPEC.md index 42acf1e..43c5c59 100644 --- a/SPEC.md +++ b/SPEC.md @@ -124,6 +124,37 @@ Management plane web application communicating with Linux Patch API agents on ea - WebSocket streaming for real-time job status from agents - Base path: `/api/v1/`, Port: 12443, TLS 1.3 only +## Host Self-Enrollment + +**1. Database Architecture** +- **Table:** A new `enrollment_requests` table to isolate unverified data from the active `hosts` table. +- **Schema Fields:** `id`, `machine_id` (from `/etc/machine-id`), `fqdn`, `ip_address`, `os_details`, `polling_token` (hashed), `created_at`, `expires_at`. + +**2. REST API Contract (Client-Facing)** +- `POST /api/v1/enroll`: + - **Payload:** `{ machine_id, fqdn, ip_address, os_details }` + - **Response:** Returns a temporary `polling_token`. +- `GET /api/v1/enroll/status/{token}`: + - **Pending:** HTTP 202. + - **Approved:** HTTP 200 containing the PKI bundle (`ca.crt`, `server.crt`, `server.key`). + - **Denied/Expired:** HTTP 404 or 403. + +**3. REST API Contract (Admin-Facing)** +- `GET /api/v1/admin/enrollments`: Lists the pending queue. +- `POST /api/v1/admin/enrollments/{id}/approve`: Generates client PKI, moves record to `hosts` table. +- `DELETE /api/v1/admin/enrollments/{id}/deny`: Purges the request. + +**4. Security & Lifecycle Guardrails** +- **Rate Limiting:** Strict IP-based rate limits on the initial `POST` endpoint to prevent DoS. +- **Auto-Purge:** A background task to delete unapproved pending requests older than 24 hours. +- **PKI Handoff:** The manager (`pm-ca`) acts as the Certificate Authority and generates the server auth certificate to maintain parity with the existing trusted deployment model. + +**5. User Interface (UI)** +- **Visibility:** Pending hosts integrated into the main Hosts view. +- **Indicators:** Queue counter/visual badge on the interface, with pending rows highlighted. +- **Filtering:** Dedicated filter to toggle the enrollment queue. +- **Conflict Resolution:** Interactive "merge/overwrite" prompt if approval detects an `fqdn` or `ip_address` collision with the active `hosts` table. + ## Certificate Management - Internal CA managed by Patch Manager, installed on the same host diff --git a/crates/pm-core/src/db.rs b/crates/pm-core/src/db.rs index a9311bc..e58d5c3 100644 --- a/crates/pm-core/src/db.rs +++ b/crates/pm-core/src/db.rs @@ -1,6 +1,8 @@ use crate::config::DatabaseConfig; +use crate::models::{CreateEnrollmentRequest, EnrollmentRequest}; use sqlx::postgres::{PgPool, PgPoolOptions}; use std::time::Duration; +use uuid::Uuid; /// Initialize and return a PostgreSQL connection pool. pub async fn init_pool(cfg: &DatabaseConfig) -> Result { @@ -56,6 +58,53 @@ pub async fn run_migrations(pool: &PgPool) -> Result<(), sqlx::migrate::MigrateE result } +// ============================================================ +// Enrollment Requests +// ============================================================ + +pub async fn create_enrollment_request( + pool: &PgPool, + req: CreateEnrollmentRequest, + token_hash: String, +) -> Result { + sqlx::query_as::< + _, + EnrollmentRequest, + >( + r#" + INSERT INTO enrollment_requests (machine_id, fqdn, ip_address, os_details, polling_token) + VALUES ($1, $2, $3, $4, $5) + RETURNING id, machine_id, fqdn, ip_address, os_details, polling_token, created_at, expires_at + "#, + ) + .bind(req.machine_id) + .bind(req.fqdn) + .bind(req.ip_address) + .bind(req.os_details) + .bind(token_hash) + .fetch_one(pool) + .await +} + +pub async fn list_enrollment_requests( + pool: &PgPool, +) -> Result, sqlx::Error> { + sqlx::query_as::<_, EnrollmentRequest>( + "SELECT id, machine_id, fqdn, ip_address, os_details, polling_token, created_at, expires_at FROM enrollment_requests ORDER BY created_at DESC", + ) + .fetch_all(pool) + .await +} + +pub async fn delete_enrollment_request(pool: &PgPool, id: Uuid) -> Result { + let result = sqlx::query("DELETE FROM enrollment_requests WHERE id = $1") + .bind(id) + .execute(pool) + .await?; + + Ok(result.rows_affected()) +} + /// Check that the database schema is at the expected version. /// Used by the worker to wait until migrations have been applied. pub async fn check_schema_version(pool: &PgPool) -> Result { diff --git a/crates/pm-core/src/models.rs b/crates/pm-core/src/models.rs index af5437e..5ac4eab 100644 --- a/crates/pm-core/src/models.rs +++ b/crates/pm-core/src/models.rs @@ -123,6 +123,51 @@ pub struct HostSummary { pub registered_at: DateTime, } +// ============================================================ +// Host Enrollment +// ============================================================ + +#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] +pub struct EnrollmentRequest { + pub id: Uuid, + pub machine_id: String, + pub fqdn: String, + pub ip_address: String, + pub os_details: serde_json::Value, + pub polling_token: String, + pub created_at: DateTime, + pub expires_at: DateTime, +} + +/// Payload for initial host enrollment request. +#[derive(Debug, Deserialize, Serialize)] +pub struct CreateEnrollmentRequest { + pub machine_id: String, + pub fqdn: String, + pub ip_address: String, + pub os_details: serde_json::Value, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "status", rename_all = "lowercase")] +pub enum EnrollmentStatusResponse { + Pending, + Approved { + ca_crt: String, + server_crt: String, + server_key: String, + }, + Denied, + NotFound, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PkiBundle { + pub ca_crt: String, + pub server_crt: String, + pub server_key: String, +} + // ============================================================ // Health Checks // ============================================================ diff --git a/crates/pm-web/Cargo.toml b/crates/pm-web/Cargo.toml index 3e8ec2a..c5bbd3a 100644 --- a/crates/pm-web/Cargo.toml +++ b/crates/pm-web/Cargo.toml @@ -36,6 +36,7 @@ dashmap = { version = "6" } reqwest = { workspace = true } lettre = { version = "0.11", default-features = false, features = ["tokio1-rustls-tls", "smtp-transport", "builder"] } rand = { workspace = true } +hex = "0.4" base64 = { workspace = true } sha2 = { workspace = true } jsonwebtoken = { workspace = true } diff --git a/crates/pm-web/src/main.rs b/crates/pm-web/src/main.rs index b0bd044..92c7f9f 100644 --- a/crates/pm-web/src/main.rs +++ b/crates/pm-web/src/main.rs @@ -9,11 +9,17 @@ use pm_auth::{ jwt, rbac::{require_auth, AuthConfig}, }; -use pm_core::{config::AppConfig, db, logging, request_id::request_id_middleware}; +use pm_core::{ + config::AppConfig, db, logging, models::PkiBundle, request_id::request_id_middleware, +}; use routes::sso::{OidcCache, SsoSession}; use routes::ws::WsTicket; use serde_json::{json, Value}; -use std::{net::SocketAddr, sync::Arc, time::Duration}; +use std::{ + net::{IpAddr, SocketAddr}, + sync::Arc, + time::{Duration, Instant}, +}; use tokio::sync::Mutex; use tower_http::{ services::{ServeDir, ServeFile}, @@ -35,6 +41,10 @@ pub struct AppState { pub oidc_cache: Arc>, /// Internal certificate authority for mTLS client cert issuance. pub ca: Arc, + /// IP-based rate limits for enrollment requests. + pub enrollment_rate_limits: Arc>, + /// Short-lived cache for approved enrollment PKI bundles. + pub approved_enrollments: Arc>, } #[tokio::main] @@ -91,6 +101,8 @@ async fn main() -> anyhow::Result<()> { let ws_tickets: Arc> = Arc::new(DashMap::new()); let sso_sessions: Arc> = Arc::new(DashMap::new()); let oidc_cache: Arc> = Arc::new(Mutex::new(OidcCache::default())); + let enrollment_rate_limits: Arc> = Arc::new(DashMap::new()); + let approved_enrollments: Arc> = Arc::new(DashMap::new()); // Background task: purge expired WS tickets every 30 seconds. { @@ -129,6 +141,31 @@ async fn main() -> anyhow::Result<()> { }); } + // Background task: purge expired enrollment rate limits every 5 minutes. + { + let limits = enrollment_rate_limits.clone(); + tokio::spawn(async move { + let mut interval = tokio::time::interval(Duration::from_secs(300)); + loop { + interval.tick().await; + let now = Instant::now(); + limits.retain(|_, v| now.duration_since(*v) < Duration::from_secs(3600)); + } + }); + } + + // Background task: purge approved enrollment PKI bundles every 10 minutes. + { + let approved = approved_enrollments.clone(); + tokio::spawn(async move { + let mut interval = tokio::time::interval(Duration::from_secs(600)); + loop { + interval.tick().await; + approved.clear(); + } + }); + } + let state = AppState { db: pool, config: Arc::new(config.clone()), @@ -137,6 +174,8 @@ async fn main() -> anyhow::Result<()> { ws_tickets, sso_sessions, ca: Arc::new(ca), + enrollment_rate_limits, + approved_enrollments, oidc_cache, }; @@ -223,6 +262,8 @@ pub fn build_router(state: AppState) -> Router { ) // Settings (admin-only) .nest("/settings", routes::settings::router()) + // Admin enrollment routes (JWT protected, Admin role enforced) + .nest("/admin", routes::enrollment::admin_router()) // Apply auth middleware to all the above .route_layer(middleware::from_fn(move |req, next| { let auth_config = auth_config.clone(); @@ -233,6 +274,8 @@ pub fn build_router(state: AppState) -> Router { .route("/status/health", get(health_handler)) // Public auth routes (no JWT needed) .nest("/api/v1/auth", routes::auth::public_router()) + // Public enrollment endpoints (rate-limited, no JWT) + .nest("/api/v1", routes::enrollment::router()) // Public SSO routes (no JWT needed) .nest("/api/v1/auth/sso", routes::sso::public_router()) // Public Azure SSO routes (no JWT needed) diff --git a/crates/pm-web/src/routes/enrollment.rs b/crates/pm-web/src/routes/enrollment.rs new file mode 100644 index 0000000..54267a2 --- /dev/null +++ b/crates/pm-web/src/routes/enrollment.rs @@ -0,0 +1,318 @@ +use crate::AppState; +use axum::{ + extract::{ConnectInfo, Path, State}, + http::{HeaderMap, StatusCode}, + response::{IntoResponse, Response}, + routing::{delete, get, post}, + Json, Router, +}; +use chrono::Utc; +use pm_auth::AuthUser; +use pm_core::{ + db, + models::{ + CreateEnrollmentRequest, EnrollmentRequest, EnrollmentStatusResponse, Host, PkiBundle, + }, +}; +use rand::{distributions::Alphanumeric, Rng}; +use serde::Serialize; +use std::net::{IpAddr, SocketAddr}; +use std::time::Instant; + +#[derive(Debug, Clone, Serialize)] +pub struct HostConflict { + pub existing_host: Host, + pub message: String, +} + +/// Define public enrollment routes. +pub fn router() -> Router { + Router::new() + .route("/enroll", post(enroll_host)) + .route("/enroll/status/:token", get(enroll_status)) +} + +/// POST /api/v1/enroll +/// Initiates host self-enrollment. +async fn enroll_host( + State(state): State, + headers: HeaderMap, + ConnectInfo(addr): ConnectInfo, + Json(payload): Json, +) -> Result)> { + // 1. IP-based Rate Limiting + // Prefer real IP from headers if behind proxy (e.g., X-Forwarded-For), else use SocketAddr + let ip = headers + .get("x-forwarded-for") + .and_then(|h| h.to_str().ok()) + .and_then(|h| h.split(',').next()) + .and_then(|h| h.trim().parse::().ok()) + .unwrap_or_else(|| addr.ip()); + + { + let mut rate_limits = state + .enrollment_rate_limits + .entry(ip) + .or_insert(Instant::now() - std::time::Duration::from_secs(3600)); + let last_request = rate_limits.value(); + if last_request.elapsed().as_secs() < 60 { + // 1 request per minute per IP + return Err(( + StatusCode::TOO_MANY_REQUESTS, + Json(serde_json::json!({ "error": "Rate limit exceeded. Try again in a minute." })), + )); + } + *rate_limits = Instant::now(); + } + + // 2. Generate secure random polling token + let polling_token: String = rand::thread_rng() + .sample_iter(&Alphanumeric) + .take(64) + .map(char::from) + .collect(); + + // For database storage, we'll hash the token (spec says hashed) + // Using a simple SHA256 or similar for the hash storage + use sha2::{Digest, Sha256}; + let mut hasher = Sha256::new(); + hasher.update(polling_token.as_bytes()); + let token_hash = hex::encode(hasher.finalize()); + + // 3. Store in DB + db::create_enrollment_request(&state.db, payload, token_hash) + .await + .map_err(|e| { + tracing::error!(error = %e, "Failed to create enrollment request"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": "Database error" })), + ) + })?; + + // 4. Return the raw token to the client + Ok(( + StatusCode::ACCEPTED, + Json(serde_json::json!({ "polling_token": polling_token })), + ) + .into_response()) +} + +/// GET /api/v1/enroll/status/:token +/// Returns status of enrollment (pending/approved/denied/not_found). +async fn enroll_status( + State(state): State, + Path(token): Path, +) -> Result, (StatusCode, Json)> { + // Hash the provided token to match DB + use sha2::{Digest, Sha256}; + let mut hasher = Sha256::new(); + hasher.update(token.as_bytes()); + let token_hash = hex::encode(hasher.finalize()); + + // 1. Check enrollment_requests table + let requests = db::list_enrollment_requests(&state.db).await.map_err(|_| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": "Database error" })), + ) + })?; + + if let Some(req) = requests.into_iter().find(|r| r.polling_token == token_hash) { + if req.expires_at < Utc::now() { + return Ok(Json(EnrollmentStatusResponse::NotFound)); + } + return Ok(Json(EnrollmentStatusResponse::Pending)); + } + + // 2. If not in pending, check if it was recently approved. + if let Some(pki) = state.approved_enrollments.get(&token_hash) { + return Ok(Json(EnrollmentStatusResponse::Approved { + ca_crt: pki.ca_crt.clone(), + server_crt: pki.server_crt.clone(), + server_key: pki.server_key.clone(), + })); + } + + Ok(Json(EnrollmentStatusResponse::NotFound)) +} + +/// Define admin enrollment routes. +pub fn admin_router() -> Router { + Router::new() + .route("/enrollments", get(list_admin_enrollments)) + .route("/enrollments/:id/approve", post(approve_enrollment)) + .route("/enrollments/:id/deny", delete(deny_enrollment)) +} + +/// GET /api/v1/admin/enrollments +/// Lists all pending enrollment requests. +async fn list_admin_enrollments( + State(state): State, + auth: AuthUser, +) -> Result>, (StatusCode, Json)> { + if !auth.role.is_admin() { + return Err(( + StatusCode::FORBIDDEN, + Json( + serde_json::json!({ "error": { "code": "forbidden", "message": "Admin role required" } }), + ), + )); + } + + db::list_enrollment_requests(&state.db) + .await + .map(|requests| Json(requests)) + .map_err(|e| { + tracing::error!(error = %e, "Failed to list enrollment requests"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": "Database error" })), + ) + }) +} + +/// POST /api/v1/admin/enrollments/{id}/approve +/// Approves a pending enrollment request, generates PKI, and moves to hosts table. +async fn approve_enrollment( + State(state): State, + Path(id): Path, + auth: AuthUser, +) -> Result)> { + if !auth.role.is_admin() { + return Err(( + StatusCode::FORBIDDEN, + Json( + serde_json::json!({ "error": { "code": "forbidden", "message": "Admin role required" } }), + ), + )); + } + + // Fetch the enrollment request + let mut requests = db::list_enrollment_requests(&state.db).await.map_err(|e| { + tracing::error!(error = %e, "Failed to list enrollment requests for approval"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": "Database error" })), + ) + })?; + + let enrollment_request = match requests.iter().position(|r| r.id == id) { + Some(idx) => requests.remove(idx), + None => return Ok(StatusCode::NOT_FOUND), + }; + + // Check for FQDN/IP collision in hosts table + if let Some(existing_host) = sqlx::query_as::<_, Host>( + "SELECT id, fqdn, 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 fqdn = $1 OR ip_address = $2" + ) + .bind(&enrollment_request.fqdn) + .bind(&enrollment_request.ip_address.to_string()) + .fetch_optional(&state.db) + .await + .map_err(|e| { + tracing::error!(error = %e, "Failed to check for host collision"); + (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": "Database error" }))) + })? { + return Err(( + StatusCode::CONFLICT, + Json(serde_json::json!({ "error": "Host collision detected", "conflict": HostConflict { existing_host, message: "FQDN or IP already exists".to_string() } })) + )); + } + + // Generate PKI bundle using CA + let issued = state + .ca + .issue_client_cert( + enrollment_request.id, + &enrollment_request.fqdn, + &enrollment_request.ip_address.to_string(), + &state.db, + ) + .await + .map_err(|e| { + tracing::error!(error = %e, "Failed to issue client certificate"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": "Certificate generation failed" })), + ) + })?; + + // Move to hosts table + let os_name = enrollment_request + .os_details + .get("name") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + sqlx::query( + r#" + INSERT INTO hosts (id, fqdn, ip_address, os_name, registered_at, updated_at, machine_id) + VALUES ($1, $2, $3, $4, NOW(), NOW(), $5) + "#, + ) + .bind(enrollment_request.id) + .bind(&enrollment_request.fqdn) + .bind(&enrollment_request.ip_address.to_string()) + .bind(os_name) + .bind(enrollment_request.machine_id) + .execute(&state.db) + .await + .map_err(|e| { + tracing::error!(error = %e, "Failed to insert host after approval"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": "Database error" })), + ) + })?; + + // Delete from enrollment_requests table + db::delete_enrollment_request(&state.db, id) + .await + .map_err(|e| { + tracing::error!(error = %e, "Failed to delete enrollment request after approval"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": "Database error" })), + ) + })?; + + // Store PKI bundle in cache for client retrieval + let pki = PkiBundle { + ca_crt: issued.ca_root_pem, + server_crt: issued.server_cert_pem, + server_key: issued.server_key_pem, + }; + state + .approved_enrollments + .insert(enrollment_request.polling_token.clone(), pki); + + Ok(StatusCode::OK) +} + +/// DELETE /api/v1/admin/enrollments/{id}/deny +/// Denies and purges a pending enrollment request. +async fn deny_enrollment( + State(state): State, + Path(id): Path, + auth: AuthUser, +) -> Result)> { + if !auth.role.is_admin() { + return Err(( + StatusCode::FORBIDDEN, + Json( + serde_json::json!({ "error": { "code": "forbidden", "message": "Admin role required" } }), + ), + )); + } + + db::delete_enrollment_request(&state.db, id) + .await + .map(|_| StatusCode::NO_CONTENT) + .map_err(|e| { + tracing::error!(error = %e, "Failed to deny enrollment request"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": "Database error" })), + ) + }) +} diff --git a/crates/pm-web/src/routes/mod.rs b/crates/pm-web/src/routes/mod.rs index d99e465..e9a1c82 100644 --- a/crates/pm-web/src/routes/mod.rs +++ b/crates/pm-web/src/routes/mod.rs @@ -2,6 +2,7 @@ pub mod auth; pub mod ca; pub mod discovery; +pub mod enrollment; pub mod groups; pub mod health_checks; pub mod hosts; diff --git a/crates/pm-worker/src/main.rs b/crates/pm-worker/src/main.rs index b3a9978..ca5fcc8 100644 --- a/crates/pm-worker/src/main.rs +++ b/crates/pm-worker/src/main.rs @@ -14,6 +14,7 @@ mod patch_poller; mod refresh_listener; mod ws_relay; +use chrono::Utc; use pm_core::{config::AppConfig, db, logging}; use sqlx::PgPool; use std::{sync::Arc, time::Duration}; @@ -31,7 +32,7 @@ use ws_relay::run_ws_relay; /// Minimum number of applied migrations the worker requires before /// accepting work. Prevents the worker from running against a schema /// that hasn't been migrated yet. -const REQUIRED_MIGRATION_COUNT: i64 = 8; +const REQUIRED_MIGRATION_COUNT: i64 = 16; /// How long to wait between schema-version checks before giving up. const SCHEMA_CHECK_TIMEOUT: Duration = Duration::from_secs(120); @@ -94,6 +95,9 @@ async fn main() -> anyhow::Result<()> { // Health check poller — runs configured service/HTTP health checks let health_check_handle = tokio::spawn(run_health_check_poller(pool.clone(), config.clone())); + // Enrollment cleanup task (runs every hour) + let enrollment_cleanup_handle = tokio::spawn(run_enrollment_cleanup_task(pool.clone())); + tracing::info!("Worker tasks started"); // Wait for all tasks (they run indefinitely) @@ -106,6 +110,8 @@ async fn main() -> anyhow::Result<()> { maint_sched_handle, ws_relay_handle, audit_verifier_handle, + health_check_handle, + enrollment_cleanup_handle, ); Ok(()) @@ -174,3 +180,29 @@ async fn run_heartbeat(pool: PgPool, interval_secs: u64) { } } } + +/// Periodically deletes expired enrollment requests. +async fn run_enrollment_cleanup_task(pool: PgPool) { + let mut interval = tokio::time::interval(Duration::from_secs(3600)); // Every hour + interval.tick().await; // Initial tick to run immediately if needed + + loop { + interval.tick().await; + let now = Utc::now(); + match sqlx::query("DELETE FROM enrollment_requests WHERE expires_at < $1") + .bind(now) + .execute(&pool) + .await + { + Ok(result) => { + if result.rows_affected() > 0 { + tracing::info!( + removed = result.rows_affected(), + "Purged expired enrollment requests" + ); + } + }, + Err(e) => tracing::error!(error = %e, "Failed to purge expired enrollment requests"), + } + } +} diff --git a/debian/changelog b/debian/changelog index 68cf2d2..a39a3e5 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,14 @@ +linux-patch-manager (0.1.7-1) noble; urgency=medium + + * Host Self-Enrollment: Added REST API and UI for automated agent enrollment + * Database: Added enrollment_requests table and migration 016 + * Security: Implemented IP-based rate limiting on public enrollment endpoints + * Backend: Added background worker to purge expired enrollment requests (24h) + * Frontend: Integrated pending enrollment queue with conflict resolution modal + * Specs: Updated SPEC.md for manager and linux_patch_api self-enrollment workflows + + -- Echo Fri, 16 May 2026 11:44:08 -0500 + linux-patch-manager (0.1.6-1) noble; urgency=medium * Phase 4: Exhaustive analysis fixes, security hardening, and code quality improvements diff --git a/frontend/package.json b/frontend/package.json index 5276cd2..f42ded0 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "patch-manager-ui", "private": true, - "version": "0.1.5", + "version": "0.1.7", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 30e7c54..c92071d 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -372,3 +372,26 @@ export const usersApi = { adminDisableMfa: (id: string) => apiClient.delete(`/users/${id}/mfa`), disableMfa: (password: string) => apiClient.delete('/auth/mfa', { data: { password } }), } + +// ── Enrollment API (Admin) ──────────────────────────────────────────────── +export interface EnrollmentRequest { + id: string + machine_id: string + fqdn: string + ip_address: string + os_details: Record + polling_token: string + created_at: string + expires_at: string +} + +export const enrollmentApi = { + listPending: (): Promise => + apiClient.get('/admin/enrollments').then(r => r.data), + + approve: (id: string): Promise => + apiClient.post(`/admin/enrollments/${id}/approve`).then(() => {}), + + deny: (id: string): Promise => + apiClient.delete(`/admin/enrollments/${id}/deny`).then(() => {}), +} diff --git a/frontend/src/pages/HostsPage.tsx b/frontend/src/pages/HostsPage.tsx index 11f2a1c..e43a4ae 100644 --- a/frontend/src/pages/HostsPage.tsx +++ b/frontend/src/pages/HostsPage.tsx @@ -5,11 +5,11 @@ import { Table, TableBody, TableCell, TableContainer, TableHead, TableRow, TablePagination, TextField, Toolbar, Tooltip, Typography, } from '@mui/material' -import { Add as AddIcon, Refresh as RefreshIcon, Delete as DeleteIcon, CheckCircle as CheckCircleIcon, Cancel as CancelIcon, Remove as RemoveIcon } from '@mui/icons-material' +import { Add as AddIcon, Refresh as RefreshIcon, Delete as DeleteIcon, CheckCircle as CheckCircleIcon, Cancel as CancelIcon, Remove as RemoveIcon, Pending as PendingIcon, GppMaybe as GppMaybeIcon, CheckCircleOutline as CheckCircleOutlineIcon, WarningAmber as WarningAmberIcon } from '@mui/icons-material' import { useNavigate } from 'react-router-dom' -import { apiClient, hostsApi } from '../api/client' +import { apiClient, hostsApi, enrollmentApi } from '../api/client' import { useAuthStore } from '../store/authStore' -import type { Host, HostHealthStatus } from '../types' +import type { Host, HostHealthStatus, EnrollmentRequest, EnrollmentConflictResponse } from '../types' const statusColor = (s: HostHealthStatus) => s === 'healthy' ? 'success' : s === 'degraded' ? 'warning' : s === 'unreachable' ? 'error' : 'default' @@ -28,6 +28,14 @@ export default function HostsPage() { const [deleteTarget, setDeleteTarget] = useState(null) const [snackbar, setSnackbar] = useState<{ open: boolean; message: string; severity: 'success' | 'error' }>({ open: false, message: '', severity: 'success' }) + // ── Enrollment state ──────────────────────────────────────────────────── + const [showPending, setShowPending] = useState(false) + const [pendingEnrollments, setPendingEnrollments] = useState([]) + const [pendingCount, setPendingCount] = useState(0) + const [denyTarget, setDenyTarget] = useState(null) + const [actionLoading, setActionLoading] = useState(null) + const [conflictModal, setConflictModal] = useState<{ request: EnrollmentRequest; existingHost: Host } | null>(null) + const load = useCallback(async () => { setLoading(true) try { @@ -39,6 +47,14 @@ export default function HostsPage() { finally { setLoading(false) } }, [page, rowsPerPage]) + const loadPending = useCallback(async () => { + try { + const data = await enrollmentApi.listPending() + setPendingEnrollments(data) + setPendingCount(data.length) + } catch { /* handled by interceptor */ } + }, []) + const handleRefresh = async (e: React.MouseEvent, hostId: string) => { e.stopPropagation() setRefreshing(hostId) @@ -63,7 +79,62 @@ export default function HostsPage() { } } - useEffect(() => { load() }, [load]) + // ── Enrollment action handlers ────────────────────────────────────────── + const handleApprove = async (req: EnrollmentRequest) => { + setActionLoading(req.id) + try { + await enrollmentApi.approve(req.id) + setSnackbar({ open: true, message: `Host "${req.fqdn}" approved`, severity: 'success' }) + load(); loadPending() + } catch (err: unknown) { + const errObj = err as { response?: { status?: number; data?: EnrollmentConflictResponse }; message?: string } + const status = errObj?.response?.status + if (status === 409 && errObj.response?.data) { + const conflictData = errObj.response.data as EnrollmentConflictResponse + setConflictModal({ request: req, existingHost: conflictData.conflict.existing_host }) + } else { + setSnackbar({ open: true, message: `Failed to approve "${req.fqdn}": ${errObj?.message || 'Unknown error'}`, severity: 'error' }) + } + } finally { + setActionLoading(null) + } + } + + const handleDeny = async () => { + if (!denyTarget) return + setActionLoading(denyTarget.id) + try { + await enrollmentApi.deny(denyTarget.id) + setSnackbar({ open: true, message: `Enrollment "${denyTarget.fqdn}" denied`, severity: 'success' }) + loadPending() + } catch { + setSnackbar({ open: true, message: `Failed to deny enrollment`, severity: 'error' }) + } finally { + setActionLoading(null) + setDenyTarget(null) + } + } + + const handleConflictResolve = async (action: 'overwrite' | 'cancel') => { + if (!conflictModal) return + if (action === 'cancel') { + setConflictModal(null) + return + } + // For overwrite: delete the existing host first, then approve + try { + await hostsApi.delete(conflictModal.existingHost.id) + await enrollmentApi.approve(conflictModal.request.id) + setSnackbar({ open: true, message: `Overwrote existing host and approved "${conflictModal.request.fqdn}"`, severity: 'success' }) + load(); loadPending() + } catch { + setSnackbar({ open: true, message: `Failed to resolve conflict`, severity: 'error' }) + } finally { + setConflictModal(null) + } + } + + useEffect(() => { load(); loadPending() }, [load, loadPending]) const filtered = hosts.filter(h => h.fqdn.toLowerCase().includes(search.toLowerCase()) || @@ -83,9 +154,21 @@ export default function HostsPage() { Hosts + + + setSearch(e.target.value)} sx={{ mr: 2 }} /> - + { load(); loadPending() }}> {canWrite && } {loading ? : ( @@ -104,53 +187,90 @@ export default function HostsPage() { - {filtered.map(h => ( - navigate(`/hosts/${h.id}`)}> - {h.fqdn} - {h.display_name} - {h.ip_address} - {h.os_name ?? h.os_family ?? '—'} - - - - - {h.health_check_status === 'all_healthy' ? ( - - ) : h.health_check_status === 'some_unhealthy' ? ( - - ) : ( - - )} - - {h.agent_version ?? '—'} - {canWrite && e.stopPropagation()}> - - handleRefresh(e, h.id)}> - {refreshing === h.id - ? - : } - - - { e.stopPropagation(); setDeleteTarget(h) }}> - - - } - - ))} + {showPending ? ( + pendingEnrollments.map(req => ( + + + + + {req.fqdn} + + + {req.fqdn} + {req.ip_address} + {(req.os_details['name'] as string) ?? 'Unknown'} + + + + {canWrite && e.stopPropagation()}> + + { e.stopPropagation(); handleApprove(req) }}> + {actionLoading === req.id ? : } + + + + { e.stopPropagation(); setDenyTarget(req) }}> + + + + } + + )) + ) : ( + filtered.map(h => ( + navigate(`/hosts/${h.id}`)}> + {h.fqdn} + {h.display_name} + {h.ip_address} + {h.os_name ?? h.os_family ?? '—'} + + + + + {h.health_check_status === 'all_healthy' ? ( + + ) : h.health_check_status === 'some_unhealthy' ? ( + + ) : ( + + )} + + {h.agent_version ?? '—'} + {canWrite && e.stopPropagation()}> + + handleRefresh(e, h.id)}> + {refreshing === h.id + ? + : } + + + { e.stopPropagation(); setDeleteTarget(h) }}> + + + } + + )) + )} - + {!showPending && ( + + )} )} @@ -164,6 +284,52 @@ export default function HostsPage() { + + {/* ── Deny Confirmation Dialog ─────────────────────────────────── */} + setDenyTarget(null)}> + Confirm Deny + + Are you sure you want to deny the enrollment for “{denyTarget?.fqdn}”? This action cannot be undone. + + + + + + + + {/* ── Conflict Modal ───────────────────────────────────────────── */} + setConflictModal(null)}> + + Host Collision Detected + + + + Approving “{conflictModal?.request.fqdn}” conflicts with an existing host: + + + Existing Host + FQDN: {conflictModal?.existingHost.fqdn} + IP: {conflictModal?.existingHost.ip_address} + ID: {conflictModal?.existingHost.id} + + + Options: + + + + + + + + setSnackbar(s => ({ ...s, open: false }))} anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}> setSnackbar(s => ({ ...s, open: false }))} diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 6d4d603..8462e47 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -376,3 +376,23 @@ export interface UpdateHealthCheckRequest { basic_auth_pass?: string target_host_id?: string | null } + +// ── Enrollment (Self-Enrollment) ───────────────────────────────────────── +export interface EnrollmentRequest { + id: string + machine_id: string + fqdn: string + ip_address: string + os_details: Record + polling_token: string // hashed token stored in DB + created_at: string + expires_at: string +} + +export interface EnrollmentConflictResponse { + error: string + conflict: { + existing_host: Host + message: string + } +} diff --git a/migrations/016_enrollment_requests.sql b/migrations/016_enrollment_requests.sql new file mode 100644 index 0000000..d0e22bd --- /dev/null +++ b/migrations/016_enrollment_requests.sql @@ -0,0 +1,16 @@ +-- Migration: 016_enrollment_requests +-- Description: Create enrollment_requests table for host self-enrollment + +CREATE TABLE enrollment_requests ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + machine_id TEXT NOT NULL UNIQUE, + fqdn TEXT NOT NULL, + ip_address INET NOT NULL, + os_details JSONB NOT NULL DEFAULT '{}', + polling_token TEXT NOT NULL UNIQUE, -- Hashed polling token + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + expires_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + INTERVAL '24 hours' +); + +CREATE INDEX idx_enrollment_requests_token ON enrollment_requests (polling_token); +CREATE INDEX idx_enrollment_requests_expires ON enrollment_requests (expires_at); diff --git a/tasks/todo.md b/tasks/todo.md index 1190c39..f2a20ea 100644 --- a/tasks/todo.md +++ b/tasks/todo.md @@ -43,3 +43,32 @@ - **JWKS caching prevents rate-limiting** — Azure AD JWKS endpoint should be cached with TTL (1 hour) to avoid fetching on every SSO login. - **tokio::sync::Mutex over std::sync::Mutex** — Axum handlers must be Send; std::sync::MutexGuard is not Send across await points. - **DashMap session cleanup** — In-memory session stores (DashMap) need periodic cleanup tasks to prevent memory leaks. Pattern: tokio::spawn with interval + retain with time-based cutoff. + +# Host Self-Enrollment Implementation Plan + +## Phases + +### Phase 1: Database & Core Models +- [x] 1a: Create SQL migration for `enrollment_requests` table +- [x] 1b: Define Rust data models for `EnrollmentRequest` in `pm-core` +- [x] 1c: Add DB interaction methods (insert, list, delete) in `pm-core` + +### Phase 2: Client-Facing API (pm-web) +- [ ] 2a: Implement `POST /api/v1/enroll` to accept payloads and generate `polling_token` +- [ ] 2b: Implement `GET /api/v1/enroll/status/{token}` to return pending/approved (PKI) statuses +- [ ] 2c: Implement IP-based rate limiting for the `/enroll` endpoint + +### Phase 3: Admin-Facing API (pm-web) +- [x] 3a: Implement `GET /api/v1/admin/enrollments` to list pending queue +- [x] 3b: Implement `POST /api/v1/admin/enrollments/{id}/approve` (generate PKI via `pm-ca`, migrate to `hosts` table) +- [x] 3c: Implement `DELETE /api/v1/admin/enrollments/{id}/deny` to purge request + +### Phase 4: Background Workers (pm-worker) +- [x] 4a: Create a scheduled task to purge `enrollment_requests` older than 24 hours + +### Phase 5: Frontend UI (pm-web/React) +- [x] 5a: Add enrollment API methods and types to frontend +- [x] 5b: Update `Hosts` view to include "Pending Enrollments" filter and visual badge +- [x] 5c: Render pending hosts in the table with highlight styling +- [x] 5d: Add Approve/Deny action buttons to pending host rows +- [x] 5e: Implement "merge/overwrite" interactive modal for `fqdn`/`ip_address` collisions on approval