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
# Logování (debug, info, warn, error)
# Logging level (debug, info, warn, error)
RUST_LOG=info
# Bind adresy uvnitř kontejneru
# Bind address inside the container
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
# Časové pásmo pro zobrazení času ve webovém UI
# Timezone for displaying timestamps in the web UI
APP_TIMEZONE=Europe/Prague
# Adresář pro exporty uvnitř kontejneru
# Directory for exports inside the container
APP_EXPORTS_DIR=/app/exports

2
Cargo.lock generated
View file

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

View file

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

View file

@ -1,6 +1,6 @@
# syntax=docker/dockerfile:1
FROM rust:1.82-bookworm AS builder
FROM rust:1.89-bookworm AS builder
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/static /app/static
COPY --from=builder /app/templates /app/templates
USER app
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
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:
- VLAN / zákaznické subnety: `10.91.0.0/16`
- VPN subnety: `10.200.0.0/16`
- Automatické přidělování subnetů pomocí **first-fit** algoritmu pro prefixy /20 až /30.
- Atomický zápis alokací přes SQLite transakci `BEGIN IMMEDIATE`.
- Webové UI s retro TUI/terminál vzhledem, server-side renderované pomocí Askama.
- Dynamické preview dalšího volného subnetu přes HTMX.
- Historie alokací s filtrem, vyhledáváním a stránkováním.
- Uvolňování subnetů (status `released`) s povinnou poznámkou.
- Auditní log všech operací.
- Exporty do Markdown, CSV a JSON.
- SQLite migrace probíhají automaticky při startu.
- Zálohovací a obnovovací skripty.
- Manage one or more IP pools manually through the web UI.
- First-run setup wizard: create the first pool before allocating subnets.
- Automatic subnet allocation using a **first-fit** algorithm for prefixes /20 through /30.
- Atomic allocation writes using SQLite `BEGIN IMMEDIATE` transactions.
- Retro TUI/terminal-style web UI, server-side rendered with Askama.
- Dynamic preview of the next free subnet via HTMX.
- Allocation history with filtering, search, and pagination.
- Release subnets (status `released`) with a mandatory note, directly from the history table or detail page.
- Released subnets free up address space and can be reused by the first-fit algorithm.
- Delete pools without losing their allocations: the subnets stay in history and keep blocking the address range if the same or an overlapping pool is added again.
- Light and dark (amber terminal) theme, switchable in the page header and remembered per browser.
- JSON REST API (`/api/v1/`) with Bearer token authentication; tokens are managed in the web UI.
- Audit log of all operations.
- Markdown, CSV, and JSON exports.
- SQLite migrations run automatically on startup.
- Backup and restore scripts.
## Architektura
## Architecture
- **Rust** edition 2024
- **Axum** + **Tokio**
- **SQLx** s **SQLite**
- **Askama** pro HTML šablony
- **HTMX** z lokálního statického souboru
- Vlastní CSS bez externích frameworků
- CIDR/IP výpočty pomocí `ipnet`
- **SQLx** with **SQLite**
- **Askama** for HTML templates
- **HTMX** from a local static file
- Custom CSS without external frameworks
- CIDR/IP calculations using `ipnet`
## Rychlý start
## Quick start
```bash
cd sub-aligator
@ -42,79 +45,141 @@ cp .env.example .env
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
- `./exports` – vygenerované exporty (Markdown, CSV, JSON)
- `./backups` – zálohy databáze
- `./data` – SQLite database
- `./exports` – generated exports (Markdown, CSV, JSON)
- `./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
./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
./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
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.csv`
- `/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.csv`
- `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.
- 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"}`.
The application provides a JSON REST API under `/api/v1/` for integrations. All API requests are authenticated with a Bearer token:
## 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
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`.
2. Následný požadavek `/24` získá `10.91.1.0/24` (vyhne se obsazenému `/30`).
3. Další požadavek `/30` zaplní mezeru a získá `10.91.0.4/30`.
1. First request `/30` receives `10.91.0.0/30`.
2. Next request `/24` receives `10.91.1.0/24` (avoids the occupied `/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 /setup` – first-run pool setup
- `GET /pools` – pool management
- `POST /pools/new` – create a new pool
- `POST /pools/{id}/delete` – delete a pool (allocations are kept)
- `GET /tokens` – API token management
- `POST /tokens/new` – create an API token
- `POST /tokens/{id}/revoke` – revoke an API token
- `POST /tokens/{id}/delete` – delete a revoked API token
- `GET /partials/pools/{slug}/preview?prefix_length=24` – HTMX preview
- `POST /allocations` – vytvoření alokace
- `GET /allocations` – seznam alokací (součást `/`)
- `GET /allocations/{id}` – detail alokace
- `POST /allocations/{id}/release` – uvolnění alokace
- `GET /exports/allocations.{md,csv,json}` – exporty
- `POST /allocations` – create an allocation
- `GET /allocations/{id}` – allocation detail
- `POST /allocations/{id}/release` – release an allocation
- `GET /exports/allocations.{md,csv,json}` – exports
- `GET|POST /api/v1/...` – JSON REST API (see above)
## Troubleshooting
### Kontejner nenastartuje
### Container does not start
```bash
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
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::models::now_utc_rfc3339;
use anyhow::Context;
use sqlx::migrate::{MigrateDatabase, Migrator};
use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqliteSynchronous};
@ -38,45 +37,9 @@ pub async fn init_db(config: &Config) -> anyhow::Result<SqlitePool> {
.await
.context("run migrations")?;
seed_default_pools(&pool).await.context("seed default pools")?;
info!("Database initialized: {}", database_url);
Ok(pool)
} else {
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<()> {
let path = dir.join("allocations.md");
let mut lines = vec![
"# IP Subnet Allocator – Export".to_string(),
"# SUB-ALIGATOR – Export".to_string(),
"".to_string(),
format!("Vygenerováno: {}", chrono::Utc::now().to_rfc3339()),
format!("Generated: {}", chrono::Utc::now().to_rfc3339()),
"".to_string(),
];
@ -58,13 +58,13 @@ async fn generate_markdown(
.collect();
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());
continue;
}
lines.push("| Subnet | Stav | Poznámka | Vytvořeno |".to_string());
lines.push("|--------|------|----------|-----------|".to_string());
lines.push("| Subnet | Status | Note | Created |".to_string());
lines.push("|--------|--------|------|---------|".to_string());
for a in pool_allocs {
lines.push(format!(
"| {} | {} | {} | {} |",

View file

@ -42,17 +42,17 @@ pub async fn create_allocation(
let actor = get_actor_from_request(&headers);
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();
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 {
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(
@ -87,7 +87,7 @@ pub async fn create_allocation(
let mut message = success.clone();
if actual_cidr_different {
message.note = format!(
"{} (preview se mezitím změnil, přiděleno: {})",
"{} (preview changed in the meantime, allocated: {})",
allocation.note, allocation.cidr
);
}
@ -122,17 +122,15 @@ pub async fn allocate_in_tx(
ticket: &str,
actor: &str,
) -> anyhow::Result<(Allocation, crate::allocator::SubnetInfo)> {
let mut conn = pool.acquire().await?;
sqlx::query("BEGIN IMMEDIATE")
.execute(&mut *conn)
.await?;
// begin_with gives us BEGIN IMMEDIATE plus automatic rollback on drop,
// so no error path can leak an open transaction into the pool.
let mut tx = pool.begin_with("BEGIN IMMEDIATE").await?;
let active: Vec<String> = sqlx::query_scalar(
"SELECT cidr FROM allocations WHERE pool_id = ? AND status IN ('active', 'reserved')",
)
.bind(pool_id)
.fetch_all(&mut *conn)
.fetch_all(&mut *tx)
.await?;
let info = find_next_subnet(parent_cidr, prefix_length, allowed_prefixes, &active)?;
@ -155,12 +153,12 @@ pub async fn allocate_in_tx(
.bind(AllocationStatus::Active.as_str())
.bind(now_utc_rfc3339())
.bind(actor)
.execute(&mut *conn)
.execute(&mut *tx)
.await?
.last_insert_rowid();
log_event(
&mut conn,
&mut tx,
"allocation_created",
Some(id),
Some(pool_id),
@ -174,7 +172,7 @@ pub async fn allocate_in_tx(
.await
.ok();
sqlx::query("COMMIT").execute(&mut *conn).await?;
tx.commit().await?;
let allocation = sqlx::query_as::<_, Allocation>("SELECT * FROM allocations WHERE id = ?")
.bind(id)
@ -195,7 +193,7 @@ pub async fn release_allocation(
let actor = get_actor_from_request(&headers);
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();
@ -205,9 +203,10 @@ pub async fn release_allocation(
id,
&jar,
&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;
@ -216,7 +215,7 @@ pub async fn release_allocation(
Ok(_) => {
let _ = regenerate_after_change(&state).await;
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) => {
warn!("Release failed for allocation {}: {}", id, e);
@ -231,18 +230,14 @@ pub async fn release_in_tx(
release_note: &str,
actor: &str,
) -> anyhow::Result<()> {
let mut conn = pool.acquire().await?;
sqlx::query("BEGIN IMMEDIATE")
.execute(&mut *conn)
.await?;
let mut tx = pool.begin_with("BEGIN IMMEDIATE").await?;
let allocation: Allocation =
sqlx::query_as("SELECT * FROM allocations WHERE id = ? AND status IN ('active', 'reserved')")
.bind(id)
.fetch_one(&mut *conn)
.fetch_one(&mut *tx)
.await
.map_err(|_| anyhow::anyhow!("Alokace neexistuje nebo již není aktivní."))?;
.map_err(|_| anyhow::anyhow!("Allocation does not exist or is no longer active."))?;
sqlx::query(
"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(release_note)
.bind(id)
.execute(&mut *conn)
.execute(&mut *tx)
.await?;
log_event(
&mut conn,
&mut tx,
"allocation_released",
Some(id),
Some(allocation.pool_id),
@ -268,12 +263,12 @@ pub async fn release_in_tx(
.await
.ok();
sqlx::query("COMMIT").execute(&mut *conn).await?;
tx.commit().await?;
Ok(())
}
async fn regenerate_after_change(state: &AppState) -> anyhow::Result<()> {
let pools: Vec<Pool> = sqlx::query_as("SELECT * FROM pools WHERE enabled = true")
pub(crate) async fn regenerate_after_change(state: &AppState) -> anyhow::Result<()> {
let pools: Vec<Pool> = sqlx::query_as("SELECT * FROM pools ORDER BY id")
.fetch_all(&state.pool)
.await?;
let allocations: Vec<Allocation> =
@ -299,7 +294,7 @@ async fn render_index_with_error(state: &AppState, _jar: &CookieJar, csrf_token:
Ok(html) => Html(html),
Err(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),
Err(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,
flash: FlashData,
) -> 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 {
pools: load_pool_templates(state).await,
pools,
all_pools,
allocations: vec![],
page: 1,
total_pages: 0,
@ -350,7 +352,7 @@ async fn render_detail_with_error(state: &AppState, id: i64, _jar: &CookieJar, c
Ok(html) => Html(html),
Err(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(),
address_count: 0,
usable_host_count: 0,
note: "Alokace nenalezena.".to_string(),
note: "Allocation not found.".to_string(),
ticket: "–".to_string(),
status: "released".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 api;
pub mod exports;
pub mod pages;
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 askama::Template;
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 serde::Deserialize;
use tracing::error;
@ -27,10 +27,19 @@ pub async fn index(
State(state): State<AppState>,
jar: CookieJar,
Query(q): Query<IndexQuery>,
) -> impl IntoResponse {
) -> Response {
let (jar, csrf_token) = ensure_csrf_cookie(jar);
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>(
"SELECT * FROM pools WHERE enabled = true ORDER BY id",
)
@ -41,6 +50,11 @@ pub async fn index(
.map(PoolTemplate::from)
.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) =
load_allocations(&state, &q.q, &q.pool, &q.status, page).await;
@ -48,6 +62,7 @@ pub async fn index(
let template = IndexTemplate {
pools,
all_pools,
allocations,
page,
total_pages,
@ -63,7 +78,7 @@ pub async fn index(
Ok(html) => (jar, Html(html)).into_response(),
Err(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 {
Some(a) => a,
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(),
Err(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,
usable_host_count: 0,
has_error: true,
error: "Pool neexistuje.".to_string(),
error: "Pool does not exist.".to_string(),
};
return Html(template.render().unwrap_or_default());
}
@ -72,7 +72,7 @@ pub async fn pool_preview(
Ok(html) => Html(html),
Err(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 api_tokens;
pub mod audit;
pub mod config;
pub mod csrf;

View file

@ -69,6 +69,24 @@ impl AllocationStatus {
}
}
#[derive(Clone, Debug, FromRow)]
pub struct ApiToken {
pub id: i64,
pub name: String,
pub token_hash: String,
pub token_prefix: String,
pub enabled: i64,
pub created_at: String,
pub created_by: String,
pub last_used_at: Option<String>,
}
impl ApiToken {
pub fn is_enabled(&self) -> bool {
self.enabled != 0
}
}
#[derive(Clone, Debug, FromRow)]
pub struct AuditEvent {
pub id: i64,

View file

@ -1,4 +1,4 @@
use crate::handlers::{allocations, exports, pages, partials};
use crate::handlers::{allocations, api, exports, pages, partials, pools, tokens};
use axum::routing::{get, post};
use axum::Router;
@ -6,6 +6,14 @@ pub fn router() -> Router<crate::state::AppState> {
Router::new()
.route("/", get(pages::index))
.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("/allocations", post(allocations::create_allocation))
.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.csv", get(exports::export_csv))
.route("/exports/allocations.json", get(exports::export_json))
.route("/api/v1/pools", get(api::list_pools))
.route("/api/v1/pools/{slug}/next", get(api::next_free))
.route(
"/api/v1/allocations",
get(api::list_allocations).post(api::create_allocation),
)
.route("/api/v1/allocations/{id}", get(api::get_allocation))
.route(
"/api/v1/allocations/{id}/release",
post(api::release_allocation),
)
}

View file

@ -11,6 +11,7 @@ pub struct BaseTemplate {
#[template(path = "index.html")]
pub struct IndexTemplate {
pub pools: Vec<PoolTemplate>,
pub all_pools: Vec<Pool>,
pub allocations: Vec<AllocationListItem>,
pub page: i64,
pub total_pages: i64,
@ -130,6 +131,7 @@ pub struct AllocationsTableTemplate {
pub query: String,
pub pool_filter: String,
pub status_filter: String,
pub csrf_token: String,
}
#[derive(Template)]
@ -140,3 +142,49 @@ pub struct AllocationDetailTemplate {
pub csrf_token: String,
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;
--hover: #ebe7de;
--active: #d4edda;
--active-fg: #155724;
--reserved: #fff3cd;
--reserved-fg: #856404;
--released: #e2e3e5;
--released-fg: #383d41;
--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;
}
@ -57,6 +84,27 @@ header {
border: 2px solid var(--border);
padding: 1rem;
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 {
@ -133,6 +181,7 @@ textarea:focus {
.btn:hover {
background: var(--fg);
color: var(--bg);
}
.btn-secondary {
@ -154,6 +203,22 @@ textarea:focus {
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 {
border: 2px solid var(--border);
padding: 0.75rem;
@ -235,17 +300,17 @@ tr:hover {
.status.active {
background: var(--active);
color: #155724;
color: var(--active-fg);
}
.status.reserved {
background: var(--reserved);
color: #856404;
color: var(--reserved-fg);
}
.status.released {
background: var(--released);
color: #383d41;
color: var(--released-fg);
}
.pagination {
@ -329,6 +394,10 @@ tr:hover {
padding: 0.5rem;
}
header {
padding-top: 3rem;
}
.grid {
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" %}
{% block title %}Detail {{ allocation.cidr }} | IP Subnet Allocator{% endblock %}
{% block title %}Detail {{ allocation.cidr }} | SUB-ALIGATOR{% endblock %}
{% block content %}
<div id="flash-container">
@ -8,7 +8,7 @@
</div>
<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-item">
@ -20,19 +20,19 @@
<div class="value">{{ allocation.pool_name }}</div>
</div>
<div class="detail-item">
<div class="label">Typ</div>
<div class="label">Type</div>
<div class="value">{{ allocation.kind }}</div>
</div>
<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>
<div class="detail-item">
<div class="label">Celkem adres</div>
<div class="label">Total addresses</div>
<div class="value">{{ allocation.address_count }}</div>
</div>
<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>
<div class="detail-item">
@ -44,15 +44,15 @@
<div class="value">{{ allocation.broadcast }}</div>
</div>
<div class="detail-item">
<div class="label">První host</div>
<div class="label">First host</div>
<div class="value">{{ allocation.first_host }}</div>
</div>
<div class="detail-item">
<div class="label">Poslední host</div>
<div class="label">Last host</div>
<div class="value">{{ allocation.last_host }}</div>
</div>
<div class="detail-item">
<div class="label">Poznámka</div>
<div class="label">Note</div>
<div class="value">{{ allocation.note }}</div>
</div>
<div class="detail-item">
@ -60,47 +60,47 @@
<div class="value">{{ allocation.ticket }}</div>
</div>
<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>
{% if allocation.released_at_local != "–" %}
<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>
<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>
{% endif %}
</div>
{% 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 %}
<a href="/" class="btn btn-secondary">Zpět</a>
<a href="/" class="btn btn-secondary">Back</a>
</section>
{% if audit_events.len() > 0 %}
<section class="box">
<div class="box-title">Auditní historie</div>
<div class="box-title">Audit history</div>
<table>
<thead>
<tr>
<th>Čas</th>
<th>Událost</th>
<th>Aktor</th>
<th>Detaily</th>
<th>Time</th>
<th>Event</th>
<th>Actor</th>
<th>Details</th>
</tr>
</thead>
<tbody>
{% for e in audit_events %}
<tr>
<td data-label="Čas">{{ e.created_at_local }}</td>
<td data-label="Událost">{{ e.event_type }}</td>
<td data-label="Aktor">{{ e.actor }}</td>
<td data-label="Detaily"><pre class="small" style="margin:0;">{{ e.details_json }}</pre></td>
<td data-label="Time">{{ e.created_at_local }}</td>
<td data-label="Event">{{ e.event_type }}</td>
<td data-label="Actor">{{ e.actor }}</td>
<td data-label="Details"><pre class="small" style="margin:0;">{{ e.details_json }}</pre></td>
</tr>
{% endfor %}
</tbody>
@ -108,19 +108,19 @@
</section>
{% 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">
<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">
<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>
<label for="release-note">Poznámka k uvolnění *</label>
<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">Release note *</label>
<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>
<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>
</dialog>
</div>
{% endblock %}

View file

@ -1,24 +1,33 @@
<!DOCTYPE html>
<html lang="cs">
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<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">
<script src="/static/htmx.min.js" defer></script>
<script src="/static/app.js" defer></script>
{% block head %}{% endblock %}
</head>
<body>
<div class="container">
<header>
<h1>IP Subnet Allocator</h1>
<p>Interní evidence a přidělování IPv4 subnetů</p>
<div class="header-nav header-nav-left">
<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>
{% block content %}{% endblock %}
<footer class="box">
<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.csv">CSV export</a> |
<a href="/exports/allocations.json">JSON export</a>

View file

@ -2,46 +2,71 @@
<thead>
<tr>
<th>Subnet</th>
<th>Typ</th>
<th>Type</th>
<th>Pool</th>
<th>Počet adres</th>
<th>Poznámka</th>
<th>Stav</th>
<th>Vytvořeno</th>
<th>Akce</th>
<th>Addresses</th>
<th>Note</th>
<th>Status</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for a in allocations %}
<tr>
<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="Počet adres">{{ a.address_count }}</td>
<td data-label="Poznámka">{{ a.note }}</td>
<td data-label="Stav"><span class="status {{ a.status }}">{{ a.status }}</span></td>
<td data-label="Vytvořeno">{{ a.created_at_local }}</td>
<td data-label="Akce"><a href="/allocations/{{ a.id }}">Detail</a></td>
<td data-label="Addresses">{{ a.address_count }}</td>
<td data-label="Note">{{ a.note }}</td>
<td data-label="Status"><span class="status {{ a.status }}">{{ a.status }}</span></td>
<td data-label="Created">{{ a.created_at_local }}</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>
{% else %}
<tr>
<td colspan="8" style="text-align:center;">Žádné alokace.</td>
<td colspan="8" style="text-align:center;">No allocations.</td>
</tr>
{% endfor %}
</tbody>
</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 %}
<div class="pagination">
<div>
{% 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 %}
</div>
<div>Strana {{ page }} / {{ total_pages }} (celkem {{ total_count }})</div>
<div>Page {{ page }} / {{ total_pages }} (total {{ total_count }})</div>
<div>
{% 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 %}
</div>
</div>

View file

@ -1,13 +1,13 @@
{% if flash.has_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 class="small">
Network: {{ flash.success_network }} |
Broadcast: {{ flash.success_broadcast }} |
První host: {{ flash.success_first_host }} |
Poslední host: {{ flash.success_last_host }} |
Použitelných hostů: {{ flash.success_usable_host_count }}
First host: {{ flash.success_first_host }} |
Last host: {{ flash.success_last_host }} |
Usable hosts: {{ flash.success_usable_host_count }}
</p>
</div>
{% endif %}

View file

@ -5,12 +5,19 @@
{% include "fragments/flash.html" %}
</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">
{% for pool in pools %}
<article class="box" id="pool-card-{{ pool.slug }}">
<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">Typ:</span> <span class="value">{{ pool.kind }}</span></p>
<p><span class="label">Type:</span> <span class="value">{{ pool.kind }}</span></p>
<form
hx-get="/partials/pools/{{ pool.slug }}/preview"
@ -18,55 +25,57 @@
hx-trigger="change from:select, load"
hx-indicator="#indicator-{{ pool.slug }}"
>
<label for="prefix-{{ pool.slug }}">Velikost subnetu</label>
<select name="prefix_length" id="prefix-{{ pool.slug }}" onchange="updateModalPrefix('{{ pool.slug }}', this.value)">
<label for="prefix-{{ pool.slug }}">Subnet size</label>
<select name="prefix_length" id="prefix-{{ pool.slug }}">
{% for pl in pool.allowed_prefixes %}
<option value="{{ pl }}" {% if pl == "24" %}selected{% endif %}>/{{ pl }}</option>
{% endfor %}
</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>
</form>
<button
class="btn"
class="btn allocate-btn"
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>
</article>
{% endfor %}
</section>
{% endif %}
<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">
<div>
<label for="q">Vyhledat</label>
<input type="text" id="q" name="q" value="{{ query }}" placeholder="CIDR nebo poznámka">
<label for="q">Search</label>
<input type="text" id="q" name="q" value="{{ query }}" placeholder="CIDR or note">
</div>
<div>
<label for="pool">Pool</label>
<select id="pool" name="pool">
<option value="" {% if pool_filter == "" %}selected{% endif %}>Všechny</option>
{% for p in pools %}
<option value="" {% if pool_filter == "" %}selected{% endif %}>All</option>
{% for p in all_pools %}
<option value="{{ p.slug }}" {% if pool_filter == p.slug %}selected{% endif %}>{{ p.name }}</option>
{% endfor %}
</select>
</div>
<div>
<label for="status">Stav</label>
<label for="status">Status</label>
<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="reserved" {% if status_filter == "reserved" %}selected{% endif %}>reserved</option>
<option value="released" {% if status_filter == "released" %}selected{% endif %}>released</option>
</select>
</div>
<div>
<button type="submit" class="btn">Filtrovat</button>
<button type="submit" class="btn">Filter</button>
<a href="/" class="btn btn-secondary">Reset</a>
</div>
</form>
@ -76,9 +85,9 @@
</div>
</section>
<dialog id="allocate-modal" class="modal-overlay" style="display:none;">
<div id="allocate-modal" class="modal-overlay" style="display:none;">
<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">
<input type="hidden" name="pool_slug" id="modal-pool-slug">
<input type="hidden" name="prefix_length" id="modal-prefix-length">
@ -94,79 +103,32 @@
<div class="value" id="modal-prefix-display"></div>
</div>
<div class="detail-item">
<div class="label">Navrhovaný subnet</div>
<div class="label">Proposed subnet</div>
<div class="value" id="modal-cidr"></div>
</div>
<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>
<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>
</div>
<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>
<label for="ticket">Ticket / reference</label>
<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>
<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>
</dialog>
<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>
</div>
{% endblock %}

View file

@ -1,23 +1,23 @@
<div class="preview-box">
{% if has_error %}
<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 %}
<p>
<span class="label">Další volný subnet:</span>
<span class="label">Next free subnet:</span>
<span class="value" id="preview-cidr">{{ cidr }}</span>
</p>
<p>
<span class="label">Celkem adres:</span>
<span class="label">Total addresses:</span>
<span class="value" id="preview-address-count">{{ address_count }}</span>
</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>
</p>
{% if prefix_length >= 31 %}
<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>
{% else %}
<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 %}