feat: pool management, setup wizard, English UI and README, remove brew formula

This commit is contained in:
SW 2026-10-01 14:07:19 +02:00
parent bc4eeef048
commit 147c6d32f1
20 changed files with 609 additions and 230 deletions

View file

@ -1,17 +0,0 @@
class SubAligator < Formula
desc "Internal IP subnet allocator"
homepage "https://git.janvete.com/seth/sub-aligator"
url "https://git.janvete.com/seth/sub-aligator/archive/v0.1.0.tar.gz"
sha256 "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

150
README.md
View file

@ -1,40 +1,40 @@
# IP Subnet Allocator # 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: - Manage one or more IP pools manually through the web UI.
- VLAN / zákaznické subnety: `10.91.0.0/16` - First-run setup wizard: create the first pool before allocating subnets.
- VPN subnety: `10.200.0.0/16` - Automatic subnet allocation using a **first-fit** algorithm for prefixes /20 through /30.
- Automatické přidělování subnetů pomocí **first-fit** algoritmu pro prefixy /20 až /30. - Atomic allocation writes using SQLite `BEGIN IMMEDIATE` transactions.
- Atomický zápis alokací přes SQLite transakci `BEGIN IMMEDIATE`. - Retro TUI/terminal-style web UI, server-side rendered with Askama.
- Webové UI s retro TUI/terminál vzhledem, server-side renderované pomocí Askama. - Dynamic preview of the next free subnet via HTMX.
- Dynamické preview dalšího volného subnetu přes HTMX. - Allocation history with filtering, search, and pagination.
- Historie alokací s filtrem, vyhledáváním a stránkováním. - Release subnets (status `released`) with a mandatory note, directly from the history table or detail page.
- Uvolňování subnetů (status `released`) s povinnou poznámkou. - Released subnets free up address space and can be reused by the first-fit algorithm.
- Auditní log všech operací. - Audit log of all operations.
- Exporty do Markdown, CSV a JSON. - Markdown, CSV, and JSON exports.
- SQLite migrace probíhají automaticky při startu. - SQLite migrations run automatically on startup.
- Zálohovací a obnovovací skripty. - Backup and restore scripts.
## Architektura ## Architecture
- **Rust** edition 2024 - **Rust** edition 2024
- **Axum** + **Tokio** - **Axum** + **Tokio**
- **SQLx** s **SQLite** - **SQLx** with **SQLite**
- **Askama** pro HTML šablony - **Askama** for HTML templates
- **HTMX** z lokálního statického souboru - **HTMX** from a local static file
- Vlastní CSS bez externích frameworků - Custom CSS without external frameworks
- CIDR/IP výpočty pomocí `ipnet` - CIDR/IP calculations using `ipnet`
## Rychlý start ## Quick start
```bash ```bash
cd sub-aligator cd sub-aligator
@ -42,79 +42,79 @@ cp .env.example .env
docker compose up -d --build docker compose up -d --build
``` ```
Otevřete prohlížeč na: Open a browser at:
``` ```
http://IP_SERVERU:8080 http://SERVER_IP:8080
``` ```
Výchozí port lze změnit v `.env` pomocí proměnné `APP_PORT`. On first launch you will be asked to create the first pool. The default port can be changed in `.env` via the `APP_PORT` variable.
## Persistentní data ## Persistent data
Docker Compose vytváří lokální adresáře: Docker Compose creates local directories:
- `./data` – SQLite databáze - `./data` – SQLite database
- `./exports` – vygenerované exporty (Markdown, CSV, JSON) - `./exports` – generated exports (Markdown, CSV, JSON)
- `./backups` – zálohy databáze - `./backups` – database backups
Tyto adresáře jsou bind mountovány do kontejneru, takže data přetrvávají i po odstranění kontejneru. These directories are bind-mounted into the container, so data persists even if the container is removed.
## Záloha a obnova ## Backup and restore
### Záloha ### Backup
```bash ```bash
./scripts/backup.sh ./scripts/backup.sh
``` ```
Skript vytvoří konzistentní zálohu databáze pomocí `sqlite3 .backup` s UTC timestampem v názvu souboru. Zálohy se ukládají do `./backups`. The script creates a consistent database backup using `sqlite3 .backup` with a UTC timestamp in the filename. Backups are stored in `./backups`.
### Obnova ### Restore
```bash ```bash
./scripts/restore.sh ./backups/subnet-allocator.db.20240930T120000Z.backup ./scripts/restore.sh ./backups/subnet-allocator.db.20240930T120000Z.backup
``` ```
**Upozornění:** obnova přepíše aktuální databázi. Před obnovou doporučujeme zastavit kontejner a vytvořit zálohu aktuálního stavu. **Warning:** restore overwrites the current database. Stop the container and back up the current state before restoring.
### Automatizace zálohy ### Automating backups
Na hostovi můžete použít systemd timer nebo cron. Příklad cron záznamu: On the host you can use a systemd timer or cron. Example cron entry:
```cron ```cron
0 2 * * * /cesta/k/sub-aligator/scripts/backup.sh >> /var/log/subnet-allocator-backup.log 2>&1 0 2 * * * /path/to/sub-aligator/scripts/backup.sh >> /var/log/subnet-allocator-backup.log 2>&1
``` ```
## Exporty ## Exports
Po každém úspěšném vytvoření nebo uvolnění subnetu se vygenerují read-only exporty: After every successful allocation or release, read-only exports are regenerated:
- `/app/exports/allocations.md` - `/app/exports/allocations.md`
- `/app/exports/allocations.csv` - `/app/exports/allocations.csv`
- `/app/exports/allocations.json` - `/app/exports/allocations.json`
Ke stažení jsou dostupné přes endpointy: They are also available for download via endpoints:
- `GET /exports/allocations.md` - `GET /exports/allocations.md`
- `GET /exports/allocations.csv` - `GET /exports/allocations.csv`
- `GET /exports/allocations.json` - `GET /exports/allocations.json`
Markdown export obsahuje samostatnou sekci pro každý pool s tabulkou aktivních a rezervovaných alokací. CSV a JSON obsahují všechny alokace včetně released. The Markdown export contains a separate section for each pool with a table of active and reserved allocations. CSV and JSON include all allocations, including released ones.
## Bezpečnostní omezení ## Security notes
- Aplikace **nemá autentizaci**. Používejte ji pouze za VPN/LAN/firewallem. - The application **has no authentication**. Use it only behind a VPN/LAN/firewall.
- CSRF tokeny jsou povinné pro POST formuláře. - CSRF tokens are required for POST forms.
- Nastaveny bezpečnostní hlavičky: `X-Content-Type-Options`, `X-Frame-Options`, `Referrer-Policy`, `Content-Security-Policy`. - Security headers are set: `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. - The application has no default Internet exposure; port binding is handled by Docker Compose.
- `/healthz` vrací pouze `{"status":"ok"}`. - `/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 ```caddy
subnet.example.com { 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`. 1. First request `/30` receives `10.91.0.0/30`.
2. Následný požadavek `/24` získá `10.91.1.0/24` (vyhne se obsazenému `/30`). 2. Next request `/24` receives `10.91.1.0/24` (avoids the occupied `/30`).
3. Další požadavek `/30` zaplní mezeru a získá `10.91.0.4/30`. 3. Another request `/30` fills the gap and receives `10.91.0.4/30`.
Algoritmus vždy respektuje CIDR zarovnání a najde první volný prostor dostatečné velikosti. The algorithm always respects CIDR alignment and finds the first free space large enough.
## Endpointy ## Endpoints
- `GET /` – hlavní stránka - `GET /` – main page
- `GET /healthz` – healthcheck - `GET /healthz` – healthcheck
- `GET /setup` – first-run pool setup
- `GET /pools` – pool management
- `POST /pools/new` – create a new pool
- `GET /partials/pools/{slug}/preview?prefix_length=24` – HTMX preview - `GET /partials/pools/{slug}/preview?prefix_length=24` – HTMX preview
- `POST /allocations` – vytvoření alokace - `POST /allocations` – create an allocation
- `GET /allocations` – seznam alokací (součást `/`) - `GET /allocations/{id}` – allocation detail
- `GET /allocations/{id}` – detail alokace - `POST /allocations/{id}/release` – release an allocation
- `POST /allocations/{id}/release` – uvolnění alokace - `GET /exports/allocations.{md,csv,json}` – exports
- `GET /exports/allocations.{md,csv,json}` – exporty
## Troubleshooting ## Troubleshooting
### Kontejner nenastartuje ### Container does not start
```bash ```bash
docker compose logs -f subnet-allocator docker compose logs -f subnet-allocator
``` ```
### Databáze je uzamčena ### Database is locked
Pokud dojde k neočekávanému ukončení, může zůstat `-wal` nebo `-shm` soubor. Zastavte kontejner a zkontrolujte `./data`. If the process terminates unexpectedly, a `-wal` or `-shm` file may remain. Stop the container and check `./data`.
### Exporty se negenerují ### Exports are not generated
Zkontrolujte práva adresáře `./exports` a proměnnou `APP_EXPORTS_DIR`. Check the permissions of the `./exports` directory and the `APP_EXPORTS_DIR` variable.
## Licence ## Licence
MIT. Viz soubor `LICENSE`. MIT. See the `LICENSE` file.

View file

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

View file

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

View file

@ -42,17 +42,17 @@ pub async fn create_allocation(
let actor = get_actor_from_request(&headers); let actor = get_actor_from_request(&headers);
if !validate_csrf(&jar, &form.csrf_token) { if !validate_csrf(&jar, &form.csrf_token) {
return render_index_with_error(&state, &jar, &csrf_token, "Neplatný CSRF token.").await.into_response(); return render_index_with_error(&state, &jar, &csrf_token, "Invalid CSRF token.").await.into_response();
} }
let note = form.note.trim(); let note = form.note.trim();
if note.len() < 3 { if note.len() < 3 {
return render_index_with_error(&state, &jar, &csrf_token, "Poznámka musí mít alespoň 3 znaky.").await.into_response(); return render_index_with_error(&state, &jar, &csrf_token, "Note must be at least 3 characters long.").await.into_response();
} }
let pool = match load_pool_by_slug(&state.pool, &form.pool_slug).await { let pool = match load_pool_by_slug(&state.pool, &form.pool_slug).await {
Some(p) => p, Some(p) => p,
None => return render_index_with_error(&state, &jar, &csrf_token, "Pool neexistuje.").await.into_response(), None => return render_index_with_error(&state, &jar, &csrf_token, "Pool does not exist.").await.into_response(),
}; };
let result = allocate_in_tx( let result = allocate_in_tx(
@ -87,7 +87,7 @@ pub async fn create_allocation(
let mut message = success.clone(); let mut message = success.clone();
if actual_cidr_different { if actual_cidr_different {
message.note = format!( message.note = format!(
"{} (preview se mezitím změnil, přiděleno: {})", "{} (preview changed in the meantime, allocated: {})",
allocation.note, allocation.cidr allocation.note, allocation.cidr
); );
} }
@ -195,7 +195,7 @@ pub async fn release_allocation(
let actor = get_actor_from_request(&headers); let actor = get_actor_from_request(&headers);
if !validate_csrf(&jar, &form.csrf_token) { if !validate_csrf(&jar, &form.csrf_token) {
return render_detail_with_error(&state, id, &jar, &csrf_token, "Neplatný CSRF token.").await.into_response(); return render_detail_with_error(&state, id, &jar, &csrf_token, "Invalid CSRF token.").await.into_response();
} }
let release_note = form.release_note.trim(); let release_note = form.release_note.trim();
@ -205,9 +205,10 @@ pub async fn release_allocation(
id, id,
&jar, &jar,
&csrf_token, &csrf_token,
"Poznámka k uvolnění musí mít alespoň 3 znaky.", "Release note must be at least 3 characters long.",
) )
.await.into_response(); .await
.into_response();
} }
let result = release_in_tx(&state.pool, id, release_note, &actor).await; let result = release_in_tx(&state.pool, id, release_note, &actor).await;
@ -216,7 +217,7 @@ pub async fn release_allocation(
Ok(_) => { Ok(_) => {
let _ = regenerate_after_change(&state).await; let _ = regenerate_after_change(&state).await;
info!("Released allocation {} by {}", id, actor); info!("Released allocation {} by {}", id, actor);
render_detail_with_error(&state, id, &jar, &csrf_token, "Subnet byl úspěšně uvolněn.").await.into_response() render_detail_with_error(&state, id, &jar, &csrf_token, "Subnet released successfully.").await.into_response()
} }
Err(e) => { Err(e) => {
warn!("Release failed for allocation {}: {}", id, e); warn!("Release failed for allocation {}: {}", id, e);
@ -242,7 +243,7 @@ pub async fn release_in_tx(
.bind(id) .bind(id)
.fetch_one(&mut *conn) .fetch_one(&mut *conn)
.await .await
.map_err(|_| anyhow::anyhow!("Alokace neexistuje nebo již není aktivní."))?; .map_err(|_| anyhow::anyhow!("Allocation does not exist or is no longer active."))?;
sqlx::query( sqlx::query(
"UPDATE allocations SET status = 'released', released_at = ?, released_by = ?, release_note = ? WHERE id = ?", "UPDATE allocations SET status = 'released', released_at = ?, released_by = ?, release_note = ? WHERE id = ?",
@ -299,7 +300,7 @@ async fn render_index_with_error(state: &AppState, _jar: &CookieJar, csrf_token:
Ok(html) => Html(html), Ok(html) => Html(html),
Err(e) => { Err(e) => {
error!("Template render error: {}", e); error!("Template render error: {}", e);
Html("Chyba při vykreslování stránky.".to_string()) Html("Error rendering page.".to_string())
} }
} }
} }
@ -310,7 +311,7 @@ async fn render_index_with_success(state: &AppState, _jar: &CookieJar, csrf_toke
Ok(html) => Html(html), Ok(html) => Html(html),
Err(e) => { Err(e) => {
error!("Template render error: {}", e); error!("Template render error: {}", e);
Html("Chyba při vykreslování stránky.".to_string()) Html("Error rendering page.".to_string())
} }
} }
} }
@ -320,8 +321,15 @@ pub async fn build_index_template(
csrf_token: &str, csrf_token: &str,
flash: FlashData, flash: FlashData,
) -> IndexTemplate { ) -> IndexTemplate {
let pools = load_pool_templates(state).await;
let all_pools = sqlx::query_as::<_, Pool>("SELECT * FROM pools ORDER BY id")
.fetch_all(&state.pool)
.await
.unwrap_or_default();
IndexTemplate { IndexTemplate {
pools: load_pool_templates(state).await, pools,
all_pools,
allocations: vec![], allocations: vec![],
page: 1, page: 1,
total_pages: 0, total_pages: 0,
@ -350,7 +358,7 @@ async fn render_detail_with_error(state: &AppState, id: i64, _jar: &CookieJar, c
Ok(html) => Html(html), Ok(html) => Html(html),
Err(e) => { Err(e) => {
error!("Template render error: {}", e); error!("Template render error: {}", e);
Html("Chyba při vykreslování stránky.".to_string()) Html("Error rendering page.".to_string())
} }
} }
} }
@ -379,7 +387,7 @@ async fn build_detail_template(
pool_name: "N/A".to_string(), pool_name: "N/A".to_string(),
address_count: 0, address_count: 0,
usable_host_count: 0, usable_host_count: 0,
note: "Alokace nenalezena.".to_string(), note: "Allocation not found.".to_string(),
ticket: "–".to_string(), ticket: "–".to_string(),
status: "released".to_string(), status: "released".to_string(),
network: "N/A".to_string(), network: "N/A".to_string(),

View file

@ -2,3 +2,4 @@ pub mod allocations;
pub mod exports; pub mod exports;
pub mod pages; pub mod pages;
pub mod partials; pub mod partials;
pub mod pools;

View file

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

View file

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

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

@ -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<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(&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<AppState>, jar: CookieJar) -> Response {
let (jar, csrf_token) = ensure_csrf_cookie(jar);
let pools: Vec<Pool> = 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<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())
}
}
}
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<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();
let mut conn = pool.acquire().await?;
sqlx::query("BEGIN IMMEDIATE")
.execute(&mut *conn)
.await?;
let existing: Option<i64> = 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(())
}

View file

@ -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::routing::{get, post};
use axum::Router; use axum::Router;
@ -6,6 +6,9 @@ pub fn router() -> Router<crate::state::AppState> {
Router::new() Router::new()
.route("/", get(pages::index)) .route("/", get(pages::index))
.route("/healthz", get(pages::healthz)) .route("/healthz", get(pages::healthz))
.route("/setup", get(pools::setup_page).post(pools::create_pool))
.route("/pools", get(pools::pool_list))
.route("/pools/new", post(pools::create_pool))
.route("/partials/pools/{slug}/preview", get(partials::pool_preview)) .route("/partials/pools/{slug}/preview", get(partials::pool_preview))
.route("/allocations", post(allocations::create_allocation)) .route("/allocations", post(allocations::create_allocation))
.route("/allocations/{id}", get(pages::allocation_detail)) .route("/allocations/{id}", get(pages::allocation_detail))

View file

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

View file

@ -154,6 +154,22 @@ textarea:focus {
color: var(--error-bg); color: var(--error-bg);
} }
.btn-link {
display: inline;
padding: 0;
border: none;
background: transparent;
color: var(--link);
font-family: inherit;
font-size: inherit;
cursor: pointer;
text-decoration: underline;
}
.btn-link:hover {
background: var(--hover);
}
.preview-box { .preview-box {
border: 2px solid var(--border); border: 2px solid var(--border);
padding: 0.75rem; padding: 0.75rem;

View file

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

View file

@ -1,5 +1,5 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="cs"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
@ -13,12 +13,13 @@
<div class="container"> <div class="container">
<header> <header>
<h1>IP Subnet Allocator</h1> <h1>IP Subnet Allocator</h1>
<p>Interní evidence a přidělování IPv4 subnetů</p> <p>Internal IPv4 subnet allocation and tracking</p>
</header> </header>
{% block content %}{% endblock %} {% block content %}{% endblock %}
<footer class="box"> <footer class="box">
<p class="small"> <p class="small">
<a href="/">Hlavní stránka</a> | <a href="/">Home</a> |
<a href="/pools">Manage pools</a> |
<a href="/exports/allocations.md">Markdown export</a> | <a href="/exports/allocations.md">Markdown export</a> |
<a href="/exports/allocations.csv">CSV export</a> | <a href="/exports/allocations.csv">CSV export</a> |
<a href="/exports/allocations.json">JSON export</a> <a href="/exports/allocations.json">JSON export</a>

View file

@ -2,46 +2,71 @@
<thead> <thead>
<tr> <tr>
<th>Subnet</th> <th>Subnet</th>
<th>Typ</th> <th>Type</th>
<th>Pool</th> <th>Pool</th>
<th>Počet adres</th> <th>Addresses</th>
<th>Poznámka</th> <th>Note</th>
<th>Stav</th> <th>Status</th>
<th>Vytvořeno</th> <th>Created</th>
<th>Akce</th> <th>Actions</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for a in allocations %} {% for a in allocations %}
<tr> <tr>
<td data-label="Subnet"><a href="/allocations/{{ a.id }}">{{ a.cidr }}</a></td> <td data-label="Subnet"><a href="/allocations/{{ a.id }}">{{ a.cidr }}</a></td>
<td data-label="Typ">{{ a.kind }}</td> <td data-label="Type">{{ a.kind }}</td>
<td data-label="Pool">{{ a.pool_name }}</td> <td data-label="Pool">{{ a.pool_name }}</td>
<td data-label="Počet adres">{{ a.address_count }}</td> <td data-label="Addresses">{{ a.address_count }}</td>
<td data-label="Poznámka">{{ a.note }}</td> <td data-label="Note">{{ a.note }}</td>
<td data-label="Stav"><span class="status {{ a.status }}">{{ a.status }}</span></td> <td data-label="Status"><span class="status {{ a.status }}">{{ a.status }}</span></td>
<td data-label="Vytvořeno">{{ a.created_at_local }}</td> <td data-label="Created">{{ a.created_at_local }}</td>
<td data-label="Akce"><a href="/allocations/{{ a.id }}">Detail</a></td> <td data-label="Actions">
<a href="/allocations/{{ a.id }}">Detail</a>
{% if a.status == "active" || a.status == "reserved" %}
| <button type="button" class="btn-link" onclick="document.getElementById('release-modal-{{ a.id }}').style.display='flex'">Release</button>
{% endif %}
</td>
</tr> </tr>
{% else %} {% else %}
<tr> <tr>
<td colspan="8" style="text-align:center;">Žádné alokace.</td> <td colspan="8" style="text-align:center;">No allocations.</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
{% for a in allocations %}
{% if a.status == "active" || a.status == "reserved" %}
<dialog 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>
</form>
<div class="modal-actions">
<button type="button" class="btn btn-secondary" onclick="document.getElementById('release-modal-{{ a.id }}').style.display='none'">Cancel</button>
<button type="submit" class="btn btn-danger" form="release-form-{{ a.id }}">Release</button>
</div>
</div>
</dialog>
{% endif %}
{% endfor %}
{% if total_pages > 1 %} {% if total_pages > 1 %}
<div class="pagination"> <div class="pagination">
<div> <div>
{% if page > 1 %} {% if page > 1 %}
<a class="btn btn-secondary" href="?page={{ page - 1 }}{% if query != "" %}&q={{ query }}{% endif %}{% if pool_filter != "" %}&pool={{ pool_filter }}{% endif %}{% if status_filter != "" %}&status={{ status_filter }}{% endif %}">← Předchozí</a> <a class="btn btn-secondary" href="?page={{ page - 1 }}{% if query != "" %}&q={{ query }}{% endif %}{% if pool_filter != "" %}&pool={{ pool_filter }}{% endif %}{% if status_filter != "" %}&status={{ status_filter }}{% endif %}">← Previous</a>
{% endif %} {% endif %}
</div> </div>
<div>Strana {{ page }} / {{ total_pages }} (celkem {{ total_count }})</div> <div>Page {{ page }} / {{ total_pages }} (total {{ total_count }})</div>
<div> <div>
{% if page < total_pages %} {% if page < total_pages %}
<a class="btn btn-secondary" href="?page={{ page + 1 }}{% if query != "" %}&q={{ query }}{% endif %}{% if pool_filter != "" %}&pool={{ pool_filter }}{% endif %}{% if status_filter != "" %}&status={{ status_filter }}{% endif %}">Další →</a> <a class="btn btn-secondary" href="?page={{ page + 1 }}{% if query != "" %}&q={{ query }}{% endif %}{% if pool_filter != "" %}&pool={{ pool_filter }}{% endif %}{% if status_filter != "" %}&status={{ status_filter }}{% endif %}">Next →</a>
{% endif %} {% endif %}
</div> </div>
</div> </div>

View file

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

View file

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

View file

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

72
templates/pools.html Normal file
View file

@ -0,0 +1,72 @@
{% extends "base.html" %}
{% block title %}Manage pools | IP Subnet Allocator{% endblock %}
{% block content %}
<section class="box">
<div class="box-title">Existing pools</div>
{% 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>
</tr>
</thead>
<tbody>
{% for p in pools %}
<tr>
<td data-label="Name">{{ p.name }}</td>
<td data-label="Slug">{{ p.slug }}</td>
<td data-label="Parent prefix">{{ p.cidr }}</td>
<td data-label="Type">{{ p.kind }}</td>
<td data-label="Allowed prefixes">{{ p.allowed_prefixes_json }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
</section>
<section class="box">
<div class="box-title">Add new pool</div>
{% if error != "" %}
<div class="message error">
<p>{{ error }}</p>
</div>
{% endif %}
<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">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">
<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>
{% endblock %}

42
templates/setup.html Normal file
View file

@ -0,0 +1,42 @@
{% extends "base.html" %}
{% block title %}Setup | IP Subnet Allocator{% 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">Lowercase letters, numbers and hyphens only. Used in URLs.</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 %}