Initial implementation of IP Subnet Allocator
This commit is contained in:
commit
bc4eeef048
41 changed files with 5841 additions and 0 deletions
12
.dockerignore
Normal file
12
.dockerignore
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
/target
|
||||
**/*.rs.bk
|
||||
.env
|
||||
.git
|
||||
.gitignore
|
||||
README.md
|
||||
LICENSE
|
||||
data/
|
||||
exports/
|
||||
backups/
|
||||
*.db
|
||||
*.db-*
|
||||
17
.env.example
Normal file
17
.env.example
Normal 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
8
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
/target
|
||||
**/*.rs.bk
|
||||
data/
|
||||
exports/
|
||||
backups/
|
||||
.env
|
||||
*.db
|
||||
*.db-*
|
||||
2644
Cargo.lock
generated
Normal file
2644
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
46
Cargo.toml
Normal file
46
Cargo.toml
Normal 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
42
Dockerfile
Normal 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
17
Formula/sub-aligator.rb
Normal 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
21
LICENSE
Normal 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
169
README.md
Normal 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
25
compose.yaml
Normal 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
|
||||
13
migrations/20240930000001_create_pools.sql
Normal file
13
migrations/20240930000001_create_pools.sql
Normal 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);
|
||||
20
migrations/20240930000002_create_allocations.sql
Normal file
20
migrations/20240930000002_create_allocations.sql
Normal 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);
|
||||
13
migrations/20240930000003_create_audit_events.sql
Normal file
13
migrations/20240930000003_create_audit_events.sql
Normal 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
32
scripts/backup.sh
Executable 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
41
scripts/restore.sh
Executable 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
264
src/allocator.rs
Normal 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
32
src/audit.rs
Normal 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
21
src/config.rs
Normal 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
34
src/csrf.rs
Normal 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
82
src/db.rs
Normal 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
154
src/exports.rs
Normal 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
469
src/handlers/allocations.rs
Normal 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(¬e_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
57
src/handlers/exports.rs
Normal 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
4
src/handlers/mod.rs
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
pub mod allocations;
|
||||
pub mod exports;
|
||||
pub mod pages;
|
||||
pub mod partials;
|
||||
263
src/handlers/pages.rs
Normal file
263
src/handlers/pages.rs
Normal 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
78
src/handlers/partials.rs
Normal 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
11
src/lib.rs
Normal 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
74
src/main.rs
Normal 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
140
src/models.rs
Normal 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
19
src/routes.rs
Normal 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
14
src/state.rs
Normal 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
142
src/templates.rs
Normal 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
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
370
static/style.css
Normal 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);
|
||||
}
|
||||
}
|
||||
126
templates/allocation_detail.html
Normal file
126
templates/allocation_detail.html
Normal 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
29
templates/base.html
Normal 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>
|
||||
48
templates/fragments/allocations_table.html
Normal file
48
templates/fragments/allocations_table.html
Normal 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 %}
|
||||
19
templates/fragments/flash.html
Normal file
19
templates/fragments/flash.html
Normal 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
172
templates/index.html
Normal 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 %}
|
||||
26
templates/partials/pool_preview.html
Normal file
26
templates/partials/pool_preview.html
Normal 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
72
tests/concurrency.rs
Normal 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);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue