diff --git a/README.md b/README.md index 3ff2b5c..297567d 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# IP Subnet Allocator +# SUB-ALIGATOR A simple internal web application for tracking and automatically allocating IPv4 subnets. @@ -19,6 +19,8 @@ It is designed to run only inside a LAN/VPN. **The application does not include - Allocation history with filtering, search, and pagination. - Release subnets (status `released`) with a mandatory note, directly from the history table or detail page. - Released subnets free up address space and can be reused by the first-fit algorithm. +- Delete pools without losing their allocations: the subnets stay in history and keep blocking the address range if the same or an overlapping pool is added again. +- Light and dark (amber terminal) theme, switchable in the page header and remembered per browser. - Audit log of all operations. - Markdown, CSV, and JSON exports. - SQLite migrations run automatically on startup. diff --git a/src/exports.rs b/src/exports.rs index 9c2f86c..71489c4 100644 --- a/src/exports.rs +++ b/src/exports.rs @@ -42,7 +42,7 @@ async fn generate_markdown( ) -> anyhow::Result<()> { let path = dir.join("allocations.md"); let mut lines = vec![ - "# IP Subnet Allocator – Export".to_string(), + "# SUB-ALIGATOR – Export".to_string(), "".to_string(), format!("Generated: {}", chrono::Utc::now().to_rfc3339()), "".to_string(), diff --git a/src/handlers/allocations.rs b/src/handlers/allocations.rs index 00b0cfc..889ded7 100644 --- a/src/handlers/allocations.rs +++ b/src/handlers/allocations.rs @@ -273,8 +273,8 @@ pub async fn release_in_tx( Ok(()) } -async fn regenerate_after_change(state: &AppState) -> anyhow::Result<()> { - let pools: Vec = sqlx::query_as("SELECT * FROM pools WHERE enabled = true") +pub(crate) async fn regenerate_after_change(state: &AppState) -> anyhow::Result<()> { + let pools: Vec = sqlx::query_as("SELECT * FROM pools ORDER BY id") .fetch_all(&state.pool) .await?; let allocations: Vec = diff --git a/src/handlers/pools.rs b/src/handlers/pools.rs index 0b66776..1bca6e5 100644 --- a/src/handlers/pools.rs +++ b/src/handlers/pools.rs @@ -2,9 +2,9 @@ use crate::audit::{get_actor_from_request, log_event}; use crate::csrf::{ensure_csrf_cookie, validate_csrf}; use crate::models::{now_utc_rfc3339, Pool}; use crate::state::AppState; -use crate::templates::{PoolListTemplate, SetupTemplate}; +use crate::templates::{PoolListTemplate, PoolRow, SetupTemplate}; use askama::Template; -use axum::extract::{Form, State}; +use axum::extract::{Form, Path, State}; use axum::http::HeaderMap; use axum::response::{Html, IntoResponse, Redirect, Response}; use axum_extra::extract::cookie::CookieJar; @@ -24,6 +24,11 @@ pub struct CreatePoolForm { pub csrf_token: String, } +#[derive(Deserialize, Debug)] +pub struct DeletePoolForm { + pub csrf_token: String, +} + pub async fn setup_page(State(state): State, jar: CookieJar) -> Response { let (jar, csrf_token) = ensure_csrf_cookie(jar); @@ -60,31 +65,124 @@ pub async fn create_pool( let actor = get_actor_from_request(&headers); if !validate_csrf(&jar, &form.csrf_token) { - return render_setup_with_error(&jar, csrf_token, "Invalid CSRF token.").into_response(); + return render_setup_with_error(csrf_token, "Invalid CSRF token.").into_response(); } match validate_and_insert_pool(&state.pool, &form, &actor).await { - Ok(_) => { - info!("Pool {} created by {}", form.slug, actor); + 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(&jar, csrf_token, &e.to_string()).into_response(), + Err(e) => render_setup_with_error(csrf_token, &e.to_string()).into_response(), } } +pub async fn delete_pool( + State(state): State, + jar: CookieJar, + headers: HeaderMap, + Path(id): Path, + Form(form): Form, +) -> 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 conn = state.pool.acquire().await?; + + sqlx::query("BEGIN IMMEDIATE").execute(&mut *conn).await?; + + let pool: Option = sqlx::query_as("SELECT * FROM pools WHERE id = ?") + .bind(id) + .fetch_optional(&mut *conn) + .await?; + + let pool = match pool { + Some(p) => p, + None => { + sqlx::query("ROLLBACK").execute(&mut *conn).await?; + anyhow::bail!("Pool not found."); + } + }; + + if !pool.is_enabled() { + sqlx::query("ROLLBACK").execute(&mut *conn).await?; + 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 *conn) + .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 *conn) + .await?; + + log_event( + &mut conn, + "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(); + + sqlx::query("COMMIT").execute(&mut *conn).await?; + + Ok(()) +} + pub async fn pool_list(State(state): State, jar: CookieJar) -> Response { let (jar, csrf_token) = ensure_csrf_cookie(jar); - let pools: Vec = sqlx::query_as::<_, Pool>("SELECT * FROM pools ORDER BY id") - .fetch_all(&state.pool) - .await - .unwrap_or_default(); - - let template = PoolListTemplate { - pools, - csrf_token, - error: String::new(), - }; + let template = build_pool_list_template(&state, csrf_token, String::new()).await; match template.render() { Ok(html) => (jar, Html(html)).into_response(), @@ -95,11 +193,64 @@ pub async fn pool_list(State(state): State, jar: CookieJar) -> Respons } } -fn render_setup_with_error( - _jar: &CookieJar, +async fn render_pool_list_with_error( + state: &AppState, csrf_token: String, error: &str, ) -> Html { + 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 = + 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 { let template = SetupTemplate { csrf_token, error: error.to_string(), @@ -114,11 +265,25 @@ fn render_setup_with_error( } } +#[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<()> { +) -> anyhow::Result { let slug = form.slug.trim().to_lowercase(); if slug.is_empty() || !slug.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') { anyhow::bail!("Slug must contain only lowercase letters, numbers and hyphens."); @@ -176,15 +341,90 @@ async fn validate_and_insert_pool( .execute(&mut *conn) .await?; - let existing: Option = sqlx::query_scalar("SELECT id FROM pools WHERE slug = ? OR cidr = ?") - .bind(&slug) + let existing_by_cidr: Option = sqlx::query_as("SELECT * FROM pools WHERE cidr = ?") .bind(&cidr) .fetch_optional(&mut *conn) .await?; - if existing.is_some() { + if let Some(existing) = existing_by_cidr { + if existing.is_enabled() { + sqlx::query("ROLLBACK").execute(&mut *conn).await?; + 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 = + sqlx::query_scalar("SELECT id FROM pools WHERE slug = ? AND id != ?") + .bind(&slug) + .bind(existing.id) + .fetch_optional(&mut *conn) + .await?; + + if slug_taken.is_some() { + sqlx::query("ROLLBACK").execute(&mut *conn).await?; + 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 *conn) + .await?; + + log_event( + &mut conn, + "pool_reenabled", + None, + Some(existing.id), + actor, + json!({ + "slug": slug, + "cidr": cidr, + "kind": kind, + "allowed_prefixes": prefixes, + }), + ) + .await + .ok(); + + sqlx::query("COMMIT").execute(&mut *conn).await?; + + return Ok(CreatePoolOutcome::Reenabled { id: existing.id }); + } + + let slug_taken: Option = sqlx::query_scalar("SELECT id FROM pools WHERE slug = ?") + .bind(&slug) + .fetch_optional(&mut *conn) + .await?; + + if slug_taken.is_some() { sqlx::query("ROLLBACK").execute(&mut *conn).await?; - anyhow::bail!("A pool with the same slug or CIDR already exists."); + anyhow::bail!("A pool with the same slug already exists."); + } + + // Two enabled pools must never cover the same address space. + let enabled_pools: Vec = + sqlx::query_as("SELECT * FROM pools WHERE enabled = true") + .fetch_all(&mut *conn) + .await?; + + for p in &enabled_pools { + if let Ok(other) = p.cidr.parse::() + && nets_overlap(&net, &other) + { + sqlx::query("ROLLBACK").execute(&mut *conn).await?; + anyhow::bail!( + "Pool range overlaps with existing pool '{}' ({}).", + p.name, + p.cidr + ); + } } let id = sqlx::query( @@ -201,6 +441,45 @@ async fn validate_and_insert_pool( .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 = + sqlx::query_as("SELECT * FROM pools WHERE enabled = false AND id != ?") + .bind(id) + .fetch_all(&mut *conn) + .await?; + + let mut reattached: i64 = 0; + for dp in &disabled_pools { + let Ok(dp_net) = dp.cidr.parse::() 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 *conn) + .await?; + + for (alloc_id, alloc_cidr) in orphans { + let Ok(alloc_net) = alloc_cidr.parse::() else { + continue; + }; + if net_contains(&net, &alloc_net) { + sqlx::query("UPDATE allocations SET pool_id = ? WHERE id = ?") + .bind(id) + .bind(alloc_id) + .execute(&mut *conn) + .await?; + reattached += 1; + } + } + } + log_event( &mut conn, "pool_created", @@ -212,6 +491,7 @@ async fn validate_and_insert_pool( "cidr": cidr, "kind": kind, "allowed_prefixes": prefixes, + "reattached_allocations": reattached, }), ) .await @@ -219,5 +499,5 @@ async fn validate_and_insert_pool( sqlx::query("COMMIT").execute(&mut *conn).await?; - Ok(()) + Ok(CreatePoolOutcome::Created { id, reattached }) } diff --git a/src/routes.rs b/src/routes.rs index 353d413..532ec9c 100644 --- a/src/routes.rs +++ b/src/routes.rs @@ -9,6 +9,7 @@ pub fn router() -> Router { .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("/partials/pools/{slug}/preview", get(partials::pool_preview)) .route("/allocations", post(allocations::create_allocation)) .route("/allocations/{id}", get(pages::allocation_detail)) diff --git a/src/templates.rs b/src/templates.rs index 2f93210..7c98ce8 100644 --- a/src/templates.rs +++ b/src/templates.rs @@ -150,10 +150,19 @@ pub struct SetupTemplate { 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(Template)] #[template(path = "pools.html")] pub struct PoolListTemplate { - pub pools: Vec, + pub pools: Vec, pub csrf_token: String, pub error: String, } diff --git a/static/app.js b/static/app.js index bffebaf..09be17a 100644 --- a/static/app.js +++ b/static/app.js @@ -25,6 +25,17 @@ function initApp() { }); }); + // 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'; + } + }); + }); + // Close modal via Cancel button document.querySelectorAll('.cancel-btn').forEach(function (btn) { btn.addEventListener('click', function () { diff --git a/static/style.css b/static/style.css index 151bfd1..85fb991 100644 --- a/static/style.css +++ b/static/style.css @@ -14,11 +14,38 @@ --card-bg: #faf8f3; --hover: #ebe7de; --active: #d4edda; + --active-fg: #155724; --reserved: #fff3cd; + --reserved-fg: #856404; --released: #e2e3e5; + --released-fg: #383d41; --link: #0044cc; } +[data-theme="dark"] { + --bg: #0c0c0c; + --fg: #ffa63d; + --muted: #b97c35; + --border: #7a4d16; + --accent: #2b1c08; + --accent-fg: #ffb966; + --success-bg: #0e2214; + --success-fg: #7ed491; + --error-bg: #260f0f; + --error-fg: #ff7b72; + --warning-bg: #26200a; + --warning-fg: #e3c05c; + --card-bg: #121212; + --hover: #241a0d; + --active: #0e2214; + --active-fg: #7ed491; + --reserved: #26200a; + --reserved-fg: #e3c05c; + --released: #1a1a1a; + --released-fg: #9a9a9a; + --link: #ffc06e; +} + * { box-sizing: border-box; } @@ -57,6 +84,27 @@ header { border: 2px solid var(--border); padding: 1rem; background: var(--card-bg); + position: relative; +} + +.header-nav { + position: absolute; + top: 0.75rem; + display: flex; + gap: 0.5rem; +} + +.header-nav-left { + left: 0.75rem; +} + +.header-nav-right { + right: 0.75rem; +} + +.header-nav .btn { + padding: 0.25rem 0.6rem; + font-size: 0.875rem; } header h1 { @@ -133,6 +181,7 @@ textarea:focus { .btn:hover { background: var(--fg); + color: var(--bg); } .btn-secondary { @@ -251,17 +300,17 @@ tr:hover { .status.active { background: var(--active); - color: #155724; + color: var(--active-fg); } .status.reserved { background: var(--reserved); - color: #856404; + color: var(--reserved-fg); } .status.released { background: var(--released); - color: #383d41; + color: var(--released-fg); } .pagination { @@ -345,6 +394,10 @@ tr:hover { padding: 0.5rem; } + header { + padding-top: 3rem; + } + .grid { grid-template-columns: 1fr; } diff --git a/static/theme.js b/static/theme.js new file mode 100644 index 0000000..7218eef --- /dev/null +++ b/static/theme.js @@ -0,0 +1,49 @@ +(function () { + var STORAGE_KEY = 'sub-aligator-theme'; + + function storedTheme() { + try { + return localStorage.getItem(STORAGE_KEY); + } catch (e) { + return null; + } + } + + function currentTheme() { + return document.documentElement.getAttribute('data-theme') === 'dark' ? 'dark' : 'light'; + } + + function applyTheme(theme) { + document.documentElement.setAttribute('data-theme', theme); + } + + // Apply the saved theme before the body renders to avoid a flash of the wrong theme. + applyTheme(storedTheme() === 'dark' ? 'dark' : 'light'); + + function updateToggle(btn) { + btn.textContent = currentTheme() === 'dark' ? '[ Light ]' : '[ Dark ]'; + btn.setAttribute('aria-pressed', currentTheme() === 'dark' ? 'true' : 'false'); + } + + function wireToggle() { + var btn = document.getElementById('theme-toggle'); + if (!btn) { + return; + } + updateToggle(btn); + btn.addEventListener('click', function () { + var next = currentTheme() === 'dark' ? 'light' : 'dark'; + applyTheme(next); + try { + localStorage.setItem(STORAGE_KEY, next); + } catch (e) {} + updateToggle(btn); + }); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', wireToggle); + } else { + wireToggle(); + } +})(); diff --git a/templates/allocation_detail.html b/templates/allocation_detail.html index 40739bc..8238301 100644 --- a/templates/allocation_detail.html +++ b/templates/allocation_detail.html @@ -1,6 +1,6 @@ {% extends "base.html" %} -{% block title %}Detail {{ allocation.cidr }} | IP Subnet Allocator{% endblock %} +{% block title %}Detail {{ allocation.cidr }} | SUB-ALIGATOR{% endblock %} {% block content %}
@@ -123,6 +123,4 @@
- - {% endblock %} diff --git a/templates/base.html b/templates/base.html index cb1a19a..325c926 100644 --- a/templates/base.html +++ b/templates/base.html @@ -4,21 +4,28 @@ - {% block title %}IP Subnet Allocator{% endblock %} + {% block title %}SUB-ALIGATOR{% endblock %} + + {% block head %}{% endblock %}
-

IP Subnet Allocator

+
+ Home +
+
+ +
+

SUB-ALIGATOR

Internal IPv4 subnet allocation and tracking

{% block content %}{% endblock %}
- - {% endblock %} diff --git a/templates/pools.html b/templates/pools.html index 66bd0bc..4b496b7 100644 --- a/templates/pools.html +++ b/templates/pools.html @@ -1,11 +1,17 @@ {% extends "base.html" %} -{% block title %}Manage pools | IP Subnet Allocator{% endblock %} +{% block title %}Manage pools | SUB-ALIGATOR{% endblock %} {% block content %}
Existing pools
+ {% if error != "" %} +
+

{{ error }}

+
+ {% endif %} + {% if pools.len() == 0 %}

No pools configured yet.

{% else %} @@ -17,16 +23,22 @@ Parent prefix Type Allowed prefixes + Allocations (active / reserved / released) + Actions {% for p in pools %} - {{ p.name }} - {{ p.slug }} - {{ p.cidr }} - {{ p.kind }} - {{ p.allowed_prefixes_json }} + {{ p.pool.name }} + {{ p.pool.slug }} + {{ p.pool.cidr }} + {{ p.pool.kind }} + {{ p.pool.allowed_prefixes_json }} + {{ p.active_count }} / {{ p.reserved_count }} / {{ p.released_count }} + + + {% endfor %} @@ -37,24 +49,19 @@
Add new pool
- {% if error != "" %} -
-

{{ error }}

-
- {% endif %} -
-

Lowercase letters, numbers and hyphens only.

+

Short machine identifier used in URLs and API paths (e.g. vpn). Lowercase letters, numbers and hyphens only.

+

If you re-add a range that was deleted before, its previous allocations are kept and will block the address space again.

@@ -69,4 +76,26 @@
+ +{% for p in pools %} + +{% endfor %} {% endblock %} diff --git a/templates/setup.html b/templates/setup.html index bd9dd7e..046159d 100644 --- a/templates/setup.html +++ b/templates/setup.html @@ -1,6 +1,6 @@ {% extends "base.html" %} -{% block title %}Setup | IP Subnet Allocator{% endblock %} +{% block title %}Setup | SUB-ALIGATOR{% endblock %} {% block content %}
@@ -19,7 +19,7 @@ -

Lowercase letters, numbers and hyphens only. Used in URLs.

+

Short machine identifier used in URLs and API paths (e.g. vlan-customers). Lowercase letters, numbers and hyphens only.