Initial implementation of IP Subnet Allocator

This commit is contained in:
SW 2026-09-30 22:02:32 +02:00
commit bc4eeef048
41 changed files with 5841 additions and 0 deletions

12
.dockerignore Normal file
View file

@ -0,0 +1,12 @@
/target
**/*.rs.bk
.env
.git
.gitignore
README.md
LICENSE
data/
exports/
backups/
*.db
*.db-*

17
.env.example Normal file
View file

@ -0,0 +1,17 @@
# Port na hostiteli, na kterém bude aplikace dostupná
APP_PORT=8080
# Logování (debug, info, warn, error)
RUST_LOG=info
# Bind adresy uvnitř kontejneru
APP_BIND=0.0.0.0:8080
# Cesta k SQLite databázi uvnitř kontejneru
DATABASE_URL=sqlite:/app/data/subnet-allocator.db
# Časové pásmo pro zobrazení času ve webovém UI
APP_TIMEZONE=Europe/Prague
# Adresář pro exporty uvnitř kontejneru
APP_EXPORTS_DIR=/app/exports

8
.gitignore vendored Normal file
View file

@ -0,0 +1,8 @@
/target
**/*.rs.bk
data/
exports/
backups/
.env
*.db
*.db-*

2644
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

46
Cargo.toml Normal file
View file

@ -0,0 +1,46 @@
[package]
name = "sub-aligator"
version = "0.1.0"
edition = "2024"
authors = ["janvete"]
description = "Internal IP subnet allocator"
license = "MIT"
repository = "https://git.janvete.com/seth/sub-aligator"
homepage = "https://git.janvete.com/seth/sub-aligator"
keywords = ["network", "ip", "subnet", "allocator", "web"]
categories = ["web-programming"]
[dependencies]
anyhow = "1"
askama = "0.12.1"
axum = "0.8.3"
chrono = { version = "0.4.41", features = ["serde"] }
csv = "1.3.3"
ipnet = "2.11.0"
rand = "0.8"
serde = { version = "1.0.219", features = ["derive"] }
serde_json = "1.0.140"
sqlx = { version = "0.8.6", features = ["runtime-tokio-rustls", "sqlite", "migrate", "chrono"] }
thiserror = "2.0.12"
tokio = { version = "1.44.2", features = ["rt-multi-thread", "macros", "signal"] }
tower = "0.5.2"
tower-http = { version = "0.7.1", features = ["fs", "set-header", "trace", "catch-panic"] }
axum-extra = { version = "0.10.1", features = ["cookie"] }
tracing = "0.1.41"
tracing-subscriber = { version = "0.3.19", features = ["env-filter"] }
[dev-dependencies]
futures = "0.3"
tempfile = "3.21.0"
tokio-test = "0.4.4"
[package.metadata.deb]
maintainer = "janvete <jan.vete@example.com>"
copyright = "2026, janvete"
extended-description = "Internal IP subnet allocator."
section = "utils"
priority = "optional"
assets = [
["target/release/sub-aligator", "usr/bin/sub-aligator", "755"],
["README.md", "usr/share/doc/sub-aligator/README.md", "644"],
]

42
Dockerfile Normal file
View file

@ -0,0 +1,42 @@
# syntax=docker/dockerfile:1
FROM rust:1.82-bookworm AS builder
WORKDIR /app
COPY Cargo.toml Cargo.lock ./
COPY src ./src
COPY migrations ./migrations
COPY static ./static
COPY templates ./templates
RUN cargo build --release
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
curl \
sqlite3 \
&& rm -rf /var/lib/apt/lists/*
RUN groupadd -r app && useradd -r -g app app
WORKDIR /app
RUN mkdir -p /app/data /app/exports /app/backups && \
chown -R app:app /app
COPY --from=builder /app/target/release/sub-aligator /usr/local/bin/sub-aligator
COPY --from=builder /app/migrations /app/migrations
COPY --from=builder /app/static /app/static
COPY --from=builder /app/templates /app/templates
USER app
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
CMD curl -fsS http://localhost:8080/healthz >/dev/null || exit 1
CMD ["/usr/local/bin/sub-aligator"]

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

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

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 janvete
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

169
README.md Normal file
View file

@ -0,0 +1,169 @@
# IP Subnet Allocator
Jednoduchá interní webová aplikace pro evidenci a automatické přidělování IPv4 subnetů.
## Účel
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.
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.
## Přehled funkcí
- 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.
## Architektura
- **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`
## Rychlý start
```bash
cd sub-aligator
cp .env.example .env
docker compose up -d --build
```
Otevřete prohlížeč na:
```
http://IP_SERVERU:8080
```
Výchozí port lze změnit v `.env` pomocí proměnné `APP_PORT`.
## Persistentní data
Docker Compose vytváří lokální adresáře:
- `./data` – SQLite databáze
- `./exports` – vygenerované exporty (Markdown, CSV, JSON)
- `./backups` – zálohy databáze
Tyto adresáře jsou bind mountovány do kontejneru, takže data přetrvávají i po odstranění kontejneru.
## Záloha a obnova
### Záloha
```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`.
### Obnova
```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.
### Automatizace zálohy
Na hostovi můžete použít systemd timer nebo cron. Příklad cron záznamu:
```cron
0 2 * * * /cesta/k/sub-aligator/scripts/backup.sh >> /var/log/subnet-allocator-backup.log 2>&1
```
## Exporty
Po každém úspěšném vytvoření nebo uvolnění subnetu se vygenerují read-only exporty:
- `/app/exports/allocations.md`
- `/app/exports/allocations.csv`
- `/app/exports/allocations.json`
Ke stažení jsou dostupné přes endpointy:
- `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.
## Bezpečnostní omezení
- 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"}`.
## Pozdější přidání autentizace přes 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`.
Příklad konfigurace Caddy s Authentik:
```caddy
subnet.example.com {
reverse_proxy /outpost.goauthentik.io/* http://authentik:9000
forward_auth http://authentik:9000 {
uri /outpost.goauthentik.io/auth/caddy
copy_headers X-Authentik-Username X-Authentik-Groups X-Authentik-Email
}
reverse_proxy subnet-allocator:8080
}
```
Po úpravě `get_actor_from_request()` bude auditní log obsahovat skutečného uživatele.
## Příklad first-fit alokace
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`.
Algoritmus vždy respektuje CIDR zarovnání a najde první volný prostor dostatečné velikosti.
## Endpointy
- `GET /` – hlavní stránka
- `GET /healthz` – healthcheck
- `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
## Troubleshooting
### Kontejner nenastartuje
```bash
docker compose logs -f subnet-allocator
```
### Databáze je uzamčena
Pokud dojde k neočekávanému ukončení, může zůstat `-wal` nebo `-shm` soubor. Zastavte kontejner a zkontrolujte `./data`.
### Exporty se negenerují
Zkontrolujte práva adresáře `./exports` a proměnnou `APP_EXPORTS_DIR`.
## Licence
MIT. Viz soubor `LICENSE`.

25
compose.yaml Normal file
View file

@ -0,0 +1,25 @@
services:
subnet-allocator:
build:
context: .
dockerfile: Dockerfile
container_name: subnet-allocator
ports:
- "${APP_PORT:-8080}:8080"
volumes:
- ./data:/app/data
- ./exports:/app/exports
- ./backups:/app/backups
environment:
- RUST_LOG=info
- APP_BIND=0.0.0.0:8080
- DATABASE_URL=sqlite:/app/data/subnet-allocator.db
- APP_TIMEZONE=Europe/Prague
- APP_EXPORTS_DIR=/app/exports
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost:8080/healthz"]
interval: 30s
timeout: 5s
start_period: 5s
retries: 3

View file

@ -0,0 +1,13 @@
CREATE TABLE IF NOT EXISTS pools (
id INTEGER PRIMARY KEY AUTOINCREMENT,
slug TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
cidr TEXT NOT NULL UNIQUE,
kind TEXT NOT NULL,
allowed_prefixes_json TEXT NOT NULL,
enabled BOOLEAN NOT NULL DEFAULT true,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_pools_slug ON pools(slug);
CREATE INDEX IF NOT EXISTS idx_pools_enabled ON pools(enabled);

View file

@ -0,0 +1,20 @@
CREATE TABLE IF NOT EXISTS allocations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
pool_id INTEGER NOT NULL REFERENCES pools(id),
cidr TEXT NOT NULL UNIQUE,
prefix_length INTEGER NOT NULL,
address_count INTEGER NOT NULL,
usable_host_count INTEGER NOT NULL,
note TEXT NOT NULL,
status TEXT NOT NULL CHECK(status IN ('active', 'reserved', 'released')),
created_at TEXT NOT NULL,
created_by TEXT NOT NULL DEFAULT 'local',
released_at TEXT NULL,
released_by TEXT NULL,
release_note TEXT NULL
);
CREATE INDEX IF NOT EXISTS idx_allocations_pool_id ON allocations(pool_id);
CREATE INDEX IF NOT EXISTS idx_allocations_status ON allocations(status);
CREATE INDEX IF NOT EXISTS idx_allocations_pool_status ON allocations(pool_id, status);
CREATE INDEX IF NOT EXISTS idx_allocations_created_at ON allocations(created_at DESC);

View file

@ -0,0 +1,13 @@
CREATE TABLE IF NOT EXISTS audit_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
event_type TEXT NOT NULL,
allocation_id INTEGER NULL REFERENCES allocations(id),
pool_id INTEGER NULL REFERENCES pools(id),
actor TEXT NOT NULL DEFAULT 'local',
details_json TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_audit_events_allocation_id ON audit_events(allocation_id);
CREATE INDEX IF NOT EXISTS idx_audit_events_pool_id ON audit_events(pool_id);
CREATE INDEX IF NOT EXISTS idx_audit_events_created_at ON audit_events(created_at DESC);

32
scripts/backup.sh Executable file
View file

@ -0,0 +1,32 @@
#!/usr/bin/env bash
set -euo pipefail
# Skript vytvoří konzistentní SQLite backup přes sqlite3 .backup příkaz.
# Nepoužívejte obyčejný cp pro běžící SQLite databázi.
DATA_DIR="${DATA_DIR:-./data}"
BACKUP_DIR="${BACKUP_DIR:-./backups}"
DB_NAME="${DB_NAME:-subnet-allocator.db}"
DB_PATH="${DATA_DIR}/${DB_NAME}"
if ! command -v sqlite3 >/dev/null 2>&1; then
echo "ERROR: sqlite3 není nainstalováno." >&2
exit 1
fi
if [ ! -f "$DB_PATH" ]; then
echo "ERROR: Databáze neexistuje: $DB_PATH" >&2
exit 1
fi
mkdir -p "$BACKUP_DIR"
TIMESTAMP=$(date -u +"%Y%m%dT%H%M%SZ")
BACKUP_FILE="${BACKUP_DIR}/${DB_NAME}.${TIMESTAMP}.backup"
echo "Vytvářím konzistentní backup: $BACKUP_FILE"
sqlite3 "$DB_PATH" ".backup '${BACKUP_FILE}'"
echo "Backup dokončen: $BACKUP_FILE"
ls -lh "$BACKUP_FILE"

41
scripts/restore.sh Executable file
View file

@ -0,0 +1,41 @@
#!/usr/bin/env bash
set -euo pipefail
# Bezpečnostní upozornění: obnova přepíše aktuální databázi.
# Zastavte kontejner před obnovou a nezapomeňte si předem zálohovat aktuální stav.
DATA_DIR="${DATA_DIR:-./data}"
BACKUP_DIR="${BACKUP_DIR:-./backups}"
DB_NAME="${DB_NAME:-subnet-allocator.db}"
DB_PATH="${DATA_DIR}/${DB_NAME}"
if [ $# -lt 1 ]; then
echo "Použití: $0 <cesta-k-backup-souboru>" >&2
echo "Dostupné backupy v ${BACKUP_DIR}:" >&2
ls -1 "$BACKUP_DIR"/*.backup 2>/dev/null || echo " žádné" >&2
exit 1
fi
BACKUP_FILE="$1"
if [ ! -f "$BACKUP_FILE" ]; then
echo "ERROR: Backup soubor neexistuje: $BACKUP_FILE" >&2
exit 1
fi
if [ -f "$DB_PATH" ]; then
echo "UPOZORNĚNÍ: Aktuální databáze ${DB_PATH} bude přepsána." >&2
read -r -p "Pokračovat? [y/N] " CONFIRM
if [[ ! "$CONFIRM" =~ ^[Yy]$ ]]; then
echo "Obnova zrušena." >&2
exit 0
fi
fi
mkdir -p "$DATA_DIR"
echo "Obnovuji databázi z: $BACKUP_FILE"
cp "$BACKUP_FILE" "$DB_PATH"
echo "Obnova dokončena: $DB_PATH"

264
src/allocator.rs Normal file
View file

@ -0,0 +1,264 @@
use ipnet::Ipv4Net;
use std::net::Ipv4Addr;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum AllocateError {
#[error("Neplatný parent prefix: {0}")]
InvalidParent(String),
#[error("Požadovaná maska /{0} není v tomto poolu povolena")]
PrefixNotAllowed(i32),
#[error("Subnet /{prefix} se do poolu {parent} nevejde")]
DoesNotFit { prefix: i32, parent: String },
#[error("Pool je plný, není dostatek volného místa pro /{0}")]
PoolFull(i32),
}
#[derive(Clone, Debug)]
pub struct SubnetInfo {
pub cidr: String,
pub prefix_length: i32,
pub address_count: i32,
pub usable_host_count: i32,
pub network: String,
pub broadcast: String,
pub first_host: String,
pub last_host: String,
}
pub fn parse_ipv4_net(cidr: &str) -> Result<Ipv4Net, AllocateError> {
cidr.parse::<Ipv4Net>()
.map_err(|_| AllocateError::InvalidParent(cidr.to_string()))
}
pub fn allowed_prefixes_default() -> Vec<i32> {
vec![20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30]
}
pub fn host_count(prefix_length: i32) -> i32 {
let hosts = 2u32.pow(32 - prefix_length as u32);
hosts as i32
}
pub fn usable_host_count(prefix_length: i32) -> i32 {
match prefix_length {
32 => 1,
31 => 2,
_ => {
let total = host_count(prefix_length);
(total - 2).max(0)
}
}
}
pub fn subnet_info(cidr: &str) -> Result<SubnetInfo, AllocateError> {
let net = parse_ipv4_net(cidr)?;
let prefix = net.prefix_len() as i32;
let total = host_count(prefix);
let usable = usable_host_count(prefix);
let network = net.network();
let broadcast = net.broadcast();
let (first, last) = if prefix == 32 {
(network, network)
} else if prefix == 31 {
(network, broadcast)
} else {
let first = increment_ip(network, 1);
let last = decrement_ip(broadcast, 1);
(first, last)
};
Ok(SubnetInfo {
cidr: cidr.to_string(),
prefix_length: prefix,
address_count: total,
usable_host_count: usable,
network: network.to_string(),
broadcast: broadcast.to_string(),
first_host: first.to_string(),
last_host: last.to_string(),
})
}
fn increment_ip(ip: Ipv4Addr, n: u32) -> Ipv4Addr {
let octets = ip.octets();
let num = u32::from_be_bytes(octets).wrapping_add(n);
Ipv4Addr::from(num)
}
fn decrement_ip(ip: Ipv4Addr, n: u32) -> Ipv4Addr {
let octets = ip.octets();
let num = u32::from_be_bytes(octets).wrapping_sub(n);
Ipv4Addr::from(num)
}
/// First-fit allocation: iterate from the lowest address of the parent prefix,
/// find the first aligned subnet of `prefix_length` that does not overlap any
/// active/reserved allocation.
pub fn find_next_subnet(
parent_cidr: &str,
prefix_length: i32,
allowed_prefixes: &[i32],
existing: &[String],
) -> Result<SubnetInfo, AllocateError> {
if !allowed_prefixes.contains(&prefix_length) {
return Err(AllocateError::PrefixNotAllowed(prefix_length));
}
let parent = parse_ipv4_net(parent_cidr)?;
if prefix_length < parent.prefix_len() as i32 {
return Err(AllocateError::DoesNotFit {
prefix: prefix_length,
parent: parent_cidr.to_string(),
});
}
let existing_nets: Vec<Ipv4Net> = existing
.iter()
.filter_map(|c| c.parse::<Ipv4Net>().ok())
.collect();
let parent_start = u32::from_be_bytes(parent.network().octets());
let parent_end = u32::from_be_bytes(parent.broadcast().octets());
let size = host_count(prefix_length) as u32;
let mut candidate_start = parent_start;
while candidate_start.saturating_add(size - 1) <= parent_end {
// Align to prefix boundary.
let mask = !(size - 1);
let aligned = candidate_start & mask;
if aligned < parent_start {
candidate_start = aligned.wrapping_add(size);
continue;
}
let candidate_end = aligned + size - 1;
if candidate_end > parent_end {
break;
}
let candidate_net = Ipv4Net::new(Ipv4Addr::from(aligned), prefix_length as u8)
.map_err(|_| AllocateError::InvalidParent(parent_cidr.to_string()))?;
let overlaps = existing_nets.iter().any(|e| e.contains(&candidate_net) || candidate_net.contains(e));
if !overlaps {
return subnet_info(&candidate_net.to_string());
}
candidate_start = candidate_end + 1;
}
Err(AllocateError::PoolFull(prefix_length))
}
#[cfg(test)]
mod tests {
use super::*;
fn allocate(parent: &str, prefix: i32, existing: &[String]) -> SubnetInfo {
find_next_subnet(parent, prefix, &allowed_prefixes_default(), existing).unwrap()
}
fn s(cidr: &str) -> String {
cidr.to_string()
}
#[test]
fn empty_pool_allocates_slash_24() {
let info = allocate("10.91.0.0/16", 24, &[]);
assert_eq!(info.cidr, "10.91.0.0/24");
}
#[test]
fn slash_30_then_slash_24() {
let mut existing = vec![];
let a = allocate("10.91.0.0/16", 30, &existing);
assert_eq!(a.cidr, "10.91.0.0/30");
existing.push(a.cidr);
let b = allocate("10.91.0.0/16", 24, &existing);
assert_eq!(b.cidr, "10.91.1.0/24");
}
#[test]
fn slash_30_then_slash_30_fills_gap() {
let mut existing = vec![];
let a = allocate("10.91.0.0/16", 30, &existing);
assert_eq!(a.cidr, "10.91.0.0/30");
existing.push(a.cidr);
let b = allocate("10.91.0.0/16", 24, &existing);
assert_eq!(b.cidr, "10.91.1.0/24");
existing.push(b.cidr);
let c = allocate("10.91.0.0/16", 30, &existing);
assert_eq!(c.cidr, "10.91.0.4/30");
}
#[test]
fn mix_30_29_24_without_overlap() {
let mut existing = vec![];
let a = allocate("10.91.0.0/16", 30, &existing);
existing.push(a.cidr);
let b = allocate("10.91.0.0/16", 29, &existing);
existing.push(b.cidr);
let c = allocate("10.91.0.0/16", 24, &existing);
existing.push(c.cidr);
assert!(!existing.is_empty());
let nets: Vec<Ipv4Net> = existing.iter().filter_map(|x| x.parse().ok()).collect();
for i in 0..nets.len() {
for j in (i + 1)..nets.len() {
assert!(
!nets[i].contains(&nets[j]) && !nets[j].contains(&nets[i]),
"overlap between {} and {}",
nets[i],
nets[j]
);
}
}
}
#[test]
fn cidr_alignment_is_respected() {
let existing = vec![s("10.91.0.128/30")];
let info = allocate("10.91.0.0/24", 25, &existing);
assert_eq!(info.cidr, "10.91.0.0/25");
}
#[test]
fn full_pool_returns_error() {
let existing = vec![s("10.91.0.0/24"), s("10.91.1.0/24")];
let err = find_next_subnet("10.91.0.0/23", 24, &allowed_prefixes_default(), &existing)
.unwrap_err();
assert!(matches!(err, AllocateError::PoolFull(24)));
}
#[test]
fn released_subnet_is_reusable() {
let mut existing = vec![s("10.91.0.0/30")];
// simulate release by removing it
existing.clear();
let info = allocate("10.91.0.0/16", 30, &existing);
assert_eq!(info.cidr, "10.91.0.0/30");
}
#[test]
fn subnet_outside_parent_is_rejected() {
let err = find_next_subnet("10.91.0.0/23", 20, &allowed_prefixes_default(), &[])
.unwrap_err();
assert!(matches!(err, AllocateError::DoesNotFit { .. }));
}
#[test]
fn prefix_not_allowed_is_rejected() {
let err = find_next_subnet("10.91.0.0/16", 19, &allowed_prefixes_default(), &[])
.unwrap_err();
assert!(matches!(err, AllocateError::PrefixNotAllowed(19)));
}
}

32
src/audit.rs Normal file
View file

@ -0,0 +1,32 @@
use crate::models::now_utc_rfc3339;
use axum::http::HeaderMap;
use serde_json::Value;
use sqlx::SqliteConnection;
pub fn get_actor_from_request(_headers: &HeaderMap) -> String {
// Placeholder: later read X-Authentik-Username or similar trusted header.
"local".to_string()
}
pub async fn log_event(
conn: &mut SqliteConnection,
event_type: &str,
allocation_id: Option<i64>,
pool_id: Option<i64>,
actor: &str,
details: Value,
) -> anyhow::Result<()> {
let json = details.to_string();
sqlx::query(
"INSERT INTO audit_events (event_type, allocation_id, pool_id, actor, details_json, created_at) VALUES (?, ?, ?, ?, ?, ?)",
)
.bind(event_type)
.bind(allocation_id)
.bind(pool_id)
.bind(actor)
.bind(json)
.bind(now_utc_rfc3339())
.execute(&mut *conn)
.await?;
Ok(())
}

21
src/config.rs Normal file
View file

@ -0,0 +1,21 @@
use std::env;
#[derive(Clone, Debug)]
pub struct Config {
pub database_url: String,
pub bind: String,
pub timezone: String,
pub exports_dir: String,
}
impl Config {
pub fn from_env() -> Self {
Self {
database_url: env::var("DATABASE_URL")
.unwrap_or_else(|_| "sqlite:subnet-allocator.db".to_string()),
bind: env::var("APP_BIND").unwrap_or_else(|_| "0.0.0.0:8080".to_string()),
timezone: env::var("APP_TIMEZONE").unwrap_or_else(|_| "Europe/Prague".to_string()),
exports_dir: env::var("APP_EXPORTS_DIR").unwrap_or_else(|_| "./exports".to_string()),
}
}
}

34
src/csrf.rs Normal file
View file

@ -0,0 +1,34 @@
use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite};
use rand::{distributions::Alphanumeric, Rng};
const CSRF_COOKIE: &str = "csrf_token";
pub fn generate_token() -> String {
rand::thread_rng()
.sample_iter(&Alphanumeric)
.take(32)
.map(char::from)
.collect()
}
pub fn ensure_csrf_cookie(jar: CookieJar) -> (CookieJar, String) {
if let Some(cookie) = jar.get(CSRF_COOKIE) {
let token = cookie.value().to_string();
if token.len() >= 32 {
return (jar, token);
}
}
let token = generate_token();
let cookie = Cookie::build((CSRF_COOKIE, token.clone()))
.http_only(true)
.same_site(SameSite::Strict)
.path("/")
.build();
(jar.add(cookie), token)
}
pub fn validate_csrf(jar: &CookieJar, token: &str) -> bool {
jar.get(CSRF_COOKIE)
.map(|c| c.value() == token)
.unwrap_or(false)
}

82
src/db.rs Normal file
View file

@ -0,0 +1,82 @@
use crate::config::Config;
use crate::models::now_utc_rfc3339;
use anyhow::Context;
use sqlx::migrate::{MigrateDatabase, Migrator};
use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqliteSynchronous};
use sqlx::{Sqlite, SqlitePool};
use std::path::Path;
use std::str::FromStr;
use tracing::info;
pub async fn init_db(config: &Config) -> anyhow::Result<SqlitePool> {
let database_url = &config.database_url;
if database_url.starts_with("sqlite:") {
let db_path = database_url
.strip_prefix("sqlite:")
.unwrap_or(database_url);
if !Sqlite::database_exists(database_url).await.unwrap_or(false) {
Sqlite::create_database(database_url)
.await
.context("create sqlite database")?;
}
let options = SqliteConnectOptions::from_str(db_path)
.context("parse sqlite connect options")?
.journal_mode(SqliteJournalMode::Wal)
.synchronous(SqliteSynchronous::Normal)
.create_if_missing(true);
let pool = SqlitePool::connect_with(options)
.await
.context("connect to sqlite")?;
Migrator::new(Path::new("./migrations"))
.await
.context("load migrations")?
.run(&pool)
.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(())
}

154
src/exports.rs Normal file
View file

@ -0,0 +1,154 @@
use crate::models::{Allocation, Pool};
use anyhow::Context;
use serde_json::json;
use std::fs;
use std::path::Path;
use tracing::{error, info};
pub async fn regenerate_exports(
exports_dir: &str,
pools: &[Pool],
allocations: &[Allocation],
) {
let dir = Path::new(exports_dir);
if let Err(e) = fs::create_dir_all(dir) {
error!("Failed to create exports dir: {}", e);
return;
}
if let Err(e) = generate_markdown(dir, pools, allocations).await {
error!("Markdown export failed: {}", e);
} else {
info!("Markdown export regenerated");
}
if let Err(e) = generate_csv(dir, allocations).await {
error!("CSV export failed: {}", e);
} else {
info!("CSV export regenerated");
}
if let Err(e) = generate_json(dir, allocations).await {
error!("JSON export failed: {}", e);
} else {
info!("JSON export regenerated");
}
}
async fn generate_markdown(
dir: &Path,
pools: &[Pool],
allocations: &[Allocation],
) -> anyhow::Result<()> {
let path = dir.join("allocations.md");
let mut lines = vec![
"# IP Subnet Allocator – Export".to_string(),
"".to_string(),
format!("Vygenerováno: {}", chrono::Utc::now().to_rfc3339()),
"".to_string(),
];
for pool in pools {
lines.push(format!("## {} ({})", pool.name, pool.cidr));
lines.push("".to_string());
let pool_allocs: Vec<&Allocation> = allocations
.iter()
.filter(|a| a.pool_id == pool.id && a.status.as_str() != "released")
.collect();
if pool_allocs.is_empty() {
lines.push("*Žádné aktivní ani rezervované alokace.*".to_string());
lines.push("".to_string());
continue;
}
lines.push("| Subnet | Stav | Poznámka | Vytvořeno |".to_string());
lines.push("|--------|------|----------|-----------|".to_string());
for a in pool_allocs {
lines.push(format!(
"| {} | {} | {} | {} |",
a.cidr, a.status.as_str(), a.note, a.created_at
));
}
lines.push("".to_string());
}
fs::write(&path, lines.join("\n")).context("write allocations.md")?;
Ok(())
}
async fn generate_csv(dir: &Path, allocations: &[Allocation]) -> anyhow::Result<()> {
let path = dir.join("allocations.csv");
let mut wtr = csv::Writer::from_path(&path).context("create csv writer")?;
wtr.write_record([
"id",
"pool_id",
"cidr",
"prefix_length",
"address_count",
"usable_host_count",
"note",
"status",
"created_at",
"created_by",
"released_at",
"released_by",
"release_note",
])?;
for a in allocations {
wtr.write_record(&[
a.id.to_string(),
a.pool_id.to_string(),
a.cidr.clone(),
a.prefix_length.to_string(),
a.address_count.to_string(),
a.usable_host_count.to_string(),
a.note.clone(),
a.status.as_str().to_string(),
a.created_at.clone(),
a.created_by.clone(),
a.released_at.clone().unwrap_or_default(),
a.released_by.clone().unwrap_or_default(),
a.release_note.clone().unwrap_or_default(),
])?;
}
wtr.flush().context("flush csv")?;
Ok(())
}
async fn generate_json(dir: &Path, allocations: &[Allocation]) -> anyhow::Result<()> {
let path = dir.join("allocations.json");
let data: Vec<serde_json::Value> = allocations
.iter()
.map(|a| {
json!({
"id": a.id,
"pool_id": a.pool_id,
"cidr": a.cidr,
"prefix_length": a.prefix_length,
"address_count": a.address_count,
"usable_host_count": a.usable_host_count,
"note": a.note,
"status": a.status.as_str(),
"created_at": a.created_at,
"created_by": a.created_by,
"released_at": a.released_at,
"released_by": a.released_by,
"release_note": a.release_note,
})
})
.collect();
let output = json!({
"generated_at": chrono::Utc::now().to_rfc3339(),
"allocations": data,
});
fs::write(&path, serde_json::to_string_pretty(&output).context("serialize json")?)
.context("write allocations.json")?;
Ok(())
}

469
src/handlers/allocations.rs Normal file
View file

@ -0,0 +1,469 @@
use crate::allocator::{find_next_subnet, subnet_info};
use crate::audit::{get_actor_from_request, log_event};
use crate::csrf::{ensure_csrf_cookie, validate_csrf};
use crate::exports::regenerate_exports;
use crate::models::{
now_utc_rfc3339, Allocation, AllocationDetail, AllocationStatus, AuditEventDisplay, Pool,
};
use crate::state::AppState;
use crate::templates::{AllocationDetailTemplate, FlashData, IndexTemplate, PoolTemplate, SuccessMessage};
use askama::Template;
use axum::extract::{Form, Path, State};
use axum::http::HeaderMap;
use axum::response::{Html, IntoResponse, Response};
use axum_extra::extract::cookie::CookieJar;
use serde::Deserialize;
use serde_json::json;
use sqlx::SqlitePool;
use tracing::{error, info, warn};
#[derive(Deserialize, Debug)]
pub struct AllocateForm {
pub pool_slug: String,
pub prefix_length: i32,
pub note: String,
pub ticket: Option<String>,
pub csrf_token: String,
}
#[derive(Deserialize, Debug)]
pub struct ReleaseForm {
pub release_note: String,
pub csrf_token: String,
}
pub async fn create_allocation(
State(state): State<AppState>,
jar: CookieJar,
headers: HeaderMap,
Form(form): Form<AllocateForm>,
) -> 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_index_with_error(&state, &jar, &csrf_token, "Neplatný CSRF token.").await.into_response();
}
let note = form.note.trim();
if note.len() < 3 {
return render_index_with_error(&state, &jar, &csrf_token, "Poznámka musí mít alespoň 3 znaky.").await.into_response();
}
let pool = match load_pool_by_slug(&state.pool, &form.pool_slug).await {
Some(p) => p,
None => return render_index_with_error(&state, &jar, &csrf_token, "Pool neexistuje.").await.into_response(),
};
let result = allocate_in_tx(
&state.pool,
pool.id,
&pool.cidr,
&pool.allowed_prefixes(),
form.prefix_length,
note,
form.ticket.as_deref().unwrap_or(""),
&actor,
)
.await;
match result {
Ok((allocation, info)) => {
let _ = regenerate_after_change(&state).await;
let success = SuccessMessage {
cidr: allocation.cidr.clone(),
note: allocation.note.clone(),
network: info.network,
broadcast: info.broadcast,
first_host: info.first_host,
last_host: info.last_host,
usable_host_count: info.usable_host_count,
};
let actual_cidr_different = allocation.cidr
!= find_preview_cidr(&pool.cidr, &pool.allowed_prefixes(), form.prefix_length);
let mut message = success.clone();
if actual_cidr_different {
message.note = format!(
"{} (preview se mezitím změnil, přiděleno: {})",
allocation.note, allocation.cidr
);
}
info!(
"Allocated {} in pool {} by {}",
allocation.cidr, pool.slug, actor
);
render_index_with_success(&state, &jar, &csrf_token, message).await.into_response()
}
Err(e) => {
warn!("Allocation failed: {}", e);
render_index_with_error(&state, &jar, &csrf_token, &e.to_string()).await.into_response()
}
}
}
fn find_preview_cidr(parent: &str, allowed: &[i32], prefix: i32) -> String {
find_next_subnet(parent, prefix, allowed, &[])
.map(|i| i.cidr)
.unwrap_or_default()
}
#[allow(clippy::too_many_arguments)]
pub async fn allocate_in_tx(
pool: &SqlitePool,
pool_id: i64,
parent_cidr: &str,
allowed_prefixes: &[i32],
prefix_length: i32,
note: &str,
ticket: &str,
actor: &str,
) -> anyhow::Result<(Allocation, crate::allocator::SubnetInfo)> {
let mut conn = pool.acquire().await?;
sqlx::query("BEGIN IMMEDIATE")
.execute(&mut *conn)
.await?;
let active: Vec<String> = sqlx::query_scalar(
"SELECT cidr FROM allocations WHERE pool_id = ? AND status IN ('active', 'reserved')",
)
.bind(pool_id)
.fetch_all(&mut *conn)
.await?;
let info = find_next_subnet(parent_cidr, prefix_length, allowed_prefixes, &active)?;
let note_with_ticket = if ticket.is_empty() {
note.to_string()
} else {
format!("{} [ticket: {}]", note, ticket)
};
let id = sqlx::query(
"INSERT INTO allocations (pool_id, cidr, prefix_length, address_count, usable_host_count, note, status, created_at, created_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"
)
.bind(pool_id)
.bind(&info.cidr)
.bind(info.prefix_length)
.bind(info.address_count)
.bind(info.usable_host_count)
.bind(&note_with_ticket)
.bind(AllocationStatus::Active.as_str())
.bind(now_utc_rfc3339())
.bind(actor)
.execute(&mut *conn)
.await?
.last_insert_rowid();
log_event(
&mut conn,
"allocation_created",
Some(id),
Some(pool_id),
actor,
json!({
"cidr": info.cidr,
"prefix_length": info.prefix_length,
"note": note_with_ticket,
}),
)
.await
.ok();
sqlx::query("COMMIT").execute(&mut *conn).await?;
let allocation = sqlx::query_as::<_, Allocation>("SELECT * FROM allocations WHERE id = ?")
.bind(id)
.fetch_one(pool)
.await?;
Ok((allocation, info))
}
pub async fn release_allocation(
State(state): State<AppState>,
jar: CookieJar,
headers: HeaderMap,
Path(id): Path<i64>,
Form(form): Form<ReleaseForm>,
) -> 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_detail_with_error(&state, id, &jar, &csrf_token, "Neplatný CSRF token.").await.into_response();
}
let release_note = form.release_note.trim();
if release_note.len() < 3 {
return render_detail_with_error(
&state,
id,
&jar,
&csrf_token,
"Poznámka k uvolnění musí mít alespoň 3 znaky.",
)
.await.into_response();
}
let result = release_in_tx(&state.pool, id, release_note, &actor).await;
match result {
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()
}
Err(e) => {
warn!("Release failed for allocation {}: {}", id, e);
render_detail_with_error(&state, id, &jar, &csrf_token, &e.to_string()).await.into_response()
}
}
}
pub async fn release_in_tx(
pool: &SqlitePool,
id: i64,
release_note: &str,
actor: &str,
) -> anyhow::Result<()> {
let mut conn = pool.acquire().await?;
sqlx::query("BEGIN IMMEDIATE")
.execute(&mut *conn)
.await?;
let allocation: Allocation =
sqlx::query_as("SELECT * FROM allocations WHERE id = ? AND status IN ('active', 'reserved')")
.bind(id)
.fetch_one(&mut *conn)
.await
.map_err(|_| anyhow::anyhow!("Alokace neexistuje nebo již není aktivní."))?;
sqlx::query(
"UPDATE allocations SET status = 'released', released_at = ?, released_by = ?, release_note = ? WHERE id = ?",
)
.bind(now_utc_rfc3339())
.bind(actor)
.bind(release_note)
.bind(id)
.execute(&mut *conn)
.await?;
log_event(
&mut conn,
"allocation_released",
Some(id),
Some(allocation.pool_id),
actor,
json!({
"cidr": allocation.cidr,
"release_note": release_note,
}),
)
.await
.ok();
sqlx::query("COMMIT").execute(&mut *conn).await?;
Ok(())
}
async fn regenerate_after_change(state: &AppState) -> anyhow::Result<()> {
let pools: Vec<Pool> = sqlx::query_as("SELECT * FROM pools WHERE enabled = true")
.fetch_all(&state.pool)
.await?;
let allocations: Vec<Allocation> =
sqlx::query_as("SELECT * FROM allocations ORDER BY created_at DESC")
.fetch_all(&state.pool)
.await?;
regenerate_exports(&state.config.exports_dir, &pools, &allocations).await;
Ok(())
}
async fn load_pool_by_slug(pool: &SqlitePool, slug: &str) -> Option<Pool> {
sqlx::query_as::<_, Pool>("SELECT * FROM pools WHERE slug = ? AND enabled = true")
.bind(slug)
.fetch_optional(pool)
.await
.ok()
.flatten()
}
async fn render_index_with_error(state: &AppState, _jar: &CookieJar, csrf_token: &str, error: &str) -> Html<String> {
let template = build_index_template(state, csrf_token, FlashData::error(error)).await;
match template.render() {
Ok(html) => Html(html),
Err(e) => {
error!("Template render error: {}", e);
Html("Chyba při vykreslování stránky.".to_string())
}
}
}
async fn render_index_with_success(state: &AppState, _jar: &CookieJar, csrf_token: &str, success: SuccessMessage) -> Html<String> {
let template = build_index_template(state, csrf_token, FlashData::from(Some(success))).await;
match template.render() {
Ok(html) => Html(html),
Err(e) => {
error!("Template render error: {}", e);
Html("Chyba při vykreslování stránky.".to_string())
}
}
}
pub async fn build_index_template(
state: &AppState,
csrf_token: &str,
flash: FlashData,
) -> IndexTemplate {
IndexTemplate {
pools: load_pool_templates(state).await,
allocations: vec![],
page: 1,
total_pages: 0,
total_count: 0,
query: String::new(),
pool_filter: String::new(),
status_filter: String::new(),
csrf_token: csrf_token.to_string(),
flash,
}
}
async fn load_pool_templates(state: &AppState) -> Vec<PoolTemplate> {
sqlx::query_as::<_, Pool>("SELECT * FROM pools WHERE enabled = true ORDER BY id")
.fetch_all(&state.pool)
.await
.unwrap_or_default()
.into_iter()
.map(PoolTemplate::from)
.collect()
}
async fn render_detail_with_error(state: &AppState, id: i64, _jar: &CookieJar, csrf_token: &str, error: &str) -> Html<String> {
let template = build_detail_template(state, id, csrf_token, FlashData::error(error)).await;
match template.render() {
Ok(html) => Html(html),
Err(e) => {
error!("Template render error: {}", e);
Html("Chyba při vykreslování stránky.".to_string())
}
}
}
async fn build_detail_template(
state: &AppState,
id: i64,
csrf_token: &str,
flash: FlashData,
) -> AllocationDetailTemplate {
let allocation = sqlx::query_as::<_, Allocation>("SELECT * FROM allocations WHERE id = ?")
.bind(id)
.fetch_optional(&state.pool)
.await
.ok()
.flatten();
let allocation = match allocation {
Some(a) => a,
None => {
return AllocationDetailTemplate {
allocation: AllocationDetail {
id: 0,
cidr: "N/A".to_string(),
kind: "N/A".to_string(),
pool_name: "N/A".to_string(),
address_count: 0,
usable_host_count: 0,
note: "Alokace nenalezena.".to_string(),
ticket: "–".to_string(),
status: "released".to_string(),
network: "N/A".to_string(),
broadcast: "N/A".to_string(),
first_host: "N/A".to_string(),
last_host: "N/A".to_string(),
created_at_local: "N/A".to_string(),
created_by: "local".to_string(),
released_at_local: "–".to_string(),
released_by: "–".to_string(),
release_note: "–".to_string(),
},
audit_events: vec![],
csrf_token: csrf_token.to_string(),
flash,
}
}
};
let pool: Pool = sqlx::query_as("SELECT * FROM pools WHERE id = ?")
.bind(allocation.pool_id)
.fetch_one(&state.pool)
.await
.unwrap();
let info = subnet_info(&allocation.cidr).unwrap();
let detail = AllocationDetail {
id: allocation.id,
cidr: allocation.cidr.clone(),
kind: pool.kind.clone(),
pool_name: pool.name,
address_count: allocation.address_count,
usable_host_count: allocation.usable_host_count,
note: allocation.note.clone(),
ticket: extract_ticket(&allocation.note).unwrap_or_else(|| "–".to_string()),
status: allocation.status.clone(),
network: info.network,
broadcast: info.broadcast,
first_host: info.first_host,
last_host: info.last_host,
created_at_local: crate::models::utc_to_local(&allocation.created_at, &state.config.timezone),
created_by: allocation.created_by.clone(),
released_at_local: allocation
.released_at
.as_ref()
.map(|d| crate::models::utc_to_local(d, &state.config.timezone))
.unwrap_or_else(|| "–".to_string()),
released_by: allocation.released_by.clone().unwrap_or_else(|| "–".to_string()),
release_note: allocation
.release_note
.clone()
.unwrap_or_else(|| "–".to_string()),
};
let audit: Vec<crate::models::AuditEvent> = sqlx::query_as(
"SELECT * FROM audit_events WHERE allocation_id = ? ORDER BY created_at DESC",
)
.bind(id)
.fetch_all(&state.pool)
.await
.unwrap_or_default();
let audit_displays: Vec<AuditEventDisplay> = audit
.into_iter()
.map(|e| AuditEventDisplay {
created_at_local: crate::models::utc_to_local(&e.created_at, &state.config.timezone),
event_type: e.event_type,
actor: e.actor,
details_json: e.details_json,
})
.collect();
AllocationDetailTemplate {
allocation: detail,
audit_events: audit_displays,
csrf_token: csrf_token.to_string(),
flash,
}
}
fn extract_ticket(note: &str) -> Option<String> {
note.split("[ticket: ")
.nth(1)
.and_then(|s| s.split(']').next())
.map(|s| s.to_string())
}

57
src/handlers/exports.rs Normal file
View file

@ -0,0 +1,57 @@
use crate::state::AppState;
use axum::extract::State;
use axum::http::{HeaderMap, HeaderValue, StatusCode};
use axum::response::IntoResponse;
use std::path::Path;
use tracing::error;
pub async fn export_md(State(state): State<AppState>) -> impl IntoResponse {
serve_export(
state.config.exports_dir.clone(),
"allocations.md",
"text/markdown; charset=utf-8",
)
}
pub async fn export_csv(State(state): State<AppState>) -> impl IntoResponse {
serve_export(
state.config.exports_dir.clone(),
"allocations.csv",
"text/csv; charset=utf-8",
)
}
pub async fn export_json(State(state): State<AppState>) -> impl IntoResponse {
serve_export(
state.config.exports_dir.clone(),
"allocations.json",
"application/json; charset=utf-8",
)
}
fn serve_export(dir: String, filename: &'static str, content_type: &'static str) -> impl IntoResponse {
let path = Path::new(&dir).join(filename);
match std::fs::read_to_string(&path) {
Ok(content) => {
let mut headers = HeaderMap::new();
headers.insert(
axum::http::header::CONTENT_TYPE,
HeaderValue::from_static(content_type),
);
let disposition = format!("attachment; filename=\"{}\"", filename);
headers.insert(
axum::http::header::CONTENT_DISPOSITION,
HeaderValue::from_str(&disposition).unwrap(),
);
(StatusCode::OK, headers, content)
}
Err(e) => {
error!("Export read error: {}", e);
(
StatusCode::NOT_FOUND,
HeaderMap::new(),
"Export nenalezen.".to_string(),
)
}
}
}

4
src/handlers/mod.rs Normal file
View file

@ -0,0 +1,4 @@
pub mod allocations;
pub mod exports;
pub mod pages;
pub mod partials;

263
src/handlers/pages.rs Normal file
View file

@ -0,0 +1,263 @@
use crate::csrf::ensure_csrf_cookie;
use crate::models::{utc_to_local, Allocation, AllocationListItem, AuditEvent, Pool};
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_extra::extract::cookie::CookieJar;
use serde::Deserialize;
use tracing::error;
const PAGE_SIZE: i64 = 50;
#[derive(Deserialize, Debug, Default)]
pub struct IndexQuery {
#[serde(default)]
pub page: i64,
#[serde(default)]
pub q: String,
#[serde(default)]
pub pool: String,
#[serde(default)]
pub status: String,
}
pub async fn index(
State(state): State<AppState>,
jar: CookieJar,
Query(q): Query<IndexQuery>,
) -> impl IntoResponse {
let (jar, csrf_token) = ensure_csrf_cookie(jar);
let page = q.page.max(1);
let pools: Vec<PoolTemplate> = sqlx::query_as::<_, Pool>(
"SELECT * FROM pools WHERE enabled = true ORDER BY id",
)
.fetch_all(&state.pool)
.await
.unwrap_or_default()
.into_iter()
.map(PoolTemplate::from)
.collect();
let (allocations, total_count) =
load_allocations(&state, &q.q, &q.pool, &q.status, page).await;
let total_pages = (total_count + PAGE_SIZE - 1) / PAGE_SIZE;
let template = IndexTemplate {
pools,
allocations,
page,
total_pages,
total_count,
query: q.q,
pool_filter: q.pool,
status_filter: q.status,
csrf_token,
flash: FlashData::default(),
};
match template.render() {
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()
}
}
}
pub async fn allocation_detail(
State(state): State<AppState>,
jar: CookieJar,
axum::extract::Path(id): axum::extract::Path<i64>,
) -> Response {
let (jar, csrf_token) = ensure_csrf_cookie(jar);
let allocation: Option<Allocation> =
sqlx::query_as("SELECT * FROM allocations WHERE id = ?")
.bind(id)
.fetch_optional(&state.pool)
.await
.unwrap_or_default();
let allocation = match allocation {
Some(a) => a,
None => {
return (jar, Html("Alokace nenalezena.".to_string())).into_response();
}
};
let pool: Pool = sqlx::query_as("SELECT * FROM pools WHERE id = ?")
.bind(allocation.pool_id)
.fetch_one(&state.pool)
.await
.unwrap();
let info = crate::allocator::subnet_info(&allocation.cidr).unwrap();
let detail = crate::models::AllocationDetail {
id: allocation.id,
cidr: allocation.cidr.clone(),
kind: pool.kind.clone(),
pool_name: pool.name,
address_count: allocation.address_count,
usable_host_count: allocation.usable_host_count,
note: allocation.note.clone(),
ticket: extract_ticket(&allocation.note).unwrap_or_else(|| "–".to_string()),
status: allocation.status.clone(),
network: info.network,
broadcast: info.broadcast,
first_host: info.first_host,
last_host: info.last_host,
created_at_local: utc_to_local(&allocation.created_at, &state.config.timezone),
created_by: allocation.created_by.clone(),
released_at_local: allocation
.released_at
.as_ref()
.map(|d| utc_to_local(d, &state.config.timezone))
.unwrap_or_else(|| "–".to_string()),
released_by: allocation.released_by.clone().unwrap_or_else(|| "–".to_string()),
release_note: allocation
.release_note
.clone()
.unwrap_or_else(|| "–".to_string()),
};
let audit: Vec<AuditEvent> = sqlx::query_as(
"SELECT * FROM audit_events WHERE allocation_id = ? ORDER BY created_at DESC",
)
.bind(id)
.fetch_all(&state.pool)
.await
.unwrap_or_default();
let audit_displays: Vec<crate::models::AuditEventDisplay> = audit
.into_iter()
.map(|e| crate::models::AuditEventDisplay {
created_at_local: utc_to_local(&e.created_at, &state.config.timezone),
event_type: e.event_type,
actor: e.actor,
details_json: e.details_json,
})
.collect();
let template = AllocationDetailTemplate {
allocation: detail,
audit_events: audit_displays,
csrf_token,
flash: FlashData::default(),
};
match template.render() {
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()
}
}
}
fn extract_ticket(note: &str) -> Option<String> {
note.split("[ticket: ")
.nth(1)
.and_then(|s| s.split(']').next())
.map(|s| s.to_string())
}
async fn load_allocations(
state: &AppState,
q: &str,
pool_filter: &str,
status_filter: &str,
page: i64,
) -> (Vec<AllocationListItem>, i64) {
let offset = (page - 1) * PAGE_SIZE;
let like = format!("%{}%", q);
let count: i64 = sqlx::query_scalar(
r#"
SELECT COUNT(*)
FROM allocations a
JOIN pools p ON p.id = a.pool_id
WHERE (a.cidr LIKE ? OR a.note LIKE ?)
AND (? = '' OR p.slug = ?)
AND (? = '' OR a.status = ?)
"#,
)
.bind(&like)
.bind(&like)
.bind(pool_filter)
.bind(pool_filter)
.bind(status_filter)
.bind(status_filter)
.fetch_one(&state.pool)
.await
.unwrap_or(0);
let allocations: Vec<Allocation> = sqlx::query_as(
r#"
SELECT a.*
FROM allocations a
JOIN pools p ON p.id = a.pool_id
WHERE (a.cidr LIKE ? OR a.note LIKE ?)
AND (? = '' OR p.slug = ?)
AND (? = '' OR a.status = ?)
ORDER BY a.created_at DESC
LIMIT ? OFFSET ?
"#,
)
.bind(&like)
.bind(&like)
.bind(pool_filter)
.bind(pool_filter)
.bind(status_filter)
.bind(status_filter)
.bind(PAGE_SIZE)
.bind(offset)
.fetch_all(&state.pool)
.await
.unwrap_or_default();
let pool_ids: Vec<i64> = allocations.iter().map(|a| a.pool_id).collect();
let pools: Vec<Pool> = if pool_ids.is_empty() {
vec![]
} else {
let placeholders = pool_ids.iter().map(|_| "?").collect::<Vec<_>>().join(",");
let query = format!("SELECT * FROM pools WHERE id IN ({})", placeholders);
let mut q = sqlx::query_as::<_, Pool>(&query);
for id in &pool_ids {
q = q.bind(id);
}
q.fetch_all(&state.pool).await.unwrap_or_default()
};
let pool_map: std::collections::HashMap<i64, Pool> = pools
.into_iter()
.map(|p| (p.id, p))
.collect();
let items = allocations
.into_iter()
.map(|a| {
let p = pool_map.get(&a.pool_id);
AllocationListItem {
id: a.id,
cidr: a.cidr,
kind: p.as_ref().map(|p| p.kind.clone()).unwrap_or_default(),
pool_name: p.as_ref().map(|p| p.name.clone()).unwrap_or_default(),
address_count: a.address_count,
note: a.note,
status: a.status.clone(),
created_at_local: utc_to_local(&a.created_at, &state.config.timezone),
}
})
.collect();
(items, count)
}
pub async fn healthz() -> impl IntoResponse {
axum::Json(serde_json::json!({ "status": "ok" }))
}

78
src/handlers/partials.rs Normal file
View file

@ -0,0 +1,78 @@
use crate::allocator::{find_next_subnet, usable_host_count};
use crate::models::Pool;
use crate::state::AppState;
use crate::templates::PoolPreviewTemplate;
use askama::Template;
use axum::extract::{Path, Query, State};
use axum::response::{Html, IntoResponse};
use serde::Deserialize;
use tracing::error;
#[derive(Deserialize, Debug)]
pub struct PreviewQuery {
pub prefix_length: i32,
}
pub async fn pool_preview(
State(state): State<AppState>,
Path(slug): Path<String>,
Query(q): Query<PreviewQuery>,
) -> impl IntoResponse {
let pool: Option<Pool> = sqlx::query_as("SELECT * FROM pools WHERE slug = ? AND enabled = true")
.bind(&slug)
.fetch_optional(&state.pool)
.await
.unwrap_or_default();
let pool = match pool {
Some(p) => p,
None => {
let template = PoolPreviewTemplate {
cidr: String::new(),
prefix_length: q.prefix_length,
address_count: 0,
usable_host_count: 0,
has_error: true,
error: "Pool neexistuje.".to_string(),
};
return Html(template.render().unwrap_or_default());
}
};
let active: Vec<String> = sqlx::query_scalar(
"SELECT cidr FROM allocations WHERE pool_id = ? AND status IN ('active', 'reserved')",
)
.bind(pool.id)
.fetch_all(&state.pool)
.await
.unwrap_or_default();
let allowed = pool.allowed_prefixes();
let template = match find_next_subnet(&pool.cidr, q.prefix_length, &allowed, &active) {
Ok(info) => PoolPreviewTemplate {
cidr: info.cidr,
prefix_length: info.prefix_length,
address_count: info.address_count,
usable_host_count: info.usable_host_count,
has_error: false,
error: String::new(),
},
Err(e) => PoolPreviewTemplate {
cidr: String::new(),
prefix_length: q.prefix_length,
address_count: usable_host_count(q.prefix_length),
usable_host_count: usable_host_count(q.prefix_length),
has_error: true,
error: e.to_string(),
},
};
match template.render() {
Ok(html) => Html(html),
Err(e) => {
error!("Preview template render error: {}", e);
Html("Chyba preview.".to_string())
}
}
}

11
src/lib.rs Normal file
View file

@ -0,0 +1,11 @@
pub mod allocator;
pub mod audit;
pub mod config;
pub mod csrf;
pub mod db;
pub mod exports;
pub mod handlers;
pub mod models;
pub mod routes;
pub mod state;
pub mod templates;

74
src/main.rs Normal file
View file

@ -0,0 +1,74 @@
use std::net::SocketAddr;
use sub_aligator::config::Config;
use sub_aligator::db::init_db;
use sub_aligator::routes::router;
use sub_aligator::state::AppState;
use tokio::net::TcpListener;
use tower_http::services::ServeDir;
use tower_http::set_header::SetResponseHeaderLayer;
use tracing::info;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
tracing_subscriber::fmt()
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
.init();
let config = Config::from_env();
let pool = init_db(&config).await?;
let state = AppState::new(config.clone(), pool);
let app = router()
.layer(SetResponseHeaderLayer::if_not_present(
axum::http::header::X_CONTENT_TYPE_OPTIONS,
axum::http::HeaderValue::from_static("nosniff"),
))
.layer(SetResponseHeaderLayer::if_not_present(
axum::http::header::X_FRAME_OPTIONS,
axum::http::HeaderValue::from_static("DENY"),
))
.layer(SetResponseHeaderLayer::if_not_present(
axum::http::header::REFERRER_POLICY,
axum::http::HeaderValue::from_static("strict-origin-when-cross-origin"),
))
.layer(SetResponseHeaderLayer::if_not_present(
axum::http::header::CONTENT_SECURITY_POLICY,
axum::http::HeaderValue::from_static(
"default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self'; form-action 'self'; frame-ancestors 'none'; base-uri 'self';",
),
))
.nest_service("/static", ServeDir::new("static"))
.with_state(state);
let addr: SocketAddr = config.bind.parse()?;
info!("Listening on http://{}", addr);
let listener = TcpListener::bind(&addr).await?;
axum::serve(listener, app)
.with_graceful_shutdown(shutdown_signal())
.await?;
Ok(())
}
async fn shutdown_signal() {
let ctrl_c = async {
tokio::signal::ctrl_c()
.await
.expect("failed to install Ctrl+C handler");
};
let terminate = async {
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
.expect("failed to install signal handler")
.recv()
.await;
};
tokio::select! {
_ = ctrl_c => {},
_ = terminate => {},
}
info!("Shutdown signal received, stopping server");
}

140
src/models.rs Normal file
View file

@ -0,0 +1,140 @@
use chrono::{DateTime, Local, Utc};
use serde::Serialize;
use sqlx::FromRow;
#[derive(Clone, Debug, FromRow)]
pub struct Pool {
pub id: i64,
pub slug: String,
pub name: String,
pub cidr: String,
pub kind: String,
pub allowed_prefixes_json: String,
pub enabled: i64,
pub created_at: String,
}
impl Pool {
pub fn allowed_prefixes(&self) -> Vec<i32> {
serde_json::from_str(&self.allowed_prefixes_json).unwrap_or_default()
}
pub fn is_enabled(&self) -> bool {
self.enabled != 0
}
}
#[derive(Clone, Debug, FromRow)]
pub struct Allocation {
pub id: i64,
pub pool_id: i64,
pub cidr: String,
pub prefix_length: i32,
pub address_count: i32,
pub usable_host_count: i32,
pub note: String,
pub status: String,
pub created_at: String,
pub created_by: String,
pub released_at: Option<String>,
pub released_by: Option<String>,
pub release_note: Option<String>,
}
impl Allocation {
pub fn status_enum(&self) -> AllocationStatus {
match self.status.as_str() {
"reserved" => AllocationStatus::Reserved,
"released" => AllocationStatus::Released,
_ => AllocationStatus::Active,
}
}
}
#[derive(Clone, Debug, sqlx::Type)]
#[sqlx(rename_all = "lowercase")]
pub enum AllocationStatus {
Active,
Reserved,
Released,
}
impl AllocationStatus {
pub fn as_str(&self) -> &'static str {
match self {
AllocationStatus::Active => "active",
AllocationStatus::Reserved => "reserved",
AllocationStatus::Released => "released",
}
}
}
#[derive(Clone, Debug, FromRow)]
pub struct AuditEvent {
pub id: i64,
pub event_type: String,
pub allocation_id: Option<i64>,
pub pool_id: Option<i64>,
pub actor: String,
pub details_json: String,
pub created_at: String,
}
#[derive(Clone, Debug, Serialize)]
pub struct AllocationListItem {
pub id: i64,
pub cidr: String,
pub kind: String,
pub pool_name: String,
pub address_count: i32,
pub note: String,
pub status: String,
pub created_at_local: String,
}
#[derive(Clone, Debug, Serialize)]
pub struct AllocationDetail {
pub id: i64,
pub cidr: String,
pub kind: String,
pub pool_name: String,
pub address_count: i32,
pub usable_host_count: i32,
pub note: String,
pub ticket: String,
pub status: String,
pub network: String,
pub broadcast: String,
pub first_host: String,
pub last_host: String,
pub created_at_local: String,
pub created_by: String,
pub released_at_local: String,
pub released_by: String,
pub release_note: String,
}
#[derive(Clone, Debug, Serialize)]
pub struct AuditEventDisplay {
pub created_at_local: String,
pub event_type: String,
pub actor: String,
pub details_json: String,
}
pub fn utc_to_local(utc_str: &str, _tz: &str) -> String {
match DateTime::parse_from_rfc3339(utc_str) {
Ok(dt) => {
let local = dt.with_timezone(&Local);
local.format("%d.%m.%Y %H:%M:%S").to_string()
}
Err(_) => {
// Fallback: try interpreting as Europe/Prague or just return as-is.
utc_str.to_string()
}
}
}
pub fn now_utc_rfc3339() -> String {
Utc::now().to_rfc3339()
}

19
src/routes.rs Normal file
View file

@ -0,0 +1,19 @@
use crate::handlers::{allocations, exports, pages, partials};
use axum::routing::{get, post};
use axum::Router;
pub fn router() -> Router<crate::state::AppState> {
Router::new()
.route("/", get(pages::index))
.route("/healthz", get(pages::healthz))
.route("/partials/pools/{slug}/preview", get(partials::pool_preview))
.route("/allocations", post(allocations::create_allocation))
.route("/allocations/{id}", get(pages::allocation_detail))
.route(
"/allocations/{id}/release",
post(allocations::release_allocation),
)
.route("/exports/allocations.md", get(exports::export_md))
.route("/exports/allocations.csv", get(exports::export_csv))
.route("/exports/allocations.json", get(exports::export_json))
}

14
src/state.rs Normal file
View file

@ -0,0 +1,14 @@
use crate::config::Config;
use sqlx::SqlitePool;
#[derive(Clone)]
pub struct AppState {
pub config: Config,
pub pool: SqlitePool,
}
impl AppState {
pub fn new(config: Config, pool: SqlitePool) -> Self {
Self { config, pool }
}
}

142
src/templates.rs Normal file
View file

@ -0,0 +1,142 @@
use crate::models::{AllocationDetail, AllocationListItem, AuditEventDisplay, Pool};
use askama::Template;
#[derive(Template)]
#[template(path = "base.html")]
pub struct BaseTemplate {
pub title: String,
}
#[derive(Template)]
#[template(path = "index.html")]
pub struct IndexTemplate {
pub pools: Vec<PoolTemplate>,
pub allocations: Vec<AllocationListItem>,
pub page: i64,
pub total_pages: i64,
pub total_count: i64,
pub query: String,
pub pool_filter: String,
pub status_filter: String,
pub csrf_token: String,
pub flash: FlashData,
}
#[derive(Clone, Debug)]
pub struct PoolTemplate {
pub id: i64,
pub slug: String,
pub name: String,
pub cidr: String,
pub kind: String,
pub allowed_prefixes: Vec<String>,
pub allowed_prefixes_json: String,
}
impl From<Pool> for PoolTemplate {
fn from(p: Pool) -> Self {
let allowed: Vec<i32> = p.allowed_prefixes();
Self {
id: p.id,
slug: p.slug.clone(),
name: p.name,
cidr: p.cidr,
kind: p.kind,
allowed_prefixes_json: serde_json::to_string(&allowed).unwrap_or_default(),
allowed_prefixes: allowed.into_iter().map(|x| x.to_string()).collect(),
}
}
}
#[derive(Clone, Debug)]
pub struct SuccessMessage {
pub cidr: String,
pub note: String,
pub network: String,
pub broadcast: String,
pub first_host: String,
pub last_host: String,
pub usable_host_count: i32,
}
#[derive(Clone, Debug, Default)]
pub struct FlashData {
pub has_success: bool,
pub success_cidr: String,
pub success_note: String,
pub success_network: String,
pub success_broadcast: String,
pub success_first_host: String,
pub success_last_host: String,
pub success_usable_host_count: i32,
pub has_error: bool,
pub error: String,
}
impl From<Option<SuccessMessage>> for FlashData {
fn from(success: Option<SuccessMessage>) -> Self {
match success {
Some(s) => FlashData {
has_success: true,
success_cidr: s.cidr,
success_note: s.note,
success_network: s.network,
success_broadcast: s.broadcast,
success_first_host: s.first_host,
success_last_host: s.last_host,
success_usable_host_count: s.usable_host_count,
has_error: false,
error: String::new(),
},
None => FlashData::default(),
}
}
}
impl FlashData {
pub fn error(message: impl Into<String>) -> Self {
FlashData {
has_error: true,
error: message.into(),
..Default::default()
}
}
}
#[derive(Template)]
#[template(path = "partials/pool_preview.html")]
pub struct PoolPreviewTemplate {
pub cidr: String,
pub prefix_length: i32,
pub address_count: i32,
pub usable_host_count: i32,
pub has_error: bool,
pub error: String,
}
#[derive(Template)]
#[template(path = "fragments/flash.html")]
pub struct FlashTemplate {
pub flash: FlashData,
}
#[derive(Template)]
#[template(path = "fragments/allocations_table.html")]
pub struct AllocationsTableTemplate {
pub allocations: Vec<AllocationListItem>,
pub page: i64,
pub total_pages: i64,
pub total_count: i64,
pub query: String,
pub pool_filter: String,
pub status_filter: String,
}
#[derive(Template)]
#[template(path = "allocation_detail.html")]
pub struct AllocationDetailTemplate {
pub allocation: AllocationDetail,
pub audit_events: Vec<AuditEventDisplay>,
pub csrf_token: String,
pub flash: FlashData,
}

1
static/htmx.min.js vendored Normal file

File diff suppressed because one or more lines are too long

370
static/style.css Normal file
View file

@ -0,0 +1,370 @@
:root {
--bg: #f4f1ea;
--fg: #1a1a1a;
--muted: #5c5c5c;
--border: #3a3a3a;
--accent: #2b2b2b;
--accent-fg: #f4f1ea;
--success-bg: #e8f5e9;
--success-fg: #1b5e20;
--error-bg: #ffebee;
--error-fg: #b71c1c;
--warning-bg: #fff8e1;
--warning-fg: #7c4a00;
--card-bg: #faf8f3;
--hover: #ebe7de;
--active: #d4edda;
--reserved: #fff3cd;
--released: #e2e3e5;
--link: #0044cc;
}
* {
box-sizing: border-box;
}
html {
font-size: 16px;
}
body {
margin: 0;
padding: 1rem;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
background: var(--bg);
color: var(--fg);
line-height: 1.5;
min-height: 100vh;
}
a {
color: var(--link);
text-decoration: underline;
}
a:hover {
background: var(--hover);
}
.container {
max-width: 1200px;
margin: 0 auto;
}
header {
text-align: center;
margin-bottom: 1.5rem;
border: 2px solid var(--border);
padding: 1rem;
background: var(--card-bg);
}
header h1 {
margin: 0;
font-size: 1.75rem;
letter-spacing: 0.05em;
text-transform: uppercase;
}
header p {
margin: 0.5rem 0 0;
color: var(--muted);
}
.box {
border: 2px solid var(--border);
background: var(--card-bg);
padding: 1rem;
margin-bottom: 1rem;
}
.box-title {
margin: -1rem -1rem 1rem;
padding: 0.5rem 1rem;
background: var(--accent);
color: var(--accent-fg);
font-weight: bold;
border-bottom: 2px solid var(--border);
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 1rem;
}
label {
display: block;
margin-bottom: 0.25rem;
font-weight: bold;
}
select,
input[type="text"],
input[type="number"],
textarea {
width: 100%;
padding: 0.5rem;
border: 2px solid var(--border);
background: var(--bg);
color: var(--fg);
font-family: inherit;
font-size: 1rem;
}
select:focus,
input:focus,
textarea:focus {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.btn {
display: inline-block;
padding: 0.5rem 1rem;
border: 2px solid var(--border);
background: var(--accent);
color: var(--accent-fg);
font-family: inherit;
font-size: 1rem;
cursor: pointer;
text-decoration: none;
}
.btn:hover {
background: var(--fg);
}
.btn-secondary {
background: var(--bg);
color: var(--fg);
}
.btn-secondary:hover {
background: var(--hover);
}
.btn-danger {
background: var(--error-bg);
color: var(--error-fg);
}
.btn-danger:hover {
background: var(--error-fg);
color: var(--error-bg);
}
.preview-box {
border: 2px solid var(--border);
padding: 0.75rem;
background: var(--bg);
margin-top: 1rem;
}
.preview-box .label {
color: var(--muted);
}
.preview-box .value {
font-weight: bold;
}
.message {
border: 2px solid var(--border);
padding: 1rem;
margin-bottom: 1rem;
}
.message.success {
background: var(--success-bg);
color: var(--success-fg);
}
.message.error {
background: var(--error-bg);
color: var(--error-fg);
}
.filters {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: end;
margin-bottom: 1rem;
}
.filters > div {
flex: 1 1 180px;
}
table {
width: 100%;
border-collapse: collapse;
border: 2px solid var(--border);
}
th,
td {
border: 1px solid var(--border);
padding: 0.5rem;
text-align: left;
vertical-align: top;
}
th {
background: var(--accent);
color: var(--accent-fg);
}
tr:nth-child(even) {
background: var(--bg);
}
tr:hover {
background: var(--hover);
}
.status {
display: inline-block;
padding: 0.125rem 0.5rem;
border: 1px solid var(--border);
font-weight: bold;
text-transform: uppercase;
font-size: 0.875rem;
}
.status.active {
background: var(--active);
color: #155724;
}
.status.reserved {
background: var(--reserved);
color: #856404;
}
.status.released {
background: var(--released);
color: #383d41;
}
.pagination {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 1rem;
flex-wrap: wrap;
gap: 0.5rem;
}
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
z-index: 1000;
}
.modal {
background: var(--bg);
border: 2px solid var(--border);
max-width: 600px;
width: 100%;
max-height: 90vh;
overflow-y: auto;
}
.modal .modal-title {
background: var(--accent);
color: var(--accent-fg);
padding: 0.75rem 1rem;
margin: 0;
border-bottom: 2px solid var(--border);
}
.modal .modal-body {
padding: 1rem;
}
.modal .modal-actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
padding: 0 1rem 1rem;
}
.detail-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 1rem;
margin-bottom: 1rem;
}
.detail-item {
border: 1px solid var(--border);
padding: 0.5rem;
background: var(--bg);
}
.detail-item .label {
color: var(--muted);
font-size: 0.875rem;
}
.detail-item .value {
font-weight: bold;
word-break: break-all;
}
.small {
font-size: 0.875rem;
color: var(--muted);
}
@media (max-width: 640px) {
body {
padding: 0.5rem;
}
.grid {
grid-template-columns: 1fr;
}
table,
thead,
tbody,
th,
td,
tr {
display: block;
}
thead {
display: none;
}
tr {
margin-bottom: 1rem;
border: 2px solid var(--border);
}
td {
border: none;
border-bottom: 1px solid var(--border);
position: relative;
padding-left: 45%;
}
td::before {
content: attr(data-label);
position: absolute;
left: 0.5rem;
top: 0.5rem;
width: 40%;
font-weight: bold;
color: var(--muted);
}
}

View file

@ -0,0 +1,126 @@
{% extends "base.html" %}
{% block title %}Detail {{ allocation.cidr }} | IP Subnet Allocator{% endblock %}
{% block content %}
<div id="flash-container">
{% include "fragments/flash.html" %}
</div>
<section class="box">
<div class="box-title">Detail alokace {{ allocation.cidr }}</div>
<div class="detail-grid">
<div class="detail-item">
<div class="label">Subnet</div>
<div class="value">{{ allocation.cidr }}</div>
</div>
<div class="detail-item">
<div class="label">Pool</div>
<div class="value">{{ allocation.pool_name }}</div>
</div>
<div class="detail-item">
<div class="label">Typ</div>
<div class="value">{{ allocation.kind }}</div>
</div>
<div class="detail-item">
<div class="label">Stav</div>
<div class="value"><span class="status {{ allocation.status }}">{{ allocation.status }}</span></div>
</div>
<div class="detail-item">
<div class="label">Celkem adres</div>
<div class="value">{{ allocation.address_count }}</div>
</div>
<div class="detail-item">
<div class="label">Použitelných hostů</div>
<div class="value">{{ allocation.usable_host_count }}</div>
</div>
<div class="detail-item">
<div class="label">Network address</div>
<div class="value">{{ allocation.network }}</div>
</div>
<div class="detail-item">
<div class="label">Broadcast address</div>
<div class="value">{{ allocation.broadcast }}</div>
</div>
<div class="detail-item">
<div class="label">První host</div>
<div class="value">{{ allocation.first_host }}</div>
</div>
<div class="detail-item">
<div class="label">Poslední host</div>
<div class="value">{{ allocation.last_host }}</div>
</div>
<div class="detail-item">
<div class="label">Poznámka</div>
<div class="value">{{ allocation.note }}</div>
</div>
<div class="detail-item">
<div class="label">Ticket / reference</div>
<div class="value">{{ allocation.ticket }}</div>
</div>
<div class="detail-item">
<div class="label">Vytvořeno</div>
<div class="value">{{ allocation.created_at_local }} ({{ allocation.created_by }})</div>
</div>
{% if allocation.released_at_local != "–" %}
<div class="detail-item">
<div class="label">Uvolněno</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="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>
{% endif %}
<a href="/" class="btn btn-secondary">Zpět</a>
</section>
{% if audit_events.len() > 0 %}
<section class="box">
<div class="box-title">Auditní historie</div>
<table>
<thead>
<tr>
<th>Čas</th>
<th>Událost</th>
<th>Aktor</th>
<th>Detaily</th>
</tr>
</thead>
<tbody>
{% for e in audit_events %}
<tr>
<td data-label="Čas">{{ e.created_at_local }}</td>
<td data-label="Událost">{{ e.event_type }}</td>
<td data-label="Aktor">{{ e.actor }}</td>
<td data-label="Detaily"><pre class="small" style="margin:0;">{{ e.details_json }}</pre></td>
</tr>
{% endfor %}
</tbody>
</table>
</section>
{% endif %}
<dialog id="release-modal" class="modal-overlay" style="display:none;">
<div class="modal">
<h2 class="modal-title">Uvolnit subnet {{ allocation.cidr }}</h2>
<form method="post" action="/allocations/{{ allocation.id }}/release" class="modal-body" id="release-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<p class="small">Subnet přejde do stavu <span class="status released">released</span> a adresní prostor bude znovu použitelný first-fit algoritmem. Záznam zůstane v historii.</p>
<label for="release-note">Poznámka k uvolnění *</label>
<textarea id="release-note" name="release_note" rows="3" required minlength="3" maxlength="500"></textarea>
</form>
<div class="modal-actions">
<button type="button" class="btn btn-secondary" onclick="document.getElementById('release-modal').style.display='none'">Zrušit</button>
<button type="submit" class="btn btn-danger" form="release-form">Uvolnit</button>
</div>
</div>
</dialog>
{% endblock %}

29
templates/base.html Normal file
View file

@ -0,0 +1,29 @@
<!DOCTYPE html>
<html lang="cs">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="htmx-config" content='{"includeIndicatorStyles":false}'>
<title>{% block title %}IP Subnet Allocator{% endblock %}</title>
<link rel="stylesheet" href="/static/style.css">
<script src="/static/htmx.min.js" defer></script>
{% block head %}{% endblock %}
</head>
<body>
<div class="container">
<header>
<h1>IP Subnet Allocator</h1>
<p>Interní evidence a přidělování IPv4 subnetů</p>
</header>
{% block content %}{% endblock %}
<footer class="box">
<p class="small">
<a href="/">Hlavní stránka</a> |
<a href="/exports/allocations.md">Markdown export</a> |
<a href="/exports/allocations.csv">CSV export</a> |
<a href="/exports/allocations.json">JSON export</a>
</p>
</footer>
</div>
</body>
</html>

View file

@ -0,0 +1,48 @@
<table>
<thead>
<tr>
<th>Subnet</th>
<th>Typ</th>
<th>Pool</th>
<th>Počet adres</th>
<th>Poznámka</th>
<th>Stav</th>
<th>Vytvořeno</th>
<th>Akce</th>
</tr>
</thead>
<tbody>
{% for a in allocations %}
<tr>
<td data-label="Subnet"><a href="/allocations/{{ a.id }}">{{ a.cidr }}</a></td>
<td data-label="Typ">{{ 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>
</tr>
{% else %}
<tr>
<td colspan="8" style="text-align:center;">Žádné alokace.</td>
</tr>
{% endfor %}
</tbody>
</table>
{% 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>
{% endif %}
</div>
<div>Strana {{ page }} / {{ total_pages }} (celkem {{ total_count }})</div>
<div>
{% if page < total_pages %}
<a class="btn btn-secondary" href="?page={{ page + 1 }}{% if query != "" %}&q={{ query }}{% endif %}{% if pool_filter != "" %}&pool={{ pool_filter }}{% endif %}{% if status_filter != "" %}&status={{ status_filter }}{% endif %}">Další →</a>
{% endif %}
</div>
</div>
{% endif %}

View file

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

172
templates/index.html Normal file
View file

@ -0,0 +1,172 @@
{% extends "base.html" %}
{% block content %}
<div id="flash-container" hx-swap-oob="true">
{% include "fragments/flash.html" %}
</div>
<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>
<form
hx-get="/partials/pools/{{ pool.slug }}/preview"
hx-target="#preview-{{ pool.slug }}"
hx-trigger="change from:select, load"
hx-indicator="#indicator-{{ pool.slug }}"
>
<label for="prefix-{{ pool.slug }}">Velikost subnetu</label>
<select name="prefix_length" id="prefix-{{ pool.slug }}" onchange="updateModalPrefix('{{ pool.slug }}', this.value)">
{% 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="preview-{{ pool.slug }}"></div>
</form>
<button
class="btn"
style="margin-top: 1rem;"
onclick="openAllocateModal('{{ pool.slug }}')"
>
[ Přidělit další volný subnet ]
</button>
</article>
{% endfor %}
</section>
<section class="box">
<div class="box-title">Přidělené subnety</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">
</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="{{ p.slug }}" {% if pool_filter == p.slug %}selected{% endif %}>{{ p.name }}</option>
{% endfor %}
</select>
</div>
<div>
<label for="status">Stav</label>
<select id="status" name="status">
<option value="" {% if status_filter == "" %}selected{% endif %}>Všechny</option>
<option value="active" {% if status_filter == "active" %}selected{% endif %}>active</option>
<option value="reserved" {% if status_filter == "reserved" %}selected{% endif %}>reserved</option>
<option value="released" {% if status_filter == "released" %}selected{% endif %}>released</option>
</select>
</div>
<div>
<button type="submit" class="btn">Filtrovat</button>
<a href="/" class="btn btn-secondary">Reset</a>
</div>
</form>
<div id="allocations-table">
{% include "fragments/allocations_table.html" %}
</div>
</section>
<dialog id="allocate-modal" class="modal-overlay" style="display:none;">
<div class="modal">
<h2 class="modal-title">Potvrdit přidělení subnetu</h2>
<form method="post" action="/allocations" class="modal-body" id="allocate-form">
<input type="hidden" name="pool_slug" id="modal-pool-slug">
<input type="hidden" name="prefix_length" id="modal-prefix-length">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div class="detail-grid">
<div class="detail-item">
<div class="label">Pool</div>
<div class="value" id="modal-pool-name"></div>
</div>
<div class="detail-item">
<div class="label">Prefix</div>
<div class="value" id="modal-prefix-display"></div>
</div>
<div class="detail-item">
<div class="label">Navrhovaný subnet</div>
<div class="value" id="modal-cidr"></div>
</div>
<div class="detail-item">
<div class="label">Celkem adres</div>
<div class="value" id="modal-address-count"></div>
</div>
<div class="detail-item">
<div class="label">Použitelných hostů</div>
<div class="value" id="modal-usable-hosts"></div>
</div>
</div>
<p class="small" id="modal-warning"></p>
<label for="note">Poznámka *</label>
<textarea id="note" name="note" rows="3" required minlength="3" maxlength="500"></textarea>
<label for="ticket">Ticket / reference</label>
<input type="text" id="ticket" name="ticket" maxlength="100">
</form>
<div class="modal-actions">
<button type="button" class="btn btn-secondary" onclick="closeAllocateModal()">Zrušit</button>
<button type="submit" class="btn" form="allocate-form">Potvrdit přidělení</button>
</div>
</div>
</dialog>
<script>
const poolData = {
{% for pool in pools %}
"{{ pool.slug }}": {
name: "{{ pool.name }}",
allowed_prefixes: {{ pool.allowed_prefixes_json|safe }}
},
{% endfor %}
};
function updateModalPrefix(slug, prefixLength) {
// preview se aktualizuje přes HTMX automaticky
}
async function openAllocateModal(slug) {
const select = document.getElementById('prefix-' + slug);
const prefixLength = select.value;
const modal = document.getElementById('allocate-modal');
document.getElementById('modal-pool-slug').value = slug;
document.getElementById('modal-prefix-length').value = prefixLength;
document.getElementById('modal-pool-name').textContent = poolData[slug].name;
document.getElementById('modal-prefix-display').textContent = '/' + prefixLength;
// Načíst preview z endpointu
const resp = await fetch('/partials/pools/' + slug + '/preview?prefix_length=' + prefixLength);
const html = await resp.text();
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
const cidr = doc.getElementById('preview-cidr')?.textContent || '–';
const addressCount = doc.getElementById('preview-address-count')?.textContent || '–';
const usableHosts = doc.getElementById('preview-usable-hosts')?.textContent || '–';
const warning = doc.getElementById('preview-warning')?.textContent || '';
document.getElementById('modal-cidr').textContent = cidr;
document.getElementById('modal-address-count').textContent = addressCount;
document.getElementById('modal-usable-hosts').textContent = usableHosts;
document.getElementById('modal-warning').textContent = warning;
modal.style.display = 'flex';
}
function closeAllocateModal() {
document.getElementById('allocate-modal').style.display = 'none';
}
</script>
{% endblock %}

View file

@ -0,0 +1,26 @@
<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>
{% else %}
<p>
<span class="label">Další volný subnet:</span>
<span class="value" id="preview-cidr">{{ cidr }}</span>
</p>
<p>
<span class="label">Celkem adres:</span>
<span class="value" id="preview-address-count">{{ address_count }}</span>
</p>
<p>
<span class="label">Použitelných hostů:</span>
<span class="value" id="preview-usable-hosts">{{ usable_host_count }}</span>
</p>
{% if prefix_length >= 31 %}
<p class="small" id="preview-warning">
Pro /31 a /32 se network/broadcast adresy dle RFC 3021 mohou shodovat s host adresami.
</p>
{% else %}
<p class="small" id="preview-warning"></p>
{% endif %}
{% endif %}
</div>

72
tests/concurrency.rs Normal file
View file

@ -0,0 +1,72 @@
use sqlx::migrate::{MigrateDatabase, Migrator};
use sqlx::{sqlite::SqlitePoolOptions, Sqlite, SqlitePool};
use std::collections::HashSet;
use std::path::Path;
async fn setup_db() -> SqlitePool {
let url = "sqlite::memory:";
Sqlite::create_database(url).await.ok();
let pool = SqlitePoolOptions::new()
.max_connections(20)
.connect(url)
.await
.unwrap();
Migrator::new(Path::new("./migrations"))
.await
.unwrap()
.run(&pool)
.await
.unwrap();
sqlx::query(
"INSERT 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("[20,21,22,23,24,25,26,27,28,29,30]")
.bind(1)
.bind(chrono::Utc::now().to_rfc3339())
.execute(&pool)
.await
.unwrap();
pool
}
#[tokio::test]
async fn concurrent_allocations_do_not_overlap() {
let pool = setup_db().await;
let mut handles = vec![];
for i in 0..5 {
let p = pool.clone();
handles.push(tokio::spawn(async move {
sub_aligator::handlers::allocations::allocate_in_tx(
&p,
1,
"10.91.0.0/16",
&[24],
24,
&format!("test allocation {}", i),
"",
"local",
)
.await
}));
}
let results: Vec<_> = futures::future::join_all(handles).await;
let mut cidrs = HashSet::new();
for r in results {
let Ok(Ok((allocation, _))) = r else { panic!("task failed") };
assert!(
cidrs.insert(allocation.cidr.clone()),
"duplicate CIDR allocated: {}",
allocation.cidr
);
}
assert_eq!(cidrs.len(), 5);
}