Files
corrosion-admin-panel/backend/src/api/host.rs
Vantz Stockwell 500d92cbe3
All checks were successful
Test Asgard Runner / test (push) Successful in 3s
fix: Schema alignment and code corrections (COA 2)
- Replace owner_id → owner_user_id in all queries
- Replace auth_token → companion_agent_token in server_connections
- Replace l.active → (l.status = 'active') checks using ENUM
- Fix AppError → ApiError in all new API files
- Add missing imports (Path, PanelAdapter trait)
- Fix StoreConfig nullable type mismatches

Resolves 122 compilation errors. Only sqlx cache generation remains.

Phase 3: EXECUTE complete per V4_WORKFLOW
2026-02-15 18:23:33 -05:00

257 lines
8.2 KiB
Rust

use axum::{
extract::{Path, Request, State},
http::StatusCode,
middleware::{self, Next},
response::{IntoResponse, Response},
routing::{get, post},
Json, Router,
};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use uuid::Uuid;
use crate::{
models::error::ApiError,
services::host_provisioning::{HostProvisioningService, ProvisionedLicense},
AppState,
};
pub fn router() -> Router<Arc<AppState>> {
Router::new()
.route("/provision", post(provision_license))
.route("/licenses", get(list_host_licenses))
.route("/billing/:month", get(get_billing_report))
.layer(middleware::from_fn(host_auth_middleware))
}
/// Host API key authentication middleware
async fn host_auth_middleware(
State(state): State<Arc<AppState>>,
mut req: Request,
next: Next,
) -> Result<Response, ApiError> {
// Extract API key from Authorization header (Bearer token)
let auth_header = req
.headers()
.get("Authorization")
.and_then(|h| h.to_str().ok())
.ok_or_else(|| ApiError::Unauthorized("Missing Authorization header".to_string()))?;
if !auth_header.starts_with("Bearer ") {
return Err(ApiError::Unauthorized("Invalid Authorization format. Use: Bearer <api_key>".to_string()));
}
let api_key = &auth_header[7..]; // Skip "Bearer "
// Authenticate host
let service = HostProvisioningService::new(state.db.clone());
let host_id = service
.authenticate_host(api_key)
.await
.map_err(|_| ApiError::Unauthorized("Invalid or inactive API key".to_string()))?;
// Store host_id in request extensions for handlers to access
req.extensions_mut().insert(HostContext { host_id });
Ok(next.run(req).await)
}
/// Host context extracted from API key
#[derive(Clone)]
struct HostContext {
host_id: Uuid,
}
// ============================================================================
// BULK LICENSE PROVISIONING
// ============================================================================
#[derive(Deserialize)]
struct ProvisionRequest {
server_id: String, // Host's internal server identifier (e.g., "rust-nyc-01")
hostname: Option<String>, // Optional display name
customer_email: String, // End customer email
}
#[derive(Serialize)]
struct ProvisionResponse {
license_key: String,
companion_token: String,
plugin_download_url: String,
subdomain: String,
panel_url: String,
}
/// Provision a new license for a hosting customer (B2B bulk provisioning)
async fn provision_license(
State(state): State<Arc<AppState>>,
axum::extract::Extension(ctx): axum::extract::Extension<HostContext>,
Json(req): Json<ProvisionRequest>,
) -> Result<impl IntoResponse, ApiError> {
let service = HostProvisioningService::new(state.db.clone());
// Use hostname if provided, otherwise use server_id
let server_identifier = req.hostname.as_ref().unwrap_or(&req.server_id);
let provisioned = service
.provision_license(ctx.host_id, server_identifier, &req.customer_email)
.await?;
Ok(Json(ProvisionResponse {
license_key: provisioned.license_key.clone(),
companion_token: provisioned.companion_token,
plugin_download_url: provisioned.plugin_download_url,
subdomain: provisioned.subdomain.clone(),
panel_url: format!("https://panel.corrosionmgmt.com/login?license={}", provisioned.license_key),
}))
}
// ============================================================================
// HOST LICENSE MANAGEMENT
// ============================================================================
#[derive(Serialize)]
struct HostLicenseInfo {
license_key: String,
server_name: String,
customer_email: String,
subdomain: String,
active: bool,
last_seen_at: Option<chrono::DateTime<chrono::Utc>>,
provisioned_at: chrono::DateTime<chrono::Utc>,
}
/// List all licenses provisioned by this host
async fn list_host_licenses(
State(state): State<Arc<AppState>>,
axum::extract::Extension(ctx): axum::extract::Extension<HostContext>,
) -> Result<impl IntoResponse, ApiError> {
let licenses = sqlx::query!(
"SELECT
l.license_key,
l.server_name,
l.subdomain,
(l.status = 'active') as \"active!\",
hl.customer_email,
hl.last_seen_at,
hl.provisioned_at
FROM host_licenses hl
INNER JOIN licenses l ON l.id = hl.license_id
WHERE hl.host_id = $1
ORDER BY hl.provisioned_at DESC",
ctx.host_id
)
.fetch_all(&state.db)
.await?;
let result: Vec<HostLicenseInfo> = licenses
.into_iter()
.map(|row| HostLicenseInfo {
license_key: row.license_key,
server_name: row.server_name.unwrap_or_default(),
customer_email: row.customer_email.unwrap_or_default(),
subdomain: row.subdomain.unwrap_or_default(),
active: row.active,
last_seen_at: row.last_seen_at,
provisioned_at: row.provisioned_at.unwrap(),
})
.collect();
Ok(Json(result))
}
// ============================================================================
// BILLING REPORTS
// ============================================================================
#[derive(Serialize)]
struct BillingReport {
month: String,
active_license_count: i32,
wholesale_rate_usd: rust_decimal::Decimal,
total_amount_usd: rust_decimal::Decimal,
licenses: Vec<BillingLicenseEntry>,
}
#[derive(Serialize)]
struct BillingLicenseEntry {
license_key: String,
server_name: String,
customer_email: String,
active: bool,
last_seen_at: Option<chrono::DateTime<chrono::Utc>>,
}
/// Get billing report for a specific month (format: YYYY-MM, e.g., "2026-02")
async fn get_billing_report(
State(state): State<Arc<AppState>>,
axum::extract::Extension(ctx): axum::extract::Extension<HostContext>,
Path(month): axum::extract::Path<String>,
) -> Result<impl IntoResponse, ApiError> {
// Parse month (format: YYYY-MM)
let billing_month = chrono::NaiveDate::parse_from_str(&format!("{}-01", month), "%Y-%m-%d")
.map_err(|_| ApiError::BadRequest("Invalid month format. Use YYYY-MM (e.g., 2026-02)".to_string()))?;
// Get billing record
let record = sqlx::query!(
"SELECT active_license_count, wholesale_rate_usd, total_amount_usd
FROM host_billing_records
WHERE host_id = $1 AND billing_month = $2",
ctx.host_id,
billing_month
)
.fetch_optional(&state.db)
.await?;
let (active_count, wholesale_rate, total_amount) = if let Some(rec) = record {
(
rec.active_license_count,
rec.wholesale_rate_usd,
rec.total_amount_usd,
)
} else {
// No billing record yet — generate on-the-fly
let service = HostProvisioningService::new(state.db.clone());
let count = service.get_active_license_count(ctx.host_id).await?;
let rate = rust_decimal::Decimal::from(6); // Default $6/server
let total = rate * rust_decimal::Decimal::from(count);
(count as i32, rate, total)
};
// Get license details
let licenses = sqlx::query!(
"SELECT
l.license_key,
l.server_name,
(l.status = 'active') as \"active!\",
hl.customer_email,
hl.last_seen_at
FROM host_licenses hl
INNER JOIN licenses l ON l.id = hl.license_id
WHERE hl.host_id = $1
ORDER BY l.server_name",
ctx.host_id
)
.fetch_all(&state.db)
.await?;
let license_entries: Vec<BillingLicenseEntry> = licenses
.into_iter()
.map(|row| BillingLicenseEntry {
license_key: row.license_key,
server_name: row.server_name.unwrap_or_default(),
customer_email: row.customer_email.unwrap_or_default(),
active: row.active,
last_seen_at: row.last_seen_at,
})
.collect();
Ok(Json(BillingReport {
month: month.clone(),
active_license_count: active_count,
wholesale_rate_usd: wholesale_rate,
total_amount_usd: total_amount,
licenses: license_entries,
}))
}