Compare commits
No commits in common. "v0.1.0" and "main" have entirely different histories.
35 changed files with 2400 additions and 325 deletions
12
.env.example
12
.env.example
|
|
@ -1,17 +1,17 @@
|
|||
# Port na hostiteli, na kterém bude aplikace dostupná
|
||||
# Host port on which the application will be available
|
||||
APP_PORT=8080
|
||||
|
||||
# Logování (debug, info, warn, error)
|
||||
# Logging level (debug, info, warn, error)
|
||||
RUST_LOG=info
|
||||
|
||||
# Bind adresy uvnitř kontejneru
|
||||
# Bind address inside the container
|
||||
APP_BIND=0.0.0.0:8080
|
||||
|
||||
# Cesta k SQLite databázi uvnitř kontejneru
|
||||
# SQLite database path inside the container
|
||||
DATABASE_URL=sqlite:/app/data/subnet-allocator.db
|
||||
|
||||
# Časové pásmo pro zobrazení času ve webovém UI
|
||||
# Timezone for displaying timestamps in the web UI
|
||||
APP_TIMEZONE=Europe/Prague
|
||||
|
||||
# Adresář pro exporty uvnitř kontejneru
|
||||
# Directory for exports inside the container
|
||||
APP_EXPORTS_DIR=/app/exports
|
||||
|
|
|
|||
2
Cargo.lock
generated
2
Cargo.lock
generated
|
|
@ -1829,10 +1829,12 @@ dependencies = [
|
|||
"chrono",
|
||||
"csv",
|
||||
"futures",
|
||||
"hex",
|
||||
"ipnet",
|
||||
"rand",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"sqlx",
|
||||
"tempfile",
|
||||
"thiserror",
|
||||
|
|
|
|||
|
|
@ -26,6 +26,8 @@ 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"] }
|
||||
sha2 = "0.10"
|
||||
hex = "0.4"
|
||||
tracing = "0.1.41"
|
||||
tracing-subscriber = { version = "0.3.19", features = ["env-filter"] }
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# syntax=docker/dockerfile:1
|
||||
|
||||
FROM rust:1.82-bookworm AS builder
|
||||
FROM rust:1.89-bookworm AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
|
@ -31,12 +31,12 @@ 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
|
||||
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
||||
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||
|
||||
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"]
|
||||
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
|
||||
|
|
|
|||
|
|
@ -1,17 +0,0 @@
|
|||
class SubAligator < Formula
|
||||
desc "Internal IP subnet allocator"
|
||||
homepage "https://git.janvete.com/seth/sub-aligator"
|
||||
url "https://git.janvete.com/seth/sub-aligator/archive/v0.1.0.tar.gz"
|
||||
sha256 "PLACEHOLDER_UPDATE_AFTER_TAG"
|
||||
license "MIT"
|
||||
|
||||
depends_on "rust" => :build
|
||||
|
||||
def install
|
||||
system "cargo", "install", *std_cargo_args
|
||||
end
|
||||
|
||||
test do
|
||||
system "#{bin}/sub-aligator", "--version"
|
||||
end
|
||||
end
|
||||
223
README.md
223
README.md
|
|
@ -1,40 +1,43 @@
|
|||
# IP Subnet Allocator
|
||||
# SUB-ALIGATOR
|
||||
|
||||
Jednoduchá interní webová aplikace pro evidenci a automatické přidělování IPv4 subnetů.
|
||||
A simple internal web application for tracking and automatically allocating IPv4 subnets.
|
||||
|
||||
## Účel
|
||||
## Purpose
|
||||
|
||||
Aplikace slouží výhradně pro interní evidenci a automatické přidělování IPv4 subnetů. Není to obecný IPAM, monitoring, inventář zařízení, správa VLAN ani síťová topologie.
|
||||
This application is intended solely for internal tracking and automatic allocation of IPv4 subnets. It is not a general IPAM, monitoring tool, device inventory, VLAN manager, or network topology viewer.
|
||||
|
||||
Je navržena pro provoz pouze uvnitř LAN/VPN. **Aplikace neobsahuje autentizaci** – musí být zařazena za firewall/reverse proxy v důvěryhodné síti.
|
||||
It is designed to run only inside a LAN/VPN. **The application does not include authentication** and must be placed behind a firewall/reverse proxy in a trusted network.
|
||||
|
||||
## Přehled funkcí
|
||||
## Features
|
||||
|
||||
- Dva přednastavené pooly:
|
||||
- VLAN / zákaznické subnety: `10.91.0.0/16`
|
||||
- VPN subnety: `10.200.0.0/16`
|
||||
- Automatické přidělování subnetů pomocí **first-fit** algoritmu pro prefixy /20 až /30.
|
||||
- Atomický zápis alokací přes SQLite transakci `BEGIN IMMEDIATE`.
|
||||
- Webové UI s retro TUI/terminál vzhledem, server-side renderované pomocí Askama.
|
||||
- Dynamické preview dalšího volného subnetu přes HTMX.
|
||||
- Historie alokací s filtrem, vyhledáváním a stránkováním.
|
||||
- Uvolňování subnetů (status `released`) s povinnou poznámkou.
|
||||
- Auditní log všech operací.
|
||||
- Exporty do Markdown, CSV a JSON.
|
||||
- SQLite migrace probíhají automaticky při startu.
|
||||
- Zálohovací a obnovovací skripty.
|
||||
- Manage one or more IP pools manually through the web UI.
|
||||
- First-run setup wizard: create the first pool before allocating subnets.
|
||||
- Automatic subnet allocation using a **first-fit** algorithm for prefixes /20 through /30.
|
||||
- Atomic allocation writes using SQLite `BEGIN IMMEDIATE` transactions.
|
||||
- Retro TUI/terminal-style web UI, server-side rendered with Askama.
|
||||
- Dynamic preview of the next free subnet via HTMX.
|
||||
- Allocation history with filtering, search, and pagination.
|
||||
- Release subnets (status `released`) with a mandatory note, directly from the history table or detail page.
|
||||
- Released subnets free up address space and can be reused by the first-fit algorithm.
|
||||
- Delete pools without losing their allocations: the subnets stay in history and keep blocking the address range if the same or an overlapping pool is added again.
|
||||
- Light and dark (amber terminal) theme, switchable in the page header and remembered per browser.
|
||||
- JSON REST API (`/api/v1/`) with Bearer token authentication; tokens are managed in the web UI.
|
||||
- Audit log of all operations.
|
||||
- Markdown, CSV, and JSON exports.
|
||||
- SQLite migrations run automatically on startup.
|
||||
- Backup and restore scripts.
|
||||
|
||||
## Architektura
|
||||
## Architecture
|
||||
|
||||
- **Rust** edition 2024
|
||||
- **Axum** + **Tokio**
|
||||
- **SQLx** s **SQLite**
|
||||
- **Askama** pro HTML šablony
|
||||
- **HTMX** z lokálního statického souboru
|
||||
- Vlastní CSS bez externích frameworků
|
||||
- CIDR/IP výpočty pomocí `ipnet`
|
||||
- **SQLx** with **SQLite**
|
||||
- **Askama** for HTML templates
|
||||
- **HTMX** from a local static file
|
||||
- Custom CSS without external frameworks
|
||||
- CIDR/IP calculations using `ipnet`
|
||||
|
||||
## Rychlý start
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
cd sub-aligator
|
||||
|
|
@ -42,79 +45,141 @@ cp .env.example .env
|
|||
docker compose up -d --build
|
||||
```
|
||||
|
||||
Otevřete prohlížeč na:
|
||||
Open a browser at:
|
||||
|
||||
```
|
||||
http://IP_SERVERU:8080
|
||||
http://SERVER_IP:8080
|
||||
```
|
||||
|
||||
Výchozí port lze změnit v `.env` pomocí proměnné `APP_PORT`.
|
||||
On first launch you will be asked to create the first pool. The default port can be changed in `.env` via the `APP_PORT` variable.
|
||||
|
||||
## Persistentní data
|
||||
## Persistent data
|
||||
|
||||
Docker Compose vytváří lokální adresáře:
|
||||
Docker Compose creates local directories:
|
||||
|
||||
- `./data` – SQLite databáze
|
||||
- `./exports` – vygenerované exporty (Markdown, CSV, JSON)
|
||||
- `./backups` – zálohy databáze
|
||||
- `./data` – SQLite database
|
||||
- `./exports` – generated exports (Markdown, CSV, JSON)
|
||||
- `./backups` – database backups
|
||||
|
||||
Tyto adresáře jsou bind mountovány do kontejneru, takže data přetrvávají i po odstranění kontejneru.
|
||||
These directories are bind-mounted into the container, so data persists even if the container is removed.
|
||||
|
||||
## Záloha a obnova
|
||||
## Backup and restore
|
||||
|
||||
### Záloha
|
||||
### Backup
|
||||
|
||||
```bash
|
||||
./scripts/backup.sh
|
||||
```
|
||||
|
||||
Skript vytvoří konzistentní zálohu databáze pomocí `sqlite3 .backup` s UTC timestampem v názvu souboru. Zálohy se ukládají do `./backups`.
|
||||
The script creates a consistent database backup using `sqlite3 .backup` with a UTC timestamp in the filename. Backups are stored in `./backups`.
|
||||
|
||||
### Obnova
|
||||
### Restore
|
||||
|
||||
```bash
|
||||
./scripts/restore.sh ./backups/subnet-allocator.db.20240930T120000Z.backup
|
||||
```
|
||||
|
||||
**Upozornění:** obnova přepíše aktuální databázi. Před obnovou doporučujeme zastavit kontejner a vytvořit zálohu aktuálního stavu.
|
||||
**Warning:** restore overwrites the current database. Stop the container and back up the current state before restoring.
|
||||
|
||||
### Automatizace zálohy
|
||||
### Automating backups
|
||||
|
||||
Na hostovi můžete použít systemd timer nebo cron. Příklad cron záznamu:
|
||||
On the host you can use a systemd timer or cron. Example cron entry:
|
||||
|
||||
```cron
|
||||
0 2 * * * /cesta/k/sub-aligator/scripts/backup.sh >> /var/log/subnet-allocator-backup.log 2>&1
|
||||
0 2 * * * /path/to/sub-aligator/scripts/backup.sh >> /var/log/subnet-allocator-backup.log 2>&1
|
||||
```
|
||||
|
||||
## Exporty
|
||||
## Exports
|
||||
|
||||
Po každém úspěšném vytvoření nebo uvolnění subnetu se vygenerují read-only exporty:
|
||||
After every successful allocation or release, read-only exports are regenerated:
|
||||
|
||||
- `/app/exports/allocations.md`
|
||||
- `/app/exports/allocations.csv`
|
||||
- `/app/exports/allocations.json`
|
||||
|
||||
Ke stažení jsou dostupné přes endpointy:
|
||||
They are also available for download via endpoints:
|
||||
|
||||
- `GET /exports/allocations.md`
|
||||
- `GET /exports/allocations.csv`
|
||||
- `GET /exports/allocations.json`
|
||||
|
||||
Markdown export obsahuje samostatnou sekci pro každý pool s tabulkou aktivních a rezervovaných alokací. CSV a JSON obsahují všechny alokace včetně released.
|
||||
The Markdown export contains a separate section for each pool with a table of active and reserved allocations. CSV and JSON include all allocations, including released ones.
|
||||
|
||||
## Bezpečnostní omezení
|
||||
## REST API
|
||||
|
||||
- Aplikace **nemá autentizaci**. Používejte ji pouze za VPN/LAN/firewallem.
|
||||
- CSRF tokeny jsou povinné pro POST formuláře.
|
||||
- Nastaveny bezpečnostní hlavičky: `X-Content-Type-Options`, `X-Frame-Options`, `Referrer-Policy`, `Content-Security-Policy`.
|
||||
- Aplikace nemá žádný defaultní přístup z Internetu; bind portu se řeší přes Docker Compose.
|
||||
- `/healthz` vrací pouze `{"status":"ok"}`.
|
||||
The application provides a JSON REST API under `/api/v1/` for integrations. All API requests are authenticated with a Bearer token:
|
||||
|
||||
## Pozdější přidání autentizace přes Caddy + Authentik
|
||||
```
|
||||
Authorization: Bearer sa_...
|
||||
```
|
||||
|
||||
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`.
|
||||
### Managing API tokens
|
||||
|
||||
Příklad konfigurace Caddy s Authentik:
|
||||
Tokens are managed in the web UI on the **API tokens** page (link in the footer):
|
||||
|
||||
- **Create** – enter a descriptive name (e.g. `monitoring-integration`). The plaintext token is shown **only once** right after creation; only its SHA-256 hash is stored in the database.
|
||||
- **Revoke** – disables the token immediately. The record is kept for audit purposes.
|
||||
- **Delete** – permanently removes a revoked token.
|
||||
|
||||
Every API action is recorded in the audit log with the actor `api:<token name>`.
|
||||
|
||||
### Endpoints
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| `GET` | `/api/v1/pools` | List enabled pools with allocation counts |
|
||||
| `GET` | `/api/v1/pools/{slug}/next?prefix_length=30` | Preview the next free subnet (informational only) |
|
||||
| `GET` | `/api/v1/allocations` | List allocations; filters: `status`, `pool`, `q`, `limit` (max 500), `offset` |
|
||||
| `GET` | `/api/v1/allocations/{id}` | Allocation detail including address range |
|
||||
| `POST` | `/api/v1/allocations` | Allocate the next free subnet |
|
||||
| `POST` | `/api/v1/allocations/{id}/release` | Release an allocation |
|
||||
|
||||
The allocation is always recalculated inside a write transaction – the preview from `/next` may differ from the actually allocated subnet under concurrent use.
|
||||
|
||||
### Examples
|
||||
|
||||
```bash
|
||||
TOKEN="sa_your_token_here"
|
||||
BASE="http://SERVER_IP:8080"
|
||||
|
||||
# List pools
|
||||
curl -s -H "Authorization: Bearer $TOKEN" $BASE/api/v1/pools
|
||||
|
||||
# Preview the next free /30 in pool "vpn"
|
||||
curl -s -H "Authorization: Bearer $TOKEN" "$BASE/api/v1/pools/vpn/next?prefix_length=30"
|
||||
|
||||
# Allocate the next free /30 (note is mandatory, min. 3 characters)
|
||||
curl -s -X POST -H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"pool_slug":"vpn","prefix_length":30,"note":"customer X edge","ticket":"INC-123"}' \
|
||||
$BASE/api/v1/allocations
|
||||
|
||||
# List active allocations in pool "vpn"
|
||||
curl -s -H "Authorization: Bearer $TOKEN" "$BASE/api/v1/allocations?status=active&pool=vpn"
|
||||
|
||||
# Release allocation 42 (release note is mandatory)
|
||||
curl -s -X POST -H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"release_note":"no longer needed"}' \
|
||||
$BASE/api/v1/allocations/42/release
|
||||
```
|
||||
|
||||
Errors are returned as `{"error":"message"}` with an appropriate status code (`400` invalid request, `401` missing/invalid token, `404` not found, `409` pool full or allocation already released).
|
||||
|
||||
## Security notes
|
||||
|
||||
- The web UI **has no authentication**. Use it only behind a VPN/LAN/firewall.
|
||||
- The REST API requires a Bearer token; only token hashes (SHA-256) are stored.
|
||||
- CSRF tokens are required for POST forms in the web UI.
|
||||
- Security headers are set: `X-Content-Type-Options`, `X-Frame-Options`, `Referrer-Policy`, `Content-Security-Policy`.
|
||||
- The application has no default Internet exposure; port binding is handled by Docker Compose.
|
||||
- `/healthz` returns only `{"status":"ok"}`.
|
||||
|
||||
## Adding authentication later via Caddy + Authentik
|
||||
|
||||
The application is prepared for authentication through a trusted reverse proxy. The function `get_actor_from_request()` in `src/audit.rs` currently returns `local`, but can be updated to read a header such as `X-Authentik-Username`.
|
||||
|
||||
Example Caddy configuration with Authentik:
|
||||
|
||||
```caddy
|
||||
subnet.example.com {
|
||||
|
|
@ -127,43 +192,51 @@ subnet.example.com {
|
|||
}
|
||||
```
|
||||
|
||||
Po úpravě `get_actor_from_request()` bude auditní log obsahovat skutečného uživatele.
|
||||
After updating `get_actor_from_request()`, the audit log will record the real user.
|
||||
|
||||
## Příklad first-fit alokace
|
||||
## First-fit allocation example
|
||||
|
||||
1. První požadavek `/30` získá `10.91.0.0/30`.
|
||||
2. Následný požadavek `/24` získá `10.91.1.0/24` (vyhne se obsazenému `/30`).
|
||||
3. Další požadavek `/30` zaplní mezeru a získá `10.91.0.4/30`.
|
||||
1. First request `/30` receives `10.91.0.0/30`.
|
||||
2. Next request `/24` receives `10.91.1.0/24` (avoids the occupied `/30`).
|
||||
3. Another request `/30` fills the gap and receives `10.91.0.4/30`.
|
||||
|
||||
Algoritmus vždy respektuje CIDR zarovnání a najde první volný prostor dostatečné velikosti.
|
||||
The algorithm always respects CIDR alignment and finds the first free space large enough.
|
||||
|
||||
## Endpointy
|
||||
## Endpoints
|
||||
|
||||
- `GET /` – hlavní stránka
|
||||
- `GET /` – main page
|
||||
- `GET /healthz` – healthcheck
|
||||
- `GET /setup` – first-run pool setup
|
||||
- `GET /pools` – pool management
|
||||
- `POST /pools/new` – create a new pool
|
||||
- `POST /pools/{id}/delete` – delete a pool (allocations are kept)
|
||||
- `GET /tokens` – API token management
|
||||
- `POST /tokens/new` – create an API token
|
||||
- `POST /tokens/{id}/revoke` – revoke an API token
|
||||
- `POST /tokens/{id}/delete` – delete a revoked API token
|
||||
- `GET /partials/pools/{slug}/preview?prefix_length=24` – HTMX preview
|
||||
- `POST /allocations` – vytvoření alokace
|
||||
- `GET /allocations` – seznam alokací (součást `/`)
|
||||
- `GET /allocations/{id}` – detail alokace
|
||||
- `POST /allocations/{id}/release` – uvolnění alokace
|
||||
- `GET /exports/allocations.{md,csv,json}` – exporty
|
||||
- `POST /allocations` – create an allocation
|
||||
- `GET /allocations/{id}` – allocation detail
|
||||
- `POST /allocations/{id}/release` – release an allocation
|
||||
- `GET /exports/allocations.{md,csv,json}` – exports
|
||||
- `GET|POST /api/v1/...` – JSON REST API (see above)
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Kontejner nenastartuje
|
||||
### Container does not start
|
||||
|
||||
```bash
|
||||
docker compose logs -f subnet-allocator
|
||||
```
|
||||
|
||||
### Databáze je uzamčena
|
||||
### Database is locked
|
||||
|
||||
Pokud dojde k neočekávanému ukončení, může zůstat `-wal` nebo `-shm` soubor. Zastavte kontejner a zkontrolujte `./data`.
|
||||
If the process terminates unexpectedly, a `-wal` or `-shm` file may remain. Stop the container and check `./data`.
|
||||
|
||||
### Exporty se negenerují
|
||||
### Exports are not generated
|
||||
|
||||
Zkontrolujte práva adresáře `./exports` a proměnnou `APP_EXPORTS_DIR`.
|
||||
Check the permissions of the `./exports` directory and the `APP_EXPORTS_DIR` variable.
|
||||
|
||||
## Licence
|
||||
|
||||
MIT. Viz soubor `LICENSE`.
|
||||
MIT. See the `LICENSE` file.
|
||||
|
|
|
|||
9
docker-entrypoint.sh
Normal file
9
docker-entrypoint.sh
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
# Ensure persistent directories exist and are writable by the app user.
|
||||
mkdir -p /app/data /app/exports /app/backups
|
||||
chown -R app:app /app/data /app/exports /app/backups
|
||||
|
||||
# Drop root privileges and run the application as the app user.
|
||||
exec runuser -u app -- /usr/local/bin/sub-aligator "$@"
|
||||
34
migrations/20241001140000_allow_released_cidr.sql
Normal file
34
migrations/20241001140000_allow_released_cidr.sql
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
-- Remove the global UNIQUE constraint on allocations.cidr so that a released
|
||||
-- subnet can be re-allocated later. Enforce uniqueness only among active and
|
||||
-- reserved allocations within the same pool.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS allocations_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
pool_id INTEGER NOT NULL REFERENCES pools(id),
|
||||
cidr TEXT NOT NULL,
|
||||
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
|
||||
);
|
||||
|
||||
INSERT INTO allocations_new SELECT * FROM allocations;
|
||||
|
||||
DROP TABLE allocations;
|
||||
|
||||
ALTER TABLE allocations_new RENAME TO allocations;
|
||||
|
||||
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);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_allocations_active_unique
|
||||
ON allocations(pool_id, cidr)
|
||||
WHERE status IN ('active', 'reserved');
|
||||
12
migrations/20261009000001_create_api_tokens.sql
Normal file
12
migrations/20261009000001_create_api_tokens.sql
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
CREATE TABLE IF NOT EXISTS api_tokens (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
token_prefix TEXT NOT NULL,
|
||||
enabled BOOLEAN NOT NULL DEFAULT true,
|
||||
created_at TEXT NOT NULL,
|
||||
created_by TEXT NOT NULL DEFAULT 'local',
|
||||
last_used_at TEXT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_api_tokens_hash ON api_tokens(token_hash);
|
||||
86
src/api_tokens.rs
Normal file
86
src/api_tokens.rs
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
use crate::models::{now_utc_rfc3339, ApiToken};
|
||||
use crate::state::AppState;
|
||||
use axum::http::{HeaderMap, StatusCode};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::Json;
|
||||
use rand::{distributions::Alphanumeric, Rng};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
pub const TOKEN_PREFIX_LEN: usize = 12;
|
||||
|
||||
/// Generate a new plaintext API token. It is shown to the user exactly once;
|
||||
/// only its SHA-256 hash is stored in the database.
|
||||
pub fn generate_api_token() -> String {
|
||||
let random: String = rand::thread_rng()
|
||||
.sample_iter(&Alphanumeric)
|
||||
.take(40)
|
||||
.map(char::from)
|
||||
.collect();
|
||||
format!("sa_{}", random)
|
||||
}
|
||||
|
||||
pub fn hash_token(token: &str) -> String {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(token.as_bytes());
|
||||
hex::encode(hasher.finalize())
|
||||
}
|
||||
|
||||
pub fn display_prefix(token: &str) -> String {
|
||||
token.chars().take(TOKEN_PREFIX_LEN).collect()
|
||||
}
|
||||
|
||||
fn unauthorized() -> Response {
|
||||
(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(serde_json::json!({ "error": "missing or invalid API token" })),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
/// Authenticate an API request via the `Authorization: Bearer <token>` header.
|
||||
/// Returns the token row on success (its name is used as the audit actor,
|
||||
/// e.g. `api:my-integration`) or a 401 JSON response.
|
||||
#[allow(clippy::result_large_err)]
|
||||
pub async fn authenticate(state: &AppState, headers: &HeaderMap) -> Result<ApiToken, Response> {
|
||||
let header = headers
|
||||
.get(axum::http::header::AUTHORIZATION)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("");
|
||||
|
||||
let token = header
|
||||
.strip_prefix("Bearer ")
|
||||
.map(str::trim)
|
||||
.filter(|t| !t.is_empty());
|
||||
|
||||
let token = match token {
|
||||
Some(t) => t,
|
||||
None => return Err(unauthorized()),
|
||||
};
|
||||
|
||||
let row: Option<ApiToken> = sqlx::query_as(
|
||||
"SELECT * FROM api_tokens WHERE token_hash = ? AND enabled = true",
|
||||
)
|
||||
.bind(hash_token(token))
|
||||
.fetch_optional(&state.pool)
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
|
||||
let row = match row {
|
||||
Some(r) if r.is_enabled() => r,
|
||||
_ => return Err(unauthorized()),
|
||||
};
|
||||
|
||||
sqlx::query("UPDATE api_tokens SET last_used_at = ? WHERE id = ?")
|
||||
.bind(now_utc_rfc3339())
|
||||
.bind(row.id)
|
||||
.execute(&state.pool)
|
||||
.await
|
||||
.ok();
|
||||
|
||||
Ok(row)
|
||||
}
|
||||
|
||||
pub fn api_actor(token: &ApiToken) -> String {
|
||||
format!("api:{}", token.name)
|
||||
}
|
||||
37
src/db.rs
37
src/db.rs
|
|
@ -1,5 +1,4 @@
|
|||
use crate::config::Config;
|
||||
use crate::models::now_utc_rfc3339;
|
||||
use anyhow::Context;
|
||||
use sqlx::migrate::{MigrateDatabase, Migrator};
|
||||
use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqliteSynchronous};
|
||||
|
|
@ -38,45 +37,9 @@ pub async fn init_db(config: &Config) -> anyhow::Result<SqlitePool> {
|
|||
.await
|
||||
.context("run migrations")?;
|
||||
|
||||
seed_default_pools(&pool).await.context("seed default pools")?;
|
||||
|
||||
info!("Database initialized: {}", database_url);
|
||||
Ok(pool)
|
||||
} else {
|
||||
anyhow::bail!("Unsupported DATABASE_URL: {}", database_url)
|
||||
}
|
||||
}
|
||||
|
||||
async fn seed_default_pools(pool: &SqlitePool) -> anyhow::Result<()> {
|
||||
let allowed = serde_json::to_string(&[20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30])?;
|
||||
|
||||
let now = now_utc_rfc3339();
|
||||
|
||||
sqlx::query(
|
||||
"INSERT OR IGNORE INTO pools (slug, name, cidr, kind, allowed_prefixes_json, enabled, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)"
|
||||
)
|
||||
.bind("vlan")
|
||||
.bind("VLAN / zákaznické subnety")
|
||||
.bind("10.91.0.0/16")
|
||||
.bind("VLAN")
|
||||
.bind(&allowed)
|
||||
.bind(true)
|
||||
.bind(&now)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
sqlx::query(
|
||||
"INSERT OR IGNORE INTO pools (slug, name, cidr, kind, allowed_prefixes_json, enabled, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)"
|
||||
)
|
||||
.bind("vpn")
|
||||
.bind("VPN subnety")
|
||||
.bind("10.200.0.0/16")
|
||||
.bind("VPN")
|
||||
.bind(&allowed)
|
||||
.bind(true)
|
||||
.bind(&now)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,9 +42,9 @@ async fn generate_markdown(
|
|||
) -> anyhow::Result<()> {
|
||||
let path = dir.join("allocations.md");
|
||||
let mut lines = vec![
|
||||
"# IP Subnet Allocator – Export".to_string(),
|
||||
"# SUB-ALIGATOR – Export".to_string(),
|
||||
"".to_string(),
|
||||
format!("Vygenerováno: {}", chrono::Utc::now().to_rfc3339()),
|
||||
format!("Generated: {}", chrono::Utc::now().to_rfc3339()),
|
||||
"".to_string(),
|
||||
];
|
||||
|
||||
|
|
@ -58,13 +58,13 @@ async fn generate_markdown(
|
|||
.collect();
|
||||
|
||||
if pool_allocs.is_empty() {
|
||||
lines.push("*Žádné aktivní ani rezervované alokace.*".to_string());
|
||||
lines.push("*No active or reserved allocations.*".to_string());
|
||||
lines.push("".to_string());
|
||||
continue;
|
||||
}
|
||||
|
||||
lines.push("| Subnet | Stav | Poznámka | Vytvořeno |".to_string());
|
||||
lines.push("|--------|------|----------|-----------|".to_string());
|
||||
lines.push("| Subnet | Status | Note | Created |".to_string());
|
||||
lines.push("|--------|--------|------|---------|".to_string());
|
||||
for a in pool_allocs {
|
||||
lines.push(format!(
|
||||
"| {} | {} | {} | {} |",
|
||||
|
|
|
|||
|
|
@ -42,17 +42,17 @@ pub async fn create_allocation(
|
|||
let actor = get_actor_from_request(&headers);
|
||||
|
||||
if !validate_csrf(&jar, &form.csrf_token) {
|
||||
return render_index_with_error(&state, &jar, &csrf_token, "Neplatný CSRF token.").await.into_response();
|
||||
return render_index_with_error(&state, &jar, &csrf_token, "Invalid CSRF token.").await.into_response();
|
||||
}
|
||||
|
||||
let note = form.note.trim();
|
||||
if note.len() < 3 {
|
||||
return render_index_with_error(&state, &jar, &csrf_token, "Poznámka musí mít alespoň 3 znaky.").await.into_response();
|
||||
return render_index_with_error(&state, &jar, &csrf_token, "Note must be at least 3 characters long.").await.into_response();
|
||||
}
|
||||
|
||||
let pool = match load_pool_by_slug(&state.pool, &form.pool_slug).await {
|
||||
Some(p) => p,
|
||||
None => return render_index_with_error(&state, &jar, &csrf_token, "Pool neexistuje.").await.into_response(),
|
||||
None => return render_index_with_error(&state, &jar, &csrf_token, "Pool does not exist.").await.into_response(),
|
||||
};
|
||||
|
||||
let result = allocate_in_tx(
|
||||
|
|
@ -87,7 +87,7 @@ pub async fn create_allocation(
|
|||
let mut message = success.clone();
|
||||
if actual_cidr_different {
|
||||
message.note = format!(
|
||||
"{} (preview se mezitím změnil, přiděleno: {})",
|
||||
"{} (preview changed in the meantime, allocated: {})",
|
||||
allocation.note, allocation.cidr
|
||||
);
|
||||
}
|
||||
|
|
@ -122,17 +122,15 @@ pub async fn allocate_in_tx(
|
|||
ticket: &str,
|
||||
actor: &str,
|
||||
) -> anyhow::Result<(Allocation, crate::allocator::SubnetInfo)> {
|
||||
let mut conn = pool.acquire().await?;
|
||||
|
||||
sqlx::query("BEGIN IMMEDIATE")
|
||||
.execute(&mut *conn)
|
||||
.await?;
|
||||
// begin_with gives us BEGIN IMMEDIATE plus automatic rollback on drop,
|
||||
// so no error path can leak an open transaction into the pool.
|
||||
let mut tx = pool.begin_with("BEGIN IMMEDIATE").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)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?;
|
||||
|
||||
let info = find_next_subnet(parent_cidr, prefix_length, allowed_prefixes, &active)?;
|
||||
|
|
@ -155,12 +153,12 @@ pub async fn allocate_in_tx(
|
|||
.bind(AllocationStatus::Active.as_str())
|
||||
.bind(now_utc_rfc3339())
|
||||
.bind(actor)
|
||||
.execute(&mut *conn)
|
||||
.execute(&mut *tx)
|
||||
.await?
|
||||
.last_insert_rowid();
|
||||
|
||||
log_event(
|
||||
&mut conn,
|
||||
&mut tx,
|
||||
"allocation_created",
|
||||
Some(id),
|
||||
Some(pool_id),
|
||||
|
|
@ -174,7 +172,7 @@ pub async fn allocate_in_tx(
|
|||
.await
|
||||
.ok();
|
||||
|
||||
sqlx::query("COMMIT").execute(&mut *conn).await?;
|
||||
tx.commit().await?;
|
||||
|
||||
let allocation = sqlx::query_as::<_, Allocation>("SELECT * FROM allocations WHERE id = ?")
|
||||
.bind(id)
|
||||
|
|
@ -195,7 +193,7 @@ pub async fn release_allocation(
|
|||
let actor = get_actor_from_request(&headers);
|
||||
|
||||
if !validate_csrf(&jar, &form.csrf_token) {
|
||||
return render_detail_with_error(&state, id, &jar, &csrf_token, "Neplatný CSRF token.").await.into_response();
|
||||
return render_detail_with_error(&state, id, &jar, &csrf_token, "Invalid CSRF token.").await.into_response();
|
||||
}
|
||||
|
||||
let release_note = form.release_note.trim();
|
||||
|
|
@ -205,9 +203,10 @@ pub async fn release_allocation(
|
|||
id,
|
||||
&jar,
|
||||
&csrf_token,
|
||||
"Poznámka k uvolnění musí mít alespoň 3 znaky.",
|
||||
"Release note must be at least 3 characters long.",
|
||||
)
|
||||
.await.into_response();
|
||||
.await
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let result = release_in_tx(&state.pool, id, release_note, &actor).await;
|
||||
|
|
@ -216,7 +215,7 @@ pub async fn release_allocation(
|
|||
Ok(_) => {
|
||||
let _ = regenerate_after_change(&state).await;
|
||||
info!("Released allocation {} by {}", id, actor);
|
||||
render_detail_with_error(&state, id, &jar, &csrf_token, "Subnet byl úspěšně uvolněn.").await.into_response()
|
||||
render_detail_with_error(&state, id, &jar, &csrf_token, "Subnet released successfully.").await.into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Release failed for allocation {}: {}", id, e);
|
||||
|
|
@ -231,18 +230,14 @@ pub async fn release_in_tx(
|
|||
release_note: &str,
|
||||
actor: &str,
|
||||
) -> anyhow::Result<()> {
|
||||
let mut conn = pool.acquire().await?;
|
||||
|
||||
sqlx::query("BEGIN IMMEDIATE")
|
||||
.execute(&mut *conn)
|
||||
.await?;
|
||||
let mut tx = pool.begin_with("BEGIN IMMEDIATE").await?;
|
||||
|
||||
let allocation: Allocation =
|
||||
sqlx::query_as("SELECT * FROM allocations WHERE id = ? AND status IN ('active', 'reserved')")
|
||||
.bind(id)
|
||||
.fetch_one(&mut *conn)
|
||||
.fetch_one(&mut *tx)
|
||||
.await
|
||||
.map_err(|_| anyhow::anyhow!("Alokace neexistuje nebo již není aktivní."))?;
|
||||
.map_err(|_| anyhow::anyhow!("Allocation does not exist or is no longer active."))?;
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE allocations SET status = 'released', released_at = ?, released_by = ?, release_note = ? WHERE id = ?",
|
||||
|
|
@ -251,11 +246,11 @@ pub async fn release_in_tx(
|
|||
.bind(actor)
|
||||
.bind(release_note)
|
||||
.bind(id)
|
||||
.execute(&mut *conn)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
log_event(
|
||||
&mut conn,
|
||||
&mut tx,
|
||||
"allocation_released",
|
||||
Some(id),
|
||||
Some(allocation.pool_id),
|
||||
|
|
@ -268,12 +263,12 @@ pub async fn release_in_tx(
|
|||
.await
|
||||
.ok();
|
||||
|
||||
sqlx::query("COMMIT").execute(&mut *conn).await?;
|
||||
tx.commit().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn regenerate_after_change(state: &AppState) -> anyhow::Result<()> {
|
||||
let pools: Vec<Pool> = sqlx::query_as("SELECT * FROM pools WHERE enabled = true")
|
||||
pub(crate) async fn regenerate_after_change(state: &AppState) -> anyhow::Result<()> {
|
||||
let pools: Vec<Pool> = sqlx::query_as("SELECT * FROM pools ORDER BY id")
|
||||
.fetch_all(&state.pool)
|
||||
.await?;
|
||||
let allocations: Vec<Allocation> =
|
||||
|
|
@ -299,7 +294,7 @@ async fn render_index_with_error(state: &AppState, _jar: &CookieJar, csrf_token:
|
|||
Ok(html) => Html(html),
|
||||
Err(e) => {
|
||||
error!("Template render error: {}", e);
|
||||
Html("Chyba při vykreslování stránky.".to_string())
|
||||
Html("Error rendering page.".to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -310,7 +305,7 @@ async fn render_index_with_success(state: &AppState, _jar: &CookieJar, csrf_toke
|
|||
Ok(html) => Html(html),
|
||||
Err(e) => {
|
||||
error!("Template render error: {}", e);
|
||||
Html("Chyba při vykreslování stránky.".to_string())
|
||||
Html("Error rendering page.".to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -320,8 +315,15 @@ pub async fn build_index_template(
|
|||
csrf_token: &str,
|
||||
flash: FlashData,
|
||||
) -> IndexTemplate {
|
||||
let pools = load_pool_templates(state).await;
|
||||
let all_pools = sqlx::query_as::<_, Pool>("SELECT * FROM pools ORDER BY id")
|
||||
.fetch_all(&state.pool)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
IndexTemplate {
|
||||
pools: load_pool_templates(state).await,
|
||||
pools,
|
||||
all_pools,
|
||||
allocations: vec![],
|
||||
page: 1,
|
||||
total_pages: 0,
|
||||
|
|
@ -350,7 +352,7 @@ async fn render_detail_with_error(state: &AppState, id: i64, _jar: &CookieJar, c
|
|||
Ok(html) => Html(html),
|
||||
Err(e) => {
|
||||
error!("Template render error: {}", e);
|
||||
Html("Chyba při vykreslování stránky.".to_string())
|
||||
Html("Error rendering page.".to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -379,7 +381,7 @@ async fn build_detail_template(
|
|||
pool_name: "N/A".to_string(),
|
||||
address_count: 0,
|
||||
usable_host_count: 0,
|
||||
note: "Alokace nenalezena.".to_string(),
|
||||
note: "Allocation not found.".to_string(),
|
||||
ticket: "–".to_string(),
|
||||
status: "released".to_string(),
|
||||
network: "N/A".to_string(),
|
||||
|
|
|
|||
476
src/handlers/api.rs
Normal file
476
src/handlers/api.rs
Normal file
|
|
@ -0,0 +1,476 @@
|
|||
use crate::allocator::{find_next_subnet, subnet_info, AllocateError};
|
||||
use crate::api_tokens::{api_actor, authenticate};
|
||||
use crate::handlers::allocations::{allocate_in_tx, regenerate_after_change, release_in_tx};
|
||||
use crate::models::{Allocation, Pool};
|
||||
use crate::state::AppState;
|
||||
use axum::extract::{Path, Query, State};
|
||||
use axum::http::{HeaderMap, StatusCode};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::Json;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use tracing::{info, warn};
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct PoolJson {
|
||||
id: i64,
|
||||
slug: String,
|
||||
name: String,
|
||||
cidr: String,
|
||||
kind: String,
|
||||
allowed_prefixes: Vec<i32>,
|
||||
active_allocations: i64,
|
||||
reserved_allocations: i64,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct AllocationJson {
|
||||
id: i64,
|
||||
pool_id: i64,
|
||||
pool_slug: String,
|
||||
pool_name: String,
|
||||
kind: String,
|
||||
cidr: String,
|
||||
prefix_length: i32,
|
||||
address_count: i32,
|
||||
usable_host_count: i32,
|
||||
note: String,
|
||||
status: String,
|
||||
created_at: String,
|
||||
created_by: String,
|
||||
released_at: Option<String>,
|
||||
released_by: Option<String>,
|
||||
release_note: Option<String>,
|
||||
}
|
||||
|
||||
fn error_response(status: StatusCode, message: &str) -> Response {
|
||||
(status, Json(json!({ "error": message }))).into_response()
|
||||
}
|
||||
|
||||
pub async fn list_pools(State(state): State<AppState>, headers: HeaderMap) -> Response {
|
||||
if let Err(r) = authenticate(&state, &headers).await {
|
||||
return r;
|
||||
}
|
||||
|
||||
let pools: Vec<Pool> =
|
||||
sqlx::query_as("SELECT * FROM pools WHERE enabled = true ORDER BY id")
|
||||
.fetch_all(&state.pool)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut out = Vec::with_capacity(pools.len());
|
||||
for p in pools {
|
||||
let (active, reserved): (i64, i64) = sqlx::query_as(
|
||||
"SELECT \
|
||||
COALESCE(SUM(CASE WHEN status = 'active' THEN 1 ELSE 0 END), 0), \
|
||||
COALESCE(SUM(CASE WHEN status = 'reserved' THEN 1 ELSE 0 END), 0) \
|
||||
FROM allocations WHERE pool_id = ?",
|
||||
)
|
||||
.bind(p.id)
|
||||
.fetch_one(&state.pool)
|
||||
.await
|
||||
.unwrap_or((0, 0));
|
||||
|
||||
let allowed_prefixes = p.allowed_prefixes();
|
||||
out.push(PoolJson {
|
||||
id: p.id,
|
||||
slug: p.slug,
|
||||
name: p.name,
|
||||
cidr: p.cidr,
|
||||
kind: p.kind,
|
||||
allowed_prefixes,
|
||||
active_allocations: active,
|
||||
reserved_allocations: reserved,
|
||||
});
|
||||
}
|
||||
|
||||
Json(json!({ "pools": out })).into_response()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct NextQuery {
|
||||
pub prefix_length: i32,
|
||||
}
|
||||
|
||||
pub async fn next_free(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Path(slug): Path<String>,
|
||||
Query(q): Query<NextQuery>,
|
||||
) -> Response {
|
||||
if let Err(r) = authenticate(&state, &headers).await {
|
||||
return r;
|
||||
}
|
||||
|
||||
let pool: Option<Pool> =
|
||||
sqlx::query_as("SELECT * FROM pools WHERE slug = ? AND enabled = true")
|
||||
.bind(&slug)
|
||||
.fetch_optional(&state.pool)
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
|
||||
let pool = match pool {
|
||||
Some(p) => p,
|
||||
None => return error_response(StatusCode::NOT_FOUND, "pool does not exist"),
|
||||
};
|
||||
|
||||
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();
|
||||
|
||||
match find_next_subnet(&pool.cidr, q.prefix_length, &pool.allowed_prefixes(), &active) {
|
||||
Ok(info) => Json(json!({
|
||||
"pool": pool.slug,
|
||||
"prefix_length": info.prefix_length,
|
||||
"cidr": info.cidr,
|
||||
"address_count": info.address_count,
|
||||
"usable_host_count": info.usable_host_count,
|
||||
"network": info.network,
|
||||
"broadcast": info.broadcast,
|
||||
"first_host": info.first_host,
|
||||
"last_host": info.last_host,
|
||||
}))
|
||||
.into_response(),
|
||||
Err(AllocateError::PrefixNotAllowed(p)) => error_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
&format!("prefix /{} is not allowed in this pool", p),
|
||||
),
|
||||
Err(e) => error_response(StatusCode::CONFLICT, &e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
pub struct AllocationsQuery {
|
||||
pub status: Option<String>,
|
||||
pub pool: Option<String>,
|
||||
pub q: Option<String>,
|
||||
pub limit: Option<i64>,
|
||||
pub offset: Option<i64>,
|
||||
}
|
||||
|
||||
pub async fn list_allocations(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Query(q): Query<AllocationsQuery>,
|
||||
) -> Response {
|
||||
if let Err(r) = authenticate(&state, &headers).await {
|
||||
return r;
|
||||
}
|
||||
|
||||
let status = q.status.unwrap_or_default();
|
||||
if !status.is_empty() && !["active", "reserved", "released"].contains(&status.as_str()) {
|
||||
return error_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"status must be one of: active, reserved, released",
|
||||
);
|
||||
}
|
||||
let pool_filter = q.pool.unwrap_or_default();
|
||||
let search = format!("%{}%", q.q.unwrap_or_default());
|
||||
let limit = q.limit.unwrap_or(50).clamp(1, 500);
|
||||
let offset = q.offset.unwrap_or(0).max(0);
|
||||
|
||||
let total: i64 = sqlx::query_scalar(
|
||||
"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(&search)
|
||||
.bind(&search)
|
||||
.bind(&pool_filter)
|
||||
.bind(&pool_filter)
|
||||
.bind(&status)
|
||||
.bind(&status)
|
||||
.fetch_one(&state.pool)
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
|
||||
let allocations: Vec<Allocation> = sqlx::query_as(
|
||||
"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(&search)
|
||||
.bind(&search)
|
||||
.bind(&pool_filter)
|
||||
.bind(&pool_filter)
|
||||
.bind(&status)
|
||||
.bind(&status)
|
||||
.bind(limit)
|
||||
.bind(offset)
|
||||
.fetch_all(&state.pool)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
let pool_map = load_pool_map(&state, &allocations).await;
|
||||
let allocations: Vec<AllocationJson> = allocations
|
||||
.into_iter()
|
||||
.filter_map(|a| {
|
||||
pool_map
|
||||
.get(&a.pool_id)
|
||||
.map(|p| allocation_to_json(a, p.clone()))
|
||||
})
|
||||
.collect();
|
||||
|
||||
Json(json!({
|
||||
"allocations": allocations,
|
||||
"total": total,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
}))
|
||||
.into_response()
|
||||
}
|
||||
|
||||
async fn load_pool_map(
|
||||
state: &AppState,
|
||||
allocations: &[Allocation],
|
||||
) -> std::collections::HashMap<i64, Pool> {
|
||||
let pool_ids: Vec<i64> = allocations.iter().map(|a| a.pool_id).collect();
|
||||
if pool_ids.is_empty() {
|
||||
return std::collections::HashMap::new();
|
||||
}
|
||||
|
||||
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()
|
||||
.into_iter()
|
||||
.map(|p| (p.id, p))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub async fn get_allocation(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Path(id): Path<i64>,
|
||||
) -> Response {
|
||||
if let Err(r) = authenticate(&state, &headers).await {
|
||||
return r;
|
||||
}
|
||||
|
||||
let allocation: Option<Allocation> =
|
||||
sqlx::query_as("SELECT * FROM allocations WHERE id = ?")
|
||||
.bind(id)
|
||||
.fetch_optional(&state.pool)
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
|
||||
let allocation = match allocation {
|
||||
Some(a) => a,
|
||||
None => return error_response(StatusCode::NOT_FOUND, "allocation does not exist"),
|
||||
};
|
||||
|
||||
let pool: Option<Pool> = sqlx::query_as("SELECT * FROM pools WHERE id = ?")
|
||||
.bind(allocation.pool_id)
|
||||
.fetch_optional(&state.pool)
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
|
||||
let pool = match pool {
|
||||
Some(p) => p,
|
||||
None => return error_response(StatusCode::NOT_FOUND, "pool does not exist"),
|
||||
};
|
||||
|
||||
let info = subnet_info(&allocation.cidr).ok();
|
||||
|
||||
let mut value = serde_json::to_value(allocation_to_json(allocation, pool))
|
||||
.unwrap_or_else(|_| json!({}));
|
||||
if let (Some(obj), Some(info)) = (value.as_object_mut(), info) {
|
||||
obj.insert("network".to_string(), json!(info.network));
|
||||
obj.insert("broadcast".to_string(), json!(info.broadcast));
|
||||
obj.insert("first_host".to_string(), json!(info.first_host));
|
||||
obj.insert("last_host".to_string(), json!(info.last_host));
|
||||
}
|
||||
|
||||
Json(value).into_response()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct AllocateRequest {
|
||||
pub pool_slug: String,
|
||||
pub prefix_length: i32,
|
||||
pub note: String,
|
||||
pub ticket: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn create_allocation(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(body): Json<AllocateRequest>,
|
||||
) -> Response {
|
||||
let token = match authenticate(&state, &headers).await {
|
||||
Ok(t) => t,
|
||||
Err(r) => return r,
|
||||
};
|
||||
let actor = api_actor(&token);
|
||||
|
||||
let note = body.note.trim();
|
||||
if note.len() < 3 {
|
||||
return error_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"note must be at least 3 characters long",
|
||||
);
|
||||
}
|
||||
|
||||
let pool: Option<Pool> =
|
||||
sqlx::query_as("SELECT * FROM pools WHERE slug = ? AND enabled = true")
|
||||
.bind(&body.pool_slug)
|
||||
.fetch_optional(&state.pool)
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
|
||||
let pool = match pool {
|
||||
Some(p) => p,
|
||||
None => return error_response(StatusCode::NOT_FOUND, "pool does not exist"),
|
||||
};
|
||||
|
||||
let result = allocate_in_tx(
|
||||
&state.pool,
|
||||
pool.id,
|
||||
&pool.cidr,
|
||||
&pool.allowed_prefixes(),
|
||||
body.prefix_length,
|
||||
note,
|
||||
body.ticket.as_deref().unwrap_or("").trim(),
|
||||
&actor,
|
||||
)
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok((allocation, info)) => {
|
||||
regenerate_after_change(&state).await.ok();
|
||||
info!(
|
||||
"API allocated {} in pool {} by {}",
|
||||
allocation.cidr, pool.slug, actor
|
||||
);
|
||||
(
|
||||
StatusCode::CREATED,
|
||||
Json(json!({
|
||||
"id": allocation.id,
|
||||
"pool": pool.slug,
|
||||
"cidr": allocation.cidr,
|
||||
"prefix_length": allocation.prefix_length,
|
||||
"address_count": allocation.address_count,
|
||||
"usable_host_count": allocation.usable_host_count,
|
||||
"network": info.network,
|
||||
"broadcast": info.broadcast,
|
||||
"first_host": info.first_host,
|
||||
"last_host": info.last_host,
|
||||
"note": allocation.note,
|
||||
"status": allocation.status,
|
||||
"created_at": allocation.created_at,
|
||||
"created_by": allocation.created_by,
|
||||
})),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("API allocation failed: {}", e);
|
||||
match e.downcast_ref::<AllocateError>() {
|
||||
Some(AllocateError::PrefixNotAllowed(p)) => error_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
&format!("prefix /{} is not allowed in this pool", p),
|
||||
),
|
||||
Some(AllocateError::PoolFull(_)) | Some(AllocateError::DoesNotFit { .. }) => {
|
||||
error_response(StatusCode::CONFLICT, &e.to_string())
|
||||
}
|
||||
_ => error_response(StatusCode::INTERNAL_SERVER_ERROR, "allocation failed"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ReleaseRequest {
|
||||
pub release_note: String,
|
||||
}
|
||||
|
||||
pub async fn release_allocation(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Path(id): Path<i64>,
|
||||
Json(body): Json<ReleaseRequest>,
|
||||
) -> Response {
|
||||
let token = match authenticate(&state, &headers).await {
|
||||
Ok(t) => t,
|
||||
Err(r) => return r,
|
||||
};
|
||||
let actor = api_actor(&token);
|
||||
|
||||
let release_note = body.release_note.trim();
|
||||
if release_note.len() < 3 {
|
||||
return error_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"release_note must be at least 3 characters long",
|
||||
);
|
||||
}
|
||||
|
||||
match release_in_tx(&state.pool, id, release_note, &actor).await {
|
||||
Ok(()) => {
|
||||
regenerate_after_change(&state).await.ok();
|
||||
info!("API released allocation {} by {}", id, actor);
|
||||
|
||||
let allocation: Option<Allocation> =
|
||||
sqlx::query_as("SELECT * FROM allocations WHERE id = ?")
|
||||
.bind(id)
|
||||
.fetch_optional(&state.pool)
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
let pool: Option<Pool> = match &allocation {
|
||||
Some(a) => sqlx::query_as("SELECT * FROM pools WHERE id = ?")
|
||||
.bind(a.pool_id)
|
||||
.fetch_optional(&state.pool)
|
||||
.await
|
||||
.ok()
|
||||
.flatten(),
|
||||
None => None,
|
||||
};
|
||||
|
||||
match (allocation, pool) {
|
||||
(Some(a), Some(p)) => {
|
||||
Json(serde_json::to_value(allocation_to_json(a, p)).unwrap_or_else(|_| json!({})))
|
||||
.into_response()
|
||||
}
|
||||
_ => Json(json!({ "id": id, "status": "released" })).into_response(),
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("API release failed for allocation {}: {}", id, e);
|
||||
error_response(StatusCode::CONFLICT, &e.to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn allocation_to_json(a: Allocation, p: Pool) -> AllocationJson {
|
||||
AllocationJson {
|
||||
id: a.id,
|
||||
pool_id: a.pool_id,
|
||||
pool_slug: p.slug,
|
||||
pool_name: p.name,
|
||||
kind: p.kind,
|
||||
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,
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,7 @@
|
|||
pub mod allocations;
|
||||
pub mod api;
|
||||
pub mod exports;
|
||||
pub mod pages;
|
||||
pub mod partials;
|
||||
pub mod pools;
|
||||
pub mod tokens;
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ use crate::state::AppState;
|
|||
use crate::templates::{AllocationDetailTemplate, FlashData, IndexTemplate, PoolTemplate};
|
||||
use askama::Template;
|
||||
use axum::extract::{Query, State};
|
||||
use axum::response::{Html, IntoResponse, Response};
|
||||
use axum::response::{Html, IntoResponse, Redirect, Response};
|
||||
use axum_extra::extract::cookie::CookieJar;
|
||||
use serde::Deserialize;
|
||||
use tracing::error;
|
||||
|
|
@ -27,10 +27,19 @@ pub async fn index(
|
|||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
Query(q): Query<IndexQuery>,
|
||||
) -> impl IntoResponse {
|
||||
) -> Response {
|
||||
let (jar, csrf_token) = ensure_csrf_cookie(jar);
|
||||
let page = q.page.max(1);
|
||||
|
||||
let pool_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM pools")
|
||||
.fetch_one(&state.pool)
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
|
||||
if pool_count == 0 {
|
||||
return Redirect::to("/setup").into_response();
|
||||
}
|
||||
|
||||
let pools: Vec<PoolTemplate> = sqlx::query_as::<_, Pool>(
|
||||
"SELECT * FROM pools WHERE enabled = true ORDER BY id",
|
||||
)
|
||||
|
|
@ -41,6 +50,11 @@ pub async fn index(
|
|||
.map(PoolTemplate::from)
|
||||
.collect();
|
||||
|
||||
let all_pools: Vec<Pool> = sqlx::query_as::<_, Pool>("SELECT * FROM pools ORDER BY id")
|
||||
.fetch_all(&state.pool)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
let (allocations, total_count) =
|
||||
load_allocations(&state, &q.q, &q.pool, &q.status, page).await;
|
||||
|
||||
|
|
@ -48,6 +62,7 @@ pub async fn index(
|
|||
|
||||
let template = IndexTemplate {
|
||||
pools,
|
||||
all_pools,
|
||||
allocations,
|
||||
page,
|
||||
total_pages,
|
||||
|
|
@ -63,7 +78,7 @@ pub async fn index(
|
|||
Ok(html) => (jar, Html(html)).into_response(),
|
||||
Err(e) => {
|
||||
error!("Index template render error: {}", e);
|
||||
(jar, Html("Chyba při vykreslování stránky.".to_string())).into_response()
|
||||
(jar, Html("Error rendering page.".to_string())).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -85,7 +100,7 @@ pub async fn allocation_detail(
|
|||
let allocation = match allocation {
|
||||
Some(a) => a,
|
||||
None => {
|
||||
return (jar, Html("Alokace nenalezena.".to_string())).into_response();
|
||||
return (jar, Html("Allocation not found.".to_string())).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -154,7 +169,7 @@ pub async fn allocation_detail(
|
|||
Ok(html) => (jar, Html(html)).into_response(),
|
||||
Err(e) => {
|
||||
error!("Detail template render error: {}", e);
|
||||
(jar, Html("Chyba při vykreslování stránky.".to_string())).into_response()
|
||||
(jar, Html("Error rendering page.".to_string())).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ pub async fn pool_preview(
|
|||
address_count: 0,
|
||||
usable_host_count: 0,
|
||||
has_error: true,
|
||||
error: "Pool neexistuje.".to_string(),
|
||||
error: "Pool does not exist.".to_string(),
|
||||
};
|
||||
return Html(template.render().unwrap_or_default());
|
||||
}
|
||||
|
|
@ -72,7 +72,7 @@ pub async fn pool_preview(
|
|||
Ok(html) => Html(html),
|
||||
Err(e) => {
|
||||
error!("Preview template render error: {}", e);
|
||||
Html("Chyba preview.".to_string())
|
||||
Html("Preview error.".to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
492
src/handlers/pools.rs
Normal file
492
src/handlers/pools.rs
Normal file
|
|
@ -0,0 +1,492 @@
|
|||
use crate::audit::{get_actor_from_request, log_event};
|
||||
use crate::csrf::{ensure_csrf_cookie, validate_csrf};
|
||||
use crate::models::{now_utc_rfc3339, Pool};
|
||||
use crate::state::AppState;
|
||||
use crate::templates::{PoolListTemplate, PoolRow, SetupTemplate};
|
||||
use askama::Template;
|
||||
use axum::extract::{Form, Path, State};
|
||||
use axum::http::HeaderMap;
|
||||
use axum::response::{Html, IntoResponse, Redirect, Response};
|
||||
use axum_extra::extract::cookie::CookieJar;
|
||||
use ipnet::Ipv4Net;
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
use sqlx::SqlitePool;
|
||||
use tracing::{error, info};
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct CreatePoolForm {
|
||||
pub slug: String,
|
||||
pub name: String,
|
||||
pub cidr: String,
|
||||
pub kind: String,
|
||||
pub allowed_prefixes: String,
|
||||
pub csrf_token: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct DeletePoolForm {
|
||||
pub csrf_token: String,
|
||||
}
|
||||
|
||||
pub async fn setup_page(State(state): State<AppState>, jar: CookieJar) -> Response {
|
||||
let (jar, csrf_token) = ensure_csrf_cookie(jar);
|
||||
|
||||
let pool_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM pools")
|
||||
.fetch_one(&state.pool)
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
|
||||
if pool_count > 0 {
|
||||
return Redirect::to("/").into_response();
|
||||
}
|
||||
|
||||
let template = SetupTemplate {
|
||||
csrf_token,
|
||||
error: String::new(),
|
||||
};
|
||||
|
||||
match template.render() {
|
||||
Ok(html) => (jar, Html(html)).into_response(),
|
||||
Err(e) => {
|
||||
error!("Setup template render error: {}", e);
|
||||
(jar, Html("Error rendering setup page.".to_string())).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn create_pool(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
headers: HeaderMap,
|
||||
Form(form): Form<CreatePoolForm>,
|
||||
) -> Response {
|
||||
let (jar, csrf_token) = ensure_csrf_cookie(jar);
|
||||
let actor = get_actor_from_request(&headers);
|
||||
|
||||
if !validate_csrf(&jar, &form.csrf_token) {
|
||||
return render_setup_with_error(csrf_token, "Invalid CSRF token.").into_response();
|
||||
}
|
||||
|
||||
match validate_and_insert_pool(&state.pool, &form, &actor).await {
|
||||
Ok(outcome) => {
|
||||
info!("Pool {} saved by {} ({:?})", form.slug, actor, outcome);
|
||||
crate::handlers::allocations::regenerate_after_change(&state)
|
||||
.await
|
||||
.ok();
|
||||
Redirect::to("/").into_response()
|
||||
}
|
||||
Err(e) => render_setup_with_error(csrf_token, &e.to_string()).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn delete_pool(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
headers: HeaderMap,
|
||||
Path(id): Path<i64>,
|
||||
Form(form): Form<DeletePoolForm>,
|
||||
) -> 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_pool_list_with_error(&state, csrf_token, "Invalid CSRF token.")
|
||||
.await
|
||||
.into_response();
|
||||
}
|
||||
|
||||
match soft_delete_pool(&state, id, &actor).await {
|
||||
Ok(()) => {
|
||||
info!("Pool {} deleted (disabled) by {}", id, actor);
|
||||
crate::handlers::allocations::regenerate_after_change(&state)
|
||||
.await
|
||||
.ok();
|
||||
Redirect::to("/pools").into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to delete pool {}: {}", id, e);
|
||||
render_pool_list_with_error(&state, csrf_token, &e.to_string())
|
||||
.await
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn soft_delete_pool(state: &AppState, id: i64, actor: &str) -> anyhow::Result<()> {
|
||||
let mut tx = state.pool.begin_with("BEGIN IMMEDIATE").await?;
|
||||
|
||||
let pool: Option<Pool> = sqlx::query_as("SELECT * FROM pools WHERE id = ?")
|
||||
.bind(id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
|
||||
let pool = match pool {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
anyhow::bail!("Pool not found.");
|
||||
}
|
||||
};
|
||||
|
||||
if !pool.is_enabled() {
|
||||
anyhow::bail!("Pool is already deleted.");
|
||||
}
|
||||
|
||||
let (active_count, reserved_count, released_count): (i64, i64, i64) = sqlx::query_as(
|
||||
"SELECT \
|
||||
COALESCE(SUM(CASE WHEN status = 'active' THEN 1 ELSE 0 END), 0), \
|
||||
COALESCE(SUM(CASE WHEN status = 'reserved' THEN 1 ELSE 0 END), 0), \
|
||||
COALESCE(SUM(CASE WHEN status = 'released' THEN 1 ELSE 0 END), 0) \
|
||||
FROM allocations WHERE pool_id = ?",
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
|
||||
// Soft delete only: the pool row and all of its allocations stay in the
|
||||
// database, so the address range keeps its history and can be re-attached
|
||||
// if the same or an overlapping pool is added again later.
|
||||
sqlx::query("UPDATE pools SET enabled = false WHERE id = ?")
|
||||
.bind(id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
log_event(
|
||||
&mut tx,
|
||||
"pool_deleted",
|
||||
None,
|
||||
Some(id),
|
||||
actor,
|
||||
json!({
|
||||
"slug": pool.slug,
|
||||
"cidr": pool.cidr,
|
||||
"allocations_kept": {
|
||||
"active": active_count,
|
||||
"reserved": reserved_count,
|
||||
"released": released_count,
|
||||
},
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.ok();
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn pool_list(State(state): State<AppState>, jar: CookieJar) -> Response {
|
||||
let (jar, csrf_token) = ensure_csrf_cookie(jar);
|
||||
|
||||
let template = build_pool_list_template(&state, csrf_token, String::new()).await;
|
||||
|
||||
match template.render() {
|
||||
Ok(html) => (jar, Html(html)).into_response(),
|
||||
Err(e) => {
|
||||
error!("Pool list template render error: {}", e);
|
||||
(jar, Html("Error rendering pool list.".to_string())).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn render_pool_list_with_error(
|
||||
state: &AppState,
|
||||
csrf_token: String,
|
||||
error: &str,
|
||||
) -> Html<String> {
|
||||
let template = build_pool_list_template(state, csrf_token, error.to_string()).await;
|
||||
|
||||
match template.render() {
|
||||
Ok(html) => Html(html),
|
||||
Err(e) => {
|
||||
error!("Pool list template render error: {}", e);
|
||||
Html("Error rendering pool list.".to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn build_pool_list_template(
|
||||
state: &AppState,
|
||||
csrf_token: String,
|
||||
error: String,
|
||||
) -> PoolListTemplate {
|
||||
let pools: Vec<Pool> =
|
||||
sqlx::query_as::<_, Pool>("SELECT * FROM pools WHERE enabled = true ORDER BY id")
|
||||
.fetch_all(&state.pool)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut rows = Vec::with_capacity(pools.len());
|
||||
for pool in pools {
|
||||
let (active_count, reserved_count, released_count): (i64, i64, i64) = sqlx::query_as(
|
||||
"SELECT \
|
||||
COALESCE(SUM(CASE WHEN status = 'active' THEN 1 ELSE 0 END), 0), \
|
||||
COALESCE(SUM(CASE WHEN status = 'reserved' THEN 1 ELSE 0 END), 0), \
|
||||
COALESCE(SUM(CASE WHEN status = 'released' THEN 1 ELSE 0 END), 0) \
|
||||
FROM allocations WHERE pool_id = ?",
|
||||
)
|
||||
.bind(pool.id)
|
||||
.fetch_one(&state.pool)
|
||||
.await
|
||||
.unwrap_or((0, 0, 0));
|
||||
|
||||
rows.push(PoolRow {
|
||||
total_count: active_count + reserved_count + released_count,
|
||||
active_count,
|
||||
reserved_count,
|
||||
released_count,
|
||||
pool,
|
||||
});
|
||||
}
|
||||
|
||||
PoolListTemplate {
|
||||
pools: rows,
|
||||
csrf_token,
|
||||
error,
|
||||
}
|
||||
}
|
||||
|
||||
fn render_setup_with_error(csrf_token: String, error: &str) -> Html<String> {
|
||||
let template = SetupTemplate {
|
||||
csrf_token,
|
||||
error: error.to_string(),
|
||||
};
|
||||
|
||||
match template.render() {
|
||||
Ok(html) => Html(html),
|
||||
Err(e) => {
|
||||
error!("Setup template render error: {}", e);
|
||||
Html("Error rendering setup page.".to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum CreatePoolOutcome {
|
||||
Created { id: i64, reattached: i64 },
|
||||
Reenabled { id: i64 },
|
||||
}
|
||||
|
||||
fn nets_overlap(a: &Ipv4Net, b: &Ipv4Net) -> bool {
|
||||
a.network() <= b.broadcast() && b.network() <= a.broadcast()
|
||||
}
|
||||
|
||||
fn net_contains(outer: &Ipv4Net, inner: &Ipv4Net) -> bool {
|
||||
outer.network() <= inner.network() && inner.broadcast() <= outer.broadcast()
|
||||
}
|
||||
|
||||
async fn validate_and_insert_pool(
|
||||
pool: &SqlitePool,
|
||||
form: &CreatePoolForm,
|
||||
actor: &str,
|
||||
) -> anyhow::Result<CreatePoolOutcome> {
|
||||
let slug = form.slug.trim().to_lowercase();
|
||||
if slug.is_empty() || !slug.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') {
|
||||
anyhow::bail!("Slug must contain only lowercase letters, numbers and hyphens.");
|
||||
}
|
||||
|
||||
let name = form.name.trim();
|
||||
if name.len() < 2 {
|
||||
anyhow::bail!("Pool name must have at least 2 characters.");
|
||||
}
|
||||
|
||||
let kind = form.kind.trim();
|
||||
if kind.is_empty() {
|
||||
anyhow::bail!("Pool type is required.");
|
||||
}
|
||||
|
||||
let net: Ipv4Net = form
|
||||
.cidr
|
||||
.trim()
|
||||
.parse()
|
||||
.map_err(|_| anyhow::anyhow!("Invalid IPv4 CIDR: {}", form.cidr))?;
|
||||
let cidr = net.to_string();
|
||||
let parent_prefix = net.prefix_len() as i32;
|
||||
|
||||
let prefixes: Vec<i32> = form
|
||||
.allowed_prefixes
|
||||
.split(',')
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.map(|s| s.trim().parse::<i32>())
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|_| anyhow::anyhow!("Allowed prefixes must be comma-separated integers."))?;
|
||||
|
||||
if prefixes.is_empty() {
|
||||
anyhow::bail!("At least one allowed prefix length is required.");
|
||||
}
|
||||
|
||||
for p in &prefixes {
|
||||
if *p < 1 || *p > 32 {
|
||||
anyhow::bail!("Prefix length {} is out of range 1-32.", p);
|
||||
}
|
||||
if *p < parent_prefix {
|
||||
anyhow::bail!(
|
||||
"Allowed prefix /{} is larger than the pool prefix /{}.",
|
||||
p,
|
||||
parent_prefix
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let allowed_json = serde_json::to_string(&prefixes)?;
|
||||
let now = now_utc_rfc3339();
|
||||
|
||||
// Automatic rollback on drop covers every bail!/? below.
|
||||
let mut tx = pool.begin_with("BEGIN IMMEDIATE").await?;
|
||||
|
||||
let existing_by_cidr: Option<Pool> = sqlx::query_as("SELECT * FROM pools WHERE cidr = ?")
|
||||
.bind(&cidr)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
|
||||
if let Some(existing) = existing_by_cidr {
|
||||
if existing.is_enabled() {
|
||||
anyhow::bail!("A pool with the same CIDR already exists.");
|
||||
}
|
||||
|
||||
// The same range was deleted before: re-enable it so all of its
|
||||
// historical allocations immediately block the address space again.
|
||||
let slug_taken: Option<i64> =
|
||||
sqlx::query_scalar("SELECT id FROM pools WHERE slug = ? AND id != ?")
|
||||
.bind(&slug)
|
||||
.bind(existing.id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
|
||||
if slug_taken.is_some() {
|
||||
anyhow::bail!("Slug is already used by another pool.");
|
||||
}
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE pools SET slug = ?, name = ?, kind = ?, allowed_prefixes_json = ?, enabled = true WHERE id = ?",
|
||||
)
|
||||
.bind(&slug)
|
||||
.bind(name)
|
||||
.bind(kind)
|
||||
.bind(&allowed_json)
|
||||
.bind(existing.id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
log_event(
|
||||
&mut tx,
|
||||
"pool_reenabled",
|
||||
None,
|
||||
Some(existing.id),
|
||||
actor,
|
||||
json!({
|
||||
"slug": slug,
|
||||
"cidr": cidr,
|
||||
"kind": kind,
|
||||
"allowed_prefixes": prefixes,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.ok();
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
return Ok(CreatePoolOutcome::Reenabled { id: existing.id });
|
||||
}
|
||||
|
||||
let slug_taken: Option<i64> = sqlx::query_scalar("SELECT id FROM pools WHERE slug = ?")
|
||||
.bind(&slug)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
|
||||
if slug_taken.is_some() {
|
||||
anyhow::bail!("A pool with the same slug already exists.");
|
||||
}
|
||||
|
||||
// Two enabled pools must never cover the same address space.
|
||||
let enabled_pools: Vec<Pool> =
|
||||
sqlx::query_as("SELECT * FROM pools WHERE enabled = true")
|
||||
.fetch_all(&mut *tx)
|
||||
.await?;
|
||||
|
||||
for p in &enabled_pools {
|
||||
if let Ok(other) = p.cidr.parse::<Ipv4Net>()
|
||||
&& nets_overlap(&net, &other)
|
||||
{
|
||||
anyhow::bail!(
|
||||
"Pool range overlaps with existing pool '{}' ({}).",
|
||||
p.name,
|
||||
p.cidr
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let id = sqlx::query(
|
||||
"INSERT INTO pools (slug, name, cidr, kind, allowed_prefixes_json, enabled, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)"
|
||||
)
|
||||
.bind(&slug)
|
||||
.bind(name)
|
||||
.bind(&cidr)
|
||||
.bind(kind)
|
||||
.bind(&allowed_json)
|
||||
.bind(true)
|
||||
.bind(&now)
|
||||
.execute(&mut *tx)
|
||||
.await?
|
||||
.last_insert_rowid();
|
||||
|
||||
// Re-attach allocations that belonged to deleted pools and fall inside
|
||||
// the new range, so the first-fit algorithm keeps respecting them and
|
||||
// no overlapping subnet can be handed out.
|
||||
let disabled_pools: Vec<Pool> =
|
||||
sqlx::query_as("SELECT * FROM pools WHERE enabled = false AND id != ?")
|
||||
.bind(id)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?;
|
||||
|
||||
let mut reattached: i64 = 0;
|
||||
for dp in &disabled_pools {
|
||||
let Ok(dp_net) = dp.cidr.parse::<Ipv4Net>() else {
|
||||
continue;
|
||||
};
|
||||
if !nets_overlap(&net, &dp_net) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let orphans: Vec<(i64, String)> =
|
||||
sqlx::query_as("SELECT id, cidr FROM allocations WHERE pool_id = ?")
|
||||
.bind(dp.id)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?;
|
||||
|
||||
for (alloc_id, alloc_cidr) in orphans {
|
||||
let Ok(alloc_net) = alloc_cidr.parse::<Ipv4Net>() else {
|
||||
continue;
|
||||
};
|
||||
if net_contains(&net, &alloc_net) {
|
||||
sqlx::query("UPDATE allocations SET pool_id = ? WHERE id = ?")
|
||||
.bind(id)
|
||||
.bind(alloc_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
reattached += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log_event(
|
||||
&mut tx,
|
||||
"pool_created",
|
||||
None,
|
||||
Some(id),
|
||||
actor,
|
||||
json!({
|
||||
"slug": slug,
|
||||
"cidr": cidr,
|
||||
"kind": kind,
|
||||
"allowed_prefixes": prefixes,
|
||||
"reattached_allocations": reattached,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.ok();
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(CreatePoolOutcome::Created { id, reattached })
|
||||
}
|
||||
325
src/handlers/tokens.rs
Normal file
325
src/handlers/tokens.rs
Normal file
|
|
@ -0,0 +1,325 @@
|
|||
use crate::api_tokens::{display_prefix, generate_api_token, hash_token};
|
||||
use crate::audit::{get_actor_from_request, log_event};
|
||||
use crate::csrf::{ensure_csrf_cookie, validate_csrf};
|
||||
use crate::models::{now_utc_rfc3339, utc_to_local, ApiToken};
|
||||
use crate::state::AppState;
|
||||
use crate::templates::{TokenListTemplate, TokenRow};
|
||||
use askama::Template;
|
||||
use axum::extract::{Form, Path, State};
|
||||
use axum::http::HeaderMap;
|
||||
use axum::response::{Html, IntoResponse, Redirect, Response};
|
||||
use axum_extra::extract::cookie::CookieJar;
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
use sqlx::SqlitePool;
|
||||
use tracing::{error, info};
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct CreateTokenForm {
|
||||
pub name: String,
|
||||
pub csrf_token: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct TokenActionForm {
|
||||
pub csrf_token: String,
|
||||
}
|
||||
|
||||
pub async fn token_list(State(state): State<AppState>, jar: CookieJar) -> Response {
|
||||
let (jar, csrf_token) = ensure_csrf_cookie(jar);
|
||||
|
||||
let template = build_token_list_template(&state, csrf_token, String::new(), None).await;
|
||||
|
||||
match template.render() {
|
||||
Ok(html) => (jar, Html(html)).into_response(),
|
||||
Err(e) => {
|
||||
error!("Token list template render error: {}", e);
|
||||
(jar, Html("Error rendering token list.".to_string())).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn create_token(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
headers: HeaderMap,
|
||||
Form(form): Form<CreateTokenForm>,
|
||||
) -> 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_token_list_with_error(&state, csrf_token, "Invalid CSRF token.")
|
||||
.await
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let name = form.name.trim();
|
||||
if name.len() < 2 || name.len() > 100 {
|
||||
return render_token_list_with_error(
|
||||
&state,
|
||||
csrf_token,
|
||||
"Token name must be between 2 and 100 characters.",
|
||||
)
|
||||
.await
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let token = generate_api_token();
|
||||
let now = now_utc_rfc3339();
|
||||
|
||||
let result: sqlx::Result<sqlx::sqlite::SqliteQueryResult> = sqlx::query(
|
||||
"INSERT INTO api_tokens (name, token_hash, token_prefix, enabled, created_at, created_by) VALUES (?, ?, ?, true, ?, ?)",
|
||||
)
|
||||
.bind(name)
|
||||
.bind(hash_token(&token))
|
||||
.bind(display_prefix(&token))
|
||||
.bind(&now)
|
||||
.bind(&actor)
|
||||
.execute(&state.pool)
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(r) => {
|
||||
let id = r.last_insert_rowid();
|
||||
info!("API token '{}' created by {}", name, actor);
|
||||
|
||||
let mut conn = match state.pool.acquire().await {
|
||||
Ok(c) => c,
|
||||
Err(_) => return Redirect::to("/tokens").into_response(),
|
||||
};
|
||||
log_event(
|
||||
&mut conn,
|
||||
"api_token_created",
|
||||
None,
|
||||
None,
|
||||
&actor,
|
||||
json!({ "token_id": id, "name": name }),
|
||||
)
|
||||
.await
|
||||
.ok();
|
||||
|
||||
let template =
|
||||
build_token_list_template(&state, csrf_token, String::new(), Some((name.to_string(), token)))
|
||||
.await;
|
||||
match template.render() {
|
||||
Ok(html) => (jar, Html(html)).into_response(),
|
||||
Err(e) => {
|
||||
error!("Token list template render error: {}", e);
|
||||
(jar, Html("Error rendering token list.".to_string())).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to create API token: {}", e);
|
||||
render_token_list_with_error(&state, csrf_token, "Failed to create token.")
|
||||
.await
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn revoke_token(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
headers: HeaderMap,
|
||||
Path(id): Path<i64>,
|
||||
Form(form): Form<TokenActionForm>,
|
||||
) -> 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_token_list_with_error(&state, csrf_token, "Invalid CSRF token.")
|
||||
.await
|
||||
.into_response();
|
||||
}
|
||||
|
||||
match set_token_enabled(&state.pool, id, false, &actor).await {
|
||||
Ok(name) => {
|
||||
info!("API token '{}' ({}) revoked by {}", name, id, actor);
|
||||
Redirect::to("/tokens").into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to revoke token {}: {}", id, e);
|
||||
render_token_list_with_error(&state, csrf_token, &e.to_string())
|
||||
.await
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn delete_token(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
headers: HeaderMap,
|
||||
Path(id): Path<i64>,
|
||||
Form(form): Form<TokenActionForm>,
|
||||
) -> 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_token_list_with_error(&state, csrf_token, "Invalid CSRF token.")
|
||||
.await
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let token: Option<ApiToken> = sqlx::query_as("SELECT * FROM api_tokens WHERE id = ?")
|
||||
.bind(id)
|
||||
.fetch_optional(&state.pool)
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
|
||||
let token = match token {
|
||||
Some(t) => t,
|
||||
None => {
|
||||
return render_token_list_with_error(&state, csrf_token, "Token not found.")
|
||||
.await
|
||||
.into_response()
|
||||
}
|
||||
};
|
||||
|
||||
if token.is_enabled() {
|
||||
return render_token_list_with_error(
|
||||
&state,
|
||||
csrf_token,
|
||||
"Only revoked tokens can be deleted. Revoke the token first.",
|
||||
)
|
||||
.await
|
||||
.into_response();
|
||||
}
|
||||
|
||||
match sqlx::query("DELETE FROM api_tokens WHERE id = ?")
|
||||
.bind(id)
|
||||
.execute(&state.pool)
|
||||
.await
|
||||
{
|
||||
Ok(_) => {
|
||||
info!("API token '{}' ({}) deleted by {}", token.name, id, actor);
|
||||
|
||||
let mut conn = match state.pool.acquire().await {
|
||||
Ok(c) => c,
|
||||
Err(_) => return Redirect::to("/tokens").into_response(),
|
||||
};
|
||||
log_event(
|
||||
&mut conn,
|
||||
"api_token_deleted",
|
||||
None,
|
||||
None,
|
||||
&actor,
|
||||
json!({ "token_id": id, "name": token.name }),
|
||||
)
|
||||
.await
|
||||
.ok();
|
||||
|
||||
Redirect::to("/tokens").into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to delete token {}: {}", id, e);
|
||||
render_token_list_with_error(&state, csrf_token, "Failed to delete token.")
|
||||
.await
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn set_token_enabled(
|
||||
pool: &SqlitePool,
|
||||
id: i64,
|
||||
enabled: bool,
|
||||
actor: &str,
|
||||
) -> anyhow::Result<String> {
|
||||
let token: Option<ApiToken> = sqlx::query_as("SELECT * FROM api_tokens WHERE id = ?")
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
|
||||
let token = match token {
|
||||
Some(t) => t,
|
||||
None => anyhow::bail!("Token not found."),
|
||||
};
|
||||
|
||||
sqlx::query("UPDATE api_tokens SET enabled = ? WHERE id = ?")
|
||||
.bind(enabled)
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
let mut conn = pool.acquire().await?;
|
||||
log_event(
|
||||
&mut conn,
|
||||
"api_token_revoked",
|
||||
None,
|
||||
None,
|
||||
actor,
|
||||
json!({ "token_id": id, "name": token.name }),
|
||||
)
|
||||
.await
|
||||
.ok();
|
||||
|
||||
Ok(token.name)
|
||||
}
|
||||
|
||||
async fn render_token_list_with_error(
|
||||
state: &AppState,
|
||||
csrf_token: String,
|
||||
error: &str,
|
||||
) -> Html<String> {
|
||||
let template = build_token_list_template(state, csrf_token, error.to_string(), None).await;
|
||||
|
||||
match template.render() {
|
||||
Ok(html) => Html(html),
|
||||
Err(e) => {
|
||||
error!("Token list template render error: {}", e);
|
||||
Html("Error rendering token list.".to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn build_token_list_template(
|
||||
state: &AppState,
|
||||
csrf_token: String,
|
||||
error: String,
|
||||
new_token: Option<(String, String)>,
|
||||
) -> TokenListTemplate {
|
||||
let tokens: Vec<ApiToken> =
|
||||
sqlx::query_as("SELECT * FROM api_tokens ORDER BY created_at DESC")
|
||||
.fetch_all(&state.pool)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
let rows: Vec<TokenRow> = tokens
|
||||
.into_iter()
|
||||
.map(|t| {
|
||||
let enabled = t.is_enabled();
|
||||
TokenRow {
|
||||
id: t.id,
|
||||
name: t.name,
|
||||
token_prefix: t.token_prefix,
|
||||
enabled,
|
||||
created_at_local: utc_to_local(&t.created_at, &state.config.timezone),
|
||||
created_by: t.created_by,
|
||||
last_used_at_local: t
|
||||
.last_used_at
|
||||
.map(|d| utc_to_local(&d, &state.config.timezone))
|
||||
.unwrap_or_else(|| "–".to_string()),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let (new_token_name, new_token_value) = match new_token {
|
||||
Some((n, v)) => (n, v),
|
||||
None => (String::new(), String::new()),
|
||||
};
|
||||
|
||||
TokenListTemplate {
|
||||
tokens: rows,
|
||||
csrf_token,
|
||||
error,
|
||||
has_new_token: !new_token_value.is_empty(),
|
||||
new_token_name,
|
||||
new_token_value,
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
pub mod allocator;
|
||||
pub mod api_tokens;
|
||||
pub mod audit;
|
||||
pub mod config;
|
||||
pub mod csrf;
|
||||
|
|
|
|||
|
|
@ -69,6 +69,24 @@ impl AllocationStatus {
|
|||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, FromRow)]
|
||||
pub struct ApiToken {
|
||||
pub id: i64,
|
||||
pub name: String,
|
||||
pub token_hash: String,
|
||||
pub token_prefix: String,
|
||||
pub enabled: i64,
|
||||
pub created_at: String,
|
||||
pub created_by: String,
|
||||
pub last_used_at: Option<String>,
|
||||
}
|
||||
|
||||
impl ApiToken {
|
||||
pub fn is_enabled(&self) -> bool {
|
||||
self.enabled != 0
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, FromRow)]
|
||||
pub struct AuditEvent {
|
||||
pub id: i64,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
use crate::handlers::{allocations, exports, pages, partials};
|
||||
use crate::handlers::{allocations, api, exports, pages, partials, pools, tokens};
|
||||
use axum::routing::{get, post};
|
||||
use axum::Router;
|
||||
|
||||
|
|
@ -6,6 +6,14 @@ pub fn router() -> Router<crate::state::AppState> {
|
|||
Router::new()
|
||||
.route("/", get(pages::index))
|
||||
.route("/healthz", get(pages::healthz))
|
||||
.route("/setup", get(pools::setup_page).post(pools::create_pool))
|
||||
.route("/pools", get(pools::pool_list))
|
||||
.route("/pools/new", post(pools::create_pool))
|
||||
.route("/pools/{id}/delete", post(pools::delete_pool))
|
||||
.route("/tokens", get(tokens::token_list))
|
||||
.route("/tokens/new", post(tokens::create_token))
|
||||
.route("/tokens/{id}/revoke", post(tokens::revoke_token))
|
||||
.route("/tokens/{id}/delete", post(tokens::delete_token))
|
||||
.route("/partials/pools/{slug}/preview", get(partials::pool_preview))
|
||||
.route("/allocations", post(allocations::create_allocation))
|
||||
.route("/allocations/{id}", get(pages::allocation_detail))
|
||||
|
|
@ -16,4 +24,15 @@ pub fn router() -> Router<crate::state::AppState> {
|
|||
.route("/exports/allocations.md", get(exports::export_md))
|
||||
.route("/exports/allocations.csv", get(exports::export_csv))
|
||||
.route("/exports/allocations.json", get(exports::export_json))
|
||||
.route("/api/v1/pools", get(api::list_pools))
|
||||
.route("/api/v1/pools/{slug}/next", get(api::next_free))
|
||||
.route(
|
||||
"/api/v1/allocations",
|
||||
get(api::list_allocations).post(api::create_allocation),
|
||||
)
|
||||
.route("/api/v1/allocations/{id}", get(api::get_allocation))
|
||||
.route(
|
||||
"/api/v1/allocations/{id}/release",
|
||||
post(api::release_allocation),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ pub struct BaseTemplate {
|
|||
#[template(path = "index.html")]
|
||||
pub struct IndexTemplate {
|
||||
pub pools: Vec<PoolTemplate>,
|
||||
pub all_pools: Vec<Pool>,
|
||||
pub allocations: Vec<AllocationListItem>,
|
||||
pub page: i64,
|
||||
pub total_pages: i64,
|
||||
|
|
@ -130,6 +131,7 @@ pub struct AllocationsTableTemplate {
|
|||
pub query: String,
|
||||
pub pool_filter: String,
|
||||
pub status_filter: String,
|
||||
pub csrf_token: String,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
|
|
@ -140,3 +142,49 @@ pub struct AllocationDetailTemplate {
|
|||
pub csrf_token: String,
|
||||
pub flash: FlashData,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "setup.html")]
|
||||
pub struct SetupTemplate {
|
||||
pub csrf_token: String,
|
||||
pub error: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct PoolRow {
|
||||
pub pool: Pool,
|
||||
pub active_count: i64,
|
||||
pub reserved_count: i64,
|
||||
pub released_count: i64,
|
||||
pub total_count: i64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct TokenRow {
|
||||
pub id: i64,
|
||||
pub name: String,
|
||||
pub token_prefix: String,
|
||||
pub enabled: bool,
|
||||
pub created_at_local: String,
|
||||
pub created_by: String,
|
||||
pub last_used_at_local: String,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "tokens.html")]
|
||||
pub struct TokenListTemplate {
|
||||
pub tokens: Vec<TokenRow>,
|
||||
pub csrf_token: String,
|
||||
pub error: String,
|
||||
pub has_new_token: bool,
|
||||
pub new_token_name: String,
|
||||
pub new_token_value: String,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "pools.html")]
|
||||
pub struct PoolListTemplate {
|
||||
pub pools: Vec<PoolRow>,
|
||||
pub csrf_token: String,
|
||||
pub error: String,
|
||||
}
|
||||
|
|
|
|||
135
static/app.js
Normal file
135
static/app.js
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
function initApp() {
|
||||
console.log('sub-aligator app.js initialized');
|
||||
|
||||
// Ensure every modal starts closed, even if the browser restored a cached state.
|
||||
document.querySelectorAll('.modal-overlay').forEach(function (modal) {
|
||||
modal.style.display = 'none';
|
||||
});
|
||||
|
||||
// Open allocation modal
|
||||
document.querySelectorAll('.allocate-btn').forEach(function (btn) {
|
||||
btn.addEventListener('click', function () {
|
||||
console.log('allocate-btn clicked', btn.dataset);
|
||||
openAllocateModal(btn);
|
||||
});
|
||||
});
|
||||
|
||||
// Open release modal from history table
|
||||
document.querySelectorAll('.release-btn').forEach(function (btn) {
|
||||
btn.addEventListener('click', function () {
|
||||
const id = btn.dataset.id;
|
||||
const modal = document.getElementById('release-modal-' + id);
|
||||
if (modal) {
|
||||
modal.style.display = 'flex';
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Open pool delete modal from the pools page
|
||||
document.querySelectorAll('.pool-delete-btn').forEach(function (btn) {
|
||||
btn.addEventListener('click', function () {
|
||||
const id = btn.dataset.id;
|
||||
const modal = document.getElementById('delete-pool-modal-' + id);
|
||||
if (modal) {
|
||||
modal.style.display = 'flex';
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Open any modal via a generic data-modal-open="<id>" trigger
|
||||
document.querySelectorAll('[data-modal-open]').forEach(function (btn) {
|
||||
btn.addEventListener('click', function () {
|
||||
const modal = document.getElementById(btn.dataset.modalOpen);
|
||||
if (modal) {
|
||||
modal.style.display = 'flex';
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Close modal via Cancel button
|
||||
document.querySelectorAll('.cancel-btn').forEach(function (btn) {
|
||||
btn.addEventListener('click', function () {
|
||||
const modalId = btn.dataset.modal;
|
||||
const modal = document.getElementById(modalId);
|
||||
if (modal) {
|
||||
modal.style.display = 'none';
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Close modal by clicking on the overlay background
|
||||
document.querySelectorAll('.modal-overlay').forEach(function (overlay) {
|
||||
overlay.addEventListener('click', function (event) {
|
||||
if (event.target === overlay) {
|
||||
overlay.style.display = 'none';
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Prevent clicks inside the modal from closing it
|
||||
document.querySelectorAll('.modal').forEach(function (modal) {
|
||||
modal.addEventListener('click', function (event) {
|
||||
event.stopPropagation();
|
||||
});
|
||||
});
|
||||
|
||||
// Hide the modal right before its form is submitted.
|
||||
document.querySelectorAll('.modal form').forEach(function (form) {
|
||||
form.addEventListener('submit', function () {
|
||||
const overlay = form.closest('.modal-overlay');
|
||||
if (overlay) {
|
||||
overlay.style.display = 'none';
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function openAllocateModal(btn) {
|
||||
const slug = btn.dataset.slug;
|
||||
const name = btn.dataset.name;
|
||||
const select = document.getElementById('prefix-' + slug);
|
||||
let prefixLength = '';
|
||||
|
||||
if (select && select.value) {
|
||||
prefixLength = select.value;
|
||||
} else if (select && select.options.length > 0) {
|
||||
prefixLength = select.options[0].value;
|
||||
} else {
|
||||
prefixLength = '24';
|
||||
}
|
||||
|
||||
console.log('openAllocateModal', { slug: slug, name: name, prefixLength: prefixLength });
|
||||
|
||||
document.getElementById('modal-pool-slug').value = slug;
|
||||
document.getElementById('modal-prefix-length').value = prefixLength;
|
||||
document.getElementById('modal-pool-name').textContent = name;
|
||||
document.getElementById('modal-prefix-display').textContent = '/' + prefixLength;
|
||||
|
||||
try {
|
||||
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;
|
||||
} catch (e) {
|
||||
console.error('preview fetch failed', e);
|
||||
document.getElementById('modal-warning').textContent = 'Could not load preview.';
|
||||
}
|
||||
|
||||
document.getElementById('allocate-modal').style.display = 'flex';
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', initApp);
|
||||
} else {
|
||||
initApp();
|
||||
}
|
||||
|
|
@ -14,11 +14,38 @@
|
|||
--card-bg: #faf8f3;
|
||||
--hover: #ebe7de;
|
||||
--active: #d4edda;
|
||||
--active-fg: #155724;
|
||||
--reserved: #fff3cd;
|
||||
--reserved-fg: #856404;
|
||||
--released: #e2e3e5;
|
||||
--released-fg: #383d41;
|
||||
--link: #0044cc;
|
||||
}
|
||||
|
||||
[data-theme="dark"] {
|
||||
--bg: #0c0c0c;
|
||||
--fg: #ffa63d;
|
||||
--muted: #b97c35;
|
||||
--border: #7a4d16;
|
||||
--accent: #2b1c08;
|
||||
--accent-fg: #ffb966;
|
||||
--success-bg: #0e2214;
|
||||
--success-fg: #7ed491;
|
||||
--error-bg: #260f0f;
|
||||
--error-fg: #ff7b72;
|
||||
--warning-bg: #26200a;
|
||||
--warning-fg: #e3c05c;
|
||||
--card-bg: #121212;
|
||||
--hover: #241a0d;
|
||||
--active: #0e2214;
|
||||
--active-fg: #7ed491;
|
||||
--reserved: #26200a;
|
||||
--reserved-fg: #e3c05c;
|
||||
--released: #1a1a1a;
|
||||
--released-fg: #9a9a9a;
|
||||
--link: #ffc06e;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
|
@ -57,6 +84,27 @@ header {
|
|||
border: 2px solid var(--border);
|
||||
padding: 1rem;
|
||||
background: var(--card-bg);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.header-nav {
|
||||
position: absolute;
|
||||
top: 0.75rem;
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.header-nav-left {
|
||||
left: 0.75rem;
|
||||
}
|
||||
|
||||
.header-nav-right {
|
||||
right: 0.75rem;
|
||||
}
|
||||
|
||||
.header-nav .btn {
|
||||
padding: 0.25rem 0.6rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
header h1 {
|
||||
|
|
@ -133,6 +181,7 @@ textarea:focus {
|
|||
|
||||
.btn:hover {
|
||||
background: var(--fg);
|
||||
color: var(--bg);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
|
|
@ -154,6 +203,22 @@ textarea:focus {
|
|||
color: var(--error-bg);
|
||||
}
|
||||
|
||||
.btn-link {
|
||||
display: inline;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--link);
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.btn-link:hover {
|
||||
background: var(--hover);
|
||||
}
|
||||
|
||||
.preview-box {
|
||||
border: 2px solid var(--border);
|
||||
padding: 0.75rem;
|
||||
|
|
@ -235,17 +300,17 @@ tr:hover {
|
|||
|
||||
.status.active {
|
||||
background: var(--active);
|
||||
color: #155724;
|
||||
color: var(--active-fg);
|
||||
}
|
||||
|
||||
.status.reserved {
|
||||
background: var(--reserved);
|
||||
color: #856404;
|
||||
color: var(--reserved-fg);
|
||||
}
|
||||
|
||||
.status.released {
|
||||
background: var(--released);
|
||||
color: #383d41;
|
||||
color: var(--released-fg);
|
||||
}
|
||||
|
||||
.pagination {
|
||||
|
|
@ -329,6 +394,10 @@ tr:hover {
|
|||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
header {
|
||||
padding-top: 3rem;
|
||||
}
|
||||
|
||||
.grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
|
|
|||
49
static/theme.js
Normal file
49
static/theme.js
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
(function () {
|
||||
var STORAGE_KEY = 'sub-aligator-theme';
|
||||
|
||||
function storedTheme() {
|
||||
try {
|
||||
return localStorage.getItem(STORAGE_KEY);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function currentTheme() {
|
||||
return document.documentElement.getAttribute('data-theme') === 'dark' ? 'dark' : 'light';
|
||||
}
|
||||
|
||||
function applyTheme(theme) {
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
}
|
||||
|
||||
// Apply the saved theme before the body renders to avoid a flash of the wrong theme.
|
||||
applyTheme(storedTheme() === 'dark' ? 'dark' : 'light');
|
||||
|
||||
function updateToggle(btn) {
|
||||
btn.textContent = currentTheme() === 'dark' ? '[ Light ]' : '[ Dark ]';
|
||||
btn.setAttribute('aria-pressed', currentTheme() === 'dark' ? 'true' : 'false');
|
||||
}
|
||||
|
||||
function wireToggle() {
|
||||
var btn = document.getElementById('theme-toggle');
|
||||
if (!btn) {
|
||||
return;
|
||||
}
|
||||
updateToggle(btn);
|
||||
btn.addEventListener('click', function () {
|
||||
var next = currentTheme() === 'dark' ? 'light' : 'dark';
|
||||
applyTheme(next);
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, next);
|
||||
} catch (e) {}
|
||||
updateToggle(btn);
|
||||
});
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', wireToggle);
|
||||
} else {
|
||||
wireToggle();
|
||||
}
|
||||
})();
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Detail {{ allocation.cidr }} | IP Subnet Allocator{% endblock %}
|
||||
{% block title %}Detail {{ allocation.cidr }} | SUB-ALIGATOR{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div id="flash-container">
|
||||
|
|
@ -8,7 +8,7 @@
|
|||
</div>
|
||||
|
||||
<section class="box">
|
||||
<div class="box-title">Detail alokace {{ allocation.cidr }}</div>
|
||||
<div class="box-title">Allocation detail {{ allocation.cidr }}</div>
|
||||
|
||||
<div class="detail-grid">
|
||||
<div class="detail-item">
|
||||
|
|
@ -20,19 +20,19 @@
|
|||
<div class="value">{{ allocation.pool_name }}</div>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<div class="label">Typ</div>
|
||||
<div class="label">Type</div>
|
||||
<div class="value">{{ allocation.kind }}</div>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<div class="label">Stav</div>
|
||||
<div class="label">Status</div>
|
||||
<div class="value"><span class="status {{ allocation.status }}">{{ allocation.status }}</span></div>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<div class="label">Celkem adres</div>
|
||||
<div class="label">Total addresses</div>
|
||||
<div class="value">{{ allocation.address_count }}</div>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<div class="label">Použitelných hostů</div>
|
||||
<div class="label">Usable hosts</div>
|
||||
<div class="value">{{ allocation.usable_host_count }}</div>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
|
|
@ -44,15 +44,15 @@
|
|||
<div class="value">{{ allocation.broadcast }}</div>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<div class="label">První host</div>
|
||||
<div class="label">First host</div>
|
||||
<div class="value">{{ allocation.first_host }}</div>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<div class="label">Poslední host</div>
|
||||
<div class="label">Last host</div>
|
||||
<div class="value">{{ allocation.last_host }}</div>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<div class="label">Poznámka</div>
|
||||
<div class="label">Note</div>
|
||||
<div class="value">{{ allocation.note }}</div>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
|
|
@ -60,47 +60,47 @@
|
|||
<div class="value">{{ allocation.ticket }}</div>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<div class="label">Vytvořeno</div>
|
||||
<div class="label">Created</div>
|
||||
<div class="value">{{ allocation.created_at_local }} ({{ allocation.created_by }})</div>
|
||||
</div>
|
||||
{% if allocation.released_at_local != "–" %}
|
||||
<div class="detail-item">
|
||||
<div class="label">Uvolněno</div>
|
||||
<div class="label">Released</div>
|
||||
<div class="value">{{ allocation.released_at_local }} ({{ allocation.released_by }})</div>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<div class="label">Poznámka k uvolnění</div>
|
||||
<div class="label">Release note</div>
|
||||
<div class="value">{{ allocation.release_note }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if allocation.status == "active" || allocation.status == "reserved" %}
|
||||
<button class="btn btn-danger" onclick="document.getElementById('release-modal').style.display='flex'">Uvolnit subnet</button>
|
||||
<button class="btn btn-danger release-btn" data-id="{{ allocation.id }}">Release subnet</button>
|
||||
{% endif %}
|
||||
|
||||
<a href="/" class="btn btn-secondary">Zpět</a>
|
||||
<a href="/" class="btn btn-secondary">Back</a>
|
||||
</section>
|
||||
|
||||
{% if audit_events.len() > 0 %}
|
||||
<section class="box">
|
||||
<div class="box-title">Auditní historie</div>
|
||||
<div class="box-title">Audit history</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Čas</th>
|
||||
<th>Událost</th>
|
||||
<th>Aktor</th>
|
||||
<th>Detaily</th>
|
||||
<th>Time</th>
|
||||
<th>Event</th>
|
||||
<th>Actor</th>
|
||||
<th>Details</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for e in audit_events %}
|
||||
<tr>
|
||||
<td data-label="Čas">{{ e.created_at_local }}</td>
|
||||
<td data-label="Událost">{{ e.event_type }}</td>
|
||||
<td data-label="Aktor">{{ e.actor }}</td>
|
||||
<td data-label="Detaily"><pre class="small" style="margin:0;">{{ e.details_json }}</pre></td>
|
||||
<td data-label="Time">{{ e.created_at_local }}</td>
|
||||
<td data-label="Event">{{ e.event_type }}</td>
|
||||
<td data-label="Actor">{{ e.actor }}</td>
|
||||
<td data-label="Details"><pre class="small" style="margin:0;">{{ e.details_json }}</pre></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
|
|
@ -108,19 +108,19 @@
|
|||
</section>
|
||||
{% endif %}
|
||||
|
||||
<dialog id="release-modal" class="modal-overlay" style="display:none;">
|
||||
<div id="release-modal-{{ allocation.id }}" class="modal-overlay" style="display:none;">
|
||||
<div class="modal">
|
||||
<h2 class="modal-title">Uvolnit subnet {{ allocation.cidr }}</h2>
|
||||
<h2 class="modal-title">Release subnet {{ allocation.cidr }}</h2>
|
||||
<form method="post" action="/allocations/{{ allocation.id }}/release" class="modal-body" id="release-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<p class="small">Subnet přejde do stavu <span class="status released">released</span> a adresní prostor bude znovu použitelný first-fit algoritmem. Záznam zůstane v historii.</p>
|
||||
<label for="release-note">Poznámka k uvolnění *</label>
|
||||
<p class="small">The subnet will be marked as <span class="status released">released</span> and its address space will become available again via the first-fit algorithm. The record remains in history.</p>
|
||||
<label for="release-note">Release note *</label>
|
||||
<textarea id="release-note" name="release_note" rows="3" required minlength="3" maxlength="500"></textarea>
|
||||
</form>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn btn-secondary" onclick="document.getElementById('release-modal').style.display='none'">Zrušit</button>
|
||||
<button type="submit" class="btn btn-danger" form="release-form">Uvolnit</button>
|
||||
<button type="button" class="btn btn-secondary cancel-btn" data-modal="release-modal-{{ allocation.id }}">Cancel</button>
|
||||
<button type="submit" class="btn btn-danger">Release</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</dialog>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -1,24 +1,33 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="cs">
|
||||
<html lang="en">
|
||||
<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>
|
||||
<title>{% block title %}SUB-ALIGATOR{% endblock %}</title>
|
||||
<script src="/static/theme.js"></script>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
<script src="/static/htmx.min.js" defer></script>
|
||||
<script src="/static/app.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>
|
||||
<div class="header-nav header-nav-left">
|
||||
<a href="/" class="btn btn-secondary">Home</a>
|
||||
</div>
|
||||
<div class="header-nav header-nav-right">
|
||||
<button type="button" id="theme-toggle" class="btn btn-secondary">[ Dark ]</button>
|
||||
</div>
|
||||
<h1>SUB-ALIGATOR</h1>
|
||||
<p>Internal IPv4 subnet allocation and tracking</p>
|
||||
</header>
|
||||
{% block content %}{% endblock %}
|
||||
<footer class="box">
|
||||
<p class="small">
|
||||
<a href="/">Hlavní stránka</a> |
|
||||
<a href="/pools">Manage pools</a> |
|
||||
<a href="/tokens">API tokens</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>
|
||||
|
|
|
|||
|
|
@ -2,46 +2,71 @@
|
|||
<thead>
|
||||
<tr>
|
||||
<th>Subnet</th>
|
||||
<th>Typ</th>
|
||||
<th>Type</th>
|
||||
<th>Pool</th>
|
||||
<th>Počet adres</th>
|
||||
<th>Poznámka</th>
|
||||
<th>Stav</th>
|
||||
<th>Vytvořeno</th>
|
||||
<th>Akce</th>
|
||||
<th>Addresses</th>
|
||||
<th>Note</th>
|
||||
<th>Status</th>
|
||||
<th>Created</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for a in allocations %}
|
||||
<tr>
|
||||
<td data-label="Subnet"><a href="/allocations/{{ a.id }}">{{ a.cidr }}</a></td>
|
||||
<td data-label="Typ">{{ a.kind }}</td>
|
||||
<td data-label="Type">{{ a.kind }}</td>
|
||||
<td data-label="Pool">{{ a.pool_name }}</td>
|
||||
<td data-label="Počet adres">{{ a.address_count }}</td>
|
||||
<td data-label="Poznámka">{{ a.note }}</td>
|
||||
<td data-label="Stav"><span class="status {{ a.status }}">{{ a.status }}</span></td>
|
||||
<td data-label="Vytvořeno">{{ a.created_at_local }}</td>
|
||||
<td data-label="Akce"><a href="/allocations/{{ a.id }}">Detail</a></td>
|
||||
<td data-label="Addresses">{{ a.address_count }}</td>
|
||||
<td data-label="Note">{{ a.note }}</td>
|
||||
<td data-label="Status"><span class="status {{ a.status }}">{{ a.status }}</span></td>
|
||||
<td data-label="Created">{{ a.created_at_local }}</td>
|
||||
<td data-label="Actions">
|
||||
<a href="/allocations/{{ a.id }}">Detail</a>
|
||||
{% if a.status == "active" || a.status == "reserved" %}
|
||||
| <button type="button" class="btn-link release-btn" data-id="{{ a.id }}">Release</button>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="8" style="text-align:center;">Žádné alokace.</td>
|
||||
<td colspan="8" style="text-align:center;">No allocations.</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{% for a in allocations %}
|
||||
{% if a.status == "active" || a.status == "reserved" %}
|
||||
<div id="release-modal-{{ a.id }}" class="modal-overlay" style="display:none;">
|
||||
<div class="modal">
|
||||
<h2 class="modal-title">Release subnet {{ a.cidr }}</h2>
|
||||
<form method="post" action="/allocations/{{ a.id }}/release" class="modal-body" id="release-form-{{ a.id }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<p class="small">The subnet will be marked as <span class="status released">released</span> and its address space will become available again. The record remains in history.</p>
|
||||
<label for="release-note-{{ a.id }}">Release note *</label>
|
||||
<textarea id="release-note-{{ a.id }}" name="release_note" rows="3" required minlength="3" maxlength="500"></textarea>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn btn-secondary cancel-btn" data-modal="release-modal-{{ a.id }}">Cancel</button>
|
||||
<button type="submit" class="btn btn-danger">Release</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% if total_pages > 1 %}
|
||||
<div class="pagination">
|
||||
<div>
|
||||
{% if page > 1 %}
|
||||
<a class="btn btn-secondary" href="?page={{ page - 1 }}{% if query != "" %}&q={{ query }}{% endif %}{% if pool_filter != "" %}&pool={{ pool_filter }}{% endif %}{% if status_filter != "" %}&status={{ status_filter }}{% endif %}">← Předchozí</a>
|
||||
<a class="btn btn-secondary" href="?page={{ page - 1 }}{% if query != "" %}&q={{ query }}{% endif %}{% if pool_filter != "" %}&pool={{ pool_filter }}{% endif %}{% if status_filter != "" %}&status={{ status_filter }}{% endif %}">← Previous</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div>Strana {{ page }} / {{ total_pages }} (celkem {{ total_count }})</div>
|
||||
<div>Page {{ page }} / {{ total_pages }} (total {{ total_count }})</div>
|
||||
<div>
|
||||
{% if page < total_pages %}
|
||||
<a class="btn btn-secondary" href="?page={{ page + 1 }}{% if query != "" %}&q={{ query }}{% endif %}{% if pool_filter != "" %}&pool={{ pool_filter }}{% endif %}{% if status_filter != "" %}&status={{ status_filter }}{% endif %}">Další →</a>
|
||||
<a class="btn btn-secondary" href="?page={{ page + 1 }}{% if query != "" %}&q={{ query }}{% endif %}{% if pool_filter != "" %}&pool={{ pool_filter }}{% endif %}{% if status_filter != "" %}&status={{ status_filter }}{% endif %}">Next →</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
{% if flash.has_success %}
|
||||
<div class="message success" id="flash-success">
|
||||
<p><strong>Přiděleno:</strong> {{ flash.success_cidr }}</p>
|
||||
<p><strong>Allocated:</strong> {{ flash.success_cidr }}</p>
|
||||
<p>{{ flash.success_note }}</p>
|
||||
<p class="small">
|
||||
Network: {{ flash.success_network }} |
|
||||
Broadcast: {{ flash.success_broadcast }} |
|
||||
První host: {{ flash.success_first_host }} |
|
||||
Poslední host: {{ flash.success_last_host }} |
|
||||
Použitelných hostů: {{ flash.success_usable_host_count }}
|
||||
First host: {{ flash.success_first_host }} |
|
||||
Last host: {{ flash.success_last_host }} |
|
||||
Usable hosts: {{ flash.success_usable_host_count }}
|
||||
</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
|
|
|||
|
|
@ -5,12 +5,19 @@
|
|||
{% include "fragments/flash.html" %}
|
||||
</div>
|
||||
|
||||
{% if pools.len() == 0 %}
|
||||
<section class="box">
|
||||
<div class="box-title">No pools configured</div>
|
||||
<p>Create at least one pool before allocating subnets.</p>
|
||||
<a href="/pools" class="btn">Add a pool</a>
|
||||
</section>
|
||||
{% else %}
|
||||
<section class="grid">
|
||||
{% for pool in pools %}
|
||||
<article class="box" id="pool-card-{{ pool.slug }}">
|
||||
<div class="box-title">{{ pool.name }}</div>
|
||||
<p><span class="label">Parent prefix:</span> <span class="value">{{ pool.cidr }}</span></p>
|
||||
<p><span class="label">Typ:</span> <span class="value">{{ pool.kind }}</span></p>
|
||||
<p><span class="label">Type:</span> <span class="value">{{ pool.kind }}</span></p>
|
||||
|
||||
<form
|
||||
hx-get="/partials/pools/{{ pool.slug }}/preview"
|
||||
|
|
@ -18,55 +25,57 @@
|
|||
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)">
|
||||
<label for="prefix-{{ pool.slug }}">Subnet size</label>
|
||||
<select name="prefix_length" id="prefix-{{ pool.slug }}">
|
||||
{% for pl in pool.allowed_prefixes %}
|
||||
<option value="{{ pl }}" {% if pl == "24" %}selected{% endif %}>/{{ pl }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<div id="indicator-{{ pool.slug }}" class="small" style="display:none;">Načítám...</div>
|
||||
<div id="indicator-{{ pool.slug }}" class="small" style="display:none;">Loading...</div>
|
||||
<div id="preview-{{ pool.slug }}"></div>
|
||||
</form>
|
||||
|
||||
<button
|
||||
class="btn"
|
||||
class="btn allocate-btn"
|
||||
style="margin-top: 1rem;"
|
||||
onclick="openAllocateModal('{{ pool.slug }}')"
|
||||
data-slug="{{ pool.slug }}"
|
||||
data-name="{{ pool.name }}"
|
||||
>
|
||||
[ Přidělit další volný subnet ]
|
||||
[ Allocate next free subnet ]
|
||||
</button>
|
||||
</article>
|
||||
{% endfor %}
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
<section class="box">
|
||||
<div class="box-title">Přidělené subnety</div>
|
||||
<div class="box-title">Allocated subnets</div>
|
||||
|
||||
<form method="get" action="/" class="filters">
|
||||
<div>
|
||||
<label for="q">Vyhledat</label>
|
||||
<input type="text" id="q" name="q" value="{{ query }}" placeholder="CIDR nebo poznámka">
|
||||
<label for="q">Search</label>
|
||||
<input type="text" id="q" name="q" value="{{ query }}" placeholder="CIDR or note">
|
||||
</div>
|
||||
<div>
|
||||
<label for="pool">Pool</label>
|
||||
<select id="pool" name="pool">
|
||||
<option value="" {% if pool_filter == "" %}selected{% endif %}>Všechny</option>
|
||||
{% for p in pools %}
|
||||
<option value="" {% if pool_filter == "" %}selected{% endif %}>All</option>
|
||||
{% for p in all_pools %}
|
||||
<option value="{{ p.slug }}" {% if pool_filter == p.slug %}selected{% endif %}>{{ p.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="status">Stav</label>
|
||||
<label for="status">Status</label>
|
||||
<select id="status" name="status">
|
||||
<option value="" {% if status_filter == "" %}selected{% endif %}>Všechny</option>
|
||||
<option value="" {% if status_filter == "" %}selected{% endif %}>All</option>
|
||||
<option value="active" {% if status_filter == "active" %}selected{% endif %}>active</option>
|
||||
<option value="reserved" {% if status_filter == "reserved" %}selected{% endif %}>reserved</option>
|
||||
<option value="released" {% if status_filter == "released" %}selected{% endif %}>released</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<button type="submit" class="btn">Filtrovat</button>
|
||||
<button type="submit" class="btn">Filter</button>
|
||||
<a href="/" class="btn btn-secondary">Reset</a>
|
||||
</div>
|
||||
</form>
|
||||
|
|
@ -76,9 +85,9 @@
|
|||
</div>
|
||||
</section>
|
||||
|
||||
<dialog id="allocate-modal" class="modal-overlay" style="display:none;">
|
||||
<div id="allocate-modal" class="modal-overlay" style="display:none;">
|
||||
<div class="modal">
|
||||
<h2 class="modal-title">Potvrdit přidělení subnetu</h2>
|
||||
<h2 class="modal-title">Confirm subnet allocation</h2>
|
||||
<form method="post" action="/allocations" class="modal-body" id="allocate-form">
|
||||
<input type="hidden" name="pool_slug" id="modal-pool-slug">
|
||||
<input type="hidden" name="prefix_length" id="modal-prefix-length">
|
||||
|
|
@ -94,79 +103,32 @@
|
|||
<div class="value" id="modal-prefix-display"></div>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<div class="label">Navrhovaný subnet</div>
|
||||
<div class="label">Proposed subnet</div>
|
||||
<div class="value" id="modal-cidr"></div>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<div class="label">Celkem adres</div>
|
||||
<div class="label">Total addresses</div>
|
||||
<div class="value" id="modal-address-count"></div>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<div class="label">Použitelných hostů</div>
|
||||
<div class="label">Usable hosts</div>
|
||||
<div class="value" id="modal-usable-hosts"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="small" id="modal-warning"></p>
|
||||
|
||||
<label for="note">Poznámka *</label>
|
||||
<label for="note">Note *</label>
|
||||
<textarea id="note" name="note" rows="3" required minlength="3" maxlength="500"></textarea>
|
||||
|
||||
<label for="ticket">Ticket / reference</label>
|
||||
<input type="text" id="ticket" name="ticket" maxlength="100">
|
||||
</form>
|
||||
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeAllocateModal()">Zrušit</button>
|
||||
<button type="submit" class="btn" form="allocate-form">Potvrdit přidělení</button>
|
||||
<button type="button" class="btn btn-secondary cancel-btn" data-modal="allocate-modal">Cancel</button>
|
||||
<button type="submit" class="btn">Confirm allocation</button>
|
||||
</div>
|
||||
</form>
|
||||
</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>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -1,23 +1,23 @@
|
|||
<div class="preview-box">
|
||||
{% if has_error %}
|
||||
<p class="error" id="preview-warning">{{ error }}</p>
|
||||
<p class="label">Další volný subnet: <span class="value">–</span></p>
|
||||
<p class="label">Next free subnet: <span class="value">–</span></p>
|
||||
{% else %}
|
||||
<p>
|
||||
<span class="label">Další volný subnet:</span>
|
||||
<span class="label">Next free subnet:</span>
|
||||
<span class="value" id="preview-cidr">{{ cidr }}</span>
|
||||
</p>
|
||||
<p>
|
||||
<span class="label">Celkem adres:</span>
|
||||
<span class="label">Total addresses:</span>
|
||||
<span class="value" id="preview-address-count">{{ address_count }}</span>
|
||||
</p>
|
||||
<p>
|
||||
<span class="label">Použitelných hostů:</span>
|
||||
<span class="label">Usable hosts:</span>
|
||||
<span class="value" id="preview-usable-hosts">{{ usable_host_count }}</span>
|
||||
</p>
|
||||
{% if prefix_length >= 31 %}
|
||||
<p class="small" id="preview-warning">
|
||||
Pro /31 a /32 se network/broadcast adresy dle RFC 3021 mohou shodovat s host adresami.
|
||||
For /31 and /32, network/broadcast addresses may coincide with host addresses per RFC 3021.
|
||||
</p>
|
||||
{% else %}
|
||||
<p class="small" id="preview-warning"></p>
|
||||
|
|
|
|||
101
templates/pools.html
Normal file
101
templates/pools.html
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Manage pools | SUB-ALIGATOR{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="box">
|
||||
<div class="box-title">Existing pools</div>
|
||||
|
||||
{% if error != "" %}
|
||||
<div class="message error">
|
||||
<p>{{ error }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if pools.len() == 0 %}
|
||||
<p>No pools configured yet.</p>
|
||||
{% else %}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Slug</th>
|
||||
<th>Parent prefix</th>
|
||||
<th>Type</th>
|
||||
<th>Allowed prefixes</th>
|
||||
<th>Allocations (active / reserved / released)</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for p in pools %}
|
||||
<tr>
|
||||
<td data-label="Name">{{ p.pool.name }}</td>
|
||||
<td data-label="Slug">{{ p.pool.slug }}</td>
|
||||
<td data-label="Parent prefix">{{ p.pool.cidr }}</td>
|
||||
<td data-label="Type">{{ p.pool.kind }}</td>
|
||||
<td data-label="Allowed prefixes">{{ p.pool.allowed_prefixes_json }}</td>
|
||||
<td data-label="Allocations">{{ p.active_count }} / {{ p.reserved_count }} / {{ p.released_count }}</td>
|
||||
<td data-label="Actions">
|
||||
<button type="button" class="btn btn-danger pool-delete-btn" data-id="{{ p.pool.id }}">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<section class="box">
|
||||
<div class="box-title">Add new pool</div>
|
||||
|
||||
<form method="post" action="/pools/new">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
|
||||
<label for="slug">Pool slug *</label>
|
||||
<input type="text" id="slug" name="slug" required maxlength="50" pattern="[a-z0-9-]+" placeholder="e.g. vpn">
|
||||
<p class="small">Short machine identifier used in URLs and API paths (e.g. <code>vpn</code>). Lowercase letters, numbers and hyphens only.</p>
|
||||
|
||||
<label for="name">Pool name *</label>
|
||||
<input type="text" id="name" name="name" required minlength="2" maxlength="100" placeholder="e.g. VPN subnets">
|
||||
|
||||
<label for="cidr">Parent prefix (CIDR) *</label>
|
||||
<input type="text" id="cidr" name="cidr" required placeholder="e.g. 10.200.0.0/16">
|
||||
<p class="small">If you re-add a range that was deleted before, its previous allocations are kept and will block the address space again.</p>
|
||||
|
||||
<label for="kind">Type *</label>
|
||||
<input type="text" id="kind" name="kind" required maxlength="50" placeholder="e.g. VPN">
|
||||
|
||||
<label for="allowed_prefixes">Allowed prefix lengths *</label>
|
||||
<input type="text" id="allowed_prefixes" name="allowed_prefixes" required value="20,21,22,23,24,25,26,27,28,29,30" placeholder="e.g. 24,25,26">
|
||||
<p class="small">Comma-separated list of prefix lengths. Each must be equal to or larger than the pool prefix.</p>
|
||||
|
||||
<div style="margin-top: 1rem;">
|
||||
<button type="submit" class="btn">Add pool</button>
|
||||
<a href="/" class="btn btn-secondary">Back</a>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{% for p in pools %}
|
||||
<div id="delete-pool-modal-{{ p.pool.id }}" class="modal-overlay" style="display:none;">
|
||||
<div class="modal">
|
||||
<h2 class="modal-title">Delete pool</h2>
|
||||
<form method="post" action="/pools/{{ p.pool.id }}/delete" class="modal-body">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<p>Pool: <strong>{{ p.pool.name }}</strong> ({{ p.pool.cidr }})</p>
|
||||
{% if p.total_count > 0 %}
|
||||
<p>This pool has {{ p.active_count }} active, {{ p.reserved_count }} reserved and {{ p.released_count }} released allocations.</p>
|
||||
<p><strong>Allocations will NOT be deleted.</strong> They stay in history and will keep blocking this address range if you later add the same or an overlapping pool again.</p>
|
||||
{% else %}
|
||||
<p>This pool has no allocations.</p>
|
||||
{% endif %}
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn btn-secondary cancel-btn" data-modal="delete-pool-modal-{{ p.pool.id }}">Cancel</button>
|
||||
<button type="submit" class="btn btn-danger">Delete pool</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endblock %}
|
||||
42
templates/setup.html
Normal file
42
templates/setup.html
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Setup | SUB-ALIGATOR{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="box">
|
||||
<div class="box-title">Initial setup</div>
|
||||
|
||||
<p>Create the first IP pool. You can add more pools later from the <a href="/pools">Manage pools</a> page.</p>
|
||||
|
||||
{% if error != "" %}
|
||||
<div class="message error">
|
||||
<p>{{ error }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="/setup">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
|
||||
<label for="slug">Pool slug *</label>
|
||||
<input type="text" id="slug" name="slug" required maxlength="50" pattern="[a-z0-9-]+" placeholder="e.g. vlan-customers">
|
||||
<p class="small">Short machine identifier used in URLs and API paths (e.g. <code>vlan-customers</code>). Lowercase letters, numbers and hyphens only.</p>
|
||||
|
||||
<label for="name">Pool name *</label>
|
||||
<input type="text" id="name" name="name" required minlength="2" maxlength="100" placeholder="e.g. VLAN / customer subnets">
|
||||
|
||||
<label for="cidr">Parent prefix (CIDR) *</label>
|
||||
<input type="text" id="cidr" name="cidr" required placeholder="e.g. 10.91.0.0/16">
|
||||
|
||||
<label for="kind">Type *</label>
|
||||
<input type="text" id="kind" name="kind" required maxlength="50" placeholder="e.g. VLAN">
|
||||
|
||||
<label for="allowed_prefixes">Allowed prefix lengths *</label>
|
||||
<input type="text" id="allowed_prefixes" name="allowed_prefixes" required value="20,21,22,23,24,25,26,27,28,29,30" placeholder="e.g. 24,25,26">
|
||||
<p class="small">Comma-separated list of prefix lengths. Each must be equal to or larger than the pool prefix.</p>
|
||||
|
||||
<div style="margin-top: 1rem;">
|
||||
<button type="submit" class="btn">Create pool</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
{% endblock %}
|
||||
120
templates/tokens.html
Normal file
120
templates/tokens.html
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}API tokens | SUB-ALIGATOR{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% if has_new_token %}
|
||||
<section class="box">
|
||||
<div class="box-title">Token created – copy it now</div>
|
||||
<div class="message success">
|
||||
<p>The new API token <strong>{{ new_token_name }}</strong> was created. Copy it now – <strong>it will not be shown again</strong>. Only its hash is stored.</p>
|
||||
<input type="text" readonly value="{{ new_token_value }}" style="font-family: inherit;">
|
||||
<p class="small" style="margin-top: 0.5rem;">Use it as: <code>Authorization: Bearer {{ new_token_value }}</code></p>
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
<section class="box">
|
||||
<div class="box-title">API tokens</div>
|
||||
|
||||
{% if error != "" %}
|
||||
<div class="message error">
|
||||
<p>{{ error }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<p class="small">Tokens authenticate the JSON REST API under <code>/api/v1/</code> via the <code>Authorization: Bearer</code> header. See the README for endpoint documentation.</p>
|
||||
|
||||
{% if tokens.len() == 0 %}
|
||||
<p>No API tokens yet.</p>
|
||||
{% else %}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Token</th>
|
||||
<th>Status</th>
|
||||
<th>Created</th>
|
||||
<th>Last used</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for t in tokens %}
|
||||
<tr>
|
||||
<td data-label="Name">{{ t.name }}</td>
|
||||
<td data-label="Token"><code>{{ t.token_prefix }}…</code></td>
|
||||
<td data-label="Status">
|
||||
{% if t.enabled %}
|
||||
<span class="status active">active</span>
|
||||
{% else %}
|
||||
<span class="status released">revoked</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td data-label="Created">{{ t.created_at_local }} ({{ t.created_by }})</td>
|
||||
<td data-label="Last used">{{ t.last_used_at_local }}</td>
|
||||
<td data-label="Actions">
|
||||
{% if t.enabled %}
|
||||
<button type="button" class="btn btn-danger" data-modal-open="revoke-token-modal-{{ t.id }}">Revoke</button>
|
||||
{% else %}
|
||||
<button type="button" class="btn btn-danger" data-modal-open="delete-token-modal-{{ t.id }}">Delete</button>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<section class="box">
|
||||
<div class="box-title">Create new token</div>
|
||||
|
||||
<form method="post" action="/tokens/new">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
|
||||
<label for="name">Token name *</label>
|
||||
<input type="text" id="name" name="name" required minlength="2" maxlength="100" placeholder="e.g. monitoring-integration">
|
||||
<p class="small">A descriptive name of the integration that will use this token. It appears in the audit log as actor <code>api:<name></code>.</p>
|
||||
|
||||
<div style="margin-top: 1rem;">
|
||||
<button type="submit" class="btn">Create token</button>
|
||||
<a href="/" class="btn btn-secondary">Back</a>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{% for t in tokens %}
|
||||
{% if t.enabled %}
|
||||
<div id="revoke-token-modal-{{ t.id }}" class="modal-overlay" style="display:none;">
|
||||
<div class="modal">
|
||||
<h2 class="modal-title">Revoke API token</h2>
|
||||
<form method="post" action="/tokens/{{ t.id }}/revoke" class="modal-body">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<p>Token: <strong>{{ t.name }}</strong> (<code>{{ t.token_prefix }}…</code>)</p>
|
||||
<p>The token will stop working immediately. The record is kept for audit purposes.</p>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn btn-secondary cancel-btn" data-modal="revoke-token-modal-{{ t.id }}">Cancel</button>
|
||||
<button type="submit" class="btn btn-danger">Revoke token</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div id="delete-token-modal-{{ t.id }}" class="modal-overlay" style="display:none;">
|
||||
<div class="modal">
|
||||
<h2 class="modal-title">Delete API token</h2>
|
||||
<form method="post" action="/tokens/{{ t.id }}/delete" class="modal-body">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<p>Token: <strong>{{ t.name }}</strong> (<code>{{ t.token_prefix }}…</code>)</p>
|
||||
<p>The revoked token record will be permanently deleted. This cannot be undone.</p>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn btn-secondary cancel-btn" data-modal="delete-token-modal-{{ t.id }}">Cancel</button>
|
||||
<button type="submit" class="btn btn-danger">Delete token</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endblock %}
|
||||
Loading…
Add table
Reference in a new issue