Compare commits

..

No commits in common. "v0.1.0" and "main" have entirely different histories.
v0.1.0 ... main

35 changed files with 2400 additions and 325 deletions

View file

@ -1,17 +1,17 @@
# Port na hostiteli, na kterém bude aplikace dostupná # Host port on which the application will be available
APP_PORT=8080 APP_PORT=8080
# Logování (debug, info, warn, error) # Logging level (debug, info, warn, error)
RUST_LOG=info RUST_LOG=info
# Bind adresy uvnitř kontejneru # Bind address inside the container
APP_BIND=0.0.0.0:8080 APP_BIND=0.0.0.0:8080
# Cesta k SQLite databázi uvnitř kontejneru # SQLite database path inside the container
DATABASE_URL=sqlite:/app/data/subnet-allocator.db DATABASE_URL=sqlite:/app/data/subnet-allocator.db
# Časové pásmo pro zobrazení času ve webovém UI # Timezone for displaying timestamps in the web UI
APP_TIMEZONE=Europe/Prague APP_TIMEZONE=Europe/Prague
# Adresář pro exporty uvnitř kontejneru # Directory for exports inside the container
APP_EXPORTS_DIR=/app/exports APP_EXPORTS_DIR=/app/exports

2
Cargo.lock generated
View file

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

View file

@ -26,6 +26,8 @@ tokio = { version = "1.44.2", features = ["rt-multi-thread", "macros", "signal"]
tower = "0.5.2" tower = "0.5.2"
tower-http = { version = "0.7.1", features = ["fs", "set-header", "trace", "catch-panic"] } tower-http = { version = "0.7.1", features = ["fs", "set-header", "trace", "catch-panic"] }
axum-extra = { version = "0.10.1", features = ["cookie"] } axum-extra = { version = "0.10.1", features = ["cookie"] }
sha2 = "0.10"
hex = "0.4"
tracing = "0.1.41" tracing = "0.1.41"
tracing-subscriber = { version = "0.3.19", features = ["env-filter"] } tracing-subscriber = { version = "0.3.19", features = ["env-filter"] }

View file

@ -1,6 +1,6 @@
# syntax=docker/dockerfile:1 # syntax=docker/dockerfile:1
FROM rust:1.82-bookworm AS builder FROM rust:1.89-bookworm AS builder
WORKDIR /app WORKDIR /app
@ -31,12 +31,12 @@ COPY --from=builder /app/target/release/sub-aligator /usr/local/bin/sub-aligator
COPY --from=builder /app/migrations /app/migrations COPY --from=builder /app/migrations /app/migrations
COPY --from=builder /app/static /app/static COPY --from=builder /app/static /app/static
COPY --from=builder /app/templates /app/templates COPY --from=builder /app/templates /app/templates
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
USER app RUN chmod +x /usr/local/bin/docker-entrypoint.sh
EXPOSE 8080 EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \ HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
CMD curl -fsS http://localhost:8080/healthz >/dev/null || exit 1 CMD curl -fsS http://localhost:8080/healthz >/dev/null || exit 1
CMD ["/usr/local/bin/sub-aligator"] ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]

View file

@ -1,17 +0,0 @@
class SubAligator < Formula
desc "Internal IP subnet allocator"
homepage "https://git.janvete.com/seth/sub-aligator"
url "https://git.janvete.com/seth/sub-aligator/archive/v0.1.0.tar.gz"
sha256 "PLACEHOLDER_UPDATE_AFTER_TAG"
license "MIT"
depends_on "rust" => :build
def install
system "cargo", "install", *std_cargo_args
end
test do
system "#{bin}/sub-aligator", "--version"
end
end

223
README.md
View file

@ -1,40 +1,43 @@
# IP Subnet Allocator # SUB-ALIGATOR
Jednoduchá interní webová aplikace pro evidenci a automatické přidělování IPv4 subnetů. A simple internal web application for tracking and automatically allocating IPv4 subnets.
## Účel ## Purpose
Aplikace slouží výhradně pro interní evidenci a automatické přidělování IPv4 subnetů. Není to obecný IPAM, monitoring, inventář zařízení, správa VLAN ani síťová topologie. This application is intended solely for internal tracking and automatic allocation of IPv4 subnets. It is not a general IPAM, monitoring tool, device inventory, VLAN manager, or network topology viewer.
Je navržena pro provoz pouze uvnitř LAN/VPN. **Aplikace neobsahuje autentizaci** – musí být zařazena za firewall/reverse proxy v důvěryhodné síti. It is designed to run only inside a LAN/VPN. **The application does not include authentication** and must be placed behind a firewall/reverse proxy in a trusted network.
## Přehled funkcí ## Features
- Dva přednastavené pooly: - Manage one or more IP pools manually through the web UI.
- VLAN / zákaznické subnety: `10.91.0.0/16` - First-run setup wizard: create the first pool before allocating subnets.
- VPN subnety: `10.200.0.0/16` - Automatic subnet allocation using a **first-fit** algorithm for prefixes /20 through /30.
- Automatické přidělování subnetů pomocí **first-fit** algoritmu pro prefixy /20 až /30. - Atomic allocation writes using SQLite `BEGIN IMMEDIATE` transactions.
- Atomický zápis alokací přes SQLite transakci `BEGIN IMMEDIATE`. - Retro TUI/terminal-style web UI, server-side rendered with Askama.
- Webové UI s retro TUI/terminál vzhledem, server-side renderované pomocí Askama. - Dynamic preview of the next free subnet via HTMX.
- Dynamické preview dalšího volného subnetu přes HTMX. - Allocation history with filtering, search, and pagination.
- Historie alokací s filtrem, vyhledáváním a stránkováním. - Release subnets (status `released`) with a mandatory note, directly from the history table or detail page.
- Uvolňování subnetů (status `released`) s povinnou poznámkou. - Released subnets free up address space and can be reused by the first-fit algorithm.
- Auditní log všech operací. - 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.
- Exporty do Markdown, CSV a JSON. - Light and dark (amber terminal) theme, switchable in the page header and remembered per browser.
- SQLite migrace probíhají automaticky při startu. - JSON REST API (`/api/v1/`) with Bearer token authentication; tokens are managed in the web UI.
- Zálohovací a obnovovací skripty. - Audit log of all operations.
- Markdown, CSV, and JSON exports.
- SQLite migrations run automatically on startup.
- Backup and restore scripts.
## Architektura ## Architecture
- **Rust** edition 2024 - **Rust** edition 2024
- **Axum** + **Tokio** - **Axum** + **Tokio**
- **SQLx** s **SQLite** - **SQLx** with **SQLite**
- **Askama** pro HTML šablony - **Askama** for HTML templates
- **HTMX** z lokálního statického souboru - **HTMX** from a local static file
- Vlastní CSS bez externích frameworků - Custom CSS without external frameworks
- CIDR/IP výpočty pomocí `ipnet` - CIDR/IP calculations using `ipnet`
## Rychlý start ## Quick start
```bash ```bash
cd sub-aligator cd sub-aligator
@ -42,79 +45,141 @@ cp .env.example .env
docker compose up -d --build docker compose up -d --build
``` ```
Otevřete prohlížeč na: Open a browser at:
``` ```
http://IP_SERVERU:8080 http://SERVER_IP:8080
``` ```
Výchozí port lze změnit v `.env` pomocí proměnné `APP_PORT`. On first launch you will be asked to create the first pool. The default port can be changed in `.env` via the `APP_PORT` variable.
## Persistentní data ## Persistent data
Docker Compose vytváří lokální adresáře: Docker Compose creates local directories:
- `./data` – SQLite databáze - `./data` – SQLite database
- `./exports` – vygenerované exporty (Markdown, CSV, JSON) - `./exports` – generated exports (Markdown, CSV, JSON)
- `./backups` – zálohy databáze - `./backups` – database backups
Tyto adresáře jsou bind mountovány do kontejneru, takže data přetrvávají i po odstranění kontejneru. These directories are bind-mounted into the container, so data persists even if the container is removed.
## Záloha a obnova ## Backup and restore
### Záloha ### Backup
```bash ```bash
./scripts/backup.sh ./scripts/backup.sh
``` ```
Skript vytvoří konzistentní zálohu databáze pomocí `sqlite3 .backup` s UTC timestampem v názvu souboru. Zálohy se ukládají do `./backups`. The script creates a consistent database backup using `sqlite3 .backup` with a UTC timestamp in the filename. Backups are stored in `./backups`.
### Obnova ### Restore
```bash ```bash
./scripts/restore.sh ./backups/subnet-allocator.db.20240930T120000Z.backup ./scripts/restore.sh ./backups/subnet-allocator.db.20240930T120000Z.backup
``` ```
**Upozornění:** obnova přepíše aktuální databázi. Před obnovou doporučujeme zastavit kontejner a vytvořit zálohu aktuálního stavu. **Warning:** restore overwrites the current database. Stop the container and back up the current state before restoring.
### Automatizace zálohy ### Automating backups
Na hostovi můžete použít systemd timer nebo cron. Příklad cron záznamu: On the host you can use a systemd timer or cron. Example cron entry:
```cron ```cron
0 2 * * * /cesta/k/sub-aligator/scripts/backup.sh >> /var/log/subnet-allocator-backup.log 2>&1 0 2 * * * /path/to/sub-aligator/scripts/backup.sh >> /var/log/subnet-allocator-backup.log 2>&1
``` ```
## Exporty ## Exports
Po každém úspěšném vytvoření nebo uvolnění subnetu se vygenerují read-only exporty: After every successful allocation or release, read-only exports are regenerated:
- `/app/exports/allocations.md` - `/app/exports/allocations.md`
- `/app/exports/allocations.csv` - `/app/exports/allocations.csv`
- `/app/exports/allocations.json` - `/app/exports/allocations.json`
Ke stažení jsou dostupné přes endpointy: They are also available for download via endpoints:
- `GET /exports/allocations.md` - `GET /exports/allocations.md`
- `GET /exports/allocations.csv` - `GET /exports/allocations.csv`
- `GET /exports/allocations.json` - `GET /exports/allocations.json`
Markdown export obsahuje samostatnou sekci pro každý pool s tabulkou aktivních a rezervovaných alokací. CSV a JSON obsahují všechny alokace včetně released. 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.
## Bezpečnostní omezení ## REST API
- Aplikace **nemá autentizaci**. Používejte ji pouze za VPN/LAN/firewallem. The application provides a JSON REST API under `/api/v1/` for integrations. All API requests are authenticated with a Bearer token:
- CSRF tokeny jsou povinné pro POST formuláře.
- Nastaveny bezpečnostní hlavičky: `X-Content-Type-Options`, `X-Frame-Options`, `Referrer-Policy`, `Content-Security-Policy`.
- Aplikace nemá žádný defaultní přístup z Internetu; bind portu se řeší přes Docker Compose.
- `/healthz` vrací pouze `{"status":"ok"}`.
## Pozdější přidání autentizace přes Caddy + Authentik ```
Authorization: Bearer sa_...
```
Aplikace je připravena pro autentizaci přes důvěryhodnou reverse proxy. Funkce `get_actor_from_request()` v `src/audit.rs` nyní vrací `local`, ale může být upravena pro čtení hlavičky, například `X-Authentik-Username`. ### Managing API tokens
Příklad konfigurace Caddy s Authentik: 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 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"}`.
## Adding authentication later via Caddy + Authentik
The application is prepared for authentication through a trusted reverse proxy. The function `get_actor_from_request()` in `src/audit.rs` currently returns `local`, but can be updated to read a header such as `X-Authentik-Username`.
Example Caddy configuration with Authentik:
```caddy ```caddy
subnet.example.com { subnet.example.com {
@ -127,43 +192,51 @@ subnet.example.com {
} }
``` ```
Po úpravě `get_actor_from_request()` bude auditní log obsahovat skutečného uživatele. After updating `get_actor_from_request()`, the audit log will record the real user.
## Příklad first-fit alokace ## First-fit allocation example
1. První požadavek `/30` získá `10.91.0.0/30`. 1. First request `/30` receives `10.91.0.0/30`.
2. Následný požadavek `/24` získá `10.91.1.0/24` (vyhne se obsazenému `/30`). 2. Next request `/24` receives `10.91.1.0/24` (avoids the occupied `/30`).
3. Další požadavek `/30` zaplní mezeru a získá `10.91.0.4/30`. 3. Another request `/30` fills the gap and receives `10.91.0.4/30`.
Algoritmus vždy respektuje CIDR zarovnání a najde první volný prostor dostatečné velikosti. The algorithm always respects CIDR alignment and finds the first free space large enough.
## Endpointy ## Endpoints
- `GET /` – hlavní stránka - `GET /` – main page
- `GET /healthz` – healthcheck - `GET /healthz` – healthcheck
- `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 - `GET /partials/pools/{slug}/preview?prefix_length=24` – HTMX preview
- `POST /allocations` – vytvoření alokace - `POST /allocations` – create an allocation
- `GET /allocations` – seznam alokací (součást `/`) - `GET /allocations/{id}` – allocation detail
- `GET /allocations/{id}` – detail alokace - `POST /allocations/{id}/release` – release an allocation
- `POST /allocations/{id}/release` – uvolnění alokace - `GET /exports/allocations.{md,csv,json}` – exports
- `GET /exports/allocations.{md,csv,json}` – exporty - `GET|POST /api/v1/...` – JSON REST API (see above)
## Troubleshooting ## Troubleshooting
### Kontejner nenastartuje ### Container does not start
```bash ```bash
docker compose logs -f subnet-allocator docker compose logs -f subnet-allocator
``` ```
### Databáze je uzamčena ### Database is locked
Pokud dojde k neočekávanému ukončení, může zůstat `-wal` nebo `-shm` soubor. Zastavte kontejner a zkontrolujte `./data`. If the process terminates unexpectedly, a `-wal` or `-shm` file may remain. Stop the container and check `./data`.
### Exporty se negenerují ### Exports are not generated
Zkontrolujte práva adresáře `./exports` a proměnnou `APP_EXPORTS_DIR`. Check the permissions of the `./exports` directory and the `APP_EXPORTS_DIR` variable.
## Licence ## Licence
MIT. Viz soubor `LICENSE`. MIT. See the `LICENSE` file.

9
docker-entrypoint.sh Normal file
View file

@ -0,0 +1,9 @@
#!/bin/sh
set -e
# Ensure persistent directories exist and are writable by the app user.
mkdir -p /app/data /app/exports /app/backups
chown -R app:app /app/data /app/exports /app/backups
# Drop root privileges and run the application as the app user.
exec runuser -u app -- /usr/local/bin/sub-aligator "$@"

View file

@ -0,0 +1,34 @@
-- Remove the global UNIQUE constraint on allocations.cidr so that a released
-- subnet can be re-allocated later. Enforce uniqueness only among active and
-- reserved allocations within the same pool.
CREATE TABLE IF NOT EXISTS allocations_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
pool_id INTEGER NOT NULL REFERENCES pools(id),
cidr TEXT NOT NULL,
prefix_length INTEGER NOT NULL,
address_count INTEGER NOT NULL,
usable_host_count INTEGER NOT NULL,
note TEXT NOT NULL,
status TEXT NOT NULL CHECK(status IN ('active', 'reserved', 'released')),
created_at TEXT NOT NULL,
created_by TEXT NOT NULL DEFAULT 'local',
released_at TEXT NULL,
released_by TEXT NULL,
release_note TEXT NULL
);
INSERT INTO allocations_new SELECT * FROM allocations;
DROP TABLE allocations;
ALTER TABLE allocations_new RENAME TO allocations;
CREATE INDEX IF NOT EXISTS idx_allocations_pool_id ON allocations(pool_id);
CREATE INDEX IF NOT EXISTS idx_allocations_status ON allocations(status);
CREATE INDEX IF NOT EXISTS idx_allocations_pool_status ON allocations(pool_id, status);
CREATE INDEX IF NOT EXISTS idx_allocations_created_at ON allocations(created_at DESC);
CREATE UNIQUE INDEX IF NOT EXISTS idx_allocations_active_unique
ON allocations(pool_id, cidr)
WHERE status IN ('active', 'reserved');

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

@ -1,5 +1,4 @@
use crate::config::Config; use crate::config::Config;
use crate::models::now_utc_rfc3339;
use anyhow::Context; use anyhow::Context;
use sqlx::migrate::{MigrateDatabase, Migrator}; use sqlx::migrate::{MigrateDatabase, Migrator};
use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqliteSynchronous}; use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqliteSynchronous};
@ -38,45 +37,9 @@ pub async fn init_db(config: &Config) -> anyhow::Result<SqlitePool> {
.await .await
.context("run migrations")?; .context("run migrations")?;
seed_default_pools(&pool).await.context("seed default pools")?;
info!("Database initialized: {}", database_url); info!("Database initialized: {}", database_url);
Ok(pool) Ok(pool)
} else { } else {
anyhow::bail!("Unsupported DATABASE_URL: {}", database_url) anyhow::bail!("Unsupported DATABASE_URL: {}", database_url)
} }
} }
async fn seed_default_pools(pool: &SqlitePool) -> anyhow::Result<()> {
let allowed = serde_json::to_string(&[20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30])?;
let now = now_utc_rfc3339();
sqlx::query(
"INSERT OR IGNORE INTO pools (slug, name, cidr, kind, allowed_prefixes_json, enabled, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)"
)
.bind("vlan")
.bind("VLAN / zákaznické subnety")
.bind("10.91.0.0/16")
.bind("VLAN")
.bind(&allowed)
.bind(true)
.bind(&now)
.execute(pool)
.await?;
sqlx::query(
"INSERT OR IGNORE INTO pools (slug, name, cidr, kind, allowed_prefixes_json, enabled, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)"
)
.bind("vpn")
.bind("VPN subnety")
.bind("10.200.0.0/16")
.bind("VPN")
.bind(&allowed)
.bind(true)
.bind(&now)
.execute(pool)
.await?;
Ok(())
}

View file

@ -42,9 +42,9 @@ async fn generate_markdown(
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
let path = dir.join("allocations.md"); let path = dir.join("allocations.md");
let mut lines = vec![ let mut lines = vec![
"# IP Subnet Allocator – Export".to_string(), "# SUB-ALIGATOR – Export".to_string(),
"".to_string(), "".to_string(),
format!("Vygenerováno: {}", chrono::Utc::now().to_rfc3339()), format!("Generated: {}", chrono::Utc::now().to_rfc3339()),
"".to_string(), "".to_string(),
]; ];
@ -58,13 +58,13 @@ async fn generate_markdown(
.collect(); .collect();
if pool_allocs.is_empty() { if pool_allocs.is_empty() {
lines.push("*Žádné aktivní ani rezervované alokace.*".to_string()); lines.push("*No active or reserved allocations.*".to_string());
lines.push("".to_string()); lines.push("".to_string());
continue; continue;
} }
lines.push("| Subnet | Stav | Poznámka | Vytvořeno |".to_string()); lines.push("| Subnet | Status | Note | Created |".to_string());
lines.push("|--------|------|----------|-----------|".to_string()); lines.push("|--------|--------|------|---------|".to_string());
for a in pool_allocs { for a in pool_allocs {
lines.push(format!( lines.push(format!(
"| {} | {} | {} | {} |", "| {} | {} | {} | {} |",

View file

@ -42,17 +42,17 @@ pub async fn create_allocation(
let actor = get_actor_from_request(&headers); let actor = get_actor_from_request(&headers);
if !validate_csrf(&jar, &form.csrf_token) { if !validate_csrf(&jar, &form.csrf_token) {
return render_index_with_error(&state, &jar, &csrf_token, "Neplatný CSRF token.").await.into_response(); return render_index_with_error(&state, &jar, &csrf_token, "Invalid CSRF token.").await.into_response();
} }
let note = form.note.trim(); let note = form.note.trim();
if note.len() < 3 { if note.len() < 3 {
return render_index_with_error(&state, &jar, &csrf_token, "Poznámka musí mít alespoň 3 znaky.").await.into_response(); return render_index_with_error(&state, &jar, &csrf_token, "Note must be at least 3 characters long.").await.into_response();
} }
let pool = match load_pool_by_slug(&state.pool, &form.pool_slug).await { let pool = match load_pool_by_slug(&state.pool, &form.pool_slug).await {
Some(p) => p, Some(p) => p,
None => return render_index_with_error(&state, &jar, &csrf_token, "Pool neexistuje.").await.into_response(), None => return render_index_with_error(&state, &jar, &csrf_token, "Pool does not exist.").await.into_response(),
}; };
let result = allocate_in_tx( let result = allocate_in_tx(
@ -87,7 +87,7 @@ pub async fn create_allocation(
let mut message = success.clone(); let mut message = success.clone();
if actual_cidr_different { if actual_cidr_different {
message.note = format!( message.note = format!(
"{} (preview se mezitím změnil, přiděleno: {})", "{} (preview changed in the meantime, allocated: {})",
allocation.note, allocation.cidr allocation.note, allocation.cidr
); );
} }
@ -122,17 +122,15 @@ pub async fn allocate_in_tx(
ticket: &str, ticket: &str,
actor: &str, actor: &str,
) -> anyhow::Result<(Allocation, crate::allocator::SubnetInfo)> { ) -> anyhow::Result<(Allocation, crate::allocator::SubnetInfo)> {
let mut conn = pool.acquire().await?; // begin_with gives us BEGIN IMMEDIATE plus automatic rollback on drop,
// so no error path can leak an open transaction into the pool.
sqlx::query("BEGIN IMMEDIATE") let mut tx = pool.begin_with("BEGIN IMMEDIATE").await?;
.execute(&mut *conn)
.await?;
let active: Vec<String> = sqlx::query_scalar( let active: Vec<String> = sqlx::query_scalar(
"SELECT cidr FROM allocations WHERE pool_id = ? AND status IN ('active', 'reserved')", "SELECT cidr FROM allocations WHERE pool_id = ? AND status IN ('active', 'reserved')",
) )
.bind(pool_id) .bind(pool_id)
.fetch_all(&mut *conn) .fetch_all(&mut *tx)
.await?; .await?;
let info = find_next_subnet(parent_cidr, prefix_length, allowed_prefixes, &active)?; 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(AllocationStatus::Active.as_str())
.bind(now_utc_rfc3339()) .bind(now_utc_rfc3339())
.bind(actor) .bind(actor)
.execute(&mut *conn) .execute(&mut *tx)
.await? .await?
.last_insert_rowid(); .last_insert_rowid();
log_event( log_event(
&mut conn, &mut tx,
"allocation_created", "allocation_created",
Some(id), Some(id),
Some(pool_id), Some(pool_id),
@ -174,7 +172,7 @@ pub async fn allocate_in_tx(
.await .await
.ok(); .ok();
sqlx::query("COMMIT").execute(&mut *conn).await?; tx.commit().await?;
let allocation = sqlx::query_as::<_, Allocation>("SELECT * FROM allocations WHERE id = ?") let allocation = sqlx::query_as::<_, Allocation>("SELECT * FROM allocations WHERE id = ?")
.bind(id) .bind(id)
@ -195,7 +193,7 @@ pub async fn release_allocation(
let actor = get_actor_from_request(&headers); let actor = get_actor_from_request(&headers);
if !validate_csrf(&jar, &form.csrf_token) { if !validate_csrf(&jar, &form.csrf_token) {
return render_detail_with_error(&state, id, &jar, &csrf_token, "Neplatný CSRF token.").await.into_response(); return render_detail_with_error(&state, id, &jar, &csrf_token, "Invalid CSRF token.").await.into_response();
} }
let release_note = form.release_note.trim(); let release_note = form.release_note.trim();
@ -205,9 +203,10 @@ pub async fn release_allocation(
id, id,
&jar, &jar,
&csrf_token, &csrf_token,
"Poznámka k uvolnění musí mít alespoň 3 znaky.", "Release note must be at least 3 characters long.",
) )
.await.into_response(); .await
.into_response();
} }
let result = release_in_tx(&state.pool, id, release_note, &actor).await; let result = release_in_tx(&state.pool, id, release_note, &actor).await;
@ -216,7 +215,7 @@ pub async fn release_allocation(
Ok(_) => { Ok(_) => {
let _ = regenerate_after_change(&state).await; let _ = regenerate_after_change(&state).await;
info!("Released allocation {} by {}", id, actor); info!("Released allocation {} by {}", id, actor);
render_detail_with_error(&state, id, &jar, &csrf_token, "Subnet byl úspěšně uvolněn.").await.into_response() render_detail_with_error(&state, id, &jar, &csrf_token, "Subnet released successfully.").await.into_response()
} }
Err(e) => { Err(e) => {
warn!("Release failed for allocation {}: {}", id, e); warn!("Release failed for allocation {}: {}", id, e);
@ -231,18 +230,14 @@ pub async fn release_in_tx(
release_note: &str, release_note: &str,
actor: &str, actor: &str,
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
let mut conn = pool.acquire().await?; let mut tx = pool.begin_with("BEGIN IMMEDIATE").await?;
sqlx::query("BEGIN IMMEDIATE")
.execute(&mut *conn)
.await?;
let allocation: Allocation = let allocation: Allocation =
sqlx::query_as("SELECT * FROM allocations WHERE id = ? AND status IN ('active', 'reserved')") sqlx::query_as("SELECT * FROM allocations WHERE id = ? AND status IN ('active', 'reserved')")
.bind(id) .bind(id)
.fetch_one(&mut *conn) .fetch_one(&mut *tx)
.await .await
.map_err(|_| anyhow::anyhow!("Alokace neexistuje nebo již není aktivní."))?; .map_err(|_| anyhow::anyhow!("Allocation does not exist or is no longer active."))?;
sqlx::query( sqlx::query(
"UPDATE allocations SET status = 'released', released_at = ?, released_by = ?, release_note = ? WHERE id = ?", "UPDATE allocations SET status = 'released', released_at = ?, released_by = ?, release_note = ? WHERE id = ?",
@ -251,11 +246,11 @@ pub async fn release_in_tx(
.bind(actor) .bind(actor)
.bind(release_note) .bind(release_note)
.bind(id) .bind(id)
.execute(&mut *conn) .execute(&mut *tx)
.await?; .await?;
log_event( log_event(
&mut conn, &mut tx,
"allocation_released", "allocation_released",
Some(id), Some(id),
Some(allocation.pool_id), Some(allocation.pool_id),
@ -268,12 +263,12 @@ pub async fn release_in_tx(
.await .await
.ok(); .ok();
sqlx::query("COMMIT").execute(&mut *conn).await?; tx.commit().await?;
Ok(()) Ok(())
} }
async fn regenerate_after_change(state: &AppState) -> anyhow::Result<()> { pub(crate) async fn regenerate_after_change(state: &AppState) -> anyhow::Result<()> {
let pools: Vec<Pool> = sqlx::query_as("SELECT * FROM pools WHERE enabled = true") let pools: Vec<Pool> = sqlx::query_as("SELECT * FROM pools ORDER BY id")
.fetch_all(&state.pool) .fetch_all(&state.pool)
.await?; .await?;
let allocations: Vec<Allocation> = let allocations: Vec<Allocation> =
@ -299,7 +294,7 @@ async fn render_index_with_error(state: &AppState, _jar: &CookieJar, csrf_token:
Ok(html) => Html(html), Ok(html) => Html(html),
Err(e) => { Err(e) => {
error!("Template render error: {}", e); error!("Template render error: {}", e);
Html("Chyba při vykreslování stránky.".to_string()) Html("Error rendering page.".to_string())
} }
} }
} }
@ -310,7 +305,7 @@ async fn render_index_with_success(state: &AppState, _jar: &CookieJar, csrf_toke
Ok(html) => Html(html), Ok(html) => Html(html),
Err(e) => { Err(e) => {
error!("Template render error: {}", e); error!("Template render error: {}", e);
Html("Chyba při vykreslování stránky.".to_string()) Html("Error rendering page.".to_string())
} }
} }
} }
@ -320,8 +315,15 @@ pub async fn build_index_template(
csrf_token: &str, csrf_token: &str,
flash: FlashData, flash: FlashData,
) -> IndexTemplate { ) -> IndexTemplate {
let pools = load_pool_templates(state).await;
let all_pools = sqlx::query_as::<_, Pool>("SELECT * FROM pools ORDER BY id")
.fetch_all(&state.pool)
.await
.unwrap_or_default();
IndexTemplate { IndexTemplate {
pools: load_pool_templates(state).await, pools,
all_pools,
allocations: vec![], allocations: vec![],
page: 1, page: 1,
total_pages: 0, total_pages: 0,
@ -350,7 +352,7 @@ async fn render_detail_with_error(state: &AppState, id: i64, _jar: &CookieJar, c
Ok(html) => Html(html), Ok(html) => Html(html),
Err(e) => { Err(e) => {
error!("Template render error: {}", e); error!("Template render error: {}", e);
Html("Chyba při vykreslování stránky.".to_string()) Html("Error rendering page.".to_string())
} }
} }
} }
@ -379,7 +381,7 @@ async fn build_detail_template(
pool_name: "N/A".to_string(), pool_name: "N/A".to_string(),
address_count: 0, address_count: 0,
usable_host_count: 0, usable_host_count: 0,
note: "Alokace nenalezena.".to_string(), note: "Allocation not found.".to_string(),
ticket: "–".to_string(), ticket: "–".to_string(),
status: "released".to_string(), status: "released".to_string(),
network: "N/A".to_string(), network: "N/A".to_string(),

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,4 +1,7 @@
pub mod allocations; pub mod allocations;
pub mod api;
pub mod exports; pub mod exports;
pub mod pages; pub mod pages;
pub mod partials; pub mod partials;
pub mod pools;
pub mod tokens;

View file

@ -4,7 +4,7 @@ use crate::state::AppState;
use crate::templates::{AllocationDetailTemplate, FlashData, IndexTemplate, PoolTemplate}; use crate::templates::{AllocationDetailTemplate, FlashData, IndexTemplate, PoolTemplate};
use askama::Template; use askama::Template;
use axum::extract::{Query, State}; use axum::extract::{Query, State};
use axum::response::{Html, IntoResponse, Response}; use axum::response::{Html, IntoResponse, Redirect, Response};
use axum_extra::extract::cookie::CookieJar; use axum_extra::extract::cookie::CookieJar;
use serde::Deserialize; use serde::Deserialize;
use tracing::error; use tracing::error;
@ -27,10 +27,19 @@ pub async fn index(
State(state): State<AppState>, State(state): State<AppState>,
jar: CookieJar, jar: CookieJar,
Query(q): Query<IndexQuery>, Query(q): Query<IndexQuery>,
) -> impl IntoResponse { ) -> Response {
let (jar, csrf_token) = ensure_csrf_cookie(jar); let (jar, csrf_token) = ensure_csrf_cookie(jar);
let page = q.page.max(1); let page = q.page.max(1);
let pool_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM pools")
.fetch_one(&state.pool)
.await
.unwrap_or(0);
if pool_count == 0 {
return Redirect::to("/setup").into_response();
}
let pools: Vec<PoolTemplate> = sqlx::query_as::<_, Pool>( let pools: Vec<PoolTemplate> = sqlx::query_as::<_, Pool>(
"SELECT * FROM pools WHERE enabled = true ORDER BY id", "SELECT * FROM pools WHERE enabled = true ORDER BY id",
) )
@ -41,6 +50,11 @@ pub async fn index(
.map(PoolTemplate::from) .map(PoolTemplate::from)
.collect(); .collect();
let all_pools: Vec<Pool> = sqlx::query_as::<_, Pool>("SELECT * FROM pools ORDER BY id")
.fetch_all(&state.pool)
.await
.unwrap_or_default();
let (allocations, total_count) = let (allocations, total_count) =
load_allocations(&state, &q.q, &q.pool, &q.status, page).await; load_allocations(&state, &q.q, &q.pool, &q.status, page).await;
@ -48,6 +62,7 @@ pub async fn index(
let template = IndexTemplate { let template = IndexTemplate {
pools, pools,
all_pools,
allocations, allocations,
page, page,
total_pages, total_pages,
@ -63,7 +78,7 @@ pub async fn index(
Ok(html) => (jar, Html(html)).into_response(), Ok(html) => (jar, Html(html)).into_response(),
Err(e) => { Err(e) => {
error!("Index template render error: {}", e); error!("Index template render error: {}", e);
(jar, Html("Chyba při vykreslování stránky.".to_string())).into_response() (jar, Html("Error rendering page.".to_string())).into_response()
} }
} }
} }
@ -85,7 +100,7 @@ pub async fn allocation_detail(
let allocation = match allocation { let allocation = match allocation {
Some(a) => a, Some(a) => a,
None => { None => {
return (jar, Html("Alokace nenalezena.".to_string())).into_response(); return (jar, Html("Allocation not found.".to_string())).into_response();
} }
}; };
@ -154,7 +169,7 @@ pub async fn allocation_detail(
Ok(html) => (jar, Html(html)).into_response(), Ok(html) => (jar, Html(html)).into_response(),
Err(e) => { Err(e) => {
error!("Detail template render error: {}", e); error!("Detail template render error: {}", e);
(jar, Html("Chyba při vykreslování stránky.".to_string())).into_response() (jar, Html("Error rendering page.".to_string())).into_response()
} }
} }
} }

View file

@ -33,7 +33,7 @@ pub async fn pool_preview(
address_count: 0, address_count: 0,
usable_host_count: 0, usable_host_count: 0,
has_error: true, has_error: true,
error: "Pool neexistuje.".to_string(), error: "Pool does not exist.".to_string(),
}; };
return Html(template.render().unwrap_or_default()); return Html(template.render().unwrap_or_default());
} }
@ -72,7 +72,7 @@ pub async fn pool_preview(
Ok(html) => Html(html), Ok(html) => Html(html),
Err(e) => { Err(e) => {
error!("Preview template render error: {}", e); error!("Preview template render error: {}", e);
Html("Chyba preview.".to_string()) Html("Preview error.".to_string())
} }
} }
} }

492
src/handlers/pools.rs Normal file
View file

@ -0,0 +1,492 @@
use crate::audit::{get_actor_from_request, log_event};
use crate::csrf::{ensure_csrf_cookie, validate_csrf};
use crate::models::{now_utc_rfc3339, Pool};
use crate::state::AppState;
use crate::templates::{PoolListTemplate, PoolRow, SetupTemplate};
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 ipnet::Ipv4Net;
use serde::Deserialize;
use serde_json::json;
use sqlx::SqlitePool;
use tracing::{error, info};
#[derive(Deserialize, Debug)]
pub struct CreatePoolForm {
pub slug: String,
pub name: String,
pub cidr: String,
pub kind: String,
pub allowed_prefixes: String,
pub csrf_token: String,
}
#[derive(Deserialize, Debug)]
pub struct DeletePoolForm {
pub csrf_token: String,
}
pub async fn setup_page(State(state): State<AppState>, jar: CookieJar) -> Response {
let (jar, csrf_token) = ensure_csrf_cookie(jar);
let pool_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM pools")
.fetch_one(&state.pool)
.await
.unwrap_or(0);
if pool_count > 0 {
return Redirect::to("/").into_response();
}
let template = SetupTemplate {
csrf_token,
error: String::new(),
};
match template.render() {
Ok(html) => (jar, Html(html)).into_response(),
Err(e) => {
error!("Setup template render error: {}", e);
(jar, Html("Error rendering setup page.".to_string())).into_response()
}
}
}
pub async fn create_pool(
State(state): State<AppState>,
jar: CookieJar,
headers: HeaderMap,
Form(form): Form<CreatePoolForm>,
) -> 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_setup_with_error(csrf_token, "Invalid CSRF token.").into_response();
}
match validate_and_insert_pool(&state.pool, &form, &actor).await {
Ok(outcome) => {
info!("Pool {} saved by {} ({:?})", form.slug, actor, outcome);
crate::handlers::allocations::regenerate_after_change(&state)
.await
.ok();
Redirect::to("/").into_response()
}
Err(e) => render_setup_with_error(csrf_token, &e.to_string()).into_response(),
}
}
pub async fn delete_pool(
State(state): State<AppState>,
jar: CookieJar,
headers: HeaderMap,
Path(id): Path<i64>,
Form(form): Form<DeletePoolForm>,
) -> 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_pool_list_with_error(&state, csrf_token, "Invalid CSRF token.")
.await
.into_response();
}
match soft_delete_pool(&state, id, &actor).await {
Ok(()) => {
info!("Pool {} deleted (disabled) by {}", id, actor);
crate::handlers::allocations::regenerate_after_change(&state)
.await
.ok();
Redirect::to("/pools").into_response()
}
Err(e) => {
error!("Failed to delete pool {}: {}", id, e);
render_pool_list_with_error(&state, csrf_token, &e.to_string())
.await
.into_response()
}
}
}
async fn soft_delete_pool(state: &AppState, id: i64, actor: &str) -> anyhow::Result<()> {
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 *tx)
.await?;
let pool = match pool {
Some(p) => p,
None => {
anyhow::bail!("Pool not found.");
}
};
if !pool.is_enabled() {
anyhow::bail!("Pool is already deleted.");
}
let (active_count, reserved_count, released_count): (i64, 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), \
COALESCE(SUM(CASE WHEN status = 'released' THEN 1 ELSE 0 END), 0) \
FROM allocations WHERE pool_id = ?",
)
.bind(id)
.fetch_one(&mut *tx)
.await?;
// Soft delete only: the pool row and all of its allocations stay in the
// database, so the address range keeps its history and can be re-attached
// if the same or an overlapping pool is added again later.
sqlx::query("UPDATE pools SET enabled = false WHERE id = ?")
.bind(id)
.execute(&mut *tx)
.await?;
log_event(
&mut tx,
"pool_deleted",
None,
Some(id),
actor,
json!({
"slug": pool.slug,
"cidr": pool.cidr,
"allocations_kept": {
"active": active_count,
"reserved": reserved_count,
"released": released_count,
},
}),
)
.await
.ok();
tx.commit().await?;
Ok(())
}
pub async fn pool_list(State(state): State<AppState>, jar: CookieJar) -> Response {
let (jar, csrf_token) = ensure_csrf_cookie(jar);
let template = build_pool_list_template(&state, csrf_token, String::new()).await;
match template.render() {
Ok(html) => (jar, Html(html)).into_response(),
Err(e) => {
error!("Pool list template render error: {}", e);
(jar, Html("Error rendering pool list.".to_string())).into_response()
}
}
}
async fn render_pool_list_with_error(
state: &AppState,
csrf_token: String,
error: &str,
) -> Html<String> {
let template = build_pool_list_template(state, csrf_token, error.to_string()).await;
match template.render() {
Ok(html) => Html(html),
Err(e) => {
error!("Pool list template render error: {}", e);
Html("Error rendering pool list.".to_string())
}
}
}
async fn build_pool_list_template(
state: &AppState,
csrf_token: String,
error: String,
) -> PoolListTemplate {
let pools: Vec<Pool> =
sqlx::query_as::<_, Pool>("SELECT * FROM pools WHERE enabled = true ORDER BY id")
.fetch_all(&state.pool)
.await
.unwrap_or_default();
let mut rows = Vec::with_capacity(pools.len());
for pool in pools {
let (active_count, reserved_count, released_count): (i64, 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), \
COALESCE(SUM(CASE WHEN status = 'released' THEN 1 ELSE 0 END), 0) \
FROM allocations WHERE pool_id = ?",
)
.bind(pool.id)
.fetch_one(&state.pool)
.await
.unwrap_or((0, 0, 0));
rows.push(PoolRow {
total_count: active_count + reserved_count + released_count,
active_count,
reserved_count,
released_count,
pool,
});
}
PoolListTemplate {
pools: rows,
csrf_token,
error,
}
}
fn render_setup_with_error(csrf_token: String, error: &str) -> Html<String> {
let template = SetupTemplate {
csrf_token,
error: error.to_string(),
};
match template.render() {
Ok(html) => Html(html),
Err(e) => {
error!("Setup template render error: {}", e);
Html("Error rendering setup page.".to_string())
}
}
}
#[derive(Debug)]
pub enum CreatePoolOutcome {
Created { id: i64, reattached: i64 },
Reenabled { id: i64 },
}
fn nets_overlap(a: &Ipv4Net, b: &Ipv4Net) -> bool {
a.network() <= b.broadcast() && b.network() <= a.broadcast()
}
fn net_contains(outer: &Ipv4Net, inner: &Ipv4Net) -> bool {
outer.network() <= inner.network() && inner.broadcast() <= outer.broadcast()
}
async fn validate_and_insert_pool(
pool: &SqlitePool,
form: &CreatePoolForm,
actor: &str,
) -> anyhow::Result<CreatePoolOutcome> {
let slug = form.slug.trim().to_lowercase();
if slug.is_empty() || !slug.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') {
anyhow::bail!("Slug must contain only lowercase letters, numbers and hyphens.");
}
let name = form.name.trim();
if name.len() < 2 {
anyhow::bail!("Pool name must have at least 2 characters.");
}
let kind = form.kind.trim();
if kind.is_empty() {
anyhow::bail!("Pool type is required.");
}
let net: Ipv4Net = form
.cidr
.trim()
.parse()
.map_err(|_| anyhow::anyhow!("Invalid IPv4 CIDR: {}", form.cidr))?;
let cidr = net.to_string();
let parent_prefix = net.prefix_len() as i32;
let prefixes: Vec<i32> = form
.allowed_prefixes
.split(',')
.filter(|s| !s.trim().is_empty())
.map(|s| s.trim().parse::<i32>())
.collect::<Result<Vec<_>, _>>()
.map_err(|_| anyhow::anyhow!("Allowed prefixes must be comma-separated integers."))?;
if prefixes.is_empty() {
anyhow::bail!("At least one allowed prefix length is required.");
}
for p in &prefixes {
if *p < 1 || *p > 32 {
anyhow::bail!("Prefix length {} is out of range 1-32.", p);
}
if *p < parent_prefix {
anyhow::bail!(
"Allowed prefix /{} is larger than the pool prefix /{}.",
p,
parent_prefix
);
}
}
let allowed_json = serde_json::to_string(&prefixes)?;
let now = now_utc_rfc3339();
// 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 *tx)
.await?;
if let Some(existing) = existing_by_cidr {
if existing.is_enabled() {
anyhow::bail!("A pool with the same CIDR already exists.");
}
// The same range was deleted before: re-enable it so all of its
// historical allocations immediately block the address space again.
let slug_taken: Option<i64> =
sqlx::query_scalar("SELECT id FROM pools WHERE slug = ? AND id != ?")
.bind(&slug)
.bind(existing.id)
.fetch_optional(&mut *tx)
.await?;
if slug_taken.is_some() {
anyhow::bail!("Slug is already used by another pool.");
}
sqlx::query(
"UPDATE pools SET slug = ?, name = ?, kind = ?, allowed_prefixes_json = ?, enabled = true WHERE id = ?",
)
.bind(&slug)
.bind(name)
.bind(kind)
.bind(&allowed_json)
.bind(existing.id)
.execute(&mut *tx)
.await?;
log_event(
&mut tx,
"pool_reenabled",
None,
Some(existing.id),
actor,
json!({
"slug": slug,
"cidr": cidr,
"kind": kind,
"allowed_prefixes": prefixes,
}),
)
.await
.ok();
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 *tx)
.await?;
if slug_taken.is_some() {
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 *tx)
.await?;
for p in &enabled_pools {
if let Ok(other) = p.cidr.parse::<Ipv4Net>()
&& nets_overlap(&net, &other)
{
anyhow::bail!(
"Pool range overlaps with existing pool '{}' ({}).",
p.name,
p.cidr
);
}
}
let id = sqlx::query(
"INSERT INTO pools (slug, name, cidr, kind, allowed_prefixes_json, enabled, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)"
)
.bind(&slug)
.bind(name)
.bind(&cidr)
.bind(kind)
.bind(&allowed_json)
.bind(true)
.bind(&now)
.execute(&mut *tx)
.await?
.last_insert_rowid();
// Re-attach allocations that belonged to deleted pools and fall inside
// the new range, so the first-fit algorithm keeps respecting them and
// no overlapping subnet can be handed out.
let disabled_pools: Vec<Pool> =
sqlx::query_as("SELECT * FROM pools WHERE enabled = false AND id != ?")
.bind(id)
.fetch_all(&mut *tx)
.await?;
let mut reattached: i64 = 0;
for dp in &disabled_pools {
let Ok(dp_net) = dp.cidr.parse::<Ipv4Net>() else {
continue;
};
if !nets_overlap(&net, &dp_net) {
continue;
}
let orphans: Vec<(i64, String)> =
sqlx::query_as("SELECT id, cidr FROM allocations WHERE pool_id = ?")
.bind(dp.id)
.fetch_all(&mut *tx)
.await?;
for (alloc_id, alloc_cidr) in orphans {
let Ok(alloc_net) = alloc_cidr.parse::<Ipv4Net>() else {
continue;
};
if net_contains(&net, &alloc_net) {
sqlx::query("UPDATE allocations SET pool_id = ? WHERE id = ?")
.bind(id)
.bind(alloc_id)
.execute(&mut *tx)
.await?;
reattached += 1;
}
}
}
log_event(
&mut tx,
"pool_created",
None,
Some(id),
actor,
json!({
"slug": slug,
"cidr": cidr,
"kind": kind,
"allowed_prefixes": prefixes,
"reattached_allocations": reattached,
}),
)
.await
.ok();
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 allocator;
pub mod api_tokens;
pub mod audit; pub mod audit;
pub mod config; pub mod config;
pub mod csrf; 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)] #[derive(Clone, Debug, FromRow)]
pub struct AuditEvent { pub struct AuditEvent {
pub id: i64, pub id: i64,

View file

@ -1,4 +1,4 @@
use crate::handlers::{allocations, exports, pages, partials}; use crate::handlers::{allocations, api, exports, pages, partials, pools, tokens};
use axum::routing::{get, post}; use axum::routing::{get, post};
use axum::Router; use axum::Router;
@ -6,6 +6,14 @@ pub fn router() -> Router<crate::state::AppState> {
Router::new() Router::new()
.route("/", get(pages::index)) .route("/", get(pages::index))
.route("/healthz", get(pages::healthz)) .route("/healthz", get(pages::healthz))
.route("/setup", get(pools::setup_page).post(pools::create_pool))
.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("/partials/pools/{slug}/preview", get(partials::pool_preview))
.route("/allocations", post(allocations::create_allocation)) .route("/allocations", post(allocations::create_allocation))
.route("/allocations/{id}", get(pages::allocation_detail)) .route("/allocations/{id}", get(pages::allocation_detail))
@ -16,4 +24,15 @@ pub fn router() -> Router<crate::state::AppState> {
.route("/exports/allocations.md", get(exports::export_md)) .route("/exports/allocations.md", get(exports::export_md))
.route("/exports/allocations.csv", get(exports::export_csv)) .route("/exports/allocations.csv", get(exports::export_csv))
.route("/exports/allocations.json", get(exports::export_json)) .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

@ -11,6 +11,7 @@ pub struct BaseTemplate {
#[template(path = "index.html")] #[template(path = "index.html")]
pub struct IndexTemplate { pub struct IndexTemplate {
pub pools: Vec<PoolTemplate>, pub pools: Vec<PoolTemplate>,
pub all_pools: Vec<Pool>,
pub allocations: Vec<AllocationListItem>, pub allocations: Vec<AllocationListItem>,
pub page: i64, pub page: i64,
pub total_pages: i64, pub total_pages: i64,
@ -130,6 +131,7 @@ pub struct AllocationsTableTemplate {
pub query: String, pub query: String,
pub pool_filter: String, pub pool_filter: String,
pub status_filter: String, pub status_filter: String,
pub csrf_token: String,
} }
#[derive(Template)] #[derive(Template)]
@ -140,3 +142,49 @@ pub struct AllocationDetailTemplate {
pub csrf_token: String, pub csrf_token: String,
pub flash: FlashData, pub flash: FlashData,
} }
#[derive(Template)]
#[template(path = "setup.html")]
pub struct SetupTemplate {
pub csrf_token: String,
pub error: String,
}
#[derive(Clone, Debug)]
pub struct PoolRow {
pub pool: Pool,
pub active_count: i64,
pub reserved_count: i64,
pub released_count: i64,
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 {
pub pools: Vec<PoolRow>,
pub csrf_token: String,
pub error: String,
}

135
static/app.js Normal file
View file

@ -0,0 +1,135 @@
function initApp() {
console.log('sub-aligator app.js initialized');
// Ensure every modal starts closed, even if the browser restored a cached state.
document.querySelectorAll('.modal-overlay').forEach(function (modal) {
modal.style.display = 'none';
});
// Open allocation modal
document.querySelectorAll('.allocate-btn').forEach(function (btn) {
btn.addEventListener('click', function () {
console.log('allocate-btn clicked', btn.dataset);
openAllocateModal(btn);
});
});
// Open release modal from history table
document.querySelectorAll('.release-btn').forEach(function (btn) {
btn.addEventListener('click', function () {
const id = btn.dataset.id;
const modal = document.getElementById('release-modal-' + id);
if (modal) {
modal.style.display = 'flex';
}
});
});
// Open pool delete modal from the pools page
document.querySelectorAll('.pool-delete-btn').forEach(function (btn) {
btn.addEventListener('click', function () {
const id = btn.dataset.id;
const modal = document.getElementById('delete-pool-modal-' + id);
if (modal) {
modal.style.display = 'flex';
}
});
});
// 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 () {
const modalId = btn.dataset.modal;
const modal = document.getElementById(modalId);
if (modal) {
modal.style.display = 'none';
}
});
});
// Close modal by clicking on the overlay background
document.querySelectorAll('.modal-overlay').forEach(function (overlay) {
overlay.addEventListener('click', function (event) {
if (event.target === overlay) {
overlay.style.display = 'none';
}
});
});
// Prevent clicks inside the modal from closing it
document.querySelectorAll('.modal').forEach(function (modal) {
modal.addEventListener('click', function (event) {
event.stopPropagation();
});
});
// Hide the modal right before its form is submitted.
document.querySelectorAll('.modal form').forEach(function (form) {
form.addEventListener('submit', function () {
const overlay = form.closest('.modal-overlay');
if (overlay) {
overlay.style.display = 'none';
}
});
});
}
async function openAllocateModal(btn) {
const slug = btn.dataset.slug;
const name = btn.dataset.name;
const select = document.getElementById('prefix-' + slug);
let prefixLength = '';
if (select && select.value) {
prefixLength = select.value;
} else if (select && select.options.length > 0) {
prefixLength = select.options[0].value;
} else {
prefixLength = '24';
}
console.log('openAllocateModal', { slug: slug, name: name, prefixLength: prefixLength });
document.getElementById('modal-pool-slug').value = slug;
document.getElementById('modal-prefix-length').value = prefixLength;
document.getElementById('modal-pool-name').textContent = name;
document.getElementById('modal-prefix-display').textContent = '/' + prefixLength;
try {
const resp = await fetch('/partials/pools/' + slug + '/preview?prefix_length=' + prefixLength);
const html = await resp.text();
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
const cidr = doc.getElementById('preview-cidr')?.textContent || '–';
const addressCount = doc.getElementById('preview-address-count')?.textContent || '–';
const usableHosts = doc.getElementById('preview-usable-hosts')?.textContent || '–';
const warning = doc.getElementById('preview-warning')?.textContent || '';
document.getElementById('modal-cidr').textContent = cidr;
document.getElementById('modal-address-count').textContent = addressCount;
document.getElementById('modal-usable-hosts').textContent = usableHosts;
document.getElementById('modal-warning').textContent = warning;
} catch (e) {
console.error('preview fetch failed', e);
document.getElementById('modal-warning').textContent = 'Could not load preview.';
}
document.getElementById('allocate-modal').style.display = 'flex';
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initApp);
} else {
initApp();
}

View file

@ -14,11 +14,38 @@
--card-bg: #faf8f3; --card-bg: #faf8f3;
--hover: #ebe7de; --hover: #ebe7de;
--active: #d4edda; --active: #d4edda;
--active-fg: #155724;
--reserved: #fff3cd; --reserved: #fff3cd;
--reserved-fg: #856404;
--released: #e2e3e5; --released: #e2e3e5;
--released-fg: #383d41;
--link: #0044cc; --link: #0044cc;
} }
[data-theme="dark"] {
--bg: #0c0c0c;
--fg: #ffa63d;
--muted: #b97c35;
--border: #7a4d16;
--accent: #2b1c08;
--accent-fg: #ffb966;
--success-bg: #0e2214;
--success-fg: #7ed491;
--error-bg: #260f0f;
--error-fg: #ff7b72;
--warning-bg: #26200a;
--warning-fg: #e3c05c;
--card-bg: #121212;
--hover: #241a0d;
--active: #0e2214;
--active-fg: #7ed491;
--reserved: #26200a;
--reserved-fg: #e3c05c;
--released: #1a1a1a;
--released-fg: #9a9a9a;
--link: #ffc06e;
}
* { * {
box-sizing: border-box; box-sizing: border-box;
} }
@ -57,6 +84,27 @@ header {
border: 2px solid var(--border); border: 2px solid var(--border);
padding: 1rem; padding: 1rem;
background: var(--card-bg); background: var(--card-bg);
position: relative;
}
.header-nav {
position: absolute;
top: 0.75rem;
display: flex;
gap: 0.5rem;
}
.header-nav-left {
left: 0.75rem;
}
.header-nav-right {
right: 0.75rem;
}
.header-nav .btn {
padding: 0.25rem 0.6rem;
font-size: 0.875rem;
} }
header h1 { header h1 {
@ -133,6 +181,7 @@ textarea:focus {
.btn:hover { .btn:hover {
background: var(--fg); background: var(--fg);
color: var(--bg);
} }
.btn-secondary { .btn-secondary {
@ -154,6 +203,22 @@ textarea:focus {
color: var(--error-bg); color: var(--error-bg);
} }
.btn-link {
display: inline;
padding: 0;
border: none;
background: transparent;
color: var(--link);
font-family: inherit;
font-size: inherit;
cursor: pointer;
text-decoration: underline;
}
.btn-link:hover {
background: var(--hover);
}
.preview-box { .preview-box {
border: 2px solid var(--border); border: 2px solid var(--border);
padding: 0.75rem; padding: 0.75rem;
@ -235,17 +300,17 @@ tr:hover {
.status.active { .status.active {
background: var(--active); background: var(--active);
color: #155724; color: var(--active-fg);
} }
.status.reserved { .status.reserved {
background: var(--reserved); background: var(--reserved);
color: #856404; color: var(--reserved-fg);
} }
.status.released { .status.released {
background: var(--released); background: var(--released);
color: #383d41; color: var(--released-fg);
} }
.pagination { .pagination {
@ -329,6 +394,10 @@ tr:hover {
padding: 0.5rem; padding: 0.5rem;
} }
header {
padding-top: 3rem;
}
.grid { .grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }

49
static/theme.js Normal file
View file

@ -0,0 +1,49 @@
(function () {
var STORAGE_KEY = 'sub-aligator-theme';
function storedTheme() {
try {
return localStorage.getItem(STORAGE_KEY);
} catch (e) {
return null;
}
}
function currentTheme() {
return document.documentElement.getAttribute('data-theme') === 'dark' ? 'dark' : 'light';
}
function applyTheme(theme) {
document.documentElement.setAttribute('data-theme', theme);
}
// Apply the saved theme before the body renders to avoid a flash of the wrong theme.
applyTheme(storedTheme() === 'dark' ? 'dark' : 'light');
function updateToggle(btn) {
btn.textContent = currentTheme() === 'dark' ? '[ Light ]' : '[ Dark ]';
btn.setAttribute('aria-pressed', currentTheme() === 'dark' ? 'true' : 'false');
}
function wireToggle() {
var btn = document.getElementById('theme-toggle');
if (!btn) {
return;
}
updateToggle(btn);
btn.addEventListener('click', function () {
var next = currentTheme() === 'dark' ? 'light' : 'dark';
applyTheme(next);
try {
localStorage.setItem(STORAGE_KEY, next);
} catch (e) {}
updateToggle(btn);
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', wireToggle);
} else {
wireToggle();
}
})();

View file

@ -1,6 +1,6 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Detail {{ allocation.cidr }} | IP Subnet Allocator{% endblock %} {% block title %}Detail {{ allocation.cidr }} | SUB-ALIGATOR{% endblock %}
{% block content %} {% block content %}
<div id="flash-container"> <div id="flash-container">
@ -8,7 +8,7 @@
</div> </div>
<section class="box"> <section class="box">
<div class="box-title">Detail alokace {{ allocation.cidr }}</div> <div class="box-title">Allocation detail {{ allocation.cidr }}</div>
<div class="detail-grid"> <div class="detail-grid">
<div class="detail-item"> <div class="detail-item">
@ -20,19 +20,19 @@
<div class="value">{{ allocation.pool_name }}</div> <div class="value">{{ allocation.pool_name }}</div>
</div> </div>
<div class="detail-item"> <div class="detail-item">
<div class="label">Typ</div> <div class="label">Type</div>
<div class="value">{{ allocation.kind }}</div> <div class="value">{{ allocation.kind }}</div>
</div> </div>
<div class="detail-item"> <div class="detail-item">
<div class="label">Stav</div> <div class="label">Status</div>
<div class="value"><span class="status {{ allocation.status }}">{{ allocation.status }}</span></div> <div class="value"><span class="status {{ allocation.status }}">{{ allocation.status }}</span></div>
</div> </div>
<div class="detail-item"> <div class="detail-item">
<div class="label">Celkem adres</div> <div class="label">Total addresses</div>
<div class="value">{{ allocation.address_count }}</div> <div class="value">{{ allocation.address_count }}</div>
</div> </div>
<div class="detail-item"> <div class="detail-item">
<div class="label">Použitelných hostů</div> <div class="label">Usable hosts</div>
<div class="value">{{ allocation.usable_host_count }}</div> <div class="value">{{ allocation.usable_host_count }}</div>
</div> </div>
<div class="detail-item"> <div class="detail-item">
@ -44,15 +44,15 @@
<div class="value">{{ allocation.broadcast }}</div> <div class="value">{{ allocation.broadcast }}</div>
</div> </div>
<div class="detail-item"> <div class="detail-item">
<div class="label">První host</div> <div class="label">First host</div>
<div class="value">{{ allocation.first_host }}</div> <div class="value">{{ allocation.first_host }}</div>
</div> </div>
<div class="detail-item"> <div class="detail-item">
<div class="label">Poslední host</div> <div class="label">Last host</div>
<div class="value">{{ allocation.last_host }}</div> <div class="value">{{ allocation.last_host }}</div>
</div> </div>
<div class="detail-item"> <div class="detail-item">
<div class="label">Poznámka</div> <div class="label">Note</div>
<div class="value">{{ allocation.note }}</div> <div class="value">{{ allocation.note }}</div>
</div> </div>
<div class="detail-item"> <div class="detail-item">
@ -60,47 +60,47 @@
<div class="value">{{ allocation.ticket }}</div> <div class="value">{{ allocation.ticket }}</div>
</div> </div>
<div class="detail-item"> <div class="detail-item">
<div class="label">Vytvořeno</div> <div class="label">Created</div>
<div class="value">{{ allocation.created_at_local }} ({{ allocation.created_by }})</div> <div class="value">{{ allocation.created_at_local }} ({{ allocation.created_by }})</div>
</div> </div>
{% if allocation.released_at_local != "–" %} {% if allocation.released_at_local != "–" %}
<div class="detail-item"> <div class="detail-item">
<div class="label">Uvolněno</div> <div class="label">Released</div>
<div class="value">{{ allocation.released_at_local }} ({{ allocation.released_by }})</div> <div class="value">{{ allocation.released_at_local }} ({{ allocation.released_by }})</div>
</div> </div>
<div class="detail-item"> <div class="detail-item">
<div class="label">Poznámka k uvolnění</div> <div class="label">Release note</div>
<div class="value">{{ allocation.release_note }}</div> <div class="value">{{ allocation.release_note }}</div>
</div> </div>
{% endif %} {% endif %}
</div> </div>
{% if allocation.status == "active" || allocation.status == "reserved" %} {% if allocation.status == "active" || allocation.status == "reserved" %}
<button class="btn btn-danger" onclick="document.getElementById('release-modal').style.display='flex'">Uvolnit subnet</button> <button class="btn btn-danger release-btn" data-id="{{ allocation.id }}">Release subnet</button>
{% endif %} {% endif %}
<a href="/" class="btn btn-secondary">Zpět</a> <a href="/" class="btn btn-secondary">Back</a>
</section> </section>
{% if audit_events.len() > 0 %} {% if audit_events.len() > 0 %}
<section class="box"> <section class="box">
<div class="box-title">Auditní historie</div> <div class="box-title">Audit history</div>
<table> <table>
<thead> <thead>
<tr> <tr>
<th>Čas</th> <th>Time</th>
<th>Událost</th> <th>Event</th>
<th>Aktor</th> <th>Actor</th>
<th>Detaily</th> <th>Details</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for e in audit_events %} {% for e in audit_events %}
<tr> <tr>
<td data-label="Čas">{{ e.created_at_local }}</td> <td data-label="Time">{{ e.created_at_local }}</td>
<td data-label="Událost">{{ e.event_type }}</td> <td data-label="Event">{{ e.event_type }}</td>
<td data-label="Aktor">{{ e.actor }}</td> <td data-label="Actor">{{ e.actor }}</td>
<td data-label="Detaily"><pre class="small" style="margin:0;">{{ e.details_json }}</pre></td> <td data-label="Details"><pre class="small" style="margin:0;">{{ e.details_json }}</pre></td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
@ -108,19 +108,19 @@
</section> </section>
{% endif %} {% endif %}
<dialog id="release-modal" class="modal-overlay" style="display:none;"> <div id="release-modal-{{ allocation.id }}" class="modal-overlay" style="display:none;">
<div class="modal"> <div class="modal">
<h2 class="modal-title">Uvolnit subnet {{ allocation.cidr }}</h2> <h2 class="modal-title">Release subnet {{ allocation.cidr }}</h2>
<form method="post" action="/allocations/{{ allocation.id }}/release" class="modal-body" id="release-form"> <form method="post" action="/allocations/{{ allocation.id }}/release" class="modal-body" id="release-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<p class="small">Subnet přejde do stavu <span class="status released">released</span> a adresní prostor bude znovu použitelný first-fit algoritmem. Záznam zůstane v historii.</p> <p class="small">The subnet will be marked as <span class="status released">released</span> and its address space will become available again via the first-fit algorithm. The record remains in history.</p>
<label for="release-note">Poznámka k uvolnění *</label> <label for="release-note">Release note *</label>
<textarea id="release-note" name="release_note" rows="3" required minlength="3" maxlength="500"></textarea> <textarea id="release-note" name="release_note" rows="3" required minlength="3" maxlength="500"></textarea>
<div class="modal-actions">
<button type="button" class="btn btn-secondary cancel-btn" data-modal="release-modal-{{ allocation.id }}">Cancel</button>
<button type="submit" class="btn btn-danger">Release</button>
</div>
</form> </form>
<div class="modal-actions">
<button type="button" class="btn btn-secondary" onclick="document.getElementById('release-modal').style.display='none'">Zrušit</button>
<button type="submit" class="btn btn-danger" form="release-form">Uvolnit</button>
</div>
</div> </div>
</dialog> </div>
{% endblock %} {% endblock %}

View file

@ -1,24 +1,33 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="cs"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="htmx-config" content='{"includeIndicatorStyles":false}'> <meta name="htmx-config" content='{"includeIndicatorStyles":false}'>
<title>{% block title %}IP Subnet Allocator{% endblock %}</title> <title>{% block title %}SUB-ALIGATOR{% endblock %}</title>
<script src="/static/theme.js"></script>
<link rel="stylesheet" href="/static/style.css"> <link rel="stylesheet" href="/static/style.css">
<script src="/static/htmx.min.js" defer></script> <script src="/static/htmx.min.js" defer></script>
<script src="/static/app.js" defer></script>
{% block head %}{% endblock %} {% block head %}{% endblock %}
</head> </head>
<body> <body>
<div class="container"> <div class="container">
<header> <header>
<h1>IP Subnet Allocator</h1> <div class="header-nav header-nav-left">
<p>Interní evidence a přidělování IPv4 subnetů</p> <a href="/" class="btn btn-secondary">Home</a>
</div>
<div class="header-nav header-nav-right">
<button type="button" id="theme-toggle" class="btn btn-secondary">[ Dark ]</button>
</div>
<h1>SUB-ALIGATOR</h1>
<p>Internal IPv4 subnet allocation and tracking</p>
</header> </header>
{% block content %}{% endblock %} {% block content %}{% endblock %}
<footer class="box"> <footer class="box">
<p class="small"> <p class="small">
<a href="/">Hlavní stránka</a> | <a href="/pools">Manage pools</a> |
<a href="/tokens">API tokens</a> |
<a href="/exports/allocations.md">Markdown export</a> | <a href="/exports/allocations.md">Markdown export</a> |
<a href="/exports/allocations.csv">CSV export</a> | <a href="/exports/allocations.csv">CSV export</a> |
<a href="/exports/allocations.json">JSON export</a> <a href="/exports/allocations.json">JSON export</a>

View file

@ -2,46 +2,71 @@
<thead> <thead>
<tr> <tr>
<th>Subnet</th> <th>Subnet</th>
<th>Typ</th> <th>Type</th>
<th>Pool</th> <th>Pool</th>
<th>Počet adres</th> <th>Addresses</th>
<th>Poznámka</th> <th>Note</th>
<th>Stav</th> <th>Status</th>
<th>Vytvořeno</th> <th>Created</th>
<th>Akce</th> <th>Actions</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for a in allocations %} {% for a in allocations %}
<tr> <tr>
<td data-label="Subnet"><a href="/allocations/{{ a.id }}">{{ a.cidr }}</a></td> <td data-label="Subnet"><a href="/allocations/{{ a.id }}">{{ a.cidr }}</a></td>
<td data-label="Typ">{{ a.kind }}</td> <td data-label="Type">{{ a.kind }}</td>
<td data-label="Pool">{{ a.pool_name }}</td> <td data-label="Pool">{{ a.pool_name }}</td>
<td data-label="Počet adres">{{ a.address_count }}</td> <td data-label="Addresses">{{ a.address_count }}</td>
<td data-label="Poznámka">{{ a.note }}</td> <td data-label="Note">{{ a.note }}</td>
<td data-label="Stav"><span class="status {{ a.status }}">{{ a.status }}</span></td> <td data-label="Status"><span class="status {{ a.status }}">{{ a.status }}</span></td>
<td data-label="Vytvořeno">{{ a.created_at_local }}</td> <td data-label="Created">{{ a.created_at_local }}</td>
<td data-label="Akce"><a href="/allocations/{{ a.id }}">Detail</a></td> <td data-label="Actions">
<a href="/allocations/{{ a.id }}">Detail</a>
{% if a.status == "active" || a.status == "reserved" %}
| <button type="button" class="btn-link release-btn" data-id="{{ a.id }}">Release</button>
{% endif %}
</td>
</tr> </tr>
{% else %} {% else %}
<tr> <tr>
<td colspan="8" style="text-align:center;">Žádné alokace.</td> <td colspan="8" style="text-align:center;">No allocations.</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
{% for a in allocations %}
{% if a.status == "active" || a.status == "reserved" %}
<div id="release-modal-{{ a.id }}" class="modal-overlay" style="display:none;">
<div class="modal">
<h2 class="modal-title">Release subnet {{ a.cidr }}</h2>
<form method="post" action="/allocations/{{ a.id }}/release" class="modal-body" id="release-form-{{ a.id }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<p class="small">The subnet will be marked as <span class="status released">released</span> and its address space will become available again. The record remains in history.</p>
<label for="release-note-{{ a.id }}">Release note *</label>
<textarea id="release-note-{{ a.id }}" name="release_note" rows="3" required minlength="3" maxlength="500"></textarea>
<div class="modal-actions">
<button type="button" class="btn btn-secondary cancel-btn" data-modal="release-modal-{{ a.id }}">Cancel</button>
<button type="submit" class="btn btn-danger">Release</button>
</div>
</form>
</div>
</div>
{% endif %}
{% endfor %}
{% if total_pages > 1 %} {% if total_pages > 1 %}
<div class="pagination"> <div class="pagination">
<div> <div>
{% if page > 1 %} {% if page > 1 %}
<a class="btn btn-secondary" href="?page={{ page - 1 }}{% if query != "" %}&q={{ query }}{% endif %}{% if pool_filter != "" %}&pool={{ pool_filter }}{% endif %}{% if status_filter != "" %}&status={{ status_filter }}{% endif %}">← Předchozí</a> <a class="btn btn-secondary" href="?page={{ page - 1 }}{% if query != "" %}&q={{ query }}{% endif %}{% if pool_filter != "" %}&pool={{ pool_filter }}{% endif %}{% if status_filter != "" %}&status={{ status_filter }}{% endif %}">← Previous</a>
{% endif %} {% endif %}
</div> </div>
<div>Strana {{ page }} / {{ total_pages }} (celkem {{ total_count }})</div> <div>Page {{ page }} / {{ total_pages }} (total {{ total_count }})</div>
<div> <div>
{% if page < total_pages %} {% if page < total_pages %}
<a class="btn btn-secondary" href="?page={{ page + 1 }}{% if query != "" %}&q={{ query }}{% endif %}{% if pool_filter != "" %}&pool={{ pool_filter }}{% endif %}{% if status_filter != "" %}&status={{ status_filter }}{% endif %}">Další →</a> <a class="btn btn-secondary" href="?page={{ page + 1 }}{% if query != "" %}&q={{ query }}{% endif %}{% if pool_filter != "" %}&pool={{ pool_filter }}{% endif %}{% if status_filter != "" %}&status={{ status_filter }}{% endif %}">Next →</a>
{% endif %} {% endif %}
</div> </div>
</div> </div>

View file

@ -1,13 +1,13 @@
{% if flash.has_success %} {% if flash.has_success %}
<div class="message success" id="flash-success"> <div class="message success" id="flash-success">
<p><strong>Přiděleno:</strong> {{ flash.success_cidr }}</p> <p><strong>Allocated:</strong> {{ flash.success_cidr }}</p>
<p>{{ flash.success_note }}</p> <p>{{ flash.success_note }}</p>
<p class="small"> <p class="small">
Network: {{ flash.success_network }} | Network: {{ flash.success_network }} |
Broadcast: {{ flash.success_broadcast }} | Broadcast: {{ flash.success_broadcast }} |
První host: {{ flash.success_first_host }} | First host: {{ flash.success_first_host }} |
Poslední host: {{ flash.success_last_host }} | Last host: {{ flash.success_last_host }} |
Použitelných hostů: {{ flash.success_usable_host_count }} Usable hosts: {{ flash.success_usable_host_count }}
</p> </p>
</div> </div>
{% endif %} {% endif %}

View file

@ -5,12 +5,19 @@
{% include "fragments/flash.html" %} {% include "fragments/flash.html" %}
</div> </div>
{% if pools.len() == 0 %}
<section class="box">
<div class="box-title">No pools configured</div>
<p>Create at least one pool before allocating subnets.</p>
<a href="/pools" class="btn">Add a pool</a>
</section>
{% else %}
<section class="grid"> <section class="grid">
{% for pool in pools %} {% for pool in pools %}
<article class="box" id="pool-card-{{ pool.slug }}"> <article class="box" id="pool-card-{{ pool.slug }}">
<div class="box-title">{{ pool.name }}</div> <div class="box-title">{{ pool.name }}</div>
<p><span class="label">Parent prefix:</span> <span class="value">{{ pool.cidr }}</span></p> <p><span class="label">Parent prefix:</span> <span class="value">{{ pool.cidr }}</span></p>
<p><span class="label">Typ:</span> <span class="value">{{ pool.kind }}</span></p> <p><span class="label">Type:</span> <span class="value">{{ pool.kind }}</span></p>
<form <form
hx-get="/partials/pools/{{ pool.slug }}/preview" hx-get="/partials/pools/{{ pool.slug }}/preview"
@ -18,55 +25,57 @@
hx-trigger="change from:select, load" hx-trigger="change from:select, load"
hx-indicator="#indicator-{{ pool.slug }}" hx-indicator="#indicator-{{ pool.slug }}"
> >
<label for="prefix-{{ pool.slug }}">Velikost subnetu</label> <label for="prefix-{{ pool.slug }}">Subnet size</label>
<select name="prefix_length" id="prefix-{{ pool.slug }}" onchange="updateModalPrefix('{{ pool.slug }}', this.value)"> <select name="prefix_length" id="prefix-{{ pool.slug }}">
{% for pl in pool.allowed_prefixes %} {% for pl in pool.allowed_prefixes %}
<option value="{{ pl }}" {% if pl == "24" %}selected{% endif %}>/{{ pl }}</option> <option value="{{ pl }}" {% if pl == "24" %}selected{% endif %}>/{{ pl }}</option>
{% endfor %} {% endfor %}
</select> </select>
<div id="indicator-{{ pool.slug }}" class="small" style="display:none;">Načítám...</div> <div id="indicator-{{ pool.slug }}" class="small" style="display:none;">Loading...</div>
<div id="preview-{{ pool.slug }}"></div> <div id="preview-{{ pool.slug }}"></div>
</form> </form>
<button <button
class="btn" class="btn allocate-btn"
style="margin-top: 1rem;" style="margin-top: 1rem;"
onclick="openAllocateModal('{{ pool.slug }}')" data-slug="{{ pool.slug }}"
data-name="{{ pool.name }}"
> >
[ Přidělit další volný subnet ] [ Allocate next free subnet ]
</button> </button>
</article> </article>
{% endfor %} {% endfor %}
</section> </section>
{% endif %}
<section class="box"> <section class="box">
<div class="box-title">Přidělené subnety</div> <div class="box-title">Allocated subnets</div>
<form method="get" action="/" class="filters"> <form method="get" action="/" class="filters">
<div> <div>
<label for="q">Vyhledat</label> <label for="q">Search</label>
<input type="text" id="q" name="q" value="{{ query }}" placeholder="CIDR nebo poznámka"> <input type="text" id="q" name="q" value="{{ query }}" placeholder="CIDR or note">
</div> </div>
<div> <div>
<label for="pool">Pool</label> <label for="pool">Pool</label>
<select id="pool" name="pool"> <select id="pool" name="pool">
<option value="" {% if pool_filter == "" %}selected{% endif %}>Všechny</option> <option value="" {% if pool_filter == "" %}selected{% endif %}>All</option>
{% for p in pools %} {% for p in all_pools %}
<option value="{{ p.slug }}" {% if pool_filter == p.slug %}selected{% endif %}>{{ p.name }}</option> <option value="{{ p.slug }}" {% if pool_filter == p.slug %}selected{% endif %}>{{ p.name }}</option>
{% endfor %} {% endfor %}
</select> </select>
</div> </div>
<div> <div>
<label for="status">Stav</label> <label for="status">Status</label>
<select id="status" name="status"> <select id="status" name="status">
<option value="" {% if status_filter == "" %}selected{% endif %}>Všechny</option> <option value="" {% if status_filter == "" %}selected{% endif %}>All</option>
<option value="active" {% if status_filter == "active" %}selected{% endif %}>active</option> <option value="active" {% if status_filter == "active" %}selected{% endif %}>active</option>
<option value="reserved" {% if status_filter == "reserved" %}selected{% endif %}>reserved</option> <option value="reserved" {% if status_filter == "reserved" %}selected{% endif %}>reserved</option>
<option value="released" {% if status_filter == "released" %}selected{% endif %}>released</option> <option value="released" {% if status_filter == "released" %}selected{% endif %}>released</option>
</select> </select>
</div> </div>
<div> <div>
<button type="submit" class="btn">Filtrovat</button> <button type="submit" class="btn">Filter</button>
<a href="/" class="btn btn-secondary">Reset</a> <a href="/" class="btn btn-secondary">Reset</a>
</div> </div>
</form> </form>
@ -76,9 +85,9 @@
</div> </div>
</section> </section>
<dialog id="allocate-modal" class="modal-overlay" style="display:none;"> <div id="allocate-modal" class="modal-overlay" style="display:none;">
<div class="modal"> <div class="modal">
<h2 class="modal-title">Potvrdit přidělení subnetu</h2> <h2 class="modal-title">Confirm subnet allocation</h2>
<form method="post" action="/allocations" class="modal-body" id="allocate-form"> <form method="post" action="/allocations" class="modal-body" id="allocate-form">
<input type="hidden" name="pool_slug" id="modal-pool-slug"> <input type="hidden" name="pool_slug" id="modal-pool-slug">
<input type="hidden" name="prefix_length" id="modal-prefix-length"> <input type="hidden" name="prefix_length" id="modal-prefix-length">
@ -94,79 +103,32 @@
<div class="value" id="modal-prefix-display"></div> <div class="value" id="modal-prefix-display"></div>
</div> </div>
<div class="detail-item"> <div class="detail-item">
<div class="label">Navrhovaný subnet</div> <div class="label">Proposed subnet</div>
<div class="value" id="modal-cidr"></div> <div class="value" id="modal-cidr"></div>
</div> </div>
<div class="detail-item"> <div class="detail-item">
<div class="label">Celkem adres</div> <div class="label">Total addresses</div>
<div class="value" id="modal-address-count"></div> <div class="value" id="modal-address-count"></div>
</div> </div>
<div class="detail-item"> <div class="detail-item">
<div class="label">Použitelných hostů</div> <div class="label">Usable hosts</div>
<div class="value" id="modal-usable-hosts"></div> <div class="value" id="modal-usable-hosts"></div>
</div> </div>
</div> </div>
<p class="small" id="modal-warning"></p> <p class="small" id="modal-warning"></p>
<label for="note">Poznámka *</label> <label for="note">Note *</label>
<textarea id="note" name="note" rows="3" required minlength="3" maxlength="500"></textarea> <textarea id="note" name="note" rows="3" required minlength="3" maxlength="500"></textarea>
<label for="ticket">Ticket / reference</label> <label for="ticket">Ticket / reference</label>
<input type="text" id="ticket" name="ticket" maxlength="100"> <input type="text" id="ticket" name="ticket" maxlength="100">
<div class="modal-actions">
<button type="button" class="btn btn-secondary cancel-btn" data-modal="allocate-modal">Cancel</button>
<button type="submit" class="btn">Confirm allocation</button>
</div>
</form> </form>
<div class="modal-actions">
<button type="button" class="btn btn-secondary" onclick="closeAllocateModal()">Zrušit</button>
<button type="submit" class="btn" form="allocate-form">Potvrdit přidělení</button>
</div>
</div> </div>
</dialog> </div>
<script>
const poolData = {
{% for pool in pools %}
"{{ pool.slug }}": {
name: "{{ pool.name }}",
allowed_prefixes: {{ pool.allowed_prefixes_json|safe }}
},
{% endfor %}
};
function updateModalPrefix(slug, prefixLength) {
// preview se aktualizuje přes HTMX automaticky
}
async function openAllocateModal(slug) {
const select = document.getElementById('prefix-' + slug);
const prefixLength = select.value;
const modal = document.getElementById('allocate-modal');
document.getElementById('modal-pool-slug').value = slug;
document.getElementById('modal-prefix-length').value = prefixLength;
document.getElementById('modal-pool-name').textContent = poolData[slug].name;
document.getElementById('modal-prefix-display').textContent = '/' + prefixLength;
// Načíst preview z endpointu
const resp = await fetch('/partials/pools/' + slug + '/preview?prefix_length=' + prefixLength);
const html = await resp.text();
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
const cidr = doc.getElementById('preview-cidr')?.textContent || '–';
const addressCount = doc.getElementById('preview-address-count')?.textContent || '–';
const usableHosts = doc.getElementById('preview-usable-hosts')?.textContent || '–';
const warning = doc.getElementById('preview-warning')?.textContent || '';
document.getElementById('modal-cidr').textContent = cidr;
document.getElementById('modal-address-count').textContent = addressCount;
document.getElementById('modal-usable-hosts').textContent = usableHosts;
document.getElementById('modal-warning').textContent = warning;
modal.style.display = 'flex';
}
function closeAllocateModal() {
document.getElementById('allocate-modal').style.display = 'none';
}
</script>
{% endblock %} {% endblock %}

View file

@ -1,23 +1,23 @@
<div class="preview-box"> <div class="preview-box">
{% if has_error %} {% if has_error %}
<p class="error" id="preview-warning">{{ error }}</p> <p class="error" id="preview-warning">{{ error }}</p>
<p class="label">Další volný subnet: <span class="value">–</span></p> <p class="label">Next free subnet: <span class="value">–</span></p>
{% else %} {% else %}
<p> <p>
<span class="label">Další volný subnet:</span> <span class="label">Next free subnet:</span>
<span class="value" id="preview-cidr">{{ cidr }}</span> <span class="value" id="preview-cidr">{{ cidr }}</span>
</p> </p>
<p> <p>
<span class="label">Celkem adres:</span> <span class="label">Total addresses:</span>
<span class="value" id="preview-address-count">{{ address_count }}</span> <span class="value" id="preview-address-count">{{ address_count }}</span>
</p> </p>
<p> <p>
<span class="label">Použitelných hostů:</span> <span class="label">Usable hosts:</span>
<span class="value" id="preview-usable-hosts">{{ usable_host_count }}</span> <span class="value" id="preview-usable-hosts">{{ usable_host_count }}</span>
</p> </p>
{% if prefix_length >= 31 %} {% if prefix_length >= 31 %}
<p class="small" id="preview-warning"> <p class="small" id="preview-warning">
Pro /31 a /32 se network/broadcast adresy dle RFC 3021 mohou shodovat s host adresami. For /31 and /32, network/broadcast addresses may coincide with host addresses per RFC 3021.
</p> </p>
{% else %} {% else %}
<p class="small" id="preview-warning"></p> <p class="small" id="preview-warning"></p>

101
templates/pools.html Normal file
View file

@ -0,0 +1,101 @@
{% extends "base.html" %}
{% block title %}Manage pools | SUB-ALIGATOR{% endblock %}
{% block content %}
<section class="box">
<div class="box-title">Existing pools</div>
{% if error != "" %}
<div class="message error">
<p>{{ error }}</p>
</div>
{% endif %}
{% if pools.len() == 0 %}
<p>No pools configured yet.</p>
{% else %}
<table>
<thead>
<tr>
<th>Name</th>
<th>Slug</th>
<th>Parent prefix</th>
<th>Type</th>
<th>Allowed prefixes</th>
<th>Allocations (active / reserved / released)</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for p in pools %}
<tr>
<td data-label="Name">{{ p.pool.name }}</td>
<td data-label="Slug">{{ p.pool.slug }}</td>
<td data-label="Parent prefix">{{ p.pool.cidr }}</td>
<td data-label="Type">{{ p.pool.kind }}</td>
<td data-label="Allowed prefixes">{{ p.pool.allowed_prefixes_json }}</td>
<td data-label="Allocations">{{ p.active_count }} / {{ p.reserved_count }} / {{ p.released_count }}</td>
<td data-label="Actions">
<button type="button" class="btn btn-danger pool-delete-btn" data-id="{{ p.pool.id }}">Delete</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
</section>
<section class="box">
<div class="box-title">Add new pool</div>
<form method="post" action="/pools/new">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label for="slug">Pool slug *</label>
<input type="text" id="slug" name="slug" required maxlength="50" pattern="[a-z0-9-]+" placeholder="e.g. vpn">
<p class="small">Short machine identifier used in URLs and API paths (e.g. <code>vpn</code>). Lowercase letters, numbers and hyphens only.</p>
<label for="name">Pool name *</label>
<input type="text" id="name" name="name" required minlength="2" maxlength="100" placeholder="e.g. VPN subnets">
<label for="cidr">Parent prefix (CIDR) *</label>
<input type="text" id="cidr" name="cidr" required placeholder="e.g. 10.200.0.0/16">
<p class="small">If you re-add a range that was deleted before, its previous allocations are kept and will block the address space again.</p>
<label for="kind">Type *</label>
<input type="text" id="kind" name="kind" required maxlength="50" placeholder="e.g. VPN">
<label for="allowed_prefixes">Allowed prefix lengths *</label>
<input type="text" id="allowed_prefixes" name="allowed_prefixes" required value="20,21,22,23,24,25,26,27,28,29,30" placeholder="e.g. 24,25,26">
<p class="small">Comma-separated list of prefix lengths. Each must be equal to or larger than the pool prefix.</p>
<div style="margin-top: 1rem;">
<button type="submit" class="btn">Add pool</button>
<a href="/" class="btn btn-secondary">Back</a>
</div>
</form>
</section>
{% for p in pools %}
<div id="delete-pool-modal-{{ p.pool.id }}" class="modal-overlay" style="display:none;">
<div class="modal">
<h2 class="modal-title">Delete pool</h2>
<form method="post" action="/pools/{{ p.pool.id }}/delete" class="modal-body">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<p>Pool: <strong>{{ p.pool.name }}</strong> ({{ p.pool.cidr }})</p>
{% if p.total_count > 0 %}
<p>This pool has {{ p.active_count }} active, {{ p.reserved_count }} reserved and {{ p.released_count }} released allocations.</p>
<p><strong>Allocations will NOT be deleted.</strong> They stay in history and will keep blocking this address range if you later add the same or an overlapping pool again.</p>
{% else %}
<p>This pool has no allocations.</p>
{% endif %}
<div class="modal-actions">
<button type="button" class="btn btn-secondary cancel-btn" data-modal="delete-pool-modal-{{ p.pool.id }}">Cancel</button>
<button type="submit" class="btn btn-danger">Delete pool</button>
</div>
</form>
</div>
</div>
{% endfor %}
{% endblock %}

42
templates/setup.html Normal file
View file

@ -0,0 +1,42 @@
{% extends "base.html" %}
{% block title %}Setup | SUB-ALIGATOR{% endblock %}
{% block content %}
<section class="box">
<div class="box-title">Initial setup</div>
<p>Create the first IP pool. You can add more pools later from the <a href="/pools">Manage pools</a> page.</p>
{% if error != "" %}
<div class="message error">
<p>{{ error }}</p>
</div>
{% endif %}
<form method="post" action="/setup">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label for="slug">Pool slug *</label>
<input type="text" id="slug" name="slug" required maxlength="50" pattern="[a-z0-9-]+" placeholder="e.g. vlan-customers">
<p class="small">Short machine identifier used in URLs and API paths (e.g. <code>vlan-customers</code>). Lowercase letters, numbers and hyphens only.</p>
<label for="name">Pool name *</label>
<input type="text" id="name" name="name" required minlength="2" maxlength="100" placeholder="e.g. VLAN / customer subnets">
<label for="cidr">Parent prefix (CIDR) *</label>
<input type="text" id="cidr" name="cidr" required placeholder="e.g. 10.91.0.0/16">
<label for="kind">Type *</label>
<input type="text" id="kind" name="kind" required maxlength="50" placeholder="e.g. VLAN">
<label for="allowed_prefixes">Allowed prefix lengths *</label>
<input type="text" id="allowed_prefixes" name="allowed_prefixes" required value="20,21,22,23,24,25,26,27,28,29,30" placeholder="e.g. 24,25,26">
<p class="small">Comma-separated list of prefix lengths. Each must be equal to or larger than the pool prefix.</p>
<div style="margin-top: 1rem;">
<button type="submit" class="btn">Create pool</button>
</div>
</form>
</section>
{% endblock %}

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 %}