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:
parent
6e24ce541a
commit
8c69c1731d
17 changed files with 1197 additions and 53 deletions
2
Cargo.lock
generated
2
Cargo.lock
generated
|
|
@ -1829,10 +1829,12 @@ dependencies = [
|
|||
"chrono",
|
||||
"csv",
|
||||
"futures",
|
||||
"hex",
|
||||
"ipnet",
|
||||
"rand",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"sqlx",
|
||||
"tempfile",
|
||||
"thiserror",
|
||||
|
|
|
|||
|
|
@ -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"] }
|
||||
|
||||
|
|
|
|||
73
README.md
73
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:<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
|
||||
|
||||
|
|
|
|||
12
migrations/20261009000001_create_api_tokens.sql
Normal file
12
migrations/20261009000001_create_api_tokens.sql
Normal 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
86
src/api_tokens.rs
Normal 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)
|
||||
}
|
||||
|
|
@ -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
476
src/handlers/api.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
325
src/handlers/tokens.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
pub mod allocator;
|
||||
pub mod api_tokens;
|
||||
pub mod audit;
|
||||
pub mod config;
|
||||
pub mod csrf;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 () {
|
||||
|
|
|
|||
|
|
@ -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
120
templates/tokens.html
Normal 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:<name></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 %}
|
||||
Loading…
Add table
Reference in a new issue