feat: JSON REST API /api/v1 with DB-managed Bearer tokens

- New endpoints: GET /api/v1/pools, GET /api/v1/pools/{slug}/next,
  GET/POST /api/v1/allocations, GET /api/v1/allocations/{id},
  POST /api/v1/allocations/{id}/release
- Bearer token auth; only SHA-256 hashes stored, plaintext shown once
- Token management GUI at /tokens (create, revoke, delete) linked in footer
- API actions audited with actor api:<token name>
- Fix latent transaction leak: replace manual BEGIN IMMEDIATE/COMMIT with
  begin_with(), which rolls back on drop; previously a failed allocation
  (e.g. disallowed prefix or full pool) returned a connection with an open
  transaction to the pool, causing 'database is locked' on later writes
- README: API documentation with curl examples
This commit is contained in:
SW 2026-10-09 14:25:17 +02:00
parent 6e24ce541a
commit 8c69c1731d
17 changed files with 1197 additions and 53 deletions

2
Cargo.lock generated
View file

@ -1829,10 +1829,12 @@ dependencies = [
"chrono",
"csv",
"futures",
"hex",
"ipnet",
"rand",
"serde",
"serde_json",
"sha2",
"sqlx",
"tempfile",
"thiserror",

View file

@ -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"] }

View file

@ -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:<token name>`.
### 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

View file

@ -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);

86
src/api_tokens.rs Normal file
View file

@ -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 <token>` 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<ApiToken, Response> {
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<ApiToken> = 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)
}

View file

@ -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<String> = 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(())
}

476
src/handlers/api.rs Normal file
View file

@ -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<i32>,
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<String>,
released_by: Option<String>,
release_note: Option<String>,
}
fn error_response(status: StatusCode, message: &str) -> Response {
(status, Json(json!({ "error": message }))).into_response()
}
pub async fn list_pools(State(state): State<AppState>, headers: HeaderMap) -> Response {
if let Err(r) = authenticate(&state, &headers).await {
return r;
}
let pools: Vec<Pool> =
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<AppState>,
headers: HeaderMap,
Path(slug): Path<String>,
Query(q): Query<NextQuery>,
) -> Response {
if let Err(r) = authenticate(&state, &headers).await {
return r;
}
let pool: Option<Pool> =
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<String> = 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<String>,
pub pool: Option<String>,
pub q: Option<String>,
pub limit: Option<i64>,
pub offset: Option<i64>,
}
pub async fn list_allocations(
State(state): State<AppState>,
headers: HeaderMap,
Query(q): Query<AllocationsQuery>,
) -> 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<Allocation> = 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<AllocationJson> = 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<i64, Pool> {
let pool_ids: Vec<i64> = 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::<Vec<_>>().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<AppState>,
headers: HeaderMap,
Path(id): Path<i64>,
) -> Response {
if let Err(r) = authenticate(&state, &headers).await {
return r;
}
let allocation: Option<Allocation> =
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<Pool> = 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<String>,
}
pub async fn create_allocation(
State(state): State<AppState>,
headers: HeaderMap,
Json(body): Json<AllocateRequest>,
) -> 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<Pool> =
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::<AllocateError>() {
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<AppState>,
headers: HeaderMap,
Path(id): Path<i64>,
Json(body): Json<ReleaseRequest>,
) -> 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<Allocation> =
sqlx::query_as("SELECT * FROM allocations WHERE id = ?")
.bind(id)
.fetch_optional(&state.pool)
.await
.ok()
.flatten();
let pool: Option<Pool> = 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,
}
}

View file

@ -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;

View file

@ -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<Pool> = 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<Pool> = 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<i64> = 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<Pool> =
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::<Ipv4Net>()
&& 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<Pool> =
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 })
}

325
src/handlers/tokens.rs Normal file
View file

@ -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<AppState>, 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<AppState>,
jar: CookieJar,
headers: HeaderMap,
Form(form): Form<CreateTokenForm>,
) -> 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::sqlite::SqliteQueryResult> = 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<AppState>,
jar: CookieJar,
headers: HeaderMap,
Path(id): Path<i64>,
Form(form): Form<TokenActionForm>,
) -> 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<AppState>,
jar: CookieJar,
headers: HeaderMap,
Path(id): Path<i64>,
Form(form): Form<TokenActionForm>,
) -> 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<ApiToken> = 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<String> {
let token: Option<ApiToken> = 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<String> {
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<ApiToken> =
sqlx::query_as("SELECT * FROM api_tokens ORDER BY created_at DESC")
.fetch_all(&state.pool)
.await
.unwrap_or_default();
let rows: Vec<TokenRow> = 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,
}
}

View file

@ -1,4 +1,5 @@
pub mod allocator;
pub mod api_tokens;
pub mod audit;
pub mod config;
pub mod csrf;

View file

@ -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<String>,
}
impl ApiToken {
pub fn is_enabled(&self) -> bool {
self.enabled != 0
}
}
#[derive(Clone, Debug, FromRow)]
pub struct AuditEvent {
pub id: i64,

View file

@ -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<crate::state::AppState> {
.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<crate::state::AppState> {
.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),
)
}

View file

@ -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<TokenRow>,
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 {

View file

@ -36,6 +36,16 @@ function initApp() {
});
});
// Open any modal via a generic data-modal-open="<id>" 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 () {

View file

@ -27,6 +27,7 @@
<footer class="box">
<p class="small">
<a href="/pools">Manage pools</a> |
<a href="/tokens">API tokens</a> |
<a href="/exports/allocations.md">Markdown export</a> |
<a href="/exports/allocations.csv">CSV export</a> |
<a href="/exports/allocations.json">JSON export</a>

120
templates/tokens.html Normal file
View file

@ -0,0 +1,120 @@
{% extends "base.html" %}
{% block title %}API tokens | SUB-ALIGATOR{% endblock %}
{% block content %}
{% if has_new_token %}
<section class="box">
<div class="box-title">Token created – copy it now</div>
<div class="message success">
<p>The new API token <strong>{{ new_token_name }}</strong> was created. Copy it now – <strong>it will not be shown again</strong>. Only its hash is stored.</p>
<input type="text" readonly value="{{ new_token_value }}" style="font-family: inherit;">
<p class="small" style="margin-top: 0.5rem;">Use it as: <code>Authorization: Bearer {{ new_token_value }}</code></p>
</div>
</section>
{% endif %}
<section class="box">
<div class="box-title">API tokens</div>
{% if error != "" %}
<div class="message error">
<p>{{ error }}</p>
</div>
{% endif %}
<p class="small">Tokens authenticate the JSON REST API under <code>/api/v1/</code> via the <code>Authorization: Bearer</code> header. See the README for endpoint documentation.</p>
{% if tokens.len() == 0 %}
<p>No API tokens yet.</p>
{% else %}
<table>
<thead>
<tr>
<th>Name</th>
<th>Token</th>
<th>Status</th>
<th>Created</th>
<th>Last used</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for t in tokens %}
<tr>
<td data-label="Name">{{ t.name }}</td>
<td data-label="Token"><code>{{ t.token_prefix }}…</code></td>
<td data-label="Status">
{% if t.enabled %}
<span class="status active">active</span>
{% else %}
<span class="status released">revoked</span>
{% endif %}
</td>
<td data-label="Created">{{ t.created_at_local }} ({{ t.created_by }})</td>
<td data-label="Last used">{{ t.last_used_at_local }}</td>
<td data-label="Actions">
{% if t.enabled %}
<button type="button" class="btn btn-danger" data-modal-open="revoke-token-modal-{{ t.id }}">Revoke</button>
{% else %}
<button type="button" class="btn btn-danger" data-modal-open="delete-token-modal-{{ t.id }}">Delete</button>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
</section>
<section class="box">
<div class="box-title">Create new token</div>
<form method="post" action="/tokens/new">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label for="name">Token name *</label>
<input type="text" id="name" name="name" required minlength="2" maxlength="100" placeholder="e.g. monitoring-integration">
<p class="small">A descriptive name of the integration that will use this token. It appears in the audit log as actor <code>api:&lt;name&gt;</code>.</p>
<div style="margin-top: 1rem;">
<button type="submit" class="btn">Create token</button>
<a href="/" class="btn btn-secondary">Back</a>
</div>
</form>
</section>
{% for t in tokens %}
{% if t.enabled %}
<div id="revoke-token-modal-{{ t.id }}" class="modal-overlay" style="display:none;">
<div class="modal">
<h2 class="modal-title">Revoke API token</h2>
<form method="post" action="/tokens/{{ t.id }}/revoke" class="modal-body">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<p>Token: <strong>{{ t.name }}</strong> (<code>{{ t.token_prefix }}…</code>)</p>
<p>The token will stop working immediately. The record is kept for audit purposes.</p>
<div class="modal-actions">
<button type="button" class="btn btn-secondary cancel-btn" data-modal="revoke-token-modal-{{ t.id }}">Cancel</button>
<button type="submit" class="btn btn-danger">Revoke token</button>
</div>
</form>
</div>
</div>
{% else %}
<div id="delete-token-modal-{{ t.id }}" class="modal-overlay" style="display:none;">
<div class="modal">
<h2 class="modal-title">Delete API token</h2>
<form method="post" action="/tokens/{{ t.id }}/delete" class="modal-body">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<p>Token: <strong>{{ t.name }}</strong> (<code>{{ t.token_prefix }}…</code>)</p>
<p>The revoked token record will be permanently deleted. This cannot be undone.</p>
<div class="modal-actions">
<button type="button" class="btn btn-secondary cancel-btn" data-modal="delete-token-modal-{{ t.id }}">Cancel</button>
<button type="submit" class="btn btn-danger">Delete token</button>
</div>
</form>
</div>
</div>
{% endif %}
{% endfor %}
{% endblock %}