diff --git a/Cargo.lock b/Cargo.lock index 1ed6089..9121e7c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1829,10 +1829,12 @@ dependencies = [ "chrono", "csv", "futures", + "hex", "ipnet", "rand", "serde", "serde_json", + "sha2", "sqlx", "tempfile", "thiserror", diff --git a/Cargo.toml b/Cargo.toml index f2bc200..99289c2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,6 +26,8 @@ tokio = { version = "1.44.2", features = ["rt-multi-thread", "macros", "signal"] tower = "0.5.2" tower-http = { version = "0.7.1", features = ["fs", "set-header", "trace", "catch-panic"] } axum-extra = { version = "0.10.1", features = ["cookie"] } +sha2 = "0.10" +hex = "0.4" tracing = "0.1.41" tracing-subscriber = { version = "0.3.19", features = ["env-filter"] } diff --git a/README.md b/README.md index 297567d..e0ec393 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ It is designed to run only inside a LAN/VPN. **The application does not include - Released subnets free up address space and can be reused by the first-fit algorithm. - Delete pools without losing their allocations: the subnets stay in history and keep blocking the address range if the same or an overlapping pool is added again. - Light and dark (amber terminal) theme, switchable in the page header and remembered per browser. +- JSON REST API (`/api/v1/`) with Bearer token authentication; tokens are managed in the web UI. - Audit log of all operations. - Markdown, CSV, and JSON exports. - SQLite migrations run automatically on startup. @@ -104,10 +105,72 @@ They are also available for download via endpoints: The Markdown export contains a separate section for each pool with a table of active and reserved allocations. CSV and JSON include all allocations, including released ones. +## REST API + +The application provides a JSON REST API under `/api/v1/` for integrations. All API requests are authenticated with a Bearer token: + +``` +Authorization: Bearer sa_... +``` + +### Managing API tokens + +Tokens are managed in the web UI on the **API tokens** page (link in the footer): + +- **Create** – enter a descriptive name (e.g. `monitoring-integration`). The plaintext token is shown **only once** right after creation; only its SHA-256 hash is stored in the database. +- **Revoke** – disables the token immediately. The record is kept for audit purposes. +- **Delete** – permanently removes a revoked token. + +Every API action is recorded in the audit log with the actor `api:`. + +### Endpoints + +| Method | Path | Description | +|--------|------|-------------| +| `GET` | `/api/v1/pools` | List enabled pools with allocation counts | +| `GET` | `/api/v1/pools/{slug}/next?prefix_length=30` | Preview the next free subnet (informational only) | +| `GET` | `/api/v1/allocations` | List allocations; filters: `status`, `pool`, `q`, `limit` (max 500), `offset` | +| `GET` | `/api/v1/allocations/{id}` | Allocation detail including address range | +| `POST` | `/api/v1/allocations` | Allocate the next free subnet | +| `POST` | `/api/v1/allocations/{id}/release` | Release an allocation | + +The allocation is always recalculated inside a write transaction – the preview from `/next` may differ from the actually allocated subnet under concurrent use. + +### Examples + +```bash +TOKEN="sa_your_token_here" +BASE="http://SERVER_IP:8080" + +# List pools +curl -s -H "Authorization: Bearer $TOKEN" $BASE/api/v1/pools + +# Preview the next free /30 in pool "vpn" +curl -s -H "Authorization: Bearer $TOKEN" "$BASE/api/v1/pools/vpn/next?prefix_length=30" + +# Allocate the next free /30 (note is mandatory, min. 3 characters) +curl -s -X POST -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"pool_slug":"vpn","prefix_length":30,"note":"customer X edge","ticket":"INC-123"}' \ + $BASE/api/v1/allocations + +# List active allocations in pool "vpn" +curl -s -H "Authorization: Bearer $TOKEN" "$BASE/api/v1/allocations?status=active&pool=vpn" + +# Release allocation 42 (release note is mandatory) +curl -s -X POST -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"release_note":"no longer needed"}' \ + $BASE/api/v1/allocations/42/release +``` + +Errors are returned as `{"error":"message"}` with an appropriate status code (`400` invalid request, `401` missing/invalid token, `404` not found, `409` pool full or allocation already released). + ## Security notes -- The application **has no authentication**. Use it only behind a VPN/LAN/firewall. -- CSRF tokens are required for POST forms. +- The web UI **has no authentication**. Use it only behind a VPN/LAN/firewall. +- The REST API requires a Bearer token; only token hashes (SHA-256) are stored. +- CSRF tokens are required for POST forms in the web UI. - Security headers are set: `X-Content-Type-Options`, `X-Frame-Options`, `Referrer-Policy`, `Content-Security-Policy`. - The application has no default Internet exposure; port binding is handled by Docker Compose. - `/healthz` returns only `{"status":"ok"}`. @@ -146,11 +209,17 @@ The algorithm always respects CIDR alignment and finds the first free space larg - `GET /setup` – first-run pool setup - `GET /pools` – pool management - `POST /pools/new` – create a new pool +- `POST /pools/{id}/delete` – delete a pool (allocations are kept) +- `GET /tokens` – API token management +- `POST /tokens/new` – create an API token +- `POST /tokens/{id}/revoke` – revoke an API token +- `POST /tokens/{id}/delete` – delete a revoked API token - `GET /partials/pools/{slug}/preview?prefix_length=24` – HTMX preview - `POST /allocations` – create an allocation - `GET /allocations/{id}` – allocation detail - `POST /allocations/{id}/release` – release an allocation - `GET /exports/allocations.{md,csv,json}` – exports +- `GET|POST /api/v1/...` – JSON REST API (see above) ## Troubleshooting diff --git a/migrations/20261009000001_create_api_tokens.sql b/migrations/20261009000001_create_api_tokens.sql new file mode 100644 index 0000000..28b5b36 --- /dev/null +++ b/migrations/20261009000001_create_api_tokens.sql @@ -0,0 +1,12 @@ +CREATE TABLE IF NOT EXISTS api_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + token_hash TEXT NOT NULL UNIQUE, + token_prefix TEXT NOT NULL, + enabled BOOLEAN NOT NULL DEFAULT true, + created_at TEXT NOT NULL, + created_by TEXT NOT NULL DEFAULT 'local', + last_used_at TEXT NULL +); + +CREATE INDEX IF NOT EXISTS idx_api_tokens_hash ON api_tokens(token_hash); diff --git a/src/api_tokens.rs b/src/api_tokens.rs new file mode 100644 index 0000000..b8a56f4 --- /dev/null +++ b/src/api_tokens.rs @@ -0,0 +1,86 @@ +use crate::models::{now_utc_rfc3339, ApiToken}; +use crate::state::AppState; +use axum::http::{HeaderMap, StatusCode}; +use axum::response::{IntoResponse, Response}; +use axum::Json; +use rand::{distributions::Alphanumeric, Rng}; +use sha2::{Digest, Sha256}; + +pub const TOKEN_PREFIX_LEN: usize = 12; + +/// Generate a new plaintext API token. It is shown to the user exactly once; +/// only its SHA-256 hash is stored in the database. +pub fn generate_api_token() -> String { + let random: String = rand::thread_rng() + .sample_iter(&Alphanumeric) + .take(40) + .map(char::from) + .collect(); + format!("sa_{}", random) +} + +pub fn hash_token(token: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(token.as_bytes()); + hex::encode(hasher.finalize()) +} + +pub fn display_prefix(token: &str) -> String { + token.chars().take(TOKEN_PREFIX_LEN).collect() +} + +fn unauthorized() -> Response { + ( + StatusCode::UNAUTHORIZED, + Json(serde_json::json!({ "error": "missing or invalid API token" })), + ) + .into_response() +} + +/// Authenticate an API request via the `Authorization: Bearer ` header. +/// Returns the token row on success (its name is used as the audit actor, +/// e.g. `api:my-integration`) or a 401 JSON response. +#[allow(clippy::result_large_err)] +pub async fn authenticate(state: &AppState, headers: &HeaderMap) -> Result { + let header = headers + .get(axum::http::header::AUTHORIZATION) + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + + let token = header + .strip_prefix("Bearer ") + .map(str::trim) + .filter(|t| !t.is_empty()); + + let token = match token { + Some(t) => t, + None => return Err(unauthorized()), + }; + + let row: Option = sqlx::query_as( + "SELECT * FROM api_tokens WHERE token_hash = ? AND enabled = true", + ) + .bind(hash_token(token)) + .fetch_optional(&state.pool) + .await + .ok() + .flatten(); + + let row = match row { + Some(r) if r.is_enabled() => r, + _ => return Err(unauthorized()), + }; + + sqlx::query("UPDATE api_tokens SET last_used_at = ? WHERE id = ?") + .bind(now_utc_rfc3339()) + .bind(row.id) + .execute(&state.pool) + .await + .ok(); + + Ok(row) +} + +pub fn api_actor(token: &ApiToken) -> String { + format!("api:{}", token.name) +} diff --git a/src/handlers/allocations.rs b/src/handlers/allocations.rs index 889ded7..feb5b2b 100644 --- a/src/handlers/allocations.rs +++ b/src/handlers/allocations.rs @@ -122,17 +122,15 @@ pub async fn allocate_in_tx( ticket: &str, actor: &str, ) -> anyhow::Result<(Allocation, crate::allocator::SubnetInfo)> { - let mut conn = pool.acquire().await?; - - sqlx::query("BEGIN IMMEDIATE") - .execute(&mut *conn) - .await?; + // begin_with gives us BEGIN IMMEDIATE plus automatic rollback on drop, + // so no error path can leak an open transaction into the pool. + let mut tx = pool.begin_with("BEGIN IMMEDIATE").await?; let active: Vec = sqlx::query_scalar( "SELECT cidr FROM allocations WHERE pool_id = ? AND status IN ('active', 'reserved')", ) .bind(pool_id) - .fetch_all(&mut *conn) + .fetch_all(&mut *tx) .await?; let info = find_next_subnet(parent_cidr, prefix_length, allowed_prefixes, &active)?; @@ -155,12 +153,12 @@ pub async fn allocate_in_tx( .bind(AllocationStatus::Active.as_str()) .bind(now_utc_rfc3339()) .bind(actor) - .execute(&mut *conn) + .execute(&mut *tx) .await? .last_insert_rowid(); log_event( - &mut conn, + &mut tx, "allocation_created", Some(id), Some(pool_id), @@ -174,7 +172,7 @@ pub async fn allocate_in_tx( .await .ok(); - sqlx::query("COMMIT").execute(&mut *conn).await?; + tx.commit().await?; let allocation = sqlx::query_as::<_, Allocation>("SELECT * FROM allocations WHERE id = ?") .bind(id) @@ -232,16 +230,12 @@ pub async fn release_in_tx( release_note: &str, actor: &str, ) -> anyhow::Result<()> { - let mut conn = pool.acquire().await?; - - sqlx::query("BEGIN IMMEDIATE") - .execute(&mut *conn) - .await?; + let mut tx = pool.begin_with("BEGIN IMMEDIATE").await?; let allocation: Allocation = sqlx::query_as("SELECT * FROM allocations WHERE id = ? AND status IN ('active', 'reserved')") .bind(id) - .fetch_one(&mut *conn) + .fetch_one(&mut *tx) .await .map_err(|_| anyhow::anyhow!("Allocation does not exist or is no longer active."))?; @@ -252,11 +246,11 @@ pub async fn release_in_tx( .bind(actor) .bind(release_note) .bind(id) - .execute(&mut *conn) + .execute(&mut *tx) .await?; log_event( - &mut conn, + &mut tx, "allocation_released", Some(id), Some(allocation.pool_id), @@ -269,7 +263,7 @@ pub async fn release_in_tx( .await .ok(); - sqlx::query("COMMIT").execute(&mut *conn).await?; + tx.commit().await?; Ok(()) } diff --git a/src/handlers/api.rs b/src/handlers/api.rs new file mode 100644 index 0000000..3e78bca --- /dev/null +++ b/src/handlers/api.rs @@ -0,0 +1,476 @@ +use crate::allocator::{find_next_subnet, subnet_info, AllocateError}; +use crate::api_tokens::{api_actor, authenticate}; +use crate::handlers::allocations::{allocate_in_tx, regenerate_after_change, release_in_tx}; +use crate::models::{Allocation, Pool}; +use crate::state::AppState; +use axum::extract::{Path, Query, State}; +use axum::http::{HeaderMap, StatusCode}; +use axum::response::{IntoResponse, Response}; +use axum::Json; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use tracing::{info, warn}; + +#[derive(Serialize)] +struct PoolJson { + id: i64, + slug: String, + name: String, + cidr: String, + kind: String, + allowed_prefixes: Vec, + active_allocations: i64, + reserved_allocations: i64, +} + +#[derive(Serialize)] +struct AllocationJson { + id: i64, + pool_id: i64, + pool_slug: String, + pool_name: String, + kind: String, + cidr: String, + prefix_length: i32, + address_count: i32, + usable_host_count: i32, + note: String, + status: String, + created_at: String, + created_by: String, + released_at: Option, + released_by: Option, + release_note: Option, +} + +fn error_response(status: StatusCode, message: &str) -> Response { + (status, Json(json!({ "error": message }))).into_response() +} + +pub async fn list_pools(State(state): State, headers: HeaderMap) -> Response { + if let Err(r) = authenticate(&state, &headers).await { + return r; + } + + let pools: Vec = + sqlx::query_as("SELECT * FROM pools WHERE enabled = true ORDER BY id") + .fetch_all(&state.pool) + .await + .unwrap_or_default(); + + let mut out = Vec::with_capacity(pools.len()); + for p in pools { + let (active, reserved): (i64, i64) = sqlx::query_as( + "SELECT \ + COALESCE(SUM(CASE WHEN status = 'active' THEN 1 ELSE 0 END), 0), \ + COALESCE(SUM(CASE WHEN status = 'reserved' THEN 1 ELSE 0 END), 0) \ + FROM allocations WHERE pool_id = ?", + ) + .bind(p.id) + .fetch_one(&state.pool) + .await + .unwrap_or((0, 0)); + + let allowed_prefixes = p.allowed_prefixes(); + out.push(PoolJson { + id: p.id, + slug: p.slug, + name: p.name, + cidr: p.cidr, + kind: p.kind, + allowed_prefixes, + active_allocations: active, + reserved_allocations: reserved, + }); + } + + Json(json!({ "pools": out })).into_response() +} + +#[derive(Deserialize)] +pub struct NextQuery { + pub prefix_length: i32, +} + +pub async fn next_free( + State(state): State, + headers: HeaderMap, + Path(slug): Path, + Query(q): Query, +) -> Response { + if let Err(r) = authenticate(&state, &headers).await { + return r; + } + + let pool: Option = + sqlx::query_as("SELECT * FROM pools WHERE slug = ? AND enabled = true") + .bind(&slug) + .fetch_optional(&state.pool) + .await + .ok() + .flatten(); + + let pool = match pool { + Some(p) => p, + None => return error_response(StatusCode::NOT_FOUND, "pool does not exist"), + }; + + let active: Vec = sqlx::query_scalar( + "SELECT cidr FROM allocations WHERE pool_id = ? AND status IN ('active', 'reserved')", + ) + .bind(pool.id) + .fetch_all(&state.pool) + .await + .unwrap_or_default(); + + match find_next_subnet(&pool.cidr, q.prefix_length, &pool.allowed_prefixes(), &active) { + Ok(info) => Json(json!({ + "pool": pool.slug, + "prefix_length": info.prefix_length, + "cidr": info.cidr, + "address_count": info.address_count, + "usable_host_count": info.usable_host_count, + "network": info.network, + "broadcast": info.broadcast, + "first_host": info.first_host, + "last_host": info.last_host, + })) + .into_response(), + Err(AllocateError::PrefixNotAllowed(p)) => error_response( + StatusCode::BAD_REQUEST, + &format!("prefix /{} is not allowed in this pool", p), + ), + Err(e) => error_response(StatusCode::CONFLICT, &e.to_string()), + } +} + +#[derive(Deserialize, Default)] +pub struct AllocationsQuery { + pub status: Option, + pub pool: Option, + pub q: Option, + pub limit: Option, + pub offset: Option, +} + +pub async fn list_allocations( + State(state): State, + headers: HeaderMap, + Query(q): Query, +) -> Response { + if let Err(r) = authenticate(&state, &headers).await { + return r; + } + + let status = q.status.unwrap_or_default(); + if !status.is_empty() && !["active", "reserved", "released"].contains(&status.as_str()) { + return error_response( + StatusCode::BAD_REQUEST, + "status must be one of: active, reserved, released", + ); + } + let pool_filter = q.pool.unwrap_or_default(); + let search = format!("%{}%", q.q.unwrap_or_default()); + let limit = q.limit.unwrap_or(50).clamp(1, 500); + let offset = q.offset.unwrap_or(0).max(0); + + let total: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM allocations a JOIN pools p ON p.id = a.pool_id \ + WHERE (a.cidr LIKE ? OR a.note LIKE ?) AND (? = '' OR p.slug = ?) AND (? = '' OR a.status = ?)", + ) + .bind(&search) + .bind(&search) + .bind(&pool_filter) + .bind(&pool_filter) + .bind(&status) + .bind(&status) + .fetch_one(&state.pool) + .await + .unwrap_or(0); + + let allocations: Vec = sqlx::query_as( + "SELECT a.* FROM allocations a JOIN pools p ON p.id = a.pool_id \ + WHERE (a.cidr LIKE ? OR a.note LIKE ?) AND (? = '' OR p.slug = ?) AND (? = '' OR a.status = ?) \ + ORDER BY a.created_at DESC LIMIT ? OFFSET ?", + ) + .bind(&search) + .bind(&search) + .bind(&pool_filter) + .bind(&pool_filter) + .bind(&status) + .bind(&status) + .bind(limit) + .bind(offset) + .fetch_all(&state.pool) + .await + .unwrap_or_default(); + + let pool_map = load_pool_map(&state, &allocations).await; + let allocations: Vec = allocations + .into_iter() + .filter_map(|a| { + pool_map + .get(&a.pool_id) + .map(|p| allocation_to_json(a, p.clone())) + }) + .collect(); + + Json(json!({ + "allocations": allocations, + "total": total, + "limit": limit, + "offset": offset, + })) + .into_response() +} + +async fn load_pool_map( + state: &AppState, + allocations: &[Allocation], +) -> std::collections::HashMap { + let pool_ids: Vec = allocations.iter().map(|a| a.pool_id).collect(); + if pool_ids.is_empty() { + return std::collections::HashMap::new(); + } + + let placeholders = pool_ids.iter().map(|_| "?").collect::>().join(","); + let query = format!("SELECT * FROM pools WHERE id IN ({})", placeholders); + let mut q = sqlx::query_as::<_, Pool>(&query); + for id in &pool_ids { + q = q.bind(id); + } + + q.fetch_all(&state.pool) + .await + .unwrap_or_default() + .into_iter() + .map(|p| (p.id, p)) + .collect() +} + +pub async fn get_allocation( + State(state): State, + headers: HeaderMap, + Path(id): Path, +) -> Response { + if let Err(r) = authenticate(&state, &headers).await { + return r; + } + + let allocation: Option = + sqlx::query_as("SELECT * FROM allocations WHERE id = ?") + .bind(id) + .fetch_optional(&state.pool) + .await + .ok() + .flatten(); + + let allocation = match allocation { + Some(a) => a, + None => return error_response(StatusCode::NOT_FOUND, "allocation does not exist"), + }; + + let pool: Option = sqlx::query_as("SELECT * FROM pools WHERE id = ?") + .bind(allocation.pool_id) + .fetch_optional(&state.pool) + .await + .ok() + .flatten(); + + let pool = match pool { + Some(p) => p, + None => return error_response(StatusCode::NOT_FOUND, "pool does not exist"), + }; + + let info = subnet_info(&allocation.cidr).ok(); + + let mut value = serde_json::to_value(allocation_to_json(allocation, pool)) + .unwrap_or_else(|_| json!({})); + if let (Some(obj), Some(info)) = (value.as_object_mut(), info) { + obj.insert("network".to_string(), json!(info.network)); + obj.insert("broadcast".to_string(), json!(info.broadcast)); + obj.insert("first_host".to_string(), json!(info.first_host)); + obj.insert("last_host".to_string(), json!(info.last_host)); + } + + Json(value).into_response() +} + +#[derive(Deserialize)] +pub struct AllocateRequest { + pub pool_slug: String, + pub prefix_length: i32, + pub note: String, + pub ticket: Option, +} + +pub async fn create_allocation( + State(state): State, + headers: HeaderMap, + Json(body): Json, +) -> Response { + let token = match authenticate(&state, &headers).await { + Ok(t) => t, + Err(r) => return r, + }; + let actor = api_actor(&token); + + let note = body.note.trim(); + if note.len() < 3 { + return error_response( + StatusCode::BAD_REQUEST, + "note must be at least 3 characters long", + ); + } + + let pool: Option = + sqlx::query_as("SELECT * FROM pools WHERE slug = ? AND enabled = true") + .bind(&body.pool_slug) + .fetch_optional(&state.pool) + .await + .ok() + .flatten(); + + let pool = match pool { + Some(p) => p, + None => return error_response(StatusCode::NOT_FOUND, "pool does not exist"), + }; + + let result = allocate_in_tx( + &state.pool, + pool.id, + &pool.cidr, + &pool.allowed_prefixes(), + body.prefix_length, + note, + body.ticket.as_deref().unwrap_or("").trim(), + &actor, + ) + .await; + + match result { + Ok((allocation, info)) => { + regenerate_after_change(&state).await.ok(); + info!( + "API allocated {} in pool {} by {}", + allocation.cidr, pool.slug, actor + ); + ( + StatusCode::CREATED, + Json(json!({ + "id": allocation.id, + "pool": pool.slug, + "cidr": allocation.cidr, + "prefix_length": allocation.prefix_length, + "address_count": allocation.address_count, + "usable_host_count": allocation.usable_host_count, + "network": info.network, + "broadcast": info.broadcast, + "first_host": info.first_host, + "last_host": info.last_host, + "note": allocation.note, + "status": allocation.status, + "created_at": allocation.created_at, + "created_by": allocation.created_by, + })), + ) + .into_response() + } + Err(e) => { + warn!("API allocation failed: {}", e); + match e.downcast_ref::() { + Some(AllocateError::PrefixNotAllowed(p)) => error_response( + StatusCode::BAD_REQUEST, + &format!("prefix /{} is not allowed in this pool", p), + ), + Some(AllocateError::PoolFull(_)) | Some(AllocateError::DoesNotFit { .. }) => { + error_response(StatusCode::CONFLICT, &e.to_string()) + } + _ => error_response(StatusCode::INTERNAL_SERVER_ERROR, "allocation failed"), + } + } + } +} + +#[derive(Deserialize)] +pub struct ReleaseRequest { + pub release_note: String, +} + +pub async fn release_allocation( + State(state): State, + headers: HeaderMap, + Path(id): Path, + Json(body): Json, +) -> Response { + let token = match authenticate(&state, &headers).await { + Ok(t) => t, + Err(r) => return r, + }; + let actor = api_actor(&token); + + let release_note = body.release_note.trim(); + if release_note.len() < 3 { + return error_response( + StatusCode::BAD_REQUEST, + "release_note must be at least 3 characters long", + ); + } + + match release_in_tx(&state.pool, id, release_note, &actor).await { + Ok(()) => { + regenerate_after_change(&state).await.ok(); + info!("API released allocation {} by {}", id, actor); + + let allocation: Option = + sqlx::query_as("SELECT * FROM allocations WHERE id = ?") + .bind(id) + .fetch_optional(&state.pool) + .await + .ok() + .flatten(); + let pool: Option = match &allocation { + Some(a) => sqlx::query_as("SELECT * FROM pools WHERE id = ?") + .bind(a.pool_id) + .fetch_optional(&state.pool) + .await + .ok() + .flatten(), + None => None, + }; + + match (allocation, pool) { + (Some(a), Some(p)) => { + Json(serde_json::to_value(allocation_to_json(a, p)).unwrap_or_else(|_| json!({}))) + .into_response() + } + _ => Json(json!({ "id": id, "status": "released" })).into_response(), + } + } + Err(e) => { + warn!("API release failed for allocation {}: {}", id, e); + error_response(StatusCode::CONFLICT, &e.to_string()) + } + } +} + +fn allocation_to_json(a: Allocation, p: Pool) -> AllocationJson { + AllocationJson { + id: a.id, + pool_id: a.pool_id, + pool_slug: p.slug, + pool_name: p.name, + kind: p.kind, + cidr: a.cidr, + prefix_length: a.prefix_length, + address_count: a.address_count, + usable_host_count: a.usable_host_count, + note: a.note, + status: a.status, + created_at: a.created_at, + created_by: a.created_by, + released_at: a.released_at, + released_by: a.released_by, + release_note: a.release_note, + } +} diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs index ea709c7..89b2787 100644 --- a/src/handlers/mod.rs +++ b/src/handlers/mod.rs @@ -1,5 +1,7 @@ pub mod allocations; +pub mod api; pub mod exports; pub mod pages; pub mod partials; pub mod pools; +pub mod tokens; diff --git a/src/handlers/pools.rs b/src/handlers/pools.rs index 1bca6e5..3f9d0c6 100644 --- a/src/handlers/pools.rs +++ b/src/handlers/pools.rs @@ -114,25 +114,21 @@ pub async fn delete_pool( } async fn soft_delete_pool(state: &AppState, id: i64, actor: &str) -> anyhow::Result<()> { - let mut conn = state.pool.acquire().await?; - - sqlx::query("BEGIN IMMEDIATE").execute(&mut *conn).await?; + let mut tx = state.pool.begin_with("BEGIN IMMEDIATE").await?; let pool: Option = sqlx::query_as("SELECT * FROM pools WHERE id = ?") .bind(id) - .fetch_optional(&mut *conn) + .fetch_optional(&mut *tx) .await?; let pool = match pool { Some(p) => p, None => { - sqlx::query("ROLLBACK").execute(&mut *conn).await?; anyhow::bail!("Pool not found."); } }; if !pool.is_enabled() { - sqlx::query("ROLLBACK").execute(&mut *conn).await?; anyhow::bail!("Pool is already deleted."); } @@ -144,7 +140,7 @@ async fn soft_delete_pool(state: &AppState, id: i64, actor: &str) -> anyhow::Res FROM allocations WHERE pool_id = ?", ) .bind(id) - .fetch_one(&mut *conn) + .fetch_one(&mut *tx) .await?; // Soft delete only: the pool row and all of its allocations stay in the @@ -152,11 +148,11 @@ async fn soft_delete_pool(state: &AppState, id: i64, actor: &str) -> anyhow::Res // if the same or an overlapping pool is added again later. sqlx::query("UPDATE pools SET enabled = false WHERE id = ?") .bind(id) - .execute(&mut *conn) + .execute(&mut *tx) .await?; log_event( - &mut conn, + &mut tx, "pool_deleted", None, Some(id), @@ -174,7 +170,7 @@ async fn soft_delete_pool(state: &AppState, id: i64, actor: &str) -> anyhow::Res .await .ok(); - sqlx::query("COMMIT").execute(&mut *conn).await?; + tx.commit().await?; Ok(()) } @@ -335,20 +331,16 @@ async fn validate_and_insert_pool( let allowed_json = serde_json::to_string(&prefixes)?; let now = now_utc_rfc3339(); - let mut conn = pool.acquire().await?; - - sqlx::query("BEGIN IMMEDIATE") - .execute(&mut *conn) - .await?; + // Automatic rollback on drop covers every bail!/? below. + let mut tx = pool.begin_with("BEGIN IMMEDIATE").await?; let existing_by_cidr: Option = sqlx::query_as("SELECT * FROM pools WHERE cidr = ?") .bind(&cidr) - .fetch_optional(&mut *conn) + .fetch_optional(&mut *tx) .await?; if let Some(existing) = existing_by_cidr { if existing.is_enabled() { - sqlx::query("ROLLBACK").execute(&mut *conn).await?; anyhow::bail!("A pool with the same CIDR already exists."); } @@ -358,11 +350,10 @@ async fn validate_and_insert_pool( sqlx::query_scalar("SELECT id FROM pools WHERE slug = ? AND id != ?") .bind(&slug) .bind(existing.id) - .fetch_optional(&mut *conn) + .fetch_optional(&mut *tx) .await?; if slug_taken.is_some() { - sqlx::query("ROLLBACK").execute(&mut *conn).await?; anyhow::bail!("Slug is already used by another pool."); } @@ -374,11 +365,11 @@ async fn validate_and_insert_pool( .bind(kind) .bind(&allowed_json) .bind(existing.id) - .execute(&mut *conn) + .execute(&mut *tx) .await?; log_event( - &mut conn, + &mut tx, "pool_reenabled", None, Some(existing.id), @@ -393,32 +384,30 @@ async fn validate_and_insert_pool( .await .ok(); - sqlx::query("COMMIT").execute(&mut *conn).await?; + tx.commit().await?; return Ok(CreatePoolOutcome::Reenabled { id: existing.id }); } let slug_taken: Option = sqlx::query_scalar("SELECT id FROM pools WHERE slug = ?") .bind(&slug) - .fetch_optional(&mut *conn) + .fetch_optional(&mut *tx) .await?; if slug_taken.is_some() { - sqlx::query("ROLLBACK").execute(&mut *conn).await?; anyhow::bail!("A pool with the same slug already exists."); } // Two enabled pools must never cover the same address space. let enabled_pools: Vec = sqlx::query_as("SELECT * FROM pools WHERE enabled = true") - .fetch_all(&mut *conn) + .fetch_all(&mut *tx) .await?; for p in &enabled_pools { if let Ok(other) = p.cidr.parse::() && nets_overlap(&net, &other) { - sqlx::query("ROLLBACK").execute(&mut *conn).await?; anyhow::bail!( "Pool range overlaps with existing pool '{}' ({}).", p.name, @@ -437,7 +426,7 @@ async fn validate_and_insert_pool( .bind(&allowed_json) .bind(true) .bind(&now) - .execute(&mut *conn) + .execute(&mut *tx) .await? .last_insert_rowid(); @@ -447,7 +436,7 @@ async fn validate_and_insert_pool( let disabled_pools: Vec = sqlx::query_as("SELECT * FROM pools WHERE enabled = false AND id != ?") .bind(id) - .fetch_all(&mut *conn) + .fetch_all(&mut *tx) .await?; let mut reattached: i64 = 0; @@ -462,7 +451,7 @@ async fn validate_and_insert_pool( let orphans: Vec<(i64, String)> = sqlx::query_as("SELECT id, cidr FROM allocations WHERE pool_id = ?") .bind(dp.id) - .fetch_all(&mut *conn) + .fetch_all(&mut *tx) .await?; for (alloc_id, alloc_cidr) in orphans { @@ -473,7 +462,7 @@ async fn validate_and_insert_pool( sqlx::query("UPDATE allocations SET pool_id = ? WHERE id = ?") .bind(id) .bind(alloc_id) - .execute(&mut *conn) + .execute(&mut *tx) .await?; reattached += 1; } @@ -481,7 +470,7 @@ async fn validate_and_insert_pool( } log_event( - &mut conn, + &mut tx, "pool_created", None, Some(id), @@ -497,7 +486,7 @@ async fn validate_and_insert_pool( .await .ok(); - sqlx::query("COMMIT").execute(&mut *conn).await?; + tx.commit().await?; Ok(CreatePoolOutcome::Created { id, reattached }) } diff --git a/src/handlers/tokens.rs b/src/handlers/tokens.rs new file mode 100644 index 0000000..46898e7 --- /dev/null +++ b/src/handlers/tokens.rs @@ -0,0 +1,325 @@ +use crate::api_tokens::{display_prefix, generate_api_token, hash_token}; +use crate::audit::{get_actor_from_request, log_event}; +use crate::csrf::{ensure_csrf_cookie, validate_csrf}; +use crate::models::{now_utc_rfc3339, utc_to_local, ApiToken}; +use crate::state::AppState; +use crate::templates::{TokenListTemplate, TokenRow}; +use askama::Template; +use axum::extract::{Form, Path, State}; +use axum::http::HeaderMap; +use axum::response::{Html, IntoResponse, Redirect, Response}; +use axum_extra::extract::cookie::CookieJar; +use serde::Deserialize; +use serde_json::json; +use sqlx::SqlitePool; +use tracing::{error, info}; + +#[derive(Deserialize, Debug)] +pub struct CreateTokenForm { + pub name: String, + pub csrf_token: String, +} + +#[derive(Deserialize, Debug)] +pub struct TokenActionForm { + pub csrf_token: String, +} + +pub async fn token_list(State(state): State, jar: CookieJar) -> Response { + let (jar, csrf_token) = ensure_csrf_cookie(jar); + + let template = build_token_list_template(&state, csrf_token, String::new(), None).await; + + match template.render() { + Ok(html) => (jar, Html(html)).into_response(), + Err(e) => { + error!("Token list template render error: {}", e); + (jar, Html("Error rendering token list.".to_string())).into_response() + } + } +} + +pub async fn create_token( + State(state): State, + jar: CookieJar, + headers: HeaderMap, + Form(form): Form, +) -> Response { + let (jar, csrf_token) = ensure_csrf_cookie(jar); + let actor = get_actor_from_request(&headers); + + if !validate_csrf(&jar, &form.csrf_token) { + return render_token_list_with_error(&state, csrf_token, "Invalid CSRF token.") + .await + .into_response(); + } + + let name = form.name.trim(); + if name.len() < 2 || name.len() > 100 { + return render_token_list_with_error( + &state, + csrf_token, + "Token name must be between 2 and 100 characters.", + ) + .await + .into_response(); + } + + let token = generate_api_token(); + let now = now_utc_rfc3339(); + + let result: sqlx::Result = sqlx::query( + "INSERT INTO api_tokens (name, token_hash, token_prefix, enabled, created_at, created_by) VALUES (?, ?, ?, true, ?, ?)", + ) + .bind(name) + .bind(hash_token(&token)) + .bind(display_prefix(&token)) + .bind(&now) + .bind(&actor) + .execute(&state.pool) + .await; + + match result { + Ok(r) => { + let id = r.last_insert_rowid(); + info!("API token '{}' created by {}", name, actor); + + let mut conn = match state.pool.acquire().await { + Ok(c) => c, + Err(_) => return Redirect::to("/tokens").into_response(), + }; + log_event( + &mut conn, + "api_token_created", + None, + None, + &actor, + json!({ "token_id": id, "name": name }), + ) + .await + .ok(); + + let template = + build_token_list_template(&state, csrf_token, String::new(), Some((name.to_string(), token))) + .await; + match template.render() { + Ok(html) => (jar, Html(html)).into_response(), + Err(e) => { + error!("Token list template render error: {}", e); + (jar, Html("Error rendering token list.".to_string())).into_response() + } + } + } + Err(e) => { + error!("Failed to create API token: {}", e); + render_token_list_with_error(&state, csrf_token, "Failed to create token.") + .await + .into_response() + } + } +} + +pub async fn revoke_token( + State(state): State, + jar: CookieJar, + headers: HeaderMap, + Path(id): Path, + Form(form): Form, +) -> Response { + let (jar, csrf_token) = ensure_csrf_cookie(jar); + let actor = get_actor_from_request(&headers); + + if !validate_csrf(&jar, &form.csrf_token) { + return render_token_list_with_error(&state, csrf_token, "Invalid CSRF token.") + .await + .into_response(); + } + + match set_token_enabled(&state.pool, id, false, &actor).await { + Ok(name) => { + info!("API token '{}' ({}) revoked by {}", name, id, actor); + Redirect::to("/tokens").into_response() + } + Err(e) => { + error!("Failed to revoke token {}: {}", id, e); + render_token_list_with_error(&state, csrf_token, &e.to_string()) + .await + .into_response() + } + } +} + +pub async fn delete_token( + State(state): State, + jar: CookieJar, + headers: HeaderMap, + Path(id): Path, + Form(form): Form, +) -> Response { + let (jar, csrf_token) = ensure_csrf_cookie(jar); + let actor = get_actor_from_request(&headers); + + if !validate_csrf(&jar, &form.csrf_token) { + return render_token_list_with_error(&state, csrf_token, "Invalid CSRF token.") + .await + .into_response(); + } + + let token: Option = sqlx::query_as("SELECT * FROM api_tokens WHERE id = ?") + .bind(id) + .fetch_optional(&state.pool) + .await + .ok() + .flatten(); + + let token = match token { + Some(t) => t, + None => { + return render_token_list_with_error(&state, csrf_token, "Token not found.") + .await + .into_response() + } + }; + + if token.is_enabled() { + return render_token_list_with_error( + &state, + csrf_token, + "Only revoked tokens can be deleted. Revoke the token first.", + ) + .await + .into_response(); + } + + match sqlx::query("DELETE FROM api_tokens WHERE id = ?") + .bind(id) + .execute(&state.pool) + .await + { + Ok(_) => { + info!("API token '{}' ({}) deleted by {}", token.name, id, actor); + + let mut conn = match state.pool.acquire().await { + Ok(c) => c, + Err(_) => return Redirect::to("/tokens").into_response(), + }; + log_event( + &mut conn, + "api_token_deleted", + None, + None, + &actor, + json!({ "token_id": id, "name": token.name }), + ) + .await + .ok(); + + Redirect::to("/tokens").into_response() + } + Err(e) => { + error!("Failed to delete token {}: {}", id, e); + render_token_list_with_error(&state, csrf_token, "Failed to delete token.") + .await + .into_response() + } + } +} + +async fn set_token_enabled( + pool: &SqlitePool, + id: i64, + enabled: bool, + actor: &str, +) -> anyhow::Result { + let token: Option = sqlx::query_as("SELECT * FROM api_tokens WHERE id = ?") + .bind(id) + .fetch_optional(pool) + .await?; + + let token = match token { + Some(t) => t, + None => anyhow::bail!("Token not found."), + }; + + sqlx::query("UPDATE api_tokens SET enabled = ? WHERE id = ?") + .bind(enabled) + .bind(id) + .execute(pool) + .await?; + + let mut conn = pool.acquire().await?; + log_event( + &mut conn, + "api_token_revoked", + None, + None, + actor, + json!({ "token_id": id, "name": token.name }), + ) + .await + .ok(); + + Ok(token.name) +} + +async fn render_token_list_with_error( + state: &AppState, + csrf_token: String, + error: &str, +) -> Html { + let template = build_token_list_template(state, csrf_token, error.to_string(), None).await; + + match template.render() { + Ok(html) => Html(html), + Err(e) => { + error!("Token list template render error: {}", e); + Html("Error rendering token list.".to_string()) + } + } +} + +async fn build_token_list_template( + state: &AppState, + csrf_token: String, + error: String, + new_token: Option<(String, String)>, +) -> TokenListTemplate { + let tokens: Vec = + sqlx::query_as("SELECT * FROM api_tokens ORDER BY created_at DESC") + .fetch_all(&state.pool) + .await + .unwrap_or_default(); + + let rows: Vec = tokens + .into_iter() + .map(|t| { + let enabled = t.is_enabled(); + TokenRow { + id: t.id, + name: t.name, + token_prefix: t.token_prefix, + enabled, + created_at_local: utc_to_local(&t.created_at, &state.config.timezone), + created_by: t.created_by, + last_used_at_local: t + .last_used_at + .map(|d| utc_to_local(&d, &state.config.timezone)) + .unwrap_or_else(|| "–".to_string()), + } + }) + .collect(); + + let (new_token_name, new_token_value) = match new_token { + Some((n, v)) => (n, v), + None => (String::new(), String::new()), + }; + + TokenListTemplate { + tokens: rows, + csrf_token, + error, + has_new_token: !new_token_value.is_empty(), + new_token_name, + new_token_value, + } +} diff --git a/src/lib.rs b/src/lib.rs index 3c606f6..929c6b4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,4 +1,5 @@ pub mod allocator; +pub mod api_tokens; pub mod audit; pub mod config; pub mod csrf; diff --git a/src/models.rs b/src/models.rs index 762eca3..9bdc939 100644 --- a/src/models.rs +++ b/src/models.rs @@ -69,6 +69,24 @@ impl AllocationStatus { } } +#[derive(Clone, Debug, FromRow)] +pub struct ApiToken { + pub id: i64, + pub name: String, + pub token_hash: String, + pub token_prefix: String, + pub enabled: i64, + pub created_at: String, + pub created_by: String, + pub last_used_at: Option, +} + +impl ApiToken { + pub fn is_enabled(&self) -> bool { + self.enabled != 0 + } +} + #[derive(Clone, Debug, FromRow)] pub struct AuditEvent { pub id: i64, diff --git a/src/routes.rs b/src/routes.rs index 532ec9c..a4ef9d2 100644 --- a/src/routes.rs +++ b/src/routes.rs @@ -1,4 +1,4 @@ -use crate::handlers::{allocations, exports, pages, partials, pools}; +use crate::handlers::{allocations, api, exports, pages, partials, pools, tokens}; use axum::routing::{get, post}; use axum::Router; @@ -10,6 +10,10 @@ pub fn router() -> Router { .route("/pools", get(pools::pool_list)) .route("/pools/new", post(pools::create_pool)) .route("/pools/{id}/delete", post(pools::delete_pool)) + .route("/tokens", get(tokens::token_list)) + .route("/tokens/new", post(tokens::create_token)) + .route("/tokens/{id}/revoke", post(tokens::revoke_token)) + .route("/tokens/{id}/delete", post(tokens::delete_token)) .route("/partials/pools/{slug}/preview", get(partials::pool_preview)) .route("/allocations", post(allocations::create_allocation)) .route("/allocations/{id}", get(pages::allocation_detail)) @@ -20,4 +24,15 @@ pub fn router() -> Router { .route("/exports/allocations.md", get(exports::export_md)) .route("/exports/allocations.csv", get(exports::export_csv)) .route("/exports/allocations.json", get(exports::export_json)) + .route("/api/v1/pools", get(api::list_pools)) + .route("/api/v1/pools/{slug}/next", get(api::next_free)) + .route( + "/api/v1/allocations", + get(api::list_allocations).post(api::create_allocation), + ) + .route("/api/v1/allocations/{id}", get(api::get_allocation)) + .route( + "/api/v1/allocations/{id}/release", + post(api::release_allocation), + ) } diff --git a/src/templates.rs b/src/templates.rs index 7c98ce8..118b7e9 100644 --- a/src/templates.rs +++ b/src/templates.rs @@ -159,6 +159,28 @@ pub struct PoolRow { pub total_count: i64, } +#[derive(Clone, Debug)] +pub struct TokenRow { + pub id: i64, + pub name: String, + pub token_prefix: String, + pub enabled: bool, + pub created_at_local: String, + pub created_by: String, + pub last_used_at_local: String, +} + +#[derive(Template)] +#[template(path = "tokens.html")] +pub struct TokenListTemplate { + pub tokens: Vec, + pub csrf_token: String, + pub error: String, + pub has_new_token: bool, + pub new_token_name: String, + pub new_token_value: String, +} + #[derive(Template)] #[template(path = "pools.html")] pub struct PoolListTemplate { diff --git a/static/app.js b/static/app.js index 09be17a..7fc5b03 100644 --- a/static/app.js +++ b/static/app.js @@ -36,6 +36,16 @@ function initApp() { }); }); + // Open any modal via a generic data-modal-open="" trigger + document.querySelectorAll('[data-modal-open]').forEach(function (btn) { + btn.addEventListener('click', function () { + const modal = document.getElementById(btn.dataset.modalOpen); + if (modal) { + modal.style.display = 'flex'; + } + }); + }); + // Close modal via Cancel button document.querySelectorAll('.cancel-btn').forEach(function (btn) { btn.addEventListener('click', function () { diff --git a/templates/base.html b/templates/base.html index 325c926..00b5b15 100644 --- a/templates/base.html +++ b/templates/base.html @@ -27,6 +27,7 @@