Compare commits

..

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

35 changed files with 325 additions and 2400 deletions

View file

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

2
Cargo.lock generated
View file

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

View file

@ -26,8 +26,6 @@ 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.89-bookworm AS builder
FROM rust:1.82-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
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
USER app
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
CMD curl -fsS http://localhost:8080/healthz >/dev/null || exit 1
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
CMD ["/usr/local/bin/sub-aligator"]

17
Formula/sub-aligator.rb Normal file
View file

@ -0,0 +1,17 @@
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,43 +1,40 @@
# SUB-ALIGATOR
# IP Subnet Allocator
A simple internal web application for tracking and automatically allocating IPv4 subnets.
Jednoduchá interní webová aplikace pro evidenci a automatické přidělování IPv4 subnetů.
## Purpose
## Účel
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.
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.
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.
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.
## Features
## Přehled funkcí
- 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.
- 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.
## Architecture
## Architektura
- **Rust** edition 2024
- **Axum** + **Tokio**
- **SQLx** with **SQLite**
- **Askama** for HTML templates
- **HTMX** from a local static file
- Custom CSS without external frameworks
- CIDR/IP calculations using `ipnet`
- **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`
## Quick start
## Rychlý start
```bash
cd sub-aligator
@ -45,141 +42,79 @@ cp .env.example .env
docker compose up -d --build
```
Open a browser at:
Otevřete prohlížeč na:
```
http://SERVER_IP:8080
http://IP_SERVERU:8080
```
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.
Výchozí port lze změnit v `.env` pomocí proměnné `APP_PORT`.
## Persistent data
## Persistentní data
Docker Compose creates local directories:
Docker Compose vytváří lokální adresáře:
- `./data` – SQLite database
- `./exports` – generated exports (Markdown, CSV, JSON)
- `./backups` – database backups
- `./data` – SQLite databáze
- `./exports` – vygenerované exporty (Markdown, CSV, JSON)
- `./backups` – zálohy databáze
These directories are bind-mounted into the container, so data persists even if the container is removed.
Tyto adresáře jsou bind mountovány do kontejneru, takže data přetrvávají i po odstranění kontejneru.
## Backup and restore
## Záloha a obnova
### Backup
### Záloha
```bash
./scripts/backup.sh
```
The script creates a consistent database backup using `sqlite3 .backup` with a UTC timestamp in the filename. Backups are stored in `./backups`.
Skript vytvoří konzistentní zálohu databáze pomocí `sqlite3 .backup` s UTC timestampem v názvu souboru. Zálohy se ukládají do `./backups`.
### Restore
### Obnova
```bash
./scripts/restore.sh ./backups/subnet-allocator.db.20240930T120000Z.backup
```
**Warning:** restore overwrites the current database. Stop the container and back up the current state before restoring.
**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.
### Automating backups
### Automatizace zálohy
On the host you can use a systemd timer or cron. Example cron entry:
Na hostovi můžete použít systemd timer nebo cron. Příklad cron záznamu:
```cron
0 2 * * * /path/to/sub-aligator/scripts/backup.sh >> /var/log/subnet-allocator-backup.log 2>&1
0 2 * * * /cesta/k/sub-aligator/scripts/backup.sh >> /var/log/subnet-allocator-backup.log 2>&1
```
## Exports
## Exporty
After every successful allocation or release, read-only exports are regenerated:
Po každém úspěšném vytvoření nebo uvolnění subnetu se vygenerují read-only exporty:
- `/app/exports/allocations.md`
- `/app/exports/allocations.csv`
- `/app/exports/allocations.json`
They are also available for download via endpoints:
Ke stažení jsou dostupné přes endpointy:
- `GET /exports/allocations.md`
- `GET /exports/allocations.csv`
- `GET /exports/allocations.json`
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.
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.
## REST API
## Bezpečnostní omezení
The application provides a JSON REST API under `/api/v1/` for integrations. All API requests are authenticated with a Bearer token:
- 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"}`.
```
Authorization: Bearer sa_...
```
## Pozdější přidání autentizace přes Caddy + Authentik
### Managing API tokens
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`.
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:
Příklad konfigurace Caddy s Authentik:
```caddy
subnet.example.com {
@ -192,51 +127,43 @@ subnet.example.com {
}
```
After updating `get_actor_from_request()`, the audit log will record the real user.
Po úpravě `get_actor_from_request()` bude auditní log obsahovat skutečného uživatele.
## First-fit allocation example
## Příklad first-fit alokace
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`.
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`.
The algorithm always respects CIDR alignment and finds the first free space large enough.
Algoritmus vždy respektuje CIDR zarovnání a najde první volný prostor dostatečné velikosti.
## Endpoints
## Endpointy
- `GET /` – main page
- `GET /` – hlavní stránka
- `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` – 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)
- `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
## Troubleshooting
### Container does not start
### Kontejner nenastartuje
```bash
docker compose logs -f subnet-allocator
```
### Database is locked
### Databáze je uzamčena
If the process terminates unexpectedly, a `-wal` or `-shm` file may remain. Stop the container and check `./data`.
Pokud dojde k neočekávanému ukončení, může zůstat `-wal` nebo `-shm` soubor. Zastavte kontejner a zkontrolujte `./data`.
### Exports are not generated
### Exporty se negenerují
Check the permissions of the `./exports` directory and the `APP_EXPORTS_DIR` variable.
Zkontrolujte práva adresáře `./exports` a proměnnou `APP_EXPORTS_DIR`.
## Licence
MIT. See the `LICENSE` file.
MIT. Viz soubor `LICENSE`.

View file

@ -1,9 +0,0 @@
#!/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

@ -1,34 +0,0 @@
-- 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

@ -1,12 +0,0 @@
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);

View file

@ -1,86 +0,0 @@
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,4 +1,5 @@
use crate::config::Config;
use crate::models::now_utc_rfc3339;
use anyhow::Context;
use sqlx::migrate::{MigrateDatabase, Migrator};
use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqliteSynchronous};
@ -37,9 +38,45 @@ 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![
"# SUB-ALIGATOR – Export".to_string(),
"# IP Subnet Allocator – Export".to_string(),
"".to_string(),
format!("Generated: {}", chrono::Utc::now().to_rfc3339()),
format!("Vygenerováno: {}", chrono::Utc::now().to_rfc3339()),
"".to_string(),
];
@ -58,13 +58,13 @@ async fn generate_markdown(
.collect();
if pool_allocs.is_empty() {
lines.push("*No active or reserved allocations.*".to_string());
lines.push("*Žádné aktivní ani rezervované alokace.*".to_string());
lines.push("".to_string());
continue;
}
lines.push("| Subnet | Status | Note | Created |".to_string());
lines.push("|--------|--------|------|---------|".to_string());
lines.push("| Subnet | Stav | Poznámka | Vytvořeno |".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, "Invalid CSRF token.").await.into_response();
return render_index_with_error(&state, &jar, &csrf_token, "Neplatný CSRF token.").await.into_response();
}
let note = form.note.trim();
if note.len() < 3 {
return render_index_with_error(&state, &jar, &csrf_token, "Note must be at least 3 characters long.").await.into_response();
return render_index_with_error(&state, &jar, &csrf_token, "Poznámka musí mít alespoň 3 znaky.").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 does not exist.").await.into_response(),
None => return render_index_with_error(&state, &jar, &csrf_token, "Pool neexistuje.").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 changed in the meantime, allocated: {})",
"{} (preview se mezitím změnil, přiděleno: {})",
allocation.note, allocation.cidr
);
}
@ -122,15 +122,17 @@ pub async fn allocate_in_tx(
ticket: &str,
actor: &str,
) -> anyhow::Result<(Allocation, crate::allocator::SubnetInfo)> {
// 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 mut conn = pool.acquire().await?;
sqlx::query("BEGIN IMMEDIATE")
.execute(&mut *conn)
.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 *tx)
.fetch_all(&mut *conn)
.await?;
let info = find_next_subnet(parent_cidr, prefix_length, allowed_prefixes, &active)?;
@ -153,12 +155,12 @@ pub async fn allocate_in_tx(
.bind(AllocationStatus::Active.as_str())
.bind(now_utc_rfc3339())
.bind(actor)
.execute(&mut *tx)
.execute(&mut *conn)
.await?
.last_insert_rowid();
log_event(
&mut tx,
&mut conn,
"allocation_created",
Some(id),
Some(pool_id),
@ -172,7 +174,7 @@ pub async fn allocate_in_tx(
.await
.ok();
tx.commit().await?;
sqlx::query("COMMIT").execute(&mut *conn).await?;
let allocation = sqlx::query_as::<_, Allocation>("SELECT * FROM allocations WHERE id = ?")
.bind(id)
@ -193,7 +195,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, "Invalid CSRF token.").await.into_response();
return render_detail_with_error(&state, id, &jar, &csrf_token, "Neplatný CSRF token.").await.into_response();
}
let release_note = form.release_note.trim();
@ -203,10 +205,9 @@ pub async fn release_allocation(
id,
&jar,
&csrf_token,
"Release note must be at least 3 characters long.",
"Poznámka k uvolnění musí mít alespoň 3 znaky.",
)
.await
.into_response();
.await.into_response();
}
let result = release_in_tx(&state.pool, id, release_note, &actor).await;
@ -215,7 +216,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 released successfully.").await.into_response()
render_detail_with_error(&state, id, &jar, &csrf_token, "Subnet byl úspěšně uvolněn.").await.into_response()
}
Err(e) => {
warn!("Release failed for allocation {}: {}", id, e);
@ -230,14 +231,18 @@ pub async fn release_in_tx(
release_note: &str,
actor: &str,
) -> anyhow::Result<()> {
let mut tx = pool.begin_with("BEGIN IMMEDIATE").await?;
let mut conn = pool.acquire().await?;
sqlx::query("BEGIN IMMEDIATE")
.execute(&mut *conn)
.await?;
let allocation: Allocation =
sqlx::query_as("SELECT * FROM allocations WHERE id = ? AND status IN ('active', 'reserved')")
.bind(id)
.fetch_one(&mut *tx)
.fetch_one(&mut *conn)
.await
.map_err(|_| anyhow::anyhow!("Allocation does not exist or is no longer active."))?;
.map_err(|_| anyhow::anyhow!("Alokace neexistuje nebo již není aktivní."))?;
sqlx::query(
"UPDATE allocations SET status = 'released', released_at = ?, released_by = ?, release_note = ? WHERE id = ?",
@ -246,11 +251,11 @@ pub async fn release_in_tx(
.bind(actor)
.bind(release_note)
.bind(id)
.execute(&mut *tx)
.execute(&mut *conn)
.await?;
log_event(
&mut tx,
&mut conn,
"allocation_released",
Some(id),
Some(allocation.pool_id),
@ -263,12 +268,12 @@ pub async fn release_in_tx(
.await
.ok();
tx.commit().await?;
sqlx::query("COMMIT").execute(&mut *conn).await?;
Ok(())
}
pub(crate) async fn regenerate_after_change(state: &AppState) -> anyhow::Result<()> {
let pools: Vec<Pool> = sqlx::query_as("SELECT * FROM pools ORDER BY id")
async fn regenerate_after_change(state: &AppState) -> anyhow::Result<()> {
let pools: Vec<Pool> = sqlx::query_as("SELECT * FROM pools WHERE enabled = true")
.fetch_all(&state.pool)
.await?;
let allocations: Vec<Allocation> =
@ -294,7 +299,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("Error rendering page.".to_string())
Html("Chyba při vykreslování stránky.".to_string())
}
}
}
@ -305,7 +310,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("Error rendering page.".to_string())
Html("Chyba při vykreslování stránky.".to_string())
}
}
}
@ -315,15 +320,8 @@ 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,
all_pools,
pools: load_pool_templates(state).await,
allocations: vec![],
page: 1,
total_pages: 0,
@ -352,7 +350,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("Error rendering page.".to_string())
Html("Chyba při vykreslování stránky.".to_string())
}
}
}
@ -381,7 +379,7 @@ async fn build_detail_template(
pool_name: "N/A".to_string(),
address_count: 0,
usable_host_count: 0,
note: "Allocation not found.".to_string(),
note: "Alokace nenalezena.".to_string(),
ticket: "–".to_string(),
status: "released".to_string(),
network: "N/A".to_string(),

View file

@ -1,476 +0,0 @@
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,7 +1,4 @@
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, Redirect, Response};
use axum::response::{Html, IntoResponse, Response};
use axum_extra::extract::cookie::CookieJar;
use serde::Deserialize;
use tracing::error;
@ -27,19 +27,10 @@ pub async fn index(
State(state): State<AppState>,
jar: CookieJar,
Query(q): Query<IndexQuery>,
) -> Response {
) -> impl IntoResponse {
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",
)
@ -50,11 +41,6 @@ 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;
@ -62,7 +48,6 @@ pub async fn index(
let template = IndexTemplate {
pools,
all_pools,
allocations,
page,
total_pages,
@ -78,7 +63,7 @@ pub async fn index(
Ok(html) => (jar, Html(html)).into_response(),
Err(e) => {
error!("Index template render error: {}", e);
(jar, Html("Error rendering page.".to_string())).into_response()
(jar, Html("Chyba při vykreslování stránky.".to_string())).into_response()
}
}
}
@ -100,7 +85,7 @@ pub async fn allocation_detail(
let allocation = match allocation {
Some(a) => a,
None => {
return (jar, Html("Allocation not found.".to_string())).into_response();
return (jar, Html("Alokace nenalezena.".to_string())).into_response();
}
};
@ -169,7 +154,7 @@ pub async fn allocation_detail(
Ok(html) => (jar, Html(html)).into_response(),
Err(e) => {
error!("Detail template render error: {}", e);
(jar, Html("Error rendering page.".to_string())).into_response()
(jar, Html("Chyba při vykreslování stránky.".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 does not exist.".to_string(),
error: "Pool neexistuje.".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("Preview error.".to_string())
Html("Chyba preview.".to_string())
}
}
}

View file

@ -1,492 +0,0 @@
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 })
}

View file

@ -1,325 +0,0 @@
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,5 +1,4 @@
pub mod allocator;
pub mod api_tokens;
pub mod audit;
pub mod config;
pub mod csrf;

View file

@ -69,24 +69,6 @@ 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, api, exports, pages, partials, pools, tokens};
use crate::handlers::{allocations, exports, pages, partials};
use axum::routing::{get, post};
use axum::Router;
@ -6,14 +6,6 @@ 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))
@ -24,15 +16,4 @@ 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,7 +11,6 @@ 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,
@ -131,7 +130,6 @@ pub struct AllocationsTableTemplate {
pub query: String,
pub pool_filter: String,
pub status_filter: String,
pub csrf_token: String,
}
#[derive(Template)]
@ -142,49 +140,3 @@ 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,
}

View file

@ -1,135 +0,0 @@
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,38 +14,11 @@
--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;
}
@ -84,27 +57,6 @@ 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 {
@ -181,7 +133,6 @@ textarea:focus {
.btn:hover {
background: var(--fg);
color: var(--bg);
}
.btn-secondary {
@ -203,22 +154,6 @@ 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;
@ -300,17 +235,17 @@ tr:hover {
.status.active {
background: var(--active);
color: var(--active-fg);
color: #155724;
}
.status.reserved {
background: var(--reserved);
color: var(--reserved-fg);
color: #856404;
}
.status.released {
background: var(--released);
color: var(--released-fg);
color: #383d41;
}
.pagination {
@ -394,10 +329,6 @@ tr:hover {
padding: 0.5rem;
}
header {
padding-top: 3rem;
}
.grid {
grid-template-columns: 1fr;
}

View file

@ -1,49 +0,0 @@
(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 }} | SUB-ALIGATOR{% endblock %}
{% block title %}Detail {{ allocation.cidr }} | IP Subnet Allocator{% endblock %}
{% block content %}
<div id="flash-container">
@ -8,7 +8,7 @@
</div>
<section class="box">
<div class="box-title">Allocation detail {{ allocation.cidr }}</div>
<div class="box-title">Detail alokace {{ 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">Type</div>
<div class="label">Typ</div>
<div class="value">{{ allocation.kind }}</div>
</div>
<div class="detail-item">
<div class="label">Status</div>
<div class="label">Stav</div>
<div class="value"><span class="status {{ allocation.status }}">{{ allocation.status }}</span></div>
</div>
<div class="detail-item">
<div class="label">Total addresses</div>
<div class="label">Celkem adres</div>
<div class="value">{{ allocation.address_count }}</div>
</div>
<div class="detail-item">
<div class="label">Usable hosts</div>
<div class="label">Použitelných hostů</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">First host</div>
<div class="label">První host</div>
<div class="value">{{ allocation.first_host }}</div>
</div>
<div class="detail-item">
<div class="label">Last host</div>
<div class="label">Poslední host</div>
<div class="value">{{ allocation.last_host }}</div>
</div>
<div class="detail-item">
<div class="label">Note</div>
<div class="label">Poznámka</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">Created</div>
<div class="label">Vytvořeno</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">Released</div>
<div class="label">Uvolněno</div>
<div class="value">{{ allocation.released_at_local }} ({{ allocation.released_by }})</div>
</div>
<div class="detail-item">
<div class="label">Release note</div>
<div class="label">Poznámka k uvolnění</div>
<div class="value">{{ allocation.release_note }}</div>
</div>
{% endif %}
</div>
{% if allocation.status == "active" || allocation.status == "reserved" %}
<button class="btn btn-danger release-btn" data-id="{{ allocation.id }}">Release subnet</button>
<button class="btn btn-danger" onclick="document.getElementById('release-modal').style.display='flex'">Uvolnit subnet</button>
{% endif %}
<a href="/" class="btn btn-secondary">Back</a>
<a href="/" class="btn btn-secondary">Zpět</a>
</section>
{% if audit_events.len() > 0 %}
<section class="box">
<div class="box-title">Audit history</div>
<div class="box-title">Auditní historie</div>
<table>
<thead>
<tr>
<th>Time</th>
<th>Event</th>
<th>Actor</th>
<th>Details</th>
<th>Čas</th>
<th>Událost</th>
<th>Aktor</th>
<th>Detaily</th>
</tr>
</thead>
<tbody>
{% for e in audit_events %}
<tr>
<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>
<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>
</tr>
{% endfor %}
</tbody>
@ -108,19 +108,19 @@
</section>
{% endif %}
<div id="release-modal-{{ allocation.id }}" class="modal-overlay" style="display:none;">
<dialog id="release-modal" class="modal-overlay" style="display:none;">
<div class="modal">
<h2 class="modal-title">Release subnet {{ allocation.cidr }}</h2>
<h2 class="modal-title">Uvolnit 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">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>
<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>
<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>
</div>
</dialog>
{% endblock %}

View file

@ -1,33 +1,24 @@
<!DOCTYPE html>
<html lang="en">
<html lang="cs">
<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 %}SUB-ALIGATOR{% endblock %}</title>
<script src="/static/theme.js"></script>
<title>{% block title %}IP Subnet Allocator{% endblock %}</title>
<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>
<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>
<h1>IP Subnet Allocator</h1>
<p>Interní evidence a přidělování IPv4 subnetů</p>
</header>
{% block content %}{% endblock %}
<footer class="box">
<p class="small">
<a href="/pools">Manage pools</a> |
<a href="/tokens">API tokens</a> |
<a href="/">Hlavní stránka</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,71 +2,46 @@
<thead>
<tr>
<th>Subnet</th>
<th>Type</th>
<th>Typ</th>
<th>Pool</th>
<th>Addresses</th>
<th>Note</th>
<th>Status</th>
<th>Created</th>
<th>Actions</th>
<th>Počet adres</th>
<th>Poznámka</th>
<th>Stav</th>
<th>Vytvořeno</th>
<th>Akce</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="Type">{{ a.kind }}</td>
<td data-label="Typ">{{ a.kind }}</td>
<td data-label="Pool">{{ a.pool_name }}</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>
<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>
</tr>
{% else %}
<tr>
<td colspan="8" style="text-align:center;">No allocations.</td>
<td colspan="8" style="text-align:center;">Žádné alokace.</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 %}">← Previous</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 %}">← Předchozí</a>
{% endif %}
</div>
<div>Page {{ page }} / {{ total_pages }} (total {{ total_count }})</div>
<div>Strana {{ page }} / {{ total_pages }} (celkem {{ 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 %}">Next →</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 %}">Další →</a>
{% endif %}
</div>
</div>

View file

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

View file

@ -5,19 +5,12 @@
{% 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">Type:</span> <span class="value">{{ pool.kind }}</span></p>
<p><span class="label">Typ:</span> <span class="value">{{ pool.kind }}</span></p>
<form
hx-get="/partials/pools/{{ pool.slug }}/preview"
@ -25,57 +18,55 @@
hx-trigger="change from:select, load"
hx-indicator="#indicator-{{ pool.slug }}"
>
<label for="prefix-{{ pool.slug }}">Subnet size</label>
<select name="prefix_length" id="prefix-{{ pool.slug }}">
<label for="prefix-{{ pool.slug }}">Velikost subnetu</label>
<select name="prefix_length" id="prefix-{{ pool.slug }}" onchange="updateModalPrefix('{{ pool.slug }}', this.value)">
{% 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;">Loading...</div>
<div id="indicator-{{ pool.slug }}" class="small" style="display:none;">Načítám...</div>
<div id="preview-{{ pool.slug }}"></div>
</form>
<button
class="btn allocate-btn"
class="btn"
style="margin-top: 1rem;"
data-slug="{{ pool.slug }}"
data-name="{{ pool.name }}"
onclick="openAllocateModal('{{ pool.slug }}')"
>
[ Allocate next free subnet ]
[ Přidělit další volný subnet ]
</button>
</article>
{% endfor %}
</section>
{% endif %}
<section class="box">
<div class="box-title">Allocated subnets</div>
<div class="box-title">Přidělené subnety</div>
<form method="get" action="/" class="filters">
<div>
<label for="q">Search</label>
<input type="text" id="q" name="q" value="{{ query }}" placeholder="CIDR or note">
<label for="q">Vyhledat</label>
<input type="text" id="q" name="q" value="{{ query }}" placeholder="CIDR nebo poznámka">
</div>
<div>
<label for="pool">Pool</label>
<select id="pool" name="pool">
<option value="" {% if pool_filter == "" %}selected{% endif %}>All</option>
{% for p in all_pools %}
<option value="" {% if pool_filter == "" %}selected{% endif %}>Všechny</option>
{% for p in pools %}
<option value="{{ p.slug }}" {% if pool_filter == p.slug %}selected{% endif %}>{{ p.name }}</option>
{% endfor %}
</select>
</div>
<div>
<label for="status">Status</label>
<label for="status">Stav</label>
<select id="status" name="status">
<option value="" {% if status_filter == "" %}selected{% endif %}>All</option>
<option value="" {% if status_filter == "" %}selected{% endif %}>Všechny</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">Filter</button>
<button type="submit" class="btn">Filtrovat</button>
<a href="/" class="btn btn-secondary">Reset</a>
</div>
</form>
@ -85,9 +76,9 @@
</div>
</section>
<div id="allocate-modal" class="modal-overlay" style="display:none;">
<dialog id="allocate-modal" class="modal-overlay" style="display:none;">
<div class="modal">
<h2 class="modal-title">Confirm subnet allocation</h2>
<h2 class="modal-title">Potvrdit přidělení subnetu</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">
@ -103,32 +94,79 @@
<div class="value" id="modal-prefix-display"></div>
</div>
<div class="detail-item">
<div class="label">Proposed subnet</div>
<div class="label">Navrhovaný subnet</div>
<div class="value" id="modal-cidr"></div>
</div>
<div class="detail-item">
<div class="label">Total addresses</div>
<div class="label">Celkem adres</div>
<div class="value" id="modal-address-count"></div>
</div>
<div class="detail-item">
<div class="label">Usable hosts</div>
<div class="label">Použitelných hostů</div>
<div class="value" id="modal-usable-hosts"></div>
</div>
</div>
<p class="small" id="modal-warning"></p>
<label for="note">Note *</label>
<label for="note">Poznámka *</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>
</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>
{% 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">Next free subnet: <span class="value">–</span></p>
<p class="label">Další volný subnet: <span class="value">–</span></p>
{% else %}
<p>
<span class="label">Next free subnet:</span>
<span class="label">Další volný subnet:</span>
<span class="value" id="preview-cidr">{{ cidr }}</span>
</p>
<p>
<span class="label">Total addresses:</span>
<span class="label">Celkem adres:</span>
<span class="value" id="preview-address-count">{{ address_count }}</span>
</p>
<p>
<span class="label">Usable hosts:</span>
<span class="label">Použitelných hostů:</span>
<span class="value" id="preview-usable-hosts">{{ usable_host_count }}</span>
</p>
{% if prefix_length >= 31 %}
<p class="small" id="preview-warning">
For /31 and /32, network/broadcast addresses may coincide with host addresses per RFC 3021.
Pro /31 a /32 se network/broadcast adresy dle RFC 3021 mohou shodovat s host adresami.
</p>
{% else %}
<p class="small" id="preview-warning"></p>

View file

@ -1,101 +0,0 @@
{% 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 %}

View file

@ -1,42 +0,0 @@
{% 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 %}

View file

@ -1,120 +0,0 @@
{% 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 %}