Compare commits
No commits in common. "main" and "v0.1.0" have entirely different histories.
35 changed files with 325 additions and 2400 deletions
12
.env.example
12
.env.example
|
|
@ -1,17 +1,17 @@
|
||||||
# Host port on which the application will be available
|
# Port na hostiteli, na kterém bude aplikace dostupná
|
||||||
APP_PORT=8080
|
APP_PORT=8080
|
||||||
|
|
||||||
# Logging level (debug, info, warn, error)
|
# Logování (debug, info, warn, error)
|
||||||
RUST_LOG=info
|
RUST_LOG=info
|
||||||
|
|
||||||
# Bind address inside the container
|
# Bind adresy uvnitř kontejneru
|
||||||
APP_BIND=0.0.0.0:8080
|
APP_BIND=0.0.0.0:8080
|
||||||
|
|
||||||
# SQLite database path inside the container
|
# Cesta k SQLite databázi uvnitř kontejneru
|
||||||
DATABASE_URL=sqlite:/app/data/subnet-allocator.db
|
DATABASE_URL=sqlite:/app/data/subnet-allocator.db
|
||||||
|
|
||||||
# Timezone for displaying timestamps in the web UI
|
# Časové pásmo pro zobrazení času ve webovém UI
|
||||||
APP_TIMEZONE=Europe/Prague
|
APP_TIMEZONE=Europe/Prague
|
||||||
|
|
||||||
# Directory for exports inside the container
|
# Adresář pro exporty uvnitř kontejneru
|
||||||
APP_EXPORTS_DIR=/app/exports
|
APP_EXPORTS_DIR=/app/exports
|
||||||
|
|
|
||||||
2
Cargo.lock
generated
2
Cargo.lock
generated
|
|
@ -1829,12 +1829,10 @@ dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"csv",
|
"csv",
|
||||||
"futures",
|
"futures",
|
||||||
"hex",
|
|
||||||
"ipnet",
|
"ipnet",
|
||||||
"rand",
|
"rand",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2",
|
|
||||||
"sqlx",
|
"sqlx",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"thiserror",
|
"thiserror",
|
||||||
|
|
|
||||||
|
|
@ -26,8 +26,6 @@ tokio = { version = "1.44.2", features = ["rt-multi-thread", "macros", "signal"]
|
||||||
tower = "0.5.2"
|
tower = "0.5.2"
|
||||||
tower-http = { version = "0.7.1", features = ["fs", "set-header", "trace", "catch-panic"] }
|
tower-http = { version = "0.7.1", features = ["fs", "set-header", "trace", "catch-panic"] }
|
||||||
axum-extra = { version = "0.10.1", features = ["cookie"] }
|
axum-extra = { version = "0.10.1", features = ["cookie"] }
|
||||||
sha2 = "0.10"
|
|
||||||
hex = "0.4"
|
|
||||||
tracing = "0.1.41"
|
tracing = "0.1.41"
|
||||||
tracing-subscriber = { version = "0.3.19", features = ["env-filter"] }
|
tracing-subscriber = { version = "0.3.19", features = ["env-filter"] }
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# syntax=docker/dockerfile:1
|
# syntax=docker/dockerfile:1
|
||||||
|
|
||||||
FROM rust:1.89-bookworm AS builder
|
FROM rust:1.82-bookworm AS builder
|
||||||
|
|
||||||
WORKDIR /app
|
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/migrations /app/migrations
|
||||||
COPY --from=builder /app/static /app/static
|
COPY --from=builder /app/static /app/static
|
||||||
COPY --from=builder /app/templates /app/templates
|
COPY --from=builder /app/templates /app/templates
|
||||||
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
|
||||||
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
USER app
|
||||||
|
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
|
|
||||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
|
||||||
CMD curl -fsS http://localhost:8080/healthz >/dev/null || exit 1
|
CMD curl -fsS http://localhost:8080/healthz >/dev/null || exit 1
|
||||||
|
|
||||||
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
|
CMD ["/usr/local/bin/sub-aligator"]
|
||||||
|
|
|
||||||
17
Formula/sub-aligator.rb
Normal file
17
Formula/sub-aligator.rb
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
class SubAligator < Formula
|
||||||
|
desc "Internal IP subnet allocator"
|
||||||
|
homepage "https://git.janvete.com/seth/sub-aligator"
|
||||||
|
url "https://git.janvete.com/seth/sub-aligator/archive/v0.1.0.tar.gz"
|
||||||
|
sha256 "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,43 +1,40 @@
|
||||||
# SUB-ALIGATOR
|
# IP Subnet Allocator
|
||||||
|
|
||||||
A simple internal web application for tracking and automatically allocating IPv4 subnets.
|
Jednoduchá interní webová aplikace pro evidenci a automatické přidělování IPv4 subnetů.
|
||||||
|
|
||||||
## Purpose
|
## Účel
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
## Features
|
## Přehled funkcí
|
||||||
|
|
||||||
- Manage one or more IP pools manually through the web UI.
|
- Dva přednastavené pooly:
|
||||||
- First-run setup wizard: create the first pool before allocating subnets.
|
- VLAN / zákaznické subnety: `10.91.0.0/16`
|
||||||
- Automatic subnet allocation using a **first-fit** algorithm for prefixes /20 through /30.
|
- VPN subnety: `10.200.0.0/16`
|
||||||
- Atomic allocation writes using SQLite `BEGIN IMMEDIATE` transactions.
|
- Automatické přidělování subnetů pomocí **first-fit** algoritmu pro prefixy /20 až /30.
|
||||||
- Retro TUI/terminal-style web UI, server-side rendered with Askama.
|
- Atomický zápis alokací přes SQLite transakci `BEGIN IMMEDIATE`.
|
||||||
- Dynamic preview of the next free subnet via HTMX.
|
- Webové UI s retro TUI/terminál vzhledem, server-side renderované pomocí Askama.
|
||||||
- Allocation history with filtering, search, and pagination.
|
- Dynamické preview dalšího volného subnetu přes HTMX.
|
||||||
- Release subnets (status `released`) with a mandatory note, directly from the history table or detail page.
|
- Historie alokací s filtrem, vyhledáváním a stránkováním.
|
||||||
- Released subnets free up address space and can be reused by the first-fit algorithm.
|
- Uvolňování subnetů (status `released`) s povinnou poznámkou.
|
||||||
- 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.
|
- Auditní log všech operací.
|
||||||
- Light and dark (amber terminal) theme, switchable in the page header and remembered per browser.
|
- Exporty do Markdown, CSV a JSON.
|
||||||
- JSON REST API (`/api/v1/`) with Bearer token authentication; tokens are managed in the web UI.
|
- SQLite migrace probíhají automaticky při startu.
|
||||||
- Audit log of all operations.
|
- Zálohovací a obnovovací skripty.
|
||||||
- Markdown, CSV, and JSON exports.
|
|
||||||
- SQLite migrations run automatically on startup.
|
|
||||||
- Backup and restore scripts.
|
|
||||||
|
|
||||||
## Architecture
|
## Architektura
|
||||||
|
|
||||||
- **Rust** edition 2024
|
- **Rust** edition 2024
|
||||||
- **Axum** + **Tokio**
|
- **Axum** + **Tokio**
|
||||||
- **SQLx** with **SQLite**
|
- **SQLx** s **SQLite**
|
||||||
- **Askama** for HTML templates
|
- **Askama** pro HTML šablony
|
||||||
- **HTMX** from a local static file
|
- **HTMX** z lokálního statického souboru
|
||||||
- Custom CSS without external frameworks
|
- Vlastní CSS bez externích frameworků
|
||||||
- CIDR/IP calculations using `ipnet`
|
- CIDR/IP výpočty pomocí `ipnet`
|
||||||
|
|
||||||
## Quick start
|
## Rychlý start
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd sub-aligator
|
cd sub-aligator
|
||||||
|
|
@ -45,141 +42,79 @@ cp .env.example .env
|
||||||
docker compose up -d --build
|
docker compose up -d --build
|
||||||
```
|
```
|
||||||
|
|
||||||
Open a browser at:
|
Otevřete prohlížeč na:
|
||||||
|
|
||||||
```
|
```
|
||||||
http://SERVER_IP:8080
|
http://IP_SERVERU:8080
|
||||||
```
|
```
|
||||||
|
|
||||||
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.
|
Výchozí port lze změnit v `.env` pomocí proměnné `APP_PORT`.
|
||||||
|
|
||||||
## Persistent data
|
## Persistentní data
|
||||||
|
|
||||||
Docker Compose creates local directories:
|
Docker Compose vytváří lokální adresáře:
|
||||||
|
|
||||||
- `./data` – SQLite database
|
- `./data` – SQLite databáze
|
||||||
- `./exports` – generated exports (Markdown, CSV, JSON)
|
- `./exports` – vygenerované exporty (Markdown, CSV, JSON)
|
||||||
- `./backups` – database backups
|
- `./backups` – zálohy databáze
|
||||||
|
|
||||||
These directories are bind-mounted into the container, so data persists even if the container is removed.
|
Tyto adresáře jsou bind mountovány do kontejneru, takže data přetrvávají i po odstranění kontejneru.
|
||||||
|
|
||||||
## Backup and restore
|
## Záloha a obnova
|
||||||
|
|
||||||
### Backup
|
### Záloha
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./scripts/backup.sh
|
./scripts/backup.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
The script creates a consistent database backup using `sqlite3 .backup` with a UTC timestamp in the filename. Backups are stored in `./backups`.
|
Skript vytvoří konzistentní zálohu databáze pomocí `sqlite3 .backup` s UTC timestampem v názvu souboru. Zálohy se ukládají do `./backups`.
|
||||||
|
|
||||||
### Restore
|
### Obnova
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./scripts/restore.sh ./backups/subnet-allocator.db.20240930T120000Z.backup
|
./scripts/restore.sh ./backups/subnet-allocator.db.20240930T120000Z.backup
|
||||||
```
|
```
|
||||||
|
|
||||||
**Warning:** restore overwrites the current database. Stop the container and back up the current state before restoring.
|
**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.
|
||||||
|
|
||||||
### Automating backups
|
### Automatizace zálohy
|
||||||
|
|
||||||
On the host you can use a systemd timer or cron. Example cron entry:
|
Na hostovi můžete použít systemd timer nebo cron. Příklad cron záznamu:
|
||||||
|
|
||||||
```cron
|
```cron
|
||||||
0 2 * * * /path/to/sub-aligator/scripts/backup.sh >> /var/log/subnet-allocator-backup.log 2>&1
|
0 2 * * * /cesta/k/sub-aligator/scripts/backup.sh >> /var/log/subnet-allocator-backup.log 2>&1
|
||||||
```
|
```
|
||||||
|
|
||||||
## Exports
|
## Exporty
|
||||||
|
|
||||||
After every successful allocation or release, read-only exports are regenerated:
|
Po každém úspěšném vytvoření nebo uvolnění subnetu se vygenerují read-only exporty:
|
||||||
|
|
||||||
- `/app/exports/allocations.md`
|
- `/app/exports/allocations.md`
|
||||||
- `/app/exports/allocations.csv`
|
- `/app/exports/allocations.csv`
|
||||||
- `/app/exports/allocations.json`
|
- `/app/exports/allocations.json`
|
||||||
|
|
||||||
They are also available for download via endpoints:
|
Ke stažení jsou dostupné přes endpointy:
|
||||||
|
|
||||||
- `GET /exports/allocations.md`
|
- `GET /exports/allocations.md`
|
||||||
- `GET /exports/allocations.csv`
|
- `GET /exports/allocations.csv`
|
||||||
- `GET /exports/allocations.json`
|
- `GET /exports/allocations.json`
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
## REST API
|
## Bezpečnostní omezení
|
||||||
|
|
||||||
The application provides a JSON REST API under `/api/v1/` for integrations. All API requests are authenticated with a Bearer token:
|
- Aplikace **nemá autentizaci**. Používejte ji pouze za VPN/LAN/firewallem.
|
||||||
|
- CSRF tokeny jsou povinné pro POST formuláře.
|
||||||
|
- Nastaveny bezpečnostní hlavičky: `X-Content-Type-Options`, `X-Frame-Options`, `Referrer-Policy`, `Content-Security-Policy`.
|
||||||
|
- Aplikace nemá žádný defaultní přístup z Internetu; bind portu se řeší přes Docker Compose.
|
||||||
|
- `/healthz` vrací pouze `{"status":"ok"}`.
|
||||||
|
|
||||||
```
|
## Pozdější přidání autentizace přes Caddy + Authentik
|
||||||
Authorization: Bearer sa_...
|
|
||||||
```
|
|
||||||
|
|
||||||
### Managing API tokens
|
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`.
|
||||||
|
|
||||||
Tokens are managed in the web UI on the **API tokens** page (link in the footer):
|
Příklad konfigurace Caddy s Authentik:
|
||||||
|
|
||||||
- **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
|
```caddy
|
||||||
subnet.example.com {
|
subnet.example.com {
|
||||||
|
|
@ -192,51 +127,43 @@ subnet.example.com {
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
After updating `get_actor_from_request()`, the audit log will record the real user.
|
Po úpravě `get_actor_from_request()` bude auditní log obsahovat skutečného uživatele.
|
||||||
|
|
||||||
## First-fit allocation example
|
## Příklad first-fit alokace
|
||||||
|
|
||||||
1. First request `/30` receives `10.91.0.0/30`.
|
1. První požadavek `/30` získá `10.91.0.0/30`.
|
||||||
2. Next request `/24` receives `10.91.1.0/24` (avoids the occupied `/30`).
|
2. Následný požadavek `/24` získá `10.91.1.0/24` (vyhne se obsazenému `/30`).
|
||||||
3. Another request `/30` fills the gap and receives `10.91.0.4/30`.
|
3. Další požadavek `/30` zaplní mezeru a získá `10.91.0.4/30`.
|
||||||
|
|
||||||
The algorithm always respects CIDR alignment and finds the first free space large enough.
|
Algoritmus vždy respektuje CIDR zarovnání a najde první volný prostor dostatečné velikosti.
|
||||||
|
|
||||||
## Endpoints
|
## Endpointy
|
||||||
|
|
||||||
- `GET /` – main page
|
- `GET /` – hlavní stránka
|
||||||
- `GET /healthz` – healthcheck
|
- `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
|
- `GET /partials/pools/{slug}/preview?prefix_length=24` – HTMX preview
|
||||||
- `POST /allocations` – create an allocation
|
- `POST /allocations` – vytvoření alokace
|
||||||
- `GET /allocations/{id}` – allocation detail
|
- `GET /allocations` – seznam alokací (součást `/`)
|
||||||
- `POST /allocations/{id}/release` – release an allocation
|
- `GET /allocations/{id}` – detail alokace
|
||||||
- `GET /exports/allocations.{md,csv,json}` – exports
|
- `POST /allocations/{id}/release` – uvolnění alokace
|
||||||
- `GET|POST /api/v1/...` – JSON REST API (see above)
|
- `GET /exports/allocations.{md,csv,json}` – exporty
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
### Container does not start
|
### Kontejner nenastartuje
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose logs -f subnet-allocator
|
docker compose logs -f subnet-allocator
|
||||||
```
|
```
|
||||||
|
|
||||||
### Database is locked
|
### Databáze je uzamčena
|
||||||
|
|
||||||
If the process terminates unexpectedly, a `-wal` or `-shm` file may remain. Stop the container and check `./data`.
|
Pokud dojde k neočekávanému ukončení, může zůstat `-wal` nebo `-shm` soubor. Zastavte kontejner a zkontrolujte `./data`.
|
||||||
|
|
||||||
### Exports are not generated
|
### Exporty se negenerují
|
||||||
|
|
||||||
Check the permissions of the `./exports` directory and the `APP_EXPORTS_DIR` variable.
|
Zkontrolujte práva adresáře `./exports` a proměnnou `APP_EXPORTS_DIR`.
|
||||||
|
|
||||||
## Licence
|
## Licence
|
||||||
|
|
||||||
MIT. See the `LICENSE` file.
|
MIT. Viz soubor `LICENSE`.
|
||||||
|
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
#!/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 "$@"
|
|
||||||
|
|
@ -1,34 +0,0 @@
|
||||||
-- 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');
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
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);
|
|
||||||
|
|
@ -1,86 +0,0 @@
|
||||||
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,4 +1,5 @@
|
||||||
use crate::config::Config;
|
use crate::config::Config;
|
||||||
|
use crate::models::now_utc_rfc3339;
|
||||||
use anyhow::Context;
|
use anyhow::Context;
|
||||||
use sqlx::migrate::{MigrateDatabase, Migrator};
|
use sqlx::migrate::{MigrateDatabase, Migrator};
|
||||||
use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqliteSynchronous};
|
use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqliteSynchronous};
|
||||||
|
|
@ -37,9 +38,45 @@ pub async fn init_db(config: &Config) -> anyhow::Result<SqlitePool> {
|
||||||
.await
|
.await
|
||||||
.context("run migrations")?;
|
.context("run migrations")?;
|
||||||
|
|
||||||
|
seed_default_pools(&pool).await.context("seed default pools")?;
|
||||||
|
|
||||||
info!("Database initialized: {}", database_url);
|
info!("Database initialized: {}", database_url);
|
||||||
Ok(pool)
|
Ok(pool)
|
||||||
} else {
|
} else {
|
||||||
anyhow::bail!("Unsupported DATABASE_URL: {}", database_url)
|
anyhow::bail!("Unsupported DATABASE_URL: {}", database_url)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn seed_default_pools(pool: &SqlitePool) -> anyhow::Result<()> {
|
||||||
|
let allowed = serde_json::to_string(&[20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30])?;
|
||||||
|
|
||||||
|
let now = now_utc_rfc3339();
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT OR IGNORE INTO pools (slug, name, cidr, kind, allowed_prefixes_json, enabled, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)"
|
||||||
|
)
|
||||||
|
.bind("vlan")
|
||||||
|
.bind("VLAN / zákaznické subnety")
|
||||||
|
.bind("10.91.0.0/16")
|
||||||
|
.bind("VLAN")
|
||||||
|
.bind(&allowed)
|
||||||
|
.bind(true)
|
||||||
|
.bind(&now)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT OR IGNORE INTO pools (slug, name, cidr, kind, allowed_prefixes_json, enabled, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)"
|
||||||
|
)
|
||||||
|
.bind("vpn")
|
||||||
|
.bind("VPN subnety")
|
||||||
|
.bind("10.200.0.0/16")
|
||||||
|
.bind("VPN")
|
||||||
|
.bind(&allowed)
|
||||||
|
.bind(true)
|
||||||
|
.bind(&now)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -42,9 +42,9 @@ async fn generate_markdown(
|
||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<()> {
|
||||||
let path = dir.join("allocations.md");
|
let path = dir.join("allocations.md");
|
||||||
let mut lines = vec![
|
let mut lines = vec![
|
||||||
"# SUB-ALIGATOR – Export".to_string(),
|
"# IP Subnet Allocator – Export".to_string(),
|
||||||
"".to_string(),
|
"".to_string(),
|
||||||
format!("Generated: {}", chrono::Utc::now().to_rfc3339()),
|
format!("Vygenerováno: {}", chrono::Utc::now().to_rfc3339()),
|
||||||
"".to_string(),
|
"".to_string(),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -58,13 +58,13 @@ async fn generate_markdown(
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
if pool_allocs.is_empty() {
|
if pool_allocs.is_empty() {
|
||||||
lines.push("*No active or reserved allocations.*".to_string());
|
lines.push("*Žádné aktivní ani rezervované alokace.*".to_string());
|
||||||
lines.push("".to_string());
|
lines.push("".to_string());
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
lines.push("| Subnet | Status | Note | Created |".to_string());
|
lines.push("| Subnet | Stav | Poznámka | Vytvořeno |".to_string());
|
||||||
lines.push("|--------|--------|------|---------|".to_string());
|
lines.push("|--------|------|----------|-----------|".to_string());
|
||||||
for a in pool_allocs {
|
for a in pool_allocs {
|
||||||
lines.push(format!(
|
lines.push(format!(
|
||||||
"| {} | {} | {} | {} |",
|
"| {} | {} | {} | {} |",
|
||||||
|
|
|
||||||
|
|
@ -42,17 +42,17 @@ pub async fn create_allocation(
|
||||||
let actor = get_actor_from_request(&headers);
|
let actor = get_actor_from_request(&headers);
|
||||||
|
|
||||||
if !validate_csrf(&jar, &form.csrf_token) {
|
if !validate_csrf(&jar, &form.csrf_token) {
|
||||||
return render_index_with_error(&state, &jar, &csrf_token, "Invalid CSRF token.").await.into_response();
|
return render_index_with_error(&state, &jar, &csrf_token, "Neplatný CSRF token.").await.into_response();
|
||||||
}
|
}
|
||||||
|
|
||||||
let note = form.note.trim();
|
let note = form.note.trim();
|
||||||
if note.len() < 3 {
|
if note.len() < 3 {
|
||||||
return render_index_with_error(&state, &jar, &csrf_token, "Note must be at least 3 characters long.").await.into_response();
|
return render_index_with_error(&state, &jar, &csrf_token, "Poznámka musí mít alespoň 3 znaky.").await.into_response();
|
||||||
}
|
}
|
||||||
|
|
||||||
let pool = match load_pool_by_slug(&state.pool, &form.pool_slug).await {
|
let pool = match load_pool_by_slug(&state.pool, &form.pool_slug).await {
|
||||||
Some(p) => p,
|
Some(p) => p,
|
||||||
None => return render_index_with_error(&state, &jar, &csrf_token, "Pool does not exist.").await.into_response(),
|
None => return render_index_with_error(&state, &jar, &csrf_token, "Pool neexistuje.").await.into_response(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let result = allocate_in_tx(
|
let result = allocate_in_tx(
|
||||||
|
|
@ -87,7 +87,7 @@ pub async fn create_allocation(
|
||||||
let mut message = success.clone();
|
let mut message = success.clone();
|
||||||
if actual_cidr_different {
|
if actual_cidr_different {
|
||||||
message.note = format!(
|
message.note = format!(
|
||||||
"{} (preview changed in the meantime, allocated: {})",
|
"{} (preview se mezitím změnil, přiděleno: {})",
|
||||||
allocation.note, allocation.cidr
|
allocation.note, allocation.cidr
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -122,15 +122,17 @@ pub async fn allocate_in_tx(
|
||||||
ticket: &str,
|
ticket: &str,
|
||||||
actor: &str,
|
actor: &str,
|
||||||
) -> anyhow::Result<(Allocation, crate::allocator::SubnetInfo)> {
|
) -> anyhow::Result<(Allocation, crate::allocator::SubnetInfo)> {
|
||||||
// begin_with gives us BEGIN IMMEDIATE plus automatic rollback on drop,
|
let mut conn = pool.acquire().await?;
|
||||||
// so no error path can leak an open transaction into the pool.
|
|
||||||
let mut tx = pool.begin_with("BEGIN IMMEDIATE").await?;
|
sqlx::query("BEGIN IMMEDIATE")
|
||||||
|
.execute(&mut *conn)
|
||||||
|
.await?;
|
||||||
|
|
||||||
let active: Vec<String> = sqlx::query_scalar(
|
let active: Vec<String> = sqlx::query_scalar(
|
||||||
"SELECT cidr FROM allocations WHERE pool_id = ? AND status IN ('active', 'reserved')",
|
"SELECT cidr FROM allocations WHERE pool_id = ? AND status IN ('active', 'reserved')",
|
||||||
)
|
)
|
||||||
.bind(pool_id)
|
.bind(pool_id)
|
||||||
.fetch_all(&mut *tx)
|
.fetch_all(&mut *conn)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let info = find_next_subnet(parent_cidr, prefix_length, allowed_prefixes, &active)?;
|
let info = find_next_subnet(parent_cidr, prefix_length, allowed_prefixes, &active)?;
|
||||||
|
|
@ -153,12 +155,12 @@ pub async fn allocate_in_tx(
|
||||||
.bind(AllocationStatus::Active.as_str())
|
.bind(AllocationStatus::Active.as_str())
|
||||||
.bind(now_utc_rfc3339())
|
.bind(now_utc_rfc3339())
|
||||||
.bind(actor)
|
.bind(actor)
|
||||||
.execute(&mut *tx)
|
.execute(&mut *conn)
|
||||||
.await?
|
.await?
|
||||||
.last_insert_rowid();
|
.last_insert_rowid();
|
||||||
|
|
||||||
log_event(
|
log_event(
|
||||||
&mut tx,
|
&mut conn,
|
||||||
"allocation_created",
|
"allocation_created",
|
||||||
Some(id),
|
Some(id),
|
||||||
Some(pool_id),
|
Some(pool_id),
|
||||||
|
|
@ -172,7 +174,7 @@ pub async fn allocate_in_tx(
|
||||||
.await
|
.await
|
||||||
.ok();
|
.ok();
|
||||||
|
|
||||||
tx.commit().await?;
|
sqlx::query("COMMIT").execute(&mut *conn).await?;
|
||||||
|
|
||||||
let allocation = sqlx::query_as::<_, Allocation>("SELECT * FROM allocations WHERE id = ?")
|
let allocation = sqlx::query_as::<_, Allocation>("SELECT * FROM allocations WHERE id = ?")
|
||||||
.bind(id)
|
.bind(id)
|
||||||
|
|
@ -193,7 +195,7 @@ pub async fn release_allocation(
|
||||||
let actor = get_actor_from_request(&headers);
|
let actor = get_actor_from_request(&headers);
|
||||||
|
|
||||||
if !validate_csrf(&jar, &form.csrf_token) {
|
if !validate_csrf(&jar, &form.csrf_token) {
|
||||||
return render_detail_with_error(&state, id, &jar, &csrf_token, "Invalid CSRF token.").await.into_response();
|
return render_detail_with_error(&state, id, &jar, &csrf_token, "Neplatný CSRF token.").await.into_response();
|
||||||
}
|
}
|
||||||
|
|
||||||
let release_note = form.release_note.trim();
|
let release_note = form.release_note.trim();
|
||||||
|
|
@ -203,10 +205,9 @@ pub async fn release_allocation(
|
||||||
id,
|
id,
|
||||||
&jar,
|
&jar,
|
||||||
&csrf_token,
|
&csrf_token,
|
||||||
"Release note must be at least 3 characters long.",
|
"Poznámka k uvolnění musí mít alespoň 3 znaky.",
|
||||||
)
|
)
|
||||||
.await
|
.await.into_response();
|
||||||
.into_response();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let result = release_in_tx(&state.pool, id, release_note, &actor).await;
|
let result = release_in_tx(&state.pool, id, release_note, &actor).await;
|
||||||
|
|
@ -215,7 +216,7 @@ pub async fn release_allocation(
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
let _ = regenerate_after_change(&state).await;
|
let _ = regenerate_after_change(&state).await;
|
||||||
info!("Released allocation {} by {}", id, actor);
|
info!("Released allocation {} by {}", id, actor);
|
||||||
render_detail_with_error(&state, id, &jar, &csrf_token, "Subnet released successfully.").await.into_response()
|
render_detail_with_error(&state, id, &jar, &csrf_token, "Subnet byl úspěšně uvolněn.").await.into_response()
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
warn!("Release failed for allocation {}: {}", id, e);
|
warn!("Release failed for allocation {}: {}", id, e);
|
||||||
|
|
@ -230,14 +231,18 @@ pub async fn release_in_tx(
|
||||||
release_note: &str,
|
release_note: &str,
|
||||||
actor: &str,
|
actor: &str,
|
||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<()> {
|
||||||
let mut tx = pool.begin_with("BEGIN IMMEDIATE").await?;
|
let mut conn = pool.acquire().await?;
|
||||||
|
|
||||||
|
sqlx::query("BEGIN IMMEDIATE")
|
||||||
|
.execute(&mut *conn)
|
||||||
|
.await?;
|
||||||
|
|
||||||
let allocation: Allocation =
|
let allocation: Allocation =
|
||||||
sqlx::query_as("SELECT * FROM allocations WHERE id = ? AND status IN ('active', 'reserved')")
|
sqlx::query_as("SELECT * FROM allocations WHERE id = ? AND status IN ('active', 'reserved')")
|
||||||
.bind(id)
|
.bind(id)
|
||||||
.fetch_one(&mut *tx)
|
.fetch_one(&mut *conn)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| anyhow::anyhow!("Allocation does not exist or is no longer active."))?;
|
.map_err(|_| anyhow::anyhow!("Alokace neexistuje nebo již není aktivní."))?;
|
||||||
|
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"UPDATE allocations SET status = 'released', released_at = ?, released_by = ?, release_note = ? WHERE id = ?",
|
"UPDATE allocations SET status = 'released', released_at = ?, released_by = ?, release_note = ? WHERE id = ?",
|
||||||
|
|
@ -246,11 +251,11 @@ pub async fn release_in_tx(
|
||||||
.bind(actor)
|
.bind(actor)
|
||||||
.bind(release_note)
|
.bind(release_note)
|
||||||
.bind(id)
|
.bind(id)
|
||||||
.execute(&mut *tx)
|
.execute(&mut *conn)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
log_event(
|
log_event(
|
||||||
&mut tx,
|
&mut conn,
|
||||||
"allocation_released",
|
"allocation_released",
|
||||||
Some(id),
|
Some(id),
|
||||||
Some(allocation.pool_id),
|
Some(allocation.pool_id),
|
||||||
|
|
@ -263,12 +268,12 @@ pub async fn release_in_tx(
|
||||||
.await
|
.await
|
||||||
.ok();
|
.ok();
|
||||||
|
|
||||||
tx.commit().await?;
|
sqlx::query("COMMIT").execute(&mut *conn).await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn regenerate_after_change(state: &AppState) -> anyhow::Result<()> {
|
async fn regenerate_after_change(state: &AppState) -> anyhow::Result<()> {
|
||||||
let pools: Vec<Pool> = sqlx::query_as("SELECT * FROM pools ORDER BY id")
|
let pools: Vec<Pool> = sqlx::query_as("SELECT * FROM pools WHERE enabled = true")
|
||||||
.fetch_all(&state.pool)
|
.fetch_all(&state.pool)
|
||||||
.await?;
|
.await?;
|
||||||
let allocations: Vec<Allocation> =
|
let allocations: Vec<Allocation> =
|
||||||
|
|
@ -294,7 +299,7 @@ async fn render_index_with_error(state: &AppState, _jar: &CookieJar, csrf_token:
|
||||||
Ok(html) => Html(html),
|
Ok(html) => Html(html),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
error!("Template render error: {}", e);
|
error!("Template render error: {}", e);
|
||||||
Html("Error rendering page.".to_string())
|
Html("Chyba při vykreslování stránky.".to_string())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -305,7 +310,7 @@ async fn render_index_with_success(state: &AppState, _jar: &CookieJar, csrf_toke
|
||||||
Ok(html) => Html(html),
|
Ok(html) => Html(html),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
error!("Template render error: {}", e);
|
error!("Template render error: {}", e);
|
||||||
Html("Error rendering page.".to_string())
|
Html("Chyba při vykreslování stránky.".to_string())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -315,15 +320,8 @@ pub async fn build_index_template(
|
||||||
csrf_token: &str,
|
csrf_token: &str,
|
||||||
flash: FlashData,
|
flash: FlashData,
|
||||||
) -> IndexTemplate {
|
) -> IndexTemplate {
|
||||||
let pools = load_pool_templates(state).await;
|
|
||||||
let all_pools = sqlx::query_as::<_, Pool>("SELECT * FROM pools ORDER BY id")
|
|
||||||
.fetch_all(&state.pool)
|
|
||||||
.await
|
|
||||||
.unwrap_or_default();
|
|
||||||
|
|
||||||
IndexTemplate {
|
IndexTemplate {
|
||||||
pools,
|
pools: load_pool_templates(state).await,
|
||||||
all_pools,
|
|
||||||
allocations: vec![],
|
allocations: vec![],
|
||||||
page: 1,
|
page: 1,
|
||||||
total_pages: 0,
|
total_pages: 0,
|
||||||
|
|
@ -352,7 +350,7 @@ async fn render_detail_with_error(state: &AppState, id: i64, _jar: &CookieJar, c
|
||||||
Ok(html) => Html(html),
|
Ok(html) => Html(html),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
error!("Template render error: {}", e);
|
error!("Template render error: {}", e);
|
||||||
Html("Error rendering page.".to_string())
|
Html("Chyba při vykreslování stránky.".to_string())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -381,7 +379,7 @@ async fn build_detail_template(
|
||||||
pool_name: "N/A".to_string(),
|
pool_name: "N/A".to_string(),
|
||||||
address_count: 0,
|
address_count: 0,
|
||||||
usable_host_count: 0,
|
usable_host_count: 0,
|
||||||
note: "Allocation not found.".to_string(),
|
note: "Alokace nenalezena.".to_string(),
|
||||||
ticket: "–".to_string(),
|
ticket: "–".to_string(),
|
||||||
status: "released".to_string(),
|
status: "released".to_string(),
|
||||||
network: "N/A".to_string(),
|
network: "N/A".to_string(),
|
||||||
|
|
|
||||||
|
|
@ -1,476 +0,0 @@
|
||||||
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,7 +1,4 @@
|
||||||
pub mod allocations;
|
pub mod allocations;
|
||||||
pub mod api;
|
|
||||||
pub mod exports;
|
pub mod exports;
|
||||||
pub mod pages;
|
pub mod pages;
|
||||||
pub mod partials;
|
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 crate::templates::{AllocationDetailTemplate, FlashData, IndexTemplate, PoolTemplate};
|
||||||
use askama::Template;
|
use askama::Template;
|
||||||
use axum::extract::{Query, State};
|
use axum::extract::{Query, State};
|
||||||
use axum::response::{Html, IntoResponse, Redirect, Response};
|
use axum::response::{Html, IntoResponse, Response};
|
||||||
use axum_extra::extract::cookie::CookieJar;
|
use axum_extra::extract::cookie::CookieJar;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use tracing::error;
|
use tracing::error;
|
||||||
|
|
@ -27,19 +27,10 @@ pub async fn index(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
jar: CookieJar,
|
jar: CookieJar,
|
||||||
Query(q): Query<IndexQuery>,
|
Query(q): Query<IndexQuery>,
|
||||||
) -> Response {
|
) -> impl IntoResponse {
|
||||||
let (jar, csrf_token) = ensure_csrf_cookie(jar);
|
let (jar, csrf_token) = ensure_csrf_cookie(jar);
|
||||||
let page = q.page.max(1);
|
let page = q.page.max(1);
|
||||||
|
|
||||||
let pool_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM pools")
|
|
||||||
.fetch_one(&state.pool)
|
|
||||||
.await
|
|
||||||
.unwrap_or(0);
|
|
||||||
|
|
||||||
if pool_count == 0 {
|
|
||||||
return Redirect::to("/setup").into_response();
|
|
||||||
}
|
|
||||||
|
|
||||||
let pools: Vec<PoolTemplate> = sqlx::query_as::<_, Pool>(
|
let pools: Vec<PoolTemplate> = sqlx::query_as::<_, Pool>(
|
||||||
"SELECT * FROM pools WHERE enabled = true ORDER BY id",
|
"SELECT * FROM pools WHERE enabled = true ORDER BY id",
|
||||||
)
|
)
|
||||||
|
|
@ -50,11 +41,6 @@ pub async fn index(
|
||||||
.map(PoolTemplate::from)
|
.map(PoolTemplate::from)
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let all_pools: Vec<Pool> = sqlx::query_as::<_, Pool>("SELECT * FROM pools ORDER BY id")
|
|
||||||
.fetch_all(&state.pool)
|
|
||||||
.await
|
|
||||||
.unwrap_or_default();
|
|
||||||
|
|
||||||
let (allocations, total_count) =
|
let (allocations, total_count) =
|
||||||
load_allocations(&state, &q.q, &q.pool, &q.status, page).await;
|
load_allocations(&state, &q.q, &q.pool, &q.status, page).await;
|
||||||
|
|
||||||
|
|
@ -62,7 +48,6 @@ pub async fn index(
|
||||||
|
|
||||||
let template = IndexTemplate {
|
let template = IndexTemplate {
|
||||||
pools,
|
pools,
|
||||||
all_pools,
|
|
||||||
allocations,
|
allocations,
|
||||||
page,
|
page,
|
||||||
total_pages,
|
total_pages,
|
||||||
|
|
@ -78,7 +63,7 @@ pub async fn index(
|
||||||
Ok(html) => (jar, Html(html)).into_response(),
|
Ok(html) => (jar, Html(html)).into_response(),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
error!("Index template render error: {}", e);
|
error!("Index template render error: {}", e);
|
||||||
(jar, Html("Error rendering page.".to_string())).into_response()
|
(jar, Html("Chyba při vykreslování stránky.".to_string())).into_response()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -100,7 +85,7 @@ pub async fn allocation_detail(
|
||||||
let allocation = match allocation {
|
let allocation = match allocation {
|
||||||
Some(a) => a,
|
Some(a) => a,
|
||||||
None => {
|
None => {
|
||||||
return (jar, Html("Allocation not found.".to_string())).into_response();
|
return (jar, Html("Alokace nenalezena.".to_string())).into_response();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -169,7 +154,7 @@ pub async fn allocation_detail(
|
||||||
Ok(html) => (jar, Html(html)).into_response(),
|
Ok(html) => (jar, Html(html)).into_response(),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
error!("Detail template render error: {}", e);
|
error!("Detail template render error: {}", e);
|
||||||
(jar, Html("Error rendering page.".to_string())).into_response()
|
(jar, Html("Chyba při vykreslování stránky.".to_string())).into_response()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,7 @@ pub async fn pool_preview(
|
||||||
address_count: 0,
|
address_count: 0,
|
||||||
usable_host_count: 0,
|
usable_host_count: 0,
|
||||||
has_error: true,
|
has_error: true,
|
||||||
error: "Pool does not exist.".to_string(),
|
error: "Pool neexistuje.".to_string(),
|
||||||
};
|
};
|
||||||
return Html(template.render().unwrap_or_default());
|
return Html(template.render().unwrap_or_default());
|
||||||
}
|
}
|
||||||
|
|
@ -72,7 +72,7 @@ pub async fn pool_preview(
|
||||||
Ok(html) => Html(html),
|
Ok(html) => Html(html),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
error!("Preview template render error: {}", e);
|
error!("Preview template render error: {}", e);
|
||||||
Html("Preview error.".to_string())
|
Html("Chyba preview.".to_string())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,492 +0,0 @@
|
||||||
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 })
|
|
||||||
}
|
|
||||||
|
|
@ -1,325 +0,0 @@
|
||||||
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,5 +1,4 @@
|
||||||
pub mod allocator;
|
pub mod allocator;
|
||||||
pub mod api_tokens;
|
|
||||||
pub mod audit;
|
pub mod audit;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod csrf;
|
pub mod csrf;
|
||||||
|
|
|
||||||
|
|
@ -69,24 +69,6 @@ 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)]
|
#[derive(Clone, Debug, FromRow)]
|
||||||
pub struct AuditEvent {
|
pub struct AuditEvent {
|
||||||
pub id: i64,
|
pub id: i64,
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
use crate::handlers::{allocations, api, exports, pages, partials, pools, tokens};
|
use crate::handlers::{allocations, exports, pages, partials};
|
||||||
use axum::routing::{get, post};
|
use axum::routing::{get, post};
|
||||||
use axum::Router;
|
use axum::Router;
|
||||||
|
|
||||||
|
|
@ -6,14 +6,6 @@ pub fn router() -> Router<crate::state::AppState> {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/", get(pages::index))
|
.route("/", get(pages::index))
|
||||||
.route("/healthz", get(pages::healthz))
|
.route("/healthz", get(pages::healthz))
|
||||||
.route("/setup", get(pools::setup_page).post(pools::create_pool))
|
|
||||||
.route("/pools", get(pools::pool_list))
|
|
||||||
.route("/pools/new", post(pools::create_pool))
|
|
||||||
.route("/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("/partials/pools/{slug}/preview", get(partials::pool_preview))
|
||||||
.route("/allocations", post(allocations::create_allocation))
|
.route("/allocations", post(allocations::create_allocation))
|
||||||
.route("/allocations/{id}", get(pages::allocation_detail))
|
.route("/allocations/{id}", get(pages::allocation_detail))
|
||||||
|
|
@ -24,15 +16,4 @@ pub fn router() -> Router<crate::state::AppState> {
|
||||||
.route("/exports/allocations.md", get(exports::export_md))
|
.route("/exports/allocations.md", get(exports::export_md))
|
||||||
.route("/exports/allocations.csv", get(exports::export_csv))
|
.route("/exports/allocations.csv", get(exports::export_csv))
|
||||||
.route("/exports/allocations.json", get(exports::export_json))
|
.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,7 +11,6 @@ pub struct BaseTemplate {
|
||||||
#[template(path = "index.html")]
|
#[template(path = "index.html")]
|
||||||
pub struct IndexTemplate {
|
pub struct IndexTemplate {
|
||||||
pub pools: Vec<PoolTemplate>,
|
pub pools: Vec<PoolTemplate>,
|
||||||
pub all_pools: Vec<Pool>,
|
|
||||||
pub allocations: Vec<AllocationListItem>,
|
pub allocations: Vec<AllocationListItem>,
|
||||||
pub page: i64,
|
pub page: i64,
|
||||||
pub total_pages: i64,
|
pub total_pages: i64,
|
||||||
|
|
@ -131,7 +130,6 @@ pub struct AllocationsTableTemplate {
|
||||||
pub query: String,
|
pub query: String,
|
||||||
pub pool_filter: String,
|
pub pool_filter: String,
|
||||||
pub status_filter: String,
|
pub status_filter: String,
|
||||||
pub csrf_token: String,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Template)]
|
#[derive(Template)]
|
||||||
|
|
@ -142,49 +140,3 @@ pub struct AllocationDetailTemplate {
|
||||||
pub csrf_token: String,
|
pub csrf_token: String,
|
||||||
pub flash: FlashData,
|
pub flash: FlashData,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Template)]
|
|
||||||
#[template(path = "setup.html")]
|
|
||||||
pub struct SetupTemplate {
|
|
||||||
pub csrf_token: String,
|
|
||||||
pub error: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(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
135
static/app.js
|
|
@ -1,135 +0,0 @@
|
||||||
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,38 +14,11 @@
|
||||||
--card-bg: #faf8f3;
|
--card-bg: #faf8f3;
|
||||||
--hover: #ebe7de;
|
--hover: #ebe7de;
|
||||||
--active: #d4edda;
|
--active: #d4edda;
|
||||||
--active-fg: #155724;
|
|
||||||
--reserved: #fff3cd;
|
--reserved: #fff3cd;
|
||||||
--reserved-fg: #856404;
|
|
||||||
--released: #e2e3e5;
|
--released: #e2e3e5;
|
||||||
--released-fg: #383d41;
|
|
||||||
--link: #0044cc;
|
--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;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
@ -84,27 +57,6 @@ header {
|
||||||
border: 2px solid var(--border);
|
border: 2px solid var(--border);
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
background: var(--card-bg);
|
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 {
|
header h1 {
|
||||||
|
|
@ -181,7 +133,6 @@ textarea:focus {
|
||||||
|
|
||||||
.btn:hover {
|
.btn:hover {
|
||||||
background: var(--fg);
|
background: var(--fg);
|
||||||
color: var(--bg);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-secondary {
|
.btn-secondary {
|
||||||
|
|
@ -203,22 +154,6 @@ textarea:focus {
|
||||||
color: var(--error-bg);
|
color: var(--error-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-link {
|
|
||||||
display: inline;
|
|
||||||
padding: 0;
|
|
||||||
border: none;
|
|
||||||
background: transparent;
|
|
||||||
color: var(--link);
|
|
||||||
font-family: inherit;
|
|
||||||
font-size: inherit;
|
|
||||||
cursor: pointer;
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-link:hover {
|
|
||||||
background: var(--hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-box {
|
.preview-box {
|
||||||
border: 2px solid var(--border);
|
border: 2px solid var(--border);
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
|
|
@ -300,17 +235,17 @@ tr:hover {
|
||||||
|
|
||||||
.status.active {
|
.status.active {
|
||||||
background: var(--active);
|
background: var(--active);
|
||||||
color: var(--active-fg);
|
color: #155724;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status.reserved {
|
.status.reserved {
|
||||||
background: var(--reserved);
|
background: var(--reserved);
|
||||||
color: var(--reserved-fg);
|
color: #856404;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status.released {
|
.status.released {
|
||||||
background: var(--released);
|
background: var(--released);
|
||||||
color: var(--released-fg);
|
color: #383d41;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pagination {
|
.pagination {
|
||||||
|
|
@ -394,10 +329,6 @@ tr:hover {
|
||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
header {
|
|
||||||
padding-top: 3rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.grid {
|
.grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,49 +0,0 @@
|
||||||
(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" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{% block title %}Detail {{ allocation.cidr }} | SUB-ALIGATOR{% endblock %}
|
{% block title %}Detail {{ allocation.cidr }} | IP Subnet Allocator{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div id="flash-container">
|
<div id="flash-container">
|
||||||
|
|
@ -8,7 +8,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section class="box">
|
<section class="box">
|
||||||
<div class="box-title">Allocation detail {{ allocation.cidr }}</div>
|
<div class="box-title">Detail alokace {{ allocation.cidr }}</div>
|
||||||
|
|
||||||
<div class="detail-grid">
|
<div class="detail-grid">
|
||||||
<div class="detail-item">
|
<div class="detail-item">
|
||||||
|
|
@ -20,19 +20,19 @@
|
||||||
<div class="value">{{ allocation.pool_name }}</div>
|
<div class="value">{{ allocation.pool_name }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="detail-item">
|
<div class="detail-item">
|
||||||
<div class="label">Type</div>
|
<div class="label">Typ</div>
|
||||||
<div class="value">{{ allocation.kind }}</div>
|
<div class="value">{{ allocation.kind }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="detail-item">
|
<div class="detail-item">
|
||||||
<div class="label">Status</div>
|
<div class="label">Stav</div>
|
||||||
<div class="value"><span class="status {{ allocation.status }}">{{ allocation.status }}</span></div>
|
<div class="value"><span class="status {{ allocation.status }}">{{ allocation.status }}</span></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="detail-item">
|
<div class="detail-item">
|
||||||
<div class="label">Total addresses</div>
|
<div class="label">Celkem adres</div>
|
||||||
<div class="value">{{ allocation.address_count }}</div>
|
<div class="value">{{ allocation.address_count }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="detail-item">
|
<div class="detail-item">
|
||||||
<div class="label">Usable hosts</div>
|
<div class="label">Použitelných hostů</div>
|
||||||
<div class="value">{{ allocation.usable_host_count }}</div>
|
<div class="value">{{ allocation.usable_host_count }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="detail-item">
|
<div class="detail-item">
|
||||||
|
|
@ -44,15 +44,15 @@
|
||||||
<div class="value">{{ allocation.broadcast }}</div>
|
<div class="value">{{ allocation.broadcast }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="detail-item">
|
<div class="detail-item">
|
||||||
<div class="label">First host</div>
|
<div class="label">První host</div>
|
||||||
<div class="value">{{ allocation.first_host }}</div>
|
<div class="value">{{ allocation.first_host }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="detail-item">
|
<div class="detail-item">
|
||||||
<div class="label">Last host</div>
|
<div class="label">Poslední host</div>
|
||||||
<div class="value">{{ allocation.last_host }}</div>
|
<div class="value">{{ allocation.last_host }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="detail-item">
|
<div class="detail-item">
|
||||||
<div class="label">Note</div>
|
<div class="label">Poznámka</div>
|
||||||
<div class="value">{{ allocation.note }}</div>
|
<div class="value">{{ allocation.note }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="detail-item">
|
<div class="detail-item">
|
||||||
|
|
@ -60,47 +60,47 @@
|
||||||
<div class="value">{{ allocation.ticket }}</div>
|
<div class="value">{{ allocation.ticket }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="detail-item">
|
<div class="detail-item">
|
||||||
<div class="label">Created</div>
|
<div class="label">Vytvořeno</div>
|
||||||
<div class="value">{{ allocation.created_at_local }} ({{ allocation.created_by }})</div>
|
<div class="value">{{ allocation.created_at_local }} ({{ allocation.created_by }})</div>
|
||||||
</div>
|
</div>
|
||||||
{% if allocation.released_at_local != "–" %}
|
{% if allocation.released_at_local != "–" %}
|
||||||
<div class="detail-item">
|
<div class="detail-item">
|
||||||
<div class="label">Released</div>
|
<div class="label">Uvolněno</div>
|
||||||
<div class="value">{{ allocation.released_at_local }} ({{ allocation.released_by }})</div>
|
<div class="value">{{ allocation.released_at_local }} ({{ allocation.released_by }})</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="detail-item">
|
<div class="detail-item">
|
||||||
<div class="label">Release note</div>
|
<div class="label">Poznámka k uvolnění</div>
|
||||||
<div class="value">{{ allocation.release_note }}</div>
|
<div class="value">{{ allocation.release_note }}</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if allocation.status == "active" || allocation.status == "reserved" %}
|
{% if allocation.status == "active" || allocation.status == "reserved" %}
|
||||||
<button class="btn btn-danger release-btn" data-id="{{ allocation.id }}">Release subnet</button>
|
<button class="btn btn-danger" onclick="document.getElementById('release-modal').style.display='flex'">Uvolnit subnet</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<a href="/" class="btn btn-secondary">Back</a>
|
<a href="/" class="btn btn-secondary">Zpět</a>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{% if audit_events.len() > 0 %}
|
{% if audit_events.len() > 0 %}
|
||||||
<section class="box">
|
<section class="box">
|
||||||
<div class="box-title">Audit history</div>
|
<div class="box-title">Auditní historie</div>
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Time</th>
|
<th>Čas</th>
|
||||||
<th>Event</th>
|
<th>Událost</th>
|
||||||
<th>Actor</th>
|
<th>Aktor</th>
|
||||||
<th>Details</th>
|
<th>Detaily</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for e in audit_events %}
|
{% for e in audit_events %}
|
||||||
<tr>
|
<tr>
|
||||||
<td data-label="Time">{{ e.created_at_local }}</td>
|
<td data-label="Čas">{{ e.created_at_local }}</td>
|
||||||
<td data-label="Event">{{ e.event_type }}</td>
|
<td data-label="Událost">{{ e.event_type }}</td>
|
||||||
<td data-label="Actor">{{ e.actor }}</td>
|
<td data-label="Aktor">{{ e.actor }}</td>
|
||||||
<td data-label="Details"><pre class="small" style="margin:0;">{{ e.details_json }}</pre></td>
|
<td data-label="Detaily"><pre class="small" style="margin:0;">{{ e.details_json }}</pre></td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|
@ -108,19 +108,19 @@
|
||||||
</section>
|
</section>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div id="release-modal-{{ allocation.id }}" class="modal-overlay" style="display:none;">
|
<dialog id="release-modal" class="modal-overlay" style="display:none;">
|
||||||
<div class="modal">
|
<div class="modal">
|
||||||
<h2 class="modal-title">Release subnet {{ allocation.cidr }}</h2>
|
<h2 class="modal-title">Uvolnit subnet {{ allocation.cidr }}</h2>
|
||||||
<form method="post" action="/allocations/{{ allocation.id }}/release" class="modal-body" id="release-form">
|
<form method="post" action="/allocations/{{ allocation.id }}/release" class="modal-body" id="release-form">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
<p class="small">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>
|
<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">Release note *</label>
|
<label for="release-note">Poznámka k uvolnění *</label>
|
||||||
<textarea id="release-note" name="release_note" rows="3" required minlength="3" maxlength="500"></textarea>
|
<textarea id="release-note" 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-{{ allocation.id }}">Cancel</button>
|
|
||||||
<button type="submit" class="btn btn-danger">Release</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button type="button" class="btn btn-secondary" onclick="document.getElementById('release-modal').style.display='none'">Zrušit</button>
|
||||||
|
<button type="submit" class="btn btn-danger" form="release-form">Uvolnit</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</dialog>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
|
|
@ -1,33 +1,24 @@
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="cs">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<meta name="htmx-config" content='{"includeIndicatorStyles":false}'>
|
<meta name="htmx-config" content='{"includeIndicatorStyles":false}'>
|
||||||
<title>{% block title %}SUB-ALIGATOR{% endblock %}</title>
|
<title>{% block title %}IP Subnet Allocator{% endblock %}</title>
|
||||||
<script src="/static/theme.js"></script>
|
|
||||||
<link rel="stylesheet" href="/static/style.css">
|
<link rel="stylesheet" href="/static/style.css">
|
||||||
<script src="/static/htmx.min.js" defer></script>
|
<script src="/static/htmx.min.js" defer></script>
|
||||||
<script src="/static/app.js" defer></script>
|
|
||||||
{% block head %}{% endblock %}
|
{% block head %}{% endblock %}
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<header>
|
<header>
|
||||||
<div class="header-nav header-nav-left">
|
<h1>IP Subnet Allocator</h1>
|
||||||
<a href="/" class="btn btn-secondary">Home</a>
|
<p>Interní evidence a přidělování IPv4 subnetů</p>
|
||||||
</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>
|
</header>
|
||||||
{% block content %}{% endblock %}
|
{% block content %}{% endblock %}
|
||||||
<footer class="box">
|
<footer class="box">
|
||||||
<p class="small">
|
<p class="small">
|
||||||
<a href="/pools">Manage pools</a> |
|
<a href="/">Hlavní stránka</a> |
|
||||||
<a href="/tokens">API tokens</a> |
|
|
||||||
<a href="/exports/allocations.md">Markdown export</a> |
|
<a href="/exports/allocations.md">Markdown export</a> |
|
||||||
<a href="/exports/allocations.csv">CSV export</a> |
|
<a href="/exports/allocations.csv">CSV export</a> |
|
||||||
<a href="/exports/allocations.json">JSON export</a>
|
<a href="/exports/allocations.json">JSON export</a>
|
||||||
|
|
|
||||||
|
|
@ -2,71 +2,46 @@
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Subnet</th>
|
<th>Subnet</th>
|
||||||
<th>Type</th>
|
<th>Typ</th>
|
||||||
<th>Pool</th>
|
<th>Pool</th>
|
||||||
<th>Addresses</th>
|
<th>Počet adres</th>
|
||||||
<th>Note</th>
|
<th>Poznámka</th>
|
||||||
<th>Status</th>
|
<th>Stav</th>
|
||||||
<th>Created</th>
|
<th>Vytvořeno</th>
|
||||||
<th>Actions</th>
|
<th>Akce</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for a in allocations %}
|
{% for a in allocations %}
|
||||||
<tr>
|
<tr>
|
||||||
<td data-label="Subnet"><a href="/allocations/{{ a.id }}">{{ a.cidr }}</a></td>
|
<td data-label="Subnet"><a href="/allocations/{{ a.id }}">{{ a.cidr }}</a></td>
|
||||||
<td data-label="Type">{{ a.kind }}</td>
|
<td data-label="Typ">{{ a.kind }}</td>
|
||||||
<td data-label="Pool">{{ a.pool_name }}</td>
|
<td data-label="Pool">{{ a.pool_name }}</td>
|
||||||
<td data-label="Addresses">{{ a.address_count }}</td>
|
<td data-label="Počet adres">{{ a.address_count }}</td>
|
||||||
<td data-label="Note">{{ a.note }}</td>
|
<td data-label="Poznámka">{{ a.note }}</td>
|
||||||
<td data-label="Status"><span class="status {{ a.status }}">{{ a.status }}</span></td>
|
<td data-label="Stav"><span class="status {{ a.status }}">{{ a.status }}</span></td>
|
||||||
<td data-label="Created">{{ a.created_at_local }}</td>
|
<td data-label="Vytvořeno">{{ a.created_at_local }}</td>
|
||||||
<td data-label="Actions">
|
<td data-label="Akce"><a href="/allocations/{{ a.id }}">Detail</a></td>
|
||||||
<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>
|
</tr>
|
||||||
{% else %}
|
{% else %}
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="8" style="text-align:center;">No allocations.</td>
|
<td colspan="8" style="text-align:center;">Žádné alokace.</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</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 %}
|
{% if total_pages > 1 %}
|
||||||
<div class="pagination">
|
<div class="pagination">
|
||||||
<div>
|
<div>
|
||||||
{% if page > 1 %}
|
{% if page > 1 %}
|
||||||
<a class="btn btn-secondary" href="?page={{ page - 1 }}{% if query != "" %}&q={{ query }}{% endif %}{% if pool_filter != "" %}&pool={{ pool_filter }}{% endif %}{% if status_filter != "" %}&status={{ status_filter }}{% endif %}">← Previous</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 %}">← Předchozí</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div>Page {{ page }} / {{ total_pages }} (total {{ total_count }})</div>
|
<div>Strana {{ page }} / {{ total_pages }} (celkem {{ total_count }})</div>
|
||||||
<div>
|
<div>
|
||||||
{% if page < total_pages %}
|
{% if page < total_pages %}
|
||||||
<a class="btn btn-secondary" href="?page={{ page + 1 }}{% if query != "" %}&q={{ query }}{% endif %}{% if pool_filter != "" %}&pool={{ pool_filter }}{% endif %}{% if status_filter != "" %}&status={{ status_filter }}{% endif %}">Next →</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 %}">Další →</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,13 @@
|
||||||
{% if flash.has_success %}
|
{% if flash.has_success %}
|
||||||
<div class="message success" id="flash-success">
|
<div class="message success" id="flash-success">
|
||||||
<p><strong>Allocated:</strong> {{ flash.success_cidr }}</p>
|
<p><strong>Přiděleno:</strong> {{ flash.success_cidr }}</p>
|
||||||
<p>{{ flash.success_note }}</p>
|
<p>{{ flash.success_note }}</p>
|
||||||
<p class="small">
|
<p class="small">
|
||||||
Network: {{ flash.success_network }} |
|
Network: {{ flash.success_network }} |
|
||||||
Broadcast: {{ flash.success_broadcast }} |
|
Broadcast: {{ flash.success_broadcast }} |
|
||||||
First host: {{ flash.success_first_host }} |
|
První host: {{ flash.success_first_host }} |
|
||||||
Last host: {{ flash.success_last_host }} |
|
Poslední host: {{ flash.success_last_host }} |
|
||||||
Usable hosts: {{ flash.success_usable_host_count }}
|
Použitelných hostů: {{ flash.success_usable_host_count }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
|
||||||
|
|
@ -5,19 +5,12 @@
|
||||||
{% include "fragments/flash.html" %}
|
{% include "fragments/flash.html" %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if pools.len() == 0 %}
|
|
||||||
<section class="box">
|
|
||||||
<div class="box-title">No pools configured</div>
|
|
||||||
<p>Create at least one pool before allocating subnets.</p>
|
|
||||||
<a href="/pools" class="btn">Add a pool</a>
|
|
||||||
</section>
|
|
||||||
{% else %}
|
|
||||||
<section class="grid">
|
<section class="grid">
|
||||||
{% for pool in pools %}
|
{% for pool in pools %}
|
||||||
<article class="box" id="pool-card-{{ pool.slug }}">
|
<article class="box" id="pool-card-{{ pool.slug }}">
|
||||||
<div class="box-title">{{ pool.name }}</div>
|
<div class="box-title">{{ pool.name }}</div>
|
||||||
<p><span class="label">Parent prefix:</span> <span class="value">{{ pool.cidr }}</span></p>
|
<p><span class="label">Parent prefix:</span> <span class="value">{{ pool.cidr }}</span></p>
|
||||||
<p><span class="label">Type:</span> <span class="value">{{ pool.kind }}</span></p>
|
<p><span class="label">Typ:</span> <span class="value">{{ pool.kind }}</span></p>
|
||||||
|
|
||||||
<form
|
<form
|
||||||
hx-get="/partials/pools/{{ pool.slug }}/preview"
|
hx-get="/partials/pools/{{ pool.slug }}/preview"
|
||||||
|
|
@ -25,57 +18,55 @@
|
||||||
hx-trigger="change from:select, load"
|
hx-trigger="change from:select, load"
|
||||||
hx-indicator="#indicator-{{ pool.slug }}"
|
hx-indicator="#indicator-{{ pool.slug }}"
|
||||||
>
|
>
|
||||||
<label for="prefix-{{ pool.slug }}">Subnet size</label>
|
<label for="prefix-{{ pool.slug }}">Velikost subnetu</label>
|
||||||
<select name="prefix_length" id="prefix-{{ pool.slug }}">
|
<select name="prefix_length" id="prefix-{{ pool.slug }}" onchange="updateModalPrefix('{{ pool.slug }}', this.value)">
|
||||||
{% for pl in pool.allowed_prefixes %}
|
{% for pl in pool.allowed_prefixes %}
|
||||||
<option value="{{ pl }}" {% if pl == "24" %}selected{% endif %}>/{{ pl }}</option>
|
<option value="{{ pl }}" {% if pl == "24" %}selected{% endif %}>/{{ pl }}</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
<div id="indicator-{{ pool.slug }}" class="small" style="display:none;">Loading...</div>
|
<div id="indicator-{{ pool.slug }}" class="small" style="display:none;">Načítám...</div>
|
||||||
<div id="preview-{{ pool.slug }}"></div>
|
<div id="preview-{{ pool.slug }}"></div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
class="btn allocate-btn"
|
class="btn"
|
||||||
style="margin-top: 1rem;"
|
style="margin-top: 1rem;"
|
||||||
data-slug="{{ pool.slug }}"
|
onclick="openAllocateModal('{{ pool.slug }}')"
|
||||||
data-name="{{ pool.name }}"
|
|
||||||
>
|
>
|
||||||
[ Allocate next free subnet ]
|
[ Přidělit další volný subnet ]
|
||||||
</button>
|
</button>
|
||||||
</article>
|
</article>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</section>
|
</section>
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<section class="box">
|
<section class="box">
|
||||||
<div class="box-title">Allocated subnets</div>
|
<div class="box-title">Přidělené subnety</div>
|
||||||
|
|
||||||
<form method="get" action="/" class="filters">
|
<form method="get" action="/" class="filters">
|
||||||
<div>
|
<div>
|
||||||
<label for="q">Search</label>
|
<label for="q">Vyhledat</label>
|
||||||
<input type="text" id="q" name="q" value="{{ query }}" placeholder="CIDR or note">
|
<input type="text" id="q" name="q" value="{{ query }}" placeholder="CIDR nebo poznámka">
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="pool">Pool</label>
|
<label for="pool">Pool</label>
|
||||||
<select id="pool" name="pool">
|
<select id="pool" name="pool">
|
||||||
<option value="" {% if pool_filter == "" %}selected{% endif %}>All</option>
|
<option value="" {% if pool_filter == "" %}selected{% endif %}>Všechny</option>
|
||||||
{% for p in all_pools %}
|
{% for p in pools %}
|
||||||
<option value="{{ p.slug }}" {% if pool_filter == p.slug %}selected{% endif %}>{{ p.name }}</option>
|
<option value="{{ p.slug }}" {% if pool_filter == p.slug %}selected{% endif %}>{{ p.name }}</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="status">Status</label>
|
<label for="status">Stav</label>
|
||||||
<select id="status" name="status">
|
<select id="status" name="status">
|
||||||
<option value="" {% if status_filter == "" %}selected{% endif %}>All</option>
|
<option value="" {% if status_filter == "" %}selected{% endif %}>Všechny</option>
|
||||||
<option value="active" {% if status_filter == "active" %}selected{% endif %}>active</option>
|
<option value="active" {% if status_filter == "active" %}selected{% endif %}>active</option>
|
||||||
<option value="reserved" {% if status_filter == "reserved" %}selected{% endif %}>reserved</option>
|
<option value="reserved" {% if status_filter == "reserved" %}selected{% endif %}>reserved</option>
|
||||||
<option value="released" {% if status_filter == "released" %}selected{% endif %}>released</option>
|
<option value="released" {% if status_filter == "released" %}selected{% endif %}>released</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<button type="submit" class="btn">Filter</button>
|
<button type="submit" class="btn">Filtrovat</button>
|
||||||
<a href="/" class="btn btn-secondary">Reset</a>
|
<a href="/" class="btn btn-secondary">Reset</a>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
@ -85,9 +76,9 @@
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div id="allocate-modal" class="modal-overlay" style="display:none;">
|
<dialog id="allocate-modal" class="modal-overlay" style="display:none;">
|
||||||
<div class="modal">
|
<div class="modal">
|
||||||
<h2 class="modal-title">Confirm subnet allocation</h2>
|
<h2 class="modal-title">Potvrdit přidělení subnetu</h2>
|
||||||
<form method="post" action="/allocations" class="modal-body" id="allocate-form">
|
<form method="post" action="/allocations" class="modal-body" id="allocate-form">
|
||||||
<input type="hidden" name="pool_slug" id="modal-pool-slug">
|
<input type="hidden" name="pool_slug" id="modal-pool-slug">
|
||||||
<input type="hidden" name="prefix_length" id="modal-prefix-length">
|
<input type="hidden" name="prefix_length" id="modal-prefix-length">
|
||||||
|
|
@ -103,32 +94,79 @@
|
||||||
<div class="value" id="modal-prefix-display"></div>
|
<div class="value" id="modal-prefix-display"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="detail-item">
|
<div class="detail-item">
|
||||||
<div class="label">Proposed subnet</div>
|
<div class="label">Navrhovaný subnet</div>
|
||||||
<div class="value" id="modal-cidr"></div>
|
<div class="value" id="modal-cidr"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="detail-item">
|
<div class="detail-item">
|
||||||
<div class="label">Total addresses</div>
|
<div class="label">Celkem adres</div>
|
||||||
<div class="value" id="modal-address-count"></div>
|
<div class="value" id="modal-address-count"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="detail-item">
|
<div class="detail-item">
|
||||||
<div class="label">Usable hosts</div>
|
<div class="label">Použitelných hostů</div>
|
||||||
<div class="value" id="modal-usable-hosts"></div>
|
<div class="value" id="modal-usable-hosts"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="small" id="modal-warning"></p>
|
<p class="small" id="modal-warning"></p>
|
||||||
|
|
||||||
<label for="note">Note *</label>
|
<label for="note">Poznámka *</label>
|
||||||
<textarea id="note" name="note" rows="3" required minlength="3" maxlength="500"></textarea>
|
<textarea id="note" name="note" rows="3" required minlength="3" maxlength="500"></textarea>
|
||||||
|
|
||||||
<label for="ticket">Ticket / reference</label>
|
<label for="ticket">Ticket / reference</label>
|
||||||
<input type="text" id="ticket" name="ticket" maxlength="100">
|
<input type="text" id="ticket" name="ticket" maxlength="100">
|
||||||
|
|
||||||
<div class="modal-actions">
|
|
||||||
<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>
|
</form>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button type="button" class="btn btn-secondary" onclick="closeAllocateModal()">Zrušit</button>
|
||||||
|
<button type="submit" class="btn" form="allocate-form">Potvrdit přidělení</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</dialog>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const poolData = {
|
||||||
|
{% for pool in pools %}
|
||||||
|
"{{ pool.slug }}": {
|
||||||
|
name: "{{ pool.name }}",
|
||||||
|
allowed_prefixes: {{ pool.allowed_prefixes_json|safe }}
|
||||||
|
},
|
||||||
|
{% endfor %}
|
||||||
|
};
|
||||||
|
|
||||||
|
function updateModalPrefix(slug, prefixLength) {
|
||||||
|
// preview se aktualizuje přes HTMX automaticky
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openAllocateModal(slug) {
|
||||||
|
const select = document.getElementById('prefix-' + slug);
|
||||||
|
const prefixLength = select.value;
|
||||||
|
const modal = document.getElementById('allocate-modal');
|
||||||
|
|
||||||
|
document.getElementById('modal-pool-slug').value = slug;
|
||||||
|
document.getElementById('modal-prefix-length').value = prefixLength;
|
||||||
|
document.getElementById('modal-pool-name').textContent = poolData[slug].name;
|
||||||
|
document.getElementById('modal-prefix-display').textContent = '/' + prefixLength;
|
||||||
|
|
||||||
|
// Načíst preview z endpointu
|
||||||
|
const resp = await fetch('/partials/pools/' + slug + '/preview?prefix_length=' + prefixLength);
|
||||||
|
const html = await resp.text();
|
||||||
|
const parser = new DOMParser();
|
||||||
|
const doc = parser.parseFromString(html, 'text/html');
|
||||||
|
|
||||||
|
const cidr = doc.getElementById('preview-cidr')?.textContent || '–';
|
||||||
|
const addressCount = doc.getElementById('preview-address-count')?.textContent || '–';
|
||||||
|
const usableHosts = doc.getElementById('preview-usable-hosts')?.textContent || '–';
|
||||||
|
const warning = doc.getElementById('preview-warning')?.textContent || '';
|
||||||
|
|
||||||
|
document.getElementById('modal-cidr').textContent = cidr;
|
||||||
|
document.getElementById('modal-address-count').textContent = addressCount;
|
||||||
|
document.getElementById('modal-usable-hosts').textContent = usableHosts;
|
||||||
|
document.getElementById('modal-warning').textContent = warning;
|
||||||
|
|
||||||
|
modal.style.display = 'flex';
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeAllocateModal() {
|
||||||
|
document.getElementById('allocate-modal').style.display = 'none';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,23 @@
|
||||||
<div class="preview-box">
|
<div class="preview-box">
|
||||||
{% if has_error %}
|
{% if has_error %}
|
||||||
<p class="error" id="preview-warning">{{ error }}</p>
|
<p class="error" id="preview-warning">{{ error }}</p>
|
||||||
<p class="label">Next free subnet: <span class="value">–</span></p>
|
<p class="label">Další volný subnet: <span class="value">–</span></p>
|
||||||
{% else %}
|
{% else %}
|
||||||
<p>
|
<p>
|
||||||
<span class="label">Next free subnet:</span>
|
<span class="label">Další volný subnet:</span>
|
||||||
<span class="value" id="preview-cidr">{{ cidr }}</span>
|
<span class="value" id="preview-cidr">{{ cidr }}</span>
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<span class="label">Total addresses:</span>
|
<span class="label">Celkem adres:</span>
|
||||||
<span class="value" id="preview-address-count">{{ address_count }}</span>
|
<span class="value" id="preview-address-count">{{ address_count }}</span>
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<span class="label">Usable hosts:</span>
|
<span class="label">Použitelných hostů:</span>
|
||||||
<span class="value" id="preview-usable-hosts">{{ usable_host_count }}</span>
|
<span class="value" id="preview-usable-hosts">{{ usable_host_count }}</span>
|
||||||
</p>
|
</p>
|
||||||
{% if prefix_length >= 31 %}
|
{% if prefix_length >= 31 %}
|
||||||
<p class="small" id="preview-warning">
|
<p class="small" id="preview-warning">
|
||||||
For /31 and /32, network/broadcast addresses may coincide with host addresses per RFC 3021.
|
Pro /31 a /32 se network/broadcast adresy dle RFC 3021 mohou shodovat s host adresami.
|
||||||
</p>
|
</p>
|
||||||
{% else %}
|
{% else %}
|
||||||
<p class="small" id="preview-warning"></p>
|
<p class="small" id="preview-warning"></p>
|
||||||
|
|
|
||||||
|
|
@ -1,101 +0,0 @@
|
||||||
{% 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 %}
|
|
||||||
|
|
@ -1,42 +0,0 @@
|
||||||
{% 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 %}
|
|
||||||
|
|
@ -1,120 +0,0 @@
|
||||||
{% 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