feat: add host self-enrollment workflow v0.1.7
All checks were successful
CI Pipeline / Rust Format Check (push) Successful in 5s
CI Pipeline / Clippy Lints (push) Successful in 53s
CI Pipeline / Rust Unit Tests (push) Successful in 1m11s
CI Pipeline / Security Audit (push) Successful in 4s
CI Pipeline / Frontend Lint & Type Check (push) Successful in 14s
CI Pipeline / Build .deb & Release (push) Has been skipped
All checks were successful
CI Pipeline / Rust Format Check (push) Successful in 5s
CI Pipeline / Clippy Lints (push) Successful in 53s
CI Pipeline / Rust Unit Tests (push) Successful in 1m11s
CI Pipeline / Security Audit (push) Successful in 4s
CI Pipeline / Frontend Lint & Type Check (push) Successful in 14s
CI Pipeline / Build .deb & Release (push) Has been skipped
This commit is contained in:
1
Cargo.lock
generated
1
Cargo.lock
generated
@ -2501,6 +2501,7 @@ dependencies = [
|
||||
"base64",
|
||||
"chrono",
|
||||
"dashmap",
|
||||
"hex",
|
||||
"ipnet",
|
||||
"jsonwebtoken",
|
||||
"lettre",
|
||||
|
||||
@ -11,7 +11,7 @@ members = [
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.6"
|
||||
version = "0.1.7"
|
||||
edition = "2021"
|
||||
authors = ["Echo <echo@moon-dragon.us>"]
|
||||
license = "MIT"
|
||||
|
||||
31
SPEC.md
31
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
|
||||
|
||||
@ -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<PgPool, sqlx::Error> {
|
||||
@ -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<EnrollmentRequest, sqlx::Error> {
|
||||
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<Vec<EnrollmentRequest>, 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<u64, sqlx::Error> {
|
||||
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<i64, sqlx::Error> {
|
||||
|
||||
@ -123,6 +123,51 @@ pub struct HostSummary {
|
||||
pub registered_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 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<Utc>,
|
||||
pub expires_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// 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
|
||||
// ============================================================
|
||||
|
||||
@ -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 }
|
||||
|
||||
@ -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<Mutex<OidcCache>>,
|
||||
/// Internal certificate authority for mTLS client cert issuance.
|
||||
pub ca: Arc<pm_ca::CertAuthority>,
|
||||
/// IP-based rate limits for enrollment requests.
|
||||
pub enrollment_rate_limits: Arc<DashMap<IpAddr, Instant>>,
|
||||
/// Short-lived cache for approved enrollment PKI bundles.
|
||||
pub approved_enrollments: Arc<DashMap<String, PkiBundle>>,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
@ -91,6 +101,8 @@ async fn main() -> anyhow::Result<()> {
|
||||
let ws_tickets: Arc<DashMap<String, WsTicket>> = Arc::new(DashMap::new());
|
||||
let sso_sessions: Arc<DashMap<String, SsoSession>> = Arc::new(DashMap::new());
|
||||
let oidc_cache: Arc<Mutex<OidcCache>> = Arc::new(Mutex::new(OidcCache::default()));
|
||||
let enrollment_rate_limits: Arc<DashMap<IpAddr, Instant>> = Arc::new(DashMap::new());
|
||||
let approved_enrollments: Arc<DashMap<String, PkiBundle>> = 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)
|
||||
|
||||
318
crates/pm-web/src/routes/enrollment.rs
Normal file
318
crates/pm-web/src/routes/enrollment.rs
Normal file
@ -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<AppState> {
|
||||
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<AppState>,
|
||||
headers: HeaderMap,
|
||||
ConnectInfo(addr): ConnectInfo<SocketAddr>,
|
||||
Json(payload): Json<CreateEnrollmentRequest>,
|
||||
) -> Result<Response, (StatusCode, Json<serde_json::Value>)> {
|
||||
// 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::<IpAddr>().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<AppState>,
|
||||
Path(token): Path<String>,
|
||||
) -> Result<Json<EnrollmentStatusResponse>, (StatusCode, Json<serde_json::Value>)> {
|
||||
// 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<AppState> {
|
||||
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<AppState>,
|
||||
auth: AuthUser,
|
||||
) -> Result<Json<Vec<EnrollmentRequest>>, (StatusCode, Json<serde_json::Value>)> {
|
||||
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<AppState>,
|
||||
Path(id): Path<uuid::Uuid>,
|
||||
auth: AuthUser,
|
||||
) -> Result<StatusCode, (StatusCode, Json<serde_json::Value>)> {
|
||||
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<AppState>,
|
||||
Path(id): Path<uuid::Uuid>,
|
||||
auth: AuthUser,
|
||||
) -> Result<StatusCode, (StatusCode, Json<serde_json::Value>)> {
|
||||
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" })),
|
||||
)
|
||||
})
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
11
debian/changelog
vendored
11
debian/changelog
vendored
@ -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 <echo@moon-dragon.us> 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
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "patch-manager-ui",
|
||||
"private": true,
|
||||
"version": "0.1.5",
|
||||
"version": "0.1.7",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@ -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<string, unknown>
|
||||
polling_token: string
|
||||
created_at: string
|
||||
expires_at: string
|
||||
}
|
||||
|
||||
export const enrollmentApi = {
|
||||
listPending: (): Promise<EnrollmentRequest[]> =>
|
||||
apiClient.get<EnrollmentRequest[]>('/admin/enrollments').then(r => r.data),
|
||||
|
||||
approve: (id: string): Promise<void> =>
|
||||
apiClient.post(`/admin/enrollments/${id}/approve`).then(() => {}),
|
||||
|
||||
deny: (id: string): Promise<void> =>
|
||||
apiClient.delete(`/admin/enrollments/${id}/deny`).then(() => {}),
|
||||
}
|
||||
|
||||
@ -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<Host | null>(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<EnrollmentRequest[]>([])
|
||||
const [pendingCount, setPendingCount] = useState(0)
|
||||
const [denyTarget, setDenyTarget] = useState<EnrollmentRequest | null>(null)
|
||||
const [actionLoading, setActionLoading] = useState<string | null>(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() {
|
||||
<Container maxWidth="xl" sx={{ mt: 3 }}>
|
||||
<Toolbar disableGutters sx={{ mb: 2 }}>
|
||||
<Typography variant="h5" fontWeight={700} sx={{ flexGrow: 1 }}>Hosts</Typography>
|
||||
<Tooltip title="Show pending enrollments">
|
||||
<Button
|
||||
variant={showPending ? "contained" : "outlined"}
|
||||
color="warning"
|
||||
startIcon={<PendingIcon />}
|
||||
onClick={() => setShowPending(s => !s)}
|
||||
sx={{ mr: 1 }}
|
||||
endIcon={pendingCount > 0 ? <Chip label={pendingCount} size="small" color="warning" variant="filled" sx={{ ml: 0.5 }} /> : undefined}
|
||||
>
|
||||
Pending
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<TextField size="small" placeholder="Search..." value={search}
|
||||
onChange={e => setSearch(e.target.value)} sx={{ mr: 2 }} />
|
||||
<Tooltip title="Refresh"><IconButton onClick={load}><RefreshIcon /></IconButton></Tooltip>
|
||||
<Tooltip title="Refresh"><IconButton onClick={() => { load(); loadPending() }}><RefreshIcon /></IconButton></Tooltip>
|
||||
{canWrite && <Button variant="contained" startIcon={<AddIcon />} onClick={() => navigate('/hosts/new')} sx={{ ml: 1 }}>Add Host</Button>}
|
||||
</Toolbar>
|
||||
{loading ? <Box display="flex" justifyContent="center" mt="4"><CircularProgress /></Box> : (
|
||||
@ -104,53 +187,90 @@ export default function HostsPage() {
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{filtered.map(h => (
|
||||
<TableRow key={h.id} hover sx={{ cursor: 'pointer' }}
|
||||
onClick={() => navigate(`/hosts/${h.id}`)}>
|
||||
<TableCell>{h.fqdn}</TableCell>
|
||||
<TableCell>{h.display_name}</TableCell>
|
||||
<TableCell>{h.ip_address}</TableCell>
|
||||
<TableCell>{h.os_name ?? h.os_family ?? '—'}</TableCell>
|
||||
<TableCell>
|
||||
<Chip size="small" label={h.health_status} color={statusColor(h.health_status)} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{h.health_check_status === 'all_healthy' ? (
|
||||
<Tooltip title="All checks healthy"><CheckCircleIcon color="success" fontSize="small" /></Tooltip>
|
||||
) : h.health_check_status === 'some_unhealthy' ? (
|
||||
<Tooltip title="Some checks unhealthy"><CancelIcon color="error" fontSize="small" /></Tooltip>
|
||||
) : (
|
||||
<Tooltip title="No checks configured"><RemoveIcon color="disabled" fontSize="small" /></Tooltip>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>{h.agent_version ?? '—'}</TableCell>
|
||||
{canWrite && <TableCell onClick={e => e.stopPropagation()}>
|
||||
<Tooltip title="Request refresh">
|
||||
<IconButton size="small" color="primary"
|
||||
disabled={refreshing === h.id}
|
||||
onClick={(e) => handleRefresh(e, h.id)}>
|
||||
{refreshing === h.id
|
||||
? <CircularProgress size={16} />
|
||||
: <RefreshIcon fontSize="small" />}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Delete"><IconButton size="small" color="error" onClick={(e) => { e.stopPropagation(); setDeleteTarget(h) }}>
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton></Tooltip>
|
||||
</TableCell>}
|
||||
</TableRow>
|
||||
))}
|
||||
{showPending ? (
|
||||
pendingEnrollments.map(req => (
|
||||
<TableRow key={req.id} hover sx={{ backgroundColor: '#fff8e1' }}>
|
||||
<TableCell>
|
||||
<Box display="flex" alignItems="center" gap={1}>
|
||||
<GppMaybeIcon color="warning" fontSize="small" />
|
||||
{req.fqdn}
|
||||
</Box>
|
||||
</TableCell>
|
||||
<TableCell>{req.fqdn}</TableCell>
|
||||
<TableCell>{req.ip_address}</TableCell>
|
||||
<TableCell>{(req.os_details['name'] as string) ?? 'Unknown'}</TableCell>
|
||||
<TableCell><Chip size="small" label="pending" color="warning" /></TableCell>
|
||||
<TableCell></TableCell>
|
||||
<TableCell>—</TableCell>
|
||||
{canWrite && <TableCell onClick={e => e.stopPropagation()}>
|
||||
<Tooltip title="Approve">
|
||||
<IconButton size="small" color="success"
|
||||
disabled={actionLoading === req.id}
|
||||
onClick={(e) => { e.stopPropagation(); handleApprove(req) }}>
|
||||
{actionLoading === req.id ? <CircularProgress size={16} /> : <CheckCircleOutlineIcon fontSize="small" />}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Deny">
|
||||
<IconButton size="small" color="error"
|
||||
disabled={actionLoading === req.id}
|
||||
onClick={(e) => { e.stopPropagation(); setDenyTarget(req) }}>
|
||||
<CancelIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</TableCell>}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
filtered.map(h => (
|
||||
<TableRow key={h.id} hover sx={{ cursor: 'pointer' }}
|
||||
onClick={() => navigate(`/hosts/${h.id}`)}>
|
||||
<TableCell>{h.fqdn}</TableCell>
|
||||
<TableCell>{h.display_name}</TableCell>
|
||||
<TableCell>{h.ip_address}</TableCell>
|
||||
<TableCell>{h.os_name ?? h.os_family ?? '—'}</TableCell>
|
||||
<TableCell>
|
||||
<Chip size="small" label={h.health_status} color={statusColor(h.health_status)} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{h.health_check_status === 'all_healthy' ? (
|
||||
<Tooltip title="All checks healthy"><CheckCircleIcon color="success" fontSize="small" /></Tooltip>
|
||||
) : h.health_check_status === 'some_unhealthy' ? (
|
||||
<Tooltip title="Some checks unhealthy"><CancelIcon color="error" fontSize="small" /></Tooltip>
|
||||
) : (
|
||||
<Tooltip title="No checks configured"><RemoveIcon color="disabled" fontSize="small" /></Tooltip>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>{h.agent_version ?? '—'}</TableCell>
|
||||
{canWrite && <TableCell onClick={e => e.stopPropagation()}>
|
||||
<Tooltip title="Request refresh">
|
||||
<IconButton size="small" color="primary"
|
||||
disabled={refreshing === h.id}
|
||||
onClick={(e) => handleRefresh(e, h.id)}>
|
||||
{refreshing === h.id
|
||||
? <CircularProgress size={16} />
|
||||
: <RefreshIcon fontSize="small" />}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Delete"><IconButton size="small" color="error" onClick={(e) => { e.stopPropagation(); setDeleteTarget(h) }}>
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton></Tooltip>
|
||||
</TableCell>}
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<TablePagination
|
||||
component="div"
|
||||
count={total}
|
||||
page={page}
|
||||
onPageChange={handleChangePage}
|
||||
rowsPerPage={rowsPerPage}
|
||||
onRowsPerPageChange={handleChangeRowsPerPage}
|
||||
rowsPerPageOptions={[10, 25, 50, 100]}
|
||||
/>
|
||||
{!showPending && (
|
||||
<TablePagination
|
||||
component="div"
|
||||
count={total}
|
||||
page={page}
|
||||
onPageChange={handleChangePage}
|
||||
rowsPerPage={rowsPerPage}
|
||||
onRowsPerPageChange={handleChangeRowsPerPage}
|
||||
rowsPerPageOptions={[10, 25, 50, 100]}
|
||||
/>
|
||||
)}
|
||||
</TableContainer>
|
||||
)}
|
||||
|
||||
@ -164,6 +284,52 @@ export default function HostsPage() {
|
||||
<Button onClick={handleDelete} color="error" variant="contained">Delete</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* ── Deny Confirmation Dialog ─────────────────────────────────── */}
|
||||
<Dialog open={denyTarget !== null} onClose={() => setDenyTarget(null)}>
|
||||
<DialogTitle>Confirm Deny</DialogTitle>
|
||||
<DialogContent>
|
||||
Are you sure you want to deny the enrollment for “{denyTarget?.fqdn}”? This action cannot be undone.
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setDenyTarget(null)}>Cancel</Button>
|
||||
<Button onClick={handleDeny} color="error" variant="contained" disabled={actionLoading === denyTarget?.id}>
|
||||
{actionLoading === denyTarget?.id ? <CircularProgress size={20} /> : 'Deny'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* ── Conflict Modal ───────────────────────────────────────────── */}
|
||||
<Dialog open={conflictModal !== null} onClose={() => setConflictModal(null)}>
|
||||
<DialogTitle sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<WarningAmberIcon color="warning" /> Host Collision Detected
|
||||
</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
<Typography variant="body2" color="text.secondary" gutterBottom>
|
||||
Approving “{conflictModal?.request.fqdn}” conflicts with an existing host:
|
||||
</Typography>
|
||||
<Paper variant="outlined" sx={{ p: 2, mt: 1, mb: 2 }}>
|
||||
<Typography variant="subtitle2">Existing Host</Typography>
|
||||
<Typography>FQDN: {conflictModal?.existingHost.fqdn}</Typography>
|
||||
<Typography>IP: {conflictModal?.existingHost.ip_address}</Typography>
|
||||
<Typography>ID: {conflictModal?.existingHost.id}</Typography>
|
||||
</Paper>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Options:
|
||||
</Typography>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => handleConflictResolve('cancel')}>Cancel</Button>
|
||||
<Button
|
||||
onClick={() => handleConflictResolve('overwrite')}
|
||||
color="error"
|
||||
variant="contained"
|
||||
>
|
||||
Overwrite Existing Host
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
<Snackbar open={snackbar.open} autoHideDuration={4000} onClose={() => setSnackbar(s => ({ ...s, open: false }))}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}>
|
||||
<Alert severity={snackbar.severity} onClose={() => setSnackbar(s => ({ ...s, open: false }))}
|
||||
|
||||
@ -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<string, unknown>
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
16
migrations/016_enrollment_requests.sql
Normal file
16
migrations/016_enrollment_requests.sql
Normal file
@ -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);
|
||||
@ -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
|
||||
|
||||
Reference in New Issue
Block a user