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
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.

View file

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

View file

@ -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!(
"| {} | {} | {} | {} |",

View file

@ -42,17 +42,17 @@ pub async fn create_allocation(
let actor = get_actor_from_request(&headers);
if !validate_csrf(&jar, &form.csrf_token) {
return render_index_with_error(&state, &jar, &csrf_token, "Neplatný CSRF token.").await.into_response();
return render_index_with_error(&state, &jar, &csrf_token, "Invalid CSRF token.").await.into_response();
}
let note = form.note.trim();
if note.len() < 3 {
return render_index_with_error(&state, &jar, &csrf_token, "Poznámka musí mít alespoň 3 znaky.").await.into_response();
return render_index_with_error(&state, &jar, &csrf_token, "Note must be at least 3 characters long.").await.into_response();
}
let pool = match load_pool_by_slug(&state.pool, &form.pool_slug).await {
Some(p) => p,
None => return render_index_with_error(&state, &jar, &csrf_token, "Pool neexistuje.").await.into_response(),
None => return render_index_with_error(&state, &jar, &csrf_token, "Pool does not exist.").await.into_response(),
};
let result = allocate_in_tx(
@ -87,7 +87,7 @@ pub async fn create_allocation(
let mut message = success.clone();
if actual_cidr_different {
message.note = format!(
"{} (preview se mezitím změnil, přiděleno: {})",
"{} (preview changed in the meantime, allocated: {})",
allocation.note, allocation.cidr
);
}
@ -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(),

View file

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

View file

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

View file

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

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

View file

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

View file

@ -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;

View file

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

View file

@ -1,5 +1,5 @@
<!DOCTYPE html>
<html lang="cs">
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
@ -13,12 +13,13 @@
<div class="container">
<header>
<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>
{% block content %}{% endblock %}
<footer class="box">
<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.csv">CSV export</a> |
<a href="/exports/allocations.json">JSON export</a>

View file

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

View file

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

View file

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

View file

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

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