From 147c6d32f1e4638cc55485622c9e564d8d51350b Mon Sep 17 00:00:00 2001 From: SW Date: Thu, 1 Oct 2026 14:07:19 +0200 Subject: [PATCH] feat: pool management, setup wizard, English UI and README, remove brew formula --- Formula/sub-aligator.rb | 17 -- README.md | 150 +++++++------- src/db.rs | 37 ---- src/exports.rs | 8 +- src/handlers/allocations.rs | 36 ++-- src/handlers/mod.rs | 1 + src/handlers/pages.rs | 25 ++- src/handlers/partials.rs | 4 +- src/handlers/pools.rs | 223 +++++++++++++++++++++ src/routes.rs | 5 +- src/templates.rs | 17 ++ static/style.css | 16 ++ templates/allocation_detail.html | 54 ++--- templates/base.html | 7 +- templates/fragments/allocations_table.html | 57 ++++-- templates/fragments/flash.html | 8 +- templates/index.html | 50 +++-- templates/partials/pool_preview.html | 10 +- templates/pools.html | 72 +++++++ templates/setup.html | 42 ++++ 20 files changed, 609 insertions(+), 230 deletions(-) delete mode 100644 Formula/sub-aligator.rb create mode 100644 src/handlers/pools.rs create mode 100644 templates/pools.html create mode 100644 templates/setup.html diff --git a/Formula/sub-aligator.rb b/Formula/sub-aligator.rb deleted file mode 100644 index 8263362..0000000 --- a/Formula/sub-aligator.rb +++ /dev/null @@ -1,17 +0,0 @@ -class SubAligator < Formula - desc "Internal IP subnet allocator" - homepage "https://git.janvete.com/seth/sub-aligator" - url "https://git.janvete.com/seth/sub-aligator/archive/v0.1.0.tar.gz" - sha256 "44b0c1282a59584d00e8b534fad3570aa8c1f90362012d4fe445039b88f61fe4" - license "MIT" - - depends_on "rust" => :build - - def install - system "cargo", "install", *std_cargo_args - end - - test do - system "#{bin}/sub-aligator", "--version" - end -end diff --git a/README.md b/README.md index 679781a..3ff2b5c 100644 --- a/README.md +++ b/README.md @@ -1,40 +1,40 @@ # IP Subnet Allocator -Jednoduchá interní webová aplikace pro evidenci a automatické přidělování IPv4 subnetů. +A simple internal web application for tracking and automatically allocating IPv4 subnets. -## Účel +## Purpose -Aplikace slouží výhradně pro interní evidenci a automatické přidělování IPv4 subnetů. Není to obecný IPAM, monitoring, inventář zařízení, správa VLAN ani síťová topologie. +This application is intended solely for internal tracking and automatic allocation of IPv4 subnets. It is not a general IPAM, monitoring tool, device inventory, VLAN manager, or network topology viewer. -Je navržena pro provoz pouze uvnitř LAN/VPN. **Aplikace neobsahuje autentizaci** – musí být zařazena za firewall/reverse proxy v důvěryhodné síti. +It is designed to run only inside a LAN/VPN. **The application does not include authentication** and must be placed behind a firewall/reverse proxy in a trusted network. -## Přehled funkcí +## Features -- Dva přednastavené pooly: - - VLAN / zákaznické subnety: `10.91.0.0/16` - - VPN subnety: `10.200.0.0/16` -- Automatické přidělování subnetů pomocí **first-fit** algoritmu pro prefixy /20 až /30. -- Atomický zápis alokací přes SQLite transakci `BEGIN IMMEDIATE`. -- Webové UI s retro TUI/terminál vzhledem, server-side renderované pomocí Askama. -- Dynamické preview dalšího volného subnetu přes HTMX. -- Historie alokací s filtrem, vyhledáváním a stránkováním. -- Uvolňování subnetů (status `released`) s povinnou poznámkou. -- Auditní log všech operací. -- Exporty do Markdown, CSV a JSON. -- SQLite migrace probíhají automaticky při startu. -- Zálohovací a obnovovací skripty. +- Manage one or more IP pools manually through the web UI. +- First-run setup wizard: create the first pool before allocating subnets. +- Automatic subnet allocation using a **first-fit** algorithm for prefixes /20 through /30. +- Atomic allocation writes using SQLite `BEGIN IMMEDIATE` transactions. +- Retro TUI/terminal-style web UI, server-side rendered with Askama. +- Dynamic preview of the next free subnet via HTMX. +- Allocation history with filtering, search, and pagination. +- Release subnets (status `released`) with a mandatory note, directly from the history table or detail page. +- Released subnets free up address space and can be reused by the first-fit algorithm. +- Audit log of all operations. +- Markdown, CSV, and JSON exports. +- SQLite migrations run automatically on startup. +- Backup and restore scripts. -## Architektura +## Architecture - **Rust** edition 2024 - **Axum** + **Tokio** -- **SQLx** s **SQLite** -- **Askama** pro HTML šablony -- **HTMX** z lokálního statického souboru -- Vlastní CSS bez externích frameworků -- CIDR/IP výpočty pomocí `ipnet` +- **SQLx** with **SQLite** +- **Askama** for HTML templates +- **HTMX** from a local static file +- Custom CSS without external frameworks +- CIDR/IP calculations using `ipnet` -## Rychlý start +## Quick start ```bash cd sub-aligator @@ -42,79 +42,79 @@ cp .env.example .env docker compose up -d --build ``` -Otevřete prohlížeč na: +Open a browser at: ``` -http://IP_SERVERU:8080 +http://SERVER_IP:8080 ``` -Výchozí port lze změnit v `.env` pomocí proměnné `APP_PORT`. +On first launch you will be asked to create the first pool. The default port can be changed in `.env` via the `APP_PORT` variable. -## Persistentní data +## Persistent data -Docker Compose vytváří lokální adresáře: +Docker Compose creates local directories: -- `./data` – SQLite databáze -- `./exports` – vygenerované exporty (Markdown, CSV, JSON) -- `./backups` – zálohy databáze +- `./data` – SQLite database +- `./exports` – generated exports (Markdown, CSV, JSON) +- `./backups` – database backups -Tyto adresáře jsou bind mountovány do kontejneru, takže data přetrvávají i po odstranění kontejneru. +These directories are bind-mounted into the container, so data persists even if the container is removed. -## Záloha a obnova +## Backup and restore -### Záloha +### Backup ```bash ./scripts/backup.sh ``` -Skript vytvoří konzistentní zálohu databáze pomocí `sqlite3 .backup` s UTC timestampem v názvu souboru. Zálohy se ukládají do `./backups`. +The script creates a consistent database backup using `sqlite3 .backup` with a UTC timestamp in the filename. Backups are stored in `./backups`. -### Obnova +### Restore ```bash ./scripts/restore.sh ./backups/subnet-allocator.db.20240930T120000Z.backup ``` -**Upozornění:** obnova přepíše aktuální databázi. Před obnovou doporučujeme zastavit kontejner a vytvořit zálohu aktuálního stavu. +**Warning:** restore overwrites the current database. Stop the container and back up the current state before restoring. -### Automatizace zálohy +### Automating backups -Na hostovi můžete použít systemd timer nebo cron. Příklad cron záznamu: +On the host you can use a systemd timer or cron. Example cron entry: ```cron -0 2 * * * /cesta/k/sub-aligator/scripts/backup.sh >> /var/log/subnet-allocator-backup.log 2>&1 +0 2 * * * /path/to/sub-aligator/scripts/backup.sh >> /var/log/subnet-allocator-backup.log 2>&1 ``` -## Exporty +## Exports -Po každém úspěšném vytvoření nebo uvolnění subnetu se vygenerují read-only exporty: +After every successful allocation or release, read-only exports are regenerated: - `/app/exports/allocations.md` - `/app/exports/allocations.csv` - `/app/exports/allocations.json` -Ke stažení jsou dostupné přes endpointy: +They are also available for download via endpoints: - `GET /exports/allocations.md` - `GET /exports/allocations.csv` - `GET /exports/allocations.json` -Markdown export obsahuje samostatnou sekci pro každý pool s tabulkou aktivních a rezervovaných alokací. CSV a JSON obsahují všechny alokace včetně released. +The Markdown export contains a separate section for each pool with a table of active and reserved allocations. CSV and JSON include all allocations, including released ones. -## Bezpečnostní omezení +## Security notes -- Aplikace **nemá autentizaci**. Používejte ji pouze za VPN/LAN/firewallem. -- CSRF tokeny jsou povinné pro POST formuláře. -- Nastaveny bezpečnostní hlavičky: `X-Content-Type-Options`, `X-Frame-Options`, `Referrer-Policy`, `Content-Security-Policy`. -- Aplikace nemá žádný defaultní přístup z Internetu; bind portu se řeší přes Docker Compose. -- `/healthz` vrací pouze `{"status":"ok"}`. +- The application **has no authentication**. Use it only behind a VPN/LAN/firewall. +- CSRF tokens are required for POST forms. +- 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"}`. -## Pozdější přidání autentizace přes Caddy + Authentik +## Adding authentication later via Caddy + Authentik -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`. +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`. -Příklad konfigurace Caddy s Authentik: +Example Caddy configuration with Authentik: ```caddy subnet.example.com { @@ -127,43 +127,45 @@ subnet.example.com { } ``` -Po úpravě `get_actor_from_request()` bude auditní log obsahovat skutečného uživatele. +After updating `get_actor_from_request()`, the audit log will record the real user. -## Příklad first-fit alokace +## First-fit allocation example -1. První požadavek `/30` získá `10.91.0.0/30`. -2. Následný požadavek `/24` získá `10.91.1.0/24` (vyhne se obsazenému `/30`). -3. Další požadavek `/30` zaplní mezeru a získá `10.91.0.4/30`. +1. First request `/30` receives `10.91.0.0/30`. +2. Next request `/24` receives `10.91.1.0/24` (avoids the occupied `/30`). +3. Another request `/30` fills the gap and receives `10.91.0.4/30`. -Algoritmus vždy respektuje CIDR zarovnání a najde první volný prostor dostatečné velikosti. +The algorithm always respects CIDR alignment and finds the first free space large enough. -## Endpointy +## Endpoints -- `GET /` – hlavní stránka +- `GET /` – main page - `GET /healthz` – healthcheck +- `GET /setup` – first-run pool setup +- `GET /pools` – pool management +- `POST /pools/new` – create a new pool - `GET /partials/pools/{slug}/preview?prefix_length=24` – HTMX preview -- `POST /allocations` – vytvoření alokace -- `GET /allocations` – seznam alokací (součást `/`) -- `GET /allocations/{id}` – detail alokace -- `POST /allocations/{id}/release` – uvolnění alokace -- `GET /exports/allocations.{md,csv,json}` – exporty +- `POST /allocations` – create an allocation +- `GET /allocations/{id}` – allocation detail +- `POST /allocations/{id}/release` – release an allocation +- `GET /exports/allocations.{md,csv,json}` – exports ## Troubleshooting -### Kontejner nenastartuje +### Container does not start ```bash docker compose logs -f subnet-allocator ``` -### Databáze je uzamčena +### Database is locked -Pokud dojde k neočekávanému ukončení, může zůstat `-wal` nebo `-shm` soubor. Zastavte kontejner a zkontrolujte `./data`. +If the process terminates unexpectedly, a `-wal` or `-shm` file may remain. Stop the container and check `./data`. -### Exporty se negenerují +### Exports are not generated -Zkontrolujte práva adresáře `./exports` a proměnnou `APP_EXPORTS_DIR`. +Check the permissions of the `./exports` directory and the `APP_EXPORTS_DIR` variable. ## Licence -MIT. Viz soubor `LICENSE`. +MIT. See the `LICENSE` file. diff --git a/src/db.rs b/src/db.rs index 87765f2..b4ed921 100644 --- a/src/db.rs +++ b/src/db.rs @@ -1,5 +1,4 @@ use crate::config::Config; -use crate::models::now_utc_rfc3339; use anyhow::Context; use sqlx::migrate::{MigrateDatabase, Migrator}; use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqliteSynchronous}; @@ -38,45 +37,9 @@ pub async fn init_db(config: &Config) -> anyhow::Result { .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(()) -} diff --git a/src/exports.rs b/src/exports.rs index ab2d592..9c2f86c 100644 --- a/src/exports.rs +++ b/src/exports.rs @@ -44,7 +44,7 @@ async fn generate_markdown( let mut lines = vec![ "# IP Subnet Allocator – Export".to_string(), "".to_string(), - format!("Vygenerováno: {}", chrono::Utc::now().to_rfc3339()), + format!("Generated: {}", chrono::Utc::now().to_rfc3339()), "".to_string(), ]; @@ -58,13 +58,13 @@ async fn generate_markdown( .collect(); if pool_allocs.is_empty() { - lines.push("*Žádné aktivní ani rezervované alokace.*".to_string()); + lines.push("*No active or reserved allocations.*".to_string()); lines.push("".to_string()); continue; } - lines.push("| Subnet | Stav | Poznámka | Vytvořeno |".to_string()); - lines.push("|--------|------|----------|-----------|".to_string()); + lines.push("| Subnet | Status | Note | Created |".to_string()); + lines.push("|--------|--------|------|---------|".to_string()); for a in pool_allocs { lines.push(format!( "| {} | {} | {} | {} |", diff --git a/src/handlers/allocations.rs b/src/handlers/allocations.rs index 1479775..00b0cfc 100644 --- a/src/handlers/allocations.rs +++ b/src/handlers/allocations.rs @@ -42,17 +42,17 @@ pub async fn create_allocation( let actor = get_actor_from_request(&headers); if !validate_csrf(&jar, &form.csrf_token) { - return render_index_with_error(&state, &jar, &csrf_token, "Neplatný CSRF token.").await.into_response(); + return render_index_with_error(&state, &jar, &csrf_token, "Invalid CSRF token.").await.into_response(); } let note = form.note.trim(); if note.len() < 3 { - return render_index_with_error(&state, &jar, &csrf_token, "Poznámka musí mít alespoň 3 znaky.").await.into_response(); + return render_index_with_error(&state, &jar, &csrf_token, "Note must be at least 3 characters long.").await.into_response(); } let pool = match load_pool_by_slug(&state.pool, &form.pool_slug).await { Some(p) => p, - None => return render_index_with_error(&state, &jar, &csrf_token, "Pool neexistuje.").await.into_response(), + None => return render_index_with_error(&state, &jar, &csrf_token, "Pool does not exist.").await.into_response(), }; let result = allocate_in_tx( @@ -87,7 +87,7 @@ pub async fn create_allocation( let mut message = success.clone(); if actual_cidr_different { message.note = format!( - "{} (preview se mezitím změnil, přiděleno: {})", + "{} (preview changed in the meantime, allocated: {})", allocation.note, allocation.cidr ); } @@ -195,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, "Neplatný CSRF token.").await.into_response(); + return render_detail_with_error(&state, id, &jar, &csrf_token, "Invalid CSRF token.").await.into_response(); } let release_note = form.release_note.trim(); @@ -205,9 +205,10 @@ pub async fn release_allocation( id, &jar, &csrf_token, - "Poznámka k uvolnění musí mít alespoň 3 znaky.", + "Release note must be at least 3 characters long.", ) - .await.into_response(); + .await + .into_response(); } let result = release_in_tx(&state.pool, id, release_note, &actor).await; @@ -216,7 +217,7 @@ pub async fn release_allocation( Ok(_) => { let _ = regenerate_after_change(&state).await; info!("Released allocation {} by {}", id, actor); - render_detail_with_error(&state, id, &jar, &csrf_token, "Subnet byl úspěšně uvolněn.").await.into_response() + render_detail_with_error(&state, id, &jar, &csrf_token, "Subnet released successfully.").await.into_response() } Err(e) => { warn!("Release failed for allocation {}: {}", id, e); @@ -242,7 +243,7 @@ pub async fn release_in_tx( .bind(id) .fetch_one(&mut *conn) .await - .map_err(|_| anyhow::anyhow!("Alokace neexistuje nebo již není aktivní."))?; + .map_err(|_| anyhow::anyhow!("Allocation does not exist or is no longer active."))?; sqlx::query( "UPDATE allocations SET status = 'released', released_at = ?, released_by = ?, release_note = ? WHERE id = ?", @@ -299,7 +300,7 @@ async fn render_index_with_error(state: &AppState, _jar: &CookieJar, csrf_token: Ok(html) => Html(html), Err(e) => { error!("Template render error: {}", e); - Html("Chyba při vykreslování stránky.".to_string()) + Html("Error rendering page.".to_string()) } } } @@ -310,7 +311,7 @@ async fn render_index_with_success(state: &AppState, _jar: &CookieJar, csrf_toke Ok(html) => Html(html), Err(e) => { error!("Template render error: {}", e); - Html("Chyba při vykreslování stránky.".to_string()) + Html("Error rendering page.".to_string()) } } } @@ -320,8 +321,15 @@ pub async fn build_index_template( csrf_token: &str, flash: FlashData, ) -> IndexTemplate { + let pools = load_pool_templates(state).await; + let all_pools = sqlx::query_as::<_, Pool>("SELECT * FROM pools ORDER BY id") + .fetch_all(&state.pool) + .await + .unwrap_or_default(); + IndexTemplate { - pools: load_pool_templates(state).await, + pools, + all_pools, allocations: vec![], page: 1, total_pages: 0, @@ -350,7 +358,7 @@ async fn render_detail_with_error(state: &AppState, id: i64, _jar: &CookieJar, c Ok(html) => Html(html), Err(e) => { error!("Template render error: {}", e); - Html("Chyba při vykreslování stránky.".to_string()) + Html("Error rendering page.".to_string()) } } } @@ -379,7 +387,7 @@ async fn build_detail_template( pool_name: "N/A".to_string(), address_count: 0, usable_host_count: 0, - note: "Alokace nenalezena.".to_string(), + note: "Allocation not found.".to_string(), ticket: "–".to_string(), status: "released".to_string(), network: "N/A".to_string(), diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs index 075b28e..ea709c7 100644 --- a/src/handlers/mod.rs +++ b/src/handlers/mod.rs @@ -2,3 +2,4 @@ pub mod allocations; pub mod exports; pub mod pages; pub mod partials; +pub mod pools; diff --git a/src/handlers/pages.rs b/src/handlers/pages.rs index a8f3404..3a371d7 100644 --- a/src/handlers/pages.rs +++ b/src/handlers/pages.rs @@ -4,7 +4,7 @@ use crate::state::AppState; use crate::templates::{AllocationDetailTemplate, FlashData, IndexTemplate, PoolTemplate}; use askama::Template; use axum::extract::{Query, State}; -use axum::response::{Html, IntoResponse, Response}; +use axum::response::{Html, IntoResponse, Redirect, Response}; use axum_extra::extract::cookie::CookieJar; use serde::Deserialize; use tracing::error; @@ -27,10 +27,19 @@ pub async fn index( State(state): State, jar: CookieJar, Query(q): Query, -) -> impl IntoResponse { +) -> Response { let (jar, csrf_token) = ensure_csrf_cookie(jar); let page = q.page.max(1); + let pool_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM pools") + .fetch_one(&state.pool) + .await + .unwrap_or(0); + + if pool_count == 0 { + return Redirect::to("/setup").into_response(); + } + let pools: Vec = sqlx::query_as::<_, Pool>( "SELECT * FROM pools WHERE enabled = true ORDER BY id", ) @@ -41,6 +50,11 @@ pub async fn index( .map(PoolTemplate::from) .collect(); + let all_pools: Vec = sqlx::query_as::<_, Pool>("SELECT * FROM pools ORDER BY id") + .fetch_all(&state.pool) + .await + .unwrap_or_default(); + let (allocations, total_count) = load_allocations(&state, &q.q, &q.pool, &q.status, page).await; @@ -48,6 +62,7 @@ pub async fn index( let template = IndexTemplate { pools, + all_pools, allocations, page, total_pages, @@ -63,7 +78,7 @@ pub async fn index( Ok(html) => (jar, Html(html)).into_response(), Err(e) => { error!("Index template render error: {}", e); - (jar, Html("Chyba při vykreslování stránky.".to_string())).into_response() + (jar, Html("Error rendering page.".to_string())).into_response() } } } @@ -85,7 +100,7 @@ pub async fn allocation_detail( let allocation = match allocation { Some(a) => a, None => { - return (jar, Html("Alokace nenalezena.".to_string())).into_response(); + return (jar, Html("Allocation not found.".to_string())).into_response(); } }; @@ -154,7 +169,7 @@ pub async fn allocation_detail( Ok(html) => (jar, Html(html)).into_response(), Err(e) => { error!("Detail template render error: {}", e); - (jar, Html("Chyba při vykreslování stránky.".to_string())).into_response() + (jar, Html("Error rendering page.".to_string())).into_response() } } } diff --git a/src/handlers/partials.rs b/src/handlers/partials.rs index c39fa87..3f44507 100644 --- a/src/handlers/partials.rs +++ b/src/handlers/partials.rs @@ -33,7 +33,7 @@ pub async fn pool_preview( address_count: 0, usable_host_count: 0, has_error: true, - error: "Pool neexistuje.".to_string(), + error: "Pool does not exist.".to_string(), }; return Html(template.render().unwrap_or_default()); } @@ -72,7 +72,7 @@ pub async fn pool_preview( Ok(html) => Html(html), Err(e) => { error!("Preview template render error: {}", e); - Html("Chyba preview.".to_string()) + Html("Preview error.".to_string()) } } } diff --git a/src/handlers/pools.rs b/src/handlers/pools.rs new file mode 100644 index 0000000..0b66776 --- /dev/null +++ b/src/handlers/pools.rs @@ -0,0 +1,223 @@ +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, SetupTemplate}; +use askama::Template; +use axum::extract::{Form, 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, +} + +pub async fn setup_page(State(state): State, 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, + jar: CookieJar, + headers: HeaderMap, + Form(form): Form, +) -> 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(&jar, csrf_token, "Invalid CSRF token.").into_response(); + } + + match validate_and_insert_pool(&state.pool, &form, &actor).await { + Ok(_) => { + info!("Pool {} created by {}", form.slug, actor); + Redirect::to("/").into_response() + } + Err(e) => render_setup_with_error(&jar, csrf_token, &e.to_string()).into_response(), + } +} + +pub async fn pool_list(State(state): State, jar: CookieJar) -> Response { + let (jar, csrf_token) = ensure_csrf_cookie(jar); + + let pools: Vec = sqlx::query_as::<_, Pool>("SELECT * FROM pools ORDER BY id") + .fetch_all(&state.pool) + .await + .unwrap_or_default(); + + let template = PoolListTemplate { + pools, + csrf_token, + error: String::new(), + }; + + 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() + } + } +} + +fn render_setup_with_error( + _jar: &CookieJar, + csrf_token: String, + error: &str, +) -> Html { + 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()) + } + } +} + +async fn validate_and_insert_pool( + pool: &SqlitePool, + form: &CreatePoolForm, + actor: &str, +) -> anyhow::Result<()> { + 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 = form + .allowed_prefixes + .split(',') + .filter(|s| !s.trim().is_empty()) + .map(|s| s.trim().parse::()) + .collect::, _>>() + .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(); + + let mut conn = pool.acquire().await?; + + sqlx::query("BEGIN IMMEDIATE") + .execute(&mut *conn) + .await?; + + let existing: Option = sqlx::query_scalar("SELECT id FROM pools WHERE slug = ? OR cidr = ?") + .bind(&slug) + .bind(&cidr) + .fetch_optional(&mut *conn) + .await?; + + if existing.is_some() { + sqlx::query("ROLLBACK").execute(&mut *conn).await?; + anyhow::bail!("A pool with the same slug or CIDR already exists."); + } + + 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 *conn) + .await? + .last_insert_rowid(); + + log_event( + &mut conn, + "pool_created", + None, + Some(id), + actor, + json!({ + "slug": slug, + "cidr": cidr, + "kind": kind, + "allowed_prefixes": prefixes, + }), + ) + .await + .ok(); + + sqlx::query("COMMIT").execute(&mut *conn).await?; + + Ok(()) +} diff --git a/src/routes.rs b/src/routes.rs index e239305..353d413 100644 --- a/src/routes.rs +++ b/src/routes.rs @@ -1,4 +1,4 @@ -use crate::handlers::{allocations, exports, pages, partials}; +use crate::handlers::{allocations, exports, pages, partials, pools}; use axum::routing::{get, post}; use axum::Router; @@ -6,6 +6,9 @@ pub fn router() -> Router { 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("/partials/pools/{slug}/preview", get(partials::pool_preview)) .route("/allocations", post(allocations::create_allocation)) .route("/allocations/{id}", get(pages::allocation_detail)) diff --git a/src/templates.rs b/src/templates.rs index 0841760..2f93210 100644 --- a/src/templates.rs +++ b/src/templates.rs @@ -11,6 +11,7 @@ pub struct BaseTemplate { #[template(path = "index.html")] pub struct IndexTemplate { pub pools: Vec, + pub all_pools: Vec, pub allocations: Vec, pub page: i64, pub total_pages: i64, @@ -130,6 +131,7 @@ pub struct AllocationsTableTemplate { pub query: String, pub pool_filter: String, pub status_filter: String, + pub csrf_token: String, } #[derive(Template)] @@ -140,3 +142,18 @@ 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(Template)] +#[template(path = "pools.html")] +pub struct PoolListTemplate { + pub pools: Vec, + pub csrf_token: String, + pub error: String, +} diff --git a/static/style.css b/static/style.css index ac4072f..151bfd1 100644 --- a/static/style.css +++ b/static/style.css @@ -154,6 +154,22 @@ textarea:focus { color: var(--error-bg); } +.btn-link { + display: inline; + padding: 0; + border: none; + background: transparent; + color: var(--link); + font-family: inherit; + font-size: inherit; + cursor: pointer; + text-decoration: underline; +} + +.btn-link:hover { + background: var(--hover); +} + .preview-box { border: 2px solid var(--border); padding: 0.75rem; diff --git a/templates/allocation_detail.html b/templates/allocation_detail.html index 9663c56..a11bcef 100644 --- a/templates/allocation_detail.html +++ b/templates/allocation_detail.html @@ -8,7 +8,7 @@
-
Detail alokace {{ allocation.cidr }}
+
Allocation detail {{ allocation.cidr }}
@@ -20,19 +20,19 @@
{{ allocation.pool_name }}
-
Typ
+
Type
{{ allocation.kind }}
-
Stav
+
Status
{{ allocation.status }}
-
Celkem adres
+
Total addresses
{{ allocation.address_count }}
-
Použitelných hostů
+
Usable hosts
{{ allocation.usable_host_count }}
@@ -44,15 +44,15 @@
{{ allocation.broadcast }}
-
První host
+
First host
{{ allocation.first_host }}
-
Poslední host
+
Last host
{{ allocation.last_host }}
-
Poznámka
+
Note
{{ allocation.note }}
@@ -60,47 +60,47 @@
{{ allocation.ticket }}
-
Vytvořeno
+
Created
{{ allocation.created_at_local }} ({{ allocation.created_by }})
{% if allocation.released_at_local != "–" %}
-
Uvolněno
+
Released
{{ allocation.released_at_local }} ({{ allocation.released_by }})
-
Poznámka k uvolnění
+
Release note
{{ allocation.release_note }}
{% endif %}
{% if allocation.status == "active" || allocation.status == "reserved" %} - + {% endif %} - Zpět + Back
{% if audit_events.len() > 0 %}
-
Auditní historie
+
Audit history
- - - - + + + + {% for e in audit_events %} - - - - + + + + {% endfor %} @@ -110,16 +110,16 @@ diff --git a/templates/base.html b/templates/base.html index 0ebbcc0..cb1a19a 100644 --- a/templates/base.html +++ b/templates/base.html @@ -1,5 +1,5 @@ - + @@ -13,12 +13,13 @@

IP Subnet Allocator

-

Interní evidence a přidělování IPv4 subnetů

+

Internal IPv4 subnet allocation and tracking

{% block content %}{% endblock %}
- + - - - - - + + + + + {% for a in allocations %} - + - - - - - + + + + + {% else %} - + {% endfor %}
ČasUdálostAktorDetailyTimeEventActorDetails
{{ e.created_at_local }}{{ e.event_type }}{{ e.actor }}
{{ e.details_json }}
{{ e.created_at_local }}{{ e.event_type }}{{ e.actor }}
{{ e.details_json }}
SubnetTypType PoolPočet adresPoznámkaStavVytvořenoAkceAddressesNoteStatusCreatedActions
{{ a.cidr }}{{ a.kind }}{{ a.kind }} {{ a.pool_name }}{{ a.address_count }}{{ a.note }}{{ a.status }}{{ a.created_at_local }}Detail{{ a.address_count }}{{ a.note }}{{ a.status }}{{ a.created_at_local }} + Detail + {% if a.status == "active" || a.status == "reserved" %} + | + {% endif %} +
Žádné alokace.No allocations.
+{% for a in allocations %} +{% if a.status == "active" || a.status == "reserved" %} + +{% endif %} +{% endfor %} + {% if total_pages > 1 %} diff --git a/templates/fragments/flash.html b/templates/fragments/flash.html index 254c257..bbbf3a0 100644 --- a/templates/fragments/flash.html +++ b/templates/fragments/flash.html @@ -1,13 +1,13 @@ {% if flash.has_success %}
-

Přiděleno: {{ flash.success_cidr }}

+

Allocated: {{ flash.success_cidr }}

{{ flash.success_note }}

Network: {{ flash.success_network }} | Broadcast: {{ flash.success_broadcast }} | - První host: {{ flash.success_first_host }} | - Poslední host: {{ flash.success_last_host }} | - Použitelných hostů: {{ flash.success_usable_host_count }} + First host: {{ flash.success_first_host }} | + Last host: {{ flash.success_last_host }} | + Usable hosts: {{ flash.success_usable_host_count }}

{% endif %} diff --git a/templates/index.html b/templates/index.html index 1af9bfb..c90c3a1 100644 --- a/templates/index.html +++ b/templates/index.html @@ -5,12 +5,20 @@ {% include "fragments/flash.html" %} +{% if pools.len() == 0 %} +
+
No pools configured
+

Create at least one pool before allocating subnets.

+ Create first pool + Manage pools +
+{% else %}
{% for pool in pools %}
{{ pool.name }}

Parent prefix: {{ pool.cidr }}

-

Typ: {{ pool.kind }}

+

Type: {{ pool.kind }}

- + - +
@@ -33,40 +41,41 @@ style="margin-top: 1rem;" onclick="openAllocateModal('{{ pool.slug }}')" > - [ Přidělit další volný subnet ] + [ Allocate next free subnet ]
{% endfor %}
+{% endif %}
-
Přidělené subnety
+
Allocated subnets
- - + +
- +
- + Reset
@@ -78,7 +87,7 @@ @@ -133,7 +142,7 @@ }; function updateModalPrefix(slug, prefixLength) { - // preview se aktualizuje přes HTMX automaticky + // preview is updated via HTMX automatically } async function openAllocateModal(slug) { @@ -146,7 +155,6 @@ 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(); diff --git a/templates/partials/pool_preview.html b/templates/partials/pool_preview.html index 45246f7..c16556a 100644 --- a/templates/partials/pool_preview.html +++ b/templates/partials/pool_preview.html @@ -1,23 +1,23 @@
{% if has_error %}

{{ error }}

-

Další volný subnet: –

+

Next free subnet: –

{% else %}

- Další volný subnet: + Next free subnet: {{ cidr }}

- Celkem adres: + Total addresses: {{ address_count }}

- Použitelných hostů: + Usable hosts: {{ usable_host_count }}

{% if prefix_length >= 31 %}

- Pro /31 a /32 se network/broadcast adresy dle RFC 3021 mohou shodovat s host adresami. + For /31 and /32, network/broadcast addresses may coincide with host addresses per RFC 3021.

{% else %}

diff --git a/templates/pools.html b/templates/pools.html new file mode 100644 index 0000000..66bd0bc --- /dev/null +++ b/templates/pools.html @@ -0,0 +1,72 @@ +{% extends "base.html" %} + +{% block title %}Manage pools | IP Subnet Allocator{% endblock %} + +{% block content %} +
+
Existing pools
+ + {% if pools.len() == 0 %} +

No pools configured yet.

+ {% else %} + + + + + + + + + + + + {% for p in pools %} + + + + + + + + {% endfor %} + +
NameSlugParent prefixTypeAllowed prefixes
{{ p.name }}{{ p.slug }}{{ p.cidr }}{{ p.kind }}{{ p.allowed_prefixes_json }}
+ {% endif %} +
+ +
+
Add new pool
+ + {% if error != "" %} +
+

{{ error }}

+
+ {% endif %} + +
+ + + + +

Lowercase letters, numbers and hyphens only.

+ + + + + + + + + + + + +

Comma-separated list of prefix lengths. Each must be equal to or larger than the pool prefix.

+ +
+ + Back +
+
+
+{% endblock %} diff --git a/templates/setup.html b/templates/setup.html new file mode 100644 index 0000000..bd9dd7e --- /dev/null +++ b/templates/setup.html @@ -0,0 +1,42 @@ +{% extends "base.html" %} + +{% block title %}Setup | IP Subnet Allocator{% endblock %} + +{% block content %} +
+
Initial setup
+ +

Create the first IP pool. You can add more pools later from the Manage pools page.

+ + {% if error != "" %} +
+

{{ error }}

+
+ {% endif %} + +
+ + + + +

Lowercase letters, numbers and hyphens only. Used in URLs.

+ + + + + + + + + + + + +

Comma-separated list of prefix lengths. Each must be equal to or larger than the pool prefix.

+ +
+ +
+
+
+{% endblock %}