diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 8e130e9b..29eaddde 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -10,7 +10,7 @@ assignees: '' e.g. 5.8.0 **FreeBSD Version** -e.g. FreeBSD 15.0-RELEASE +e.g. FreeBSD 15.1-RELEASE **Component** Which part of AiFw is affected? diff --git a/.github/workflows/build-iso.yml b/.github/workflows/build-iso.yml index d6181cff..5d192fbe 100644 --- a/.github/workflows/build-iso.yml +++ b/.github/workflows/build-iso.yml @@ -12,9 +12,11 @@ on: permissions: contents: write + id-token: write + attestations: write env: - FREEBSD_VERSION: "15.0" + FREEBSD_VERSION: "15.1" jobs: build-ui: @@ -187,6 +189,10 @@ jobs: if [ -f "$f" ] && [ ! -f "${f}.xz" ]; then xz -T0 -9 "$f" sha256 "${f}.xz" > "${f}.xz.sha256" + # build-iso.sh hashed the uncompressed file, which no + # longer exists after xz — drop its checksum so we never + # sign or publish a sum nobody can verify. + rm -f "${f}.sha256" fi done cd /home/runner/work/AiFw/AiFw @@ -195,6 +201,45 @@ jobs: mkdir -p /home/runner/work/AiFw/AiFw/output cp /usr/obj/aifw-iso/output/* /home/runner/work/AiFw/AiFw/output/ + # Scan the SOURCE tree, not output/: the release artifacts are + # xz-compressed images syft cannot see inside, so an SBOM of output/ + # would be an empty inventory. The source scan covers Cargo.lock and + # aifw-ui/package-lock.json — the dependency set actually compiled + # into this release. + - name: Generate release CycloneDX SBOM + uses: anchore/sbom-action@f8bdd1d8ac5e901a77a92f111440fdb1b593736b # v0.20.6 + with: + path: . + format: cyclonedx-json + output-file: output/aifw-release.cdx.json + upload-artifact: false + + - name: Sign release checksums + env: + MINISIGN_SECRET_KEY: ${{ secrets.MINISIGN_SECRET_KEY }} + MINISIGN_PASSWORD: ${{ secrets.MINISIGN_PASSWORD }} + run: | + set -euo pipefail + test -n "$MINISIGN_SECRET_KEY" || { echo "MINISIGN_SECRET_KEY is required" >&2; exit 1; } + sudo apt-get update + sudo apt-get install -y minisign + key_file="$RUNNER_TEMP/minisign.key" + trap 'rm -f "$key_file"' EXIT + printf '%s' "$MINISIGN_SECRET_KEY" > "$key_file" + chmod 600 "$key_file" + pubkey=freebsd/overlay/usr/local/etc/aifw/update-signing.pub + cp "$pubkey" output/update-signing.pub + for checksum in output/*.sha256; do + # minisign reads the key password from stdin when there is no + # tty; harmless if the key is passwordless. + printf '%s\n' "${MINISIGN_PASSWORD:-}" | \ + minisign -S -s "$key_file" -m "$checksum" -x "${checksum}.minisig" + # Verify against the COMMITTED public key immediately: catches a + # rotated/mismatched MINISIGN_SECRET_KEY before anything ships, + # since appliances verify with the key compiled from this file. + minisign -Vm "$checksum" -x "${checksum}.minisig" -p "$pubkey" + done + - name: Upload ISO artifact uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: @@ -223,6 +268,25 @@ jobs: path: output/aifw-components-*.json retention-days: 5 + - name: Upload SBOMs and signatures + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: supply-chain + path: | + output/*.cdx.json + output/*.minisig + output/update-signing.pub + retention-days: 5 + + - name: Attest release artifacts + uses: actions/attest-build-provenance@e8998f949152b193b063cb0ec769d69d929409be # v2.4.0 + with: + subject-path: | + output/aifw-*.iso.xz + output/aifw-*.img.xz + output/aifw-update-*.tar.xz + output/aifw-release.cdx.json + smoke-boot: name: Appliance boot smoke (qemu) needs: build-iso @@ -263,7 +327,6 @@ jobs: name: boot-smoke-artifacts path: smoke-artifacts/ retention-days: 7 - release: name: Create GitHub Release # smoke-boot gates the release: an image nobody booted never ships (#533). @@ -296,6 +359,25 @@ jobs: name: components path: release-assets/ + - name: Download supply-chain assets + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: supply-chain + path: release-assets/ + + - name: Verify signatures before publication + run: | + set -euo pipefail + sudo apt-get update + sudo apt-get install -y minisign + # The committed key from checkout — the same file compiled into + # the appliance updater — not a copy that traveled with the + # artifacts being verified. + pubkey=freebsd/overlay/usr/local/etc/aifw/update-signing.pub + for checksum in release-assets/*.sha256; do + minisign -Vm "$checksum" -x "${checksum}.minisig" -p "$pubkey" + done + - name: Extract version from tag id: version run: if [ -n "${{ inputs.version }}" ]; then @@ -309,7 +391,12 @@ jobs: with: name: "AiFw v${{ steps.version.outputs.version }}" draft: false - prerelease: ${{ contains(github.ref_name, '-rc') || contains(github.ref_name, '-beta') || contains(github.ref_name, '-alpha') }} + # ALWAYS pre-release: field appliances auto-pull /releases/latest, + # which skips pre-releases. A plain v* tag used to publish stable + # instantly — an untested build every appliance would grab the + # moment CI finished. Promote after testing with: + # gh release edit --prerelease=false --latest + prerelease: true generate_release_notes: true files: | release-assets/* @@ -335,6 +422,15 @@ jobs: ### Verify Downloads ```bash - sha256sum -c aifw-${{ steps.version.outputs.version }}-amd64.iso.sha256 - sha256sum -c aifw-${{ steps.version.outputs.version }}-amd64.img.sha256 + minisign -Vm aifw-${{ steps.version.outputs.version }}-amd64.iso.xz.sha256 \ + -x aifw-${{ steps.version.outputs.version }}-amd64.iso.xz.sha256.minisig \ + -p update-signing.pub + sha256sum -c aifw-${{ steps.version.outputs.version }}-amd64.iso.xz.sha256 + ``` + + GitHub build provenance can also be verified with: + + ```bash + gh attestation verify aifw-${{ steps.version.outputs.version }}-amd64.iso.xz \ + --repo ${{ github.repository }} ``` diff --git a/.github/workflows/fq-codel-freebsd.yml b/.github/workflows/fq-codel-freebsd.yml new file mode 100644 index 00000000..0bae5b3d --- /dev/null +++ b/.github/workflows/fq-codel-freebsd.yml @@ -0,0 +1,37 @@ +name: FQ-CoDel FreeBSD qualification + +on: + pull_request: + paths: + - "aifw-common/src/ratelimit.rs" + - "aifw-core/src/shaping.rs" + - "freebsd/overlay/usr/local/libexec/aifw*dummynet*" + - "freebsd/tests/t08-fq-codel.sh" + - ".github/workflows/fq-codel-freebsd.yml" + workflow_dispatch: + +permissions: + contents: read + +jobs: + functional: + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + - name: Exercise real FreeBSD dummynet + uses: vmactions/freebsd-vm@77ed28d336d03fe19a3f4f7266c1d2c4714dd79d # v1.5.2 + with: + release: "15.0" + usesh: true + prepare: | + install -m 755 freebsd/overlay/usr/local/libexec/aifw-dummynet-control /usr/local/libexec/aifw-dummynet-control + run: | + sh freebsd/tests/t08-fq-codel.sh 2>&1 | tee fq-codel-results.txt + - name: Upload raw result + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: fq-codel-freebsd-${{ github.sha }} + path: fq-codel-results.txt + if-no-files-found: warn diff --git a/.github/workflows/freebsd-functional.yml b/.github/workflows/freebsd-functional.yml index c461cc66..d2aa4986 100644 --- a/.github/workflows/freebsd-functional.yml +++ b/.github/workflows/freebsd-functional.yml @@ -30,7 +30,7 @@ jobs: - name: Run harness in FreeBSD VM uses: vmactions/freebsd-vm@77ed28d336d03fe19a3f4f7266c1d2c4714dd79d # v1.5.2 with: - release: "15.0" + release: "15.1" usesh: true copyback: true prepare: | diff --git a/.gitignore b/.gitignore index d8f24a5e..87a8b5a6 100644 --- a/.gitignore +++ b/.gitignore @@ -29,5 +29,8 @@ node_modules/ freebsd/release/ freebsd/ui-export/ +# Local-only review/status ledger +/status.md + # CLAUDE.md is project policy and is tracked in the repo — do not ignore it. Cargo.lock diff --git a/CLAUDE.md b/CLAUDE.md index 339a583b..3683add0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -99,6 +99,10 @@ Central type: `Database` struct in `aifw-core/src/db.rs` wrapping `SqlitePool`. - `Authorization: ApiKey ` header - `?ticket=` query param (WebSocket/SSE) — single-use, 30-second ticket issued by `POST /auth/ws-ticket` (see `auth::ws_ticket`). +- `aifw_at` HttpOnly session cookie (browser UI; SEC-M7 #304, see + `auth::cookies`) — fallback when no header/ticket is present. Cookie-authed + non-GET requests must also send the `X-AiFw-Csrf` header; the UI never + stores the JWT in `localStorage`. **AppState** holds all engines as `Arc`, shared `Arc`, and `SqlitePool`. Passed to handlers via Axum's `State` extractor. diff --git a/Cargo.toml b/Cargo.toml index eaed5f6a..fe6c12a7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,7 +20,7 @@ resolver = "2" [workspace.package] -version = "5.109.4" +version = "5.111.0" edition = "2024" license = "MIT" repository = "https://github.com/ZerosAndOnesLLC/AiFw" diff --git a/README.md b/README.md index 76379bed..ec942875 100644 --- a/README.md +++ b/README.md @@ -74,7 +74,7 @@ High-performance firewall for FreeBSD built in Rust on top of pf. Optional AI/ML ## Features - **Stateful packet filtering** via FreeBSD's pf with anchor isolation -- **NAT** — SNAT, DNAT/RDR, masquerade, binat (NAT64/NAT46 in development — rule types exist, cross-family translation data plane is being built, #531) +- **NAT** — SNAT, DNAT/RDR, masquerade, binat, NAT64/NAT46 (real cross-family translation via pf af-to, FreeBSD 15+, with DNS64 in the resolver) - **Connection tracking** — real-time state table monitoring, top talkers, protocol breakdown - **Rate limiting & traffic shaping** — HFSC/PriQ queues, per-IP overload tables, SYN flood protection (CoDel via dummynet FQ-CoDel in development, #532) - **AI/ML threat detection** *(optional, WIP)* — experimental port scan, DDoS, brute force, C2 beacon, DNS tunnel detection with auto-response (disabled by default, not yet production-ready) diff --git a/aifw-api/src/auth/cookies.rs b/aifw-api/src/auth/cookies.rs new file mode 100644 index 00000000..7475f243 --- /dev/null +++ b/aifw-api/src/auth/cookies.rs @@ -0,0 +1,173 @@ +//! Session cookies for the browser UI (SEC-M7 #304). +//! +//! The web UI authenticates with `HttpOnly` cookies instead of storing the +//! JWT in `localStorage`, so an XSS bug can no longer exfiltrate the token. +//! Non-browser clients (CLI, API keys, scripts) keep using `Authorization` +//! headers — cookies are an additional credential source, not a replacement. +//! +//! Two cookies are issued at login and rotated on refresh: +//! - [`ACCESS_COOKIE`]: the access JWT, sent on every request (`Path=/`). +//! - [`REFRESH_COOKIE`]: the refresh token, scoped to `Path=/api/v1/auth` so +//! it only travels to the refresh/logout endpoints. +//! +//! Both are `HttpOnly; SameSite=Strict`, plus `Secure` when the API serves +//! TLS. Cross-site request forgery is blocked twice over: `SameSite=Strict` +//! keeps browsers from attaching the cookies cross-site at all, and the auth +//! middleware additionally requires the custom [`CSRF_HEADER`] on unsafe +//! methods when the credential came from a cookie (a cross-site page cannot +//! set custom headers without a CORS preflight, and credentialed cross-origin +//! requests are never approved — the CORS layer does not allow credentials). + +use axum::http::{HeaderMap, HeaderValue}; + +use super::config::AuthSettings; +use super::tokens::TokenPair; + +/// Access-token cookie: the JWT, attached to every same-site request. +pub const ACCESS_COOKIE: &str = "aifw_at"; +/// Refresh-token cookie, scoped to the auth endpoints only. +pub const REFRESH_COOKIE: &str = "aifw_rt"; +/// Path scope for [`REFRESH_COOKIE`]. +const REFRESH_PATH: &str = "/api/v1/auth"; +/// Header the UI sends on every request; required by the auth middleware for +/// unsafe methods when authenticating via cookie (CSRF defense in depth). +pub const CSRF_HEADER: &str = "x-aifw-csrf"; + +/// Read a cookie's value from the request headers. Handles multiple `Cookie` +/// headers and the standard `name=value; name2=value2` form. +pub fn cookie_value(headers: &HeaderMap, name: &str) -> Option { + for header in headers.get_all(axum::http::header::COOKIE) { + let Ok(s) = header.to_str() else { continue }; + for pair in s.split(';') { + let mut it = pair.trim().splitn(2, '='); + if it.next() == Some(name) { + return Some(it.next().unwrap_or("").to_string()); + } + } + } + None +} + +fn build_cookie(name: &str, value: &str, path: &str, max_age_secs: i64, secure: bool) -> String { + let secure = if secure { "; Secure" } else { "" }; + format!( + "{name}={value}; Path={path}; Max-Age={max_age_secs}; HttpOnly; SameSite=Strict{secure}" + ) +} + +/// `Set-Cookie` values installing a freshly issued token pair. +pub fn session_cookies( + tokens: &TokenPair, + settings: &AuthSettings, + secure: bool, +) -> Vec { + let access = build_cookie( + ACCESS_COOKIE, + &tokens.access_token, + "/", + settings.access_token_expiry_mins.max(0) * 60, + secure, + ); + let refresh = build_cookie( + REFRESH_COOKIE, + &tokens.refresh_token, + REFRESH_PATH, + settings.refresh_token_expiry_days.max(0) * 86_400, + secure, + ); + // The values are ASCII we produced ourselves (JWT / rfx_ hex + fixed + // attributes), so HeaderValue conversion cannot fail. + [access, refresh] + .into_iter() + .filter_map(|c| HeaderValue::from_str(&c).ok()) + .collect() +} + +/// `Set-Cookie` values expiring both session cookies (logout). +pub fn clear_cookies(secure: bool) -> Vec { + [ + build_cookie(ACCESS_COOKIE, "", "/", 0, secure), + build_cookie(REFRESH_COOKIE, "", REFRESH_PATH, 0, secure), + ] + .into_iter() + .filter_map(|c| HeaderValue::from_str(&c).ok()) + .collect() +} + +/// Append `Set-Cookie` headers onto a response's header map. +pub fn append_set_cookies(headers: &mut HeaderMap, cookies: Vec) { + for c in cookies { + headers.append(axum::http::header::SET_COOKIE, c); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn settings() -> AuthSettings { + AuthSettings { + jwt_secret: "s".into(), + access_token_expiry_mins: 60, + refresh_token_expiry_days: 7, + ..AuthSettings::default() + } + } + + fn pair() -> TokenPair { + TokenPair { + access_token: "jwt-value".into(), + refresh_token: "rfx_abc".into(), + access_expires_at: String::new(), + refresh_expires_at: String::new(), + token_type: "Bearer".into(), + } + } + + #[test] + fn parses_cookie_header() { + let mut h = HeaderMap::new(); + h.insert( + axum::http::header::COOKIE, + HeaderValue::from_static("foo=1; aifw_at=tok.en; bar=2"), + ); + assert_eq!(cookie_value(&h, ACCESS_COOKIE).as_deref(), Some("tok.en")); + assert_eq!(cookie_value(&h, REFRESH_COOKIE), None); + } + + #[test] + fn session_cookies_are_httponly_samesite_strict() { + for c in session_cookies(&pair(), &settings(), false) { + let s = c.to_str().unwrap(); + assert!(s.contains("HttpOnly"), "{s}"); + assert!(s.contains("SameSite=Strict"), "{s}"); + assert!(!s.contains("Secure"), "{s}"); + } + } + + #[test] + fn secure_flag_follows_tls() { + for c in session_cookies(&pair(), &settings(), true) { + assert!(c.to_str().unwrap().contains("; Secure")); + } + } + + #[test] + fn refresh_cookie_is_path_scoped() { + let cookies = session_cookies(&pair(), &settings(), false); + let refresh = cookies[1].to_str().unwrap(); + assert!( + refresh.starts_with("aifw_rt=rfx_abc; Path=/api/v1/auth;"), + "{refresh}" + ); + let access = cookies[0].to_str().unwrap(); + assert!(access.starts_with("aifw_at=jwt-value; Path=/;"), "{access}"); + } + + #[test] + fn clear_cookies_expire_immediately() { + for c in clear_cookies(false) { + assert!(c.to_str().unwrap().contains("Max-Age=0")); + } + } +} diff --git a/aifw-api/src/auth/middleware.rs b/aifw-api/src/auth/middleware.rs index 789aba60..f859fc3e 100644 --- a/aifw-api/src/auth/middleware.rs +++ b/aifw-api/src/auth/middleware.rs @@ -50,74 +50,98 @@ pub async fn auth_middleware( use std::time::Instant; // Credentials: Authorization: Bearer , Authorization: ApiKey , - // or ?ticket= (short-lived, single-use; see auth::ws_ticket). + // ?ticket= (short-lived, single-use; see auth::ws_ticket), or the + // HttpOnly session cookie set at login (SEC-M7 #304 — browser UI). let auth_header = headers.get("authorization").and_then(|v| v.to_str().ok()); + // The cookie is a fallback only: an Authorization header or ticket wins, + // so non-browser clients and the WS/SSE ticket flow are unaffected. + let cookie_token = if auth_header.is_none() && query_ticket(request.uri().query()).is_none() { + super::cookies::cookie_value(&headers, super::cookies::ACCESS_COOKIE) + } else { + None + }; + + // CSRF defense in depth (on top of SameSite=Strict): a cookie is attached + // by the browser, not the page, so state-changing requests must also carry + // the custom header only same-origin script can set. + if cookie_token.is_some() + && !matches!( + *request.method(), + axum::http::Method::GET | axum::http::Method::HEAD | axum::http::Method::OPTIONS + ) + && !headers.contains_key(super::cookies::CSRF_HEADER) + { + return Err(StatusCode::FORBIDDEN); + } + // Resolve (user_id, perm_from_token, role_from_token, api_key_name) from the credential - let (user_id, jwt_perm, jwt_role, api_key_name) = - if let Some(token) = auth_header.and_then(|h| h.strip_prefix("Bearer ")) { - // PERF-H21: reuse a previously decoded token. The entry deadline - // is capped at the token's `exp` on insert, so a live cache hit - // is always still within the token's validity window. - let now = Instant::now(); - let claims = if let Some(entry) = state.auth_token_cache.get(token) - && entry.value().1 > now - { - entry.value().0.clone() - } else { - let claims = verify_access_token(token, &state.auth_settings) - .map_err(|_| StatusCode::UNAUTHORIZED)? - .claims; - // Cap TTL at the token's remaining lifetime (never negative), - // and at AUTH_TOKEN_CACHE_TTL so a rotated jwt_secret can't - // keep validating an old token for longer than that window. - let ttl = std::time::Duration::from_secs(token_cache_ttl_secs( - claims.exp, - chrono::Utc::now().timestamp(), - )); - // Bound memory: sweep expired entries when the map grows large - // (these caches are otherwise evicted only lazily). - if state.auth_token_cache.len() >= AUTH_TOKEN_CACHE_MAX { - state - .auth_token_cache - .retain(|_, (_, deadline)| *deadline > now); - } + let (user_id, jwt_perm, jwt_role, api_key_name) = if let Some(token) = auth_header + .and_then(|h| h.strip_prefix("Bearer ")) + .or(cookie_token.as_deref()) + { + // PERF-H21: reuse a previously decoded token. The entry deadline + // is capped at the token's `exp` on insert, so a live cache hit + // is always still within the token's validity window. + let now = Instant::now(); + let claims = if let Some(entry) = state.auth_token_cache.get(token) + && entry.value().1 > now + { + entry.value().0.clone() + } else { + let claims = verify_access_token(token, &state.auth_settings) + .map_err(|_| StatusCode::UNAUTHORIZED)? + .claims; + // Cap TTL at the token's remaining lifetime (never negative), + // and at AUTH_TOKEN_CACHE_TTL so a rotated jwt_secret can't + // keep validating an old token for longer than that window. + let ttl = std::time::Duration::from_secs(token_cache_ttl_secs( + claims.exp, + chrono::Utc::now().timestamp(), + )); + // Bound memory: sweep expired entries when the map grows large + // (these caches are otherwise evicted only lazily). + if state.auth_token_cache.len() >= AUTH_TOKEN_CACHE_MAX { state .auth_token_cache - .insert(token.to_string(), (claims.clone(), now + ttl)); - claims - }; - let jti = &claims.jti; - // JTI revocation: positive cache to skip the DB lookup. Cached - // entry value=true means revoked, value=false means not revoked. - let revoked = if let Some(entry) = state.auth_jti_cache.get(jti) - && entry.value().1 > now - { - entry.value().0 - } else { - let r = is_token_revoked(&state.pool, jti).await; - state - .auth_jti_cache - .insert(jti.clone(), (r, now + AUTH_JTI_CACHE_TTL)); - r - }; - if revoked { - return Err(StatusCode::UNAUTHORIZED); + .retain(|_, (_, deadline)| *deadline > now); } - (claims.sub, claims.perm, claims.role, None) - } else if let Some(key) = auth_header.and_then(|h| h.strip_prefix("ApiKey ")) { - let (uid, key_name) = verify_api_key(&state.pool, key).await?; - (uid, None, None, Some(key_name)) // API keys don't carry JWT claims — will do DB lookup - } else if let Some(ticket_id) = query_ticket(request.uri().query()) { - let uid = state - .ws_tickets - .consume(&ticket_id) - .await - .ok_or(StatusCode::UNAUTHORIZED)?; - (uid, None, None, None) // tickets inherit permissions from the DB row + state + .auth_token_cache + .insert(token.to_string(), (claims.clone(), now + ttl)); + claims + }; + let jti = &claims.jti; + // JTI revocation: positive cache to skip the DB lookup. Cached + // entry value=true means revoked, value=false means not revoked. + let revoked = if let Some(entry) = state.auth_jti_cache.get(jti) + && entry.value().1 > now + { + entry.value().0 } else { - return Err(StatusCode::UNAUTHORIZED); + let r = is_token_revoked(&state.pool, jti).await; + state + .auth_jti_cache + .insert(jti.clone(), (r, now + AUTH_JTI_CACHE_TTL)); + r }; + if revoked { + return Err(StatusCode::UNAUTHORIZED); + } + (claims.sub, claims.perm, claims.role, None) + } else if let Some(key) = auth_header.and_then(|h| h.strip_prefix("ApiKey ")) { + let (uid, key_name) = verify_api_key(&state.pool, key).await?; + (uid, None, None, Some(key_name)) // API keys don't carry JWT claims — will do DB lookup + } else if let Some(ticket_id) = query_ticket(request.uri().query()) { + let uid = state + .ws_tickets + .consume(&ticket_id) + .await + .ok_or(StatusCode::UNAUTHORIZED)?; + (uid, None, None, None) // tickets inherit permissions from the DB row + } else { + return Err(StatusCode::UNAUTHORIZED); + }; // User-by-id with TTL cache. Disabled-user lockout has up to // AUTH_USER_CACHE_TTL latency — acceptable trade for skipping a DB hit diff --git a/aifw-api/src/auth/mod.rs b/aifw-api/src/auth/mod.rs index c97b56d8..fbc94237 100644 --- a/aifw-api/src/auth/mod.rs +++ b/aifw-api/src/auth/mod.rs @@ -7,6 +7,7 @@ pub mod api_keys; pub mod config; +pub mod cookies; pub mod jwt_key; pub mod middleware; pub mod migrate; diff --git a/aifw-api/src/backup.rs b/aifw-api/src/backup.rs index 17e136e2..9b438445 100644 --- a/aifw-api/src/backup.rs +++ b/aifw-api/src/backup.rs @@ -459,6 +459,22 @@ pub(crate) async fn commit_confirm_arm_with_snapshot( description: String, timeout_secs: u64, ) -> Result { + // Never arm with a rollback target that can't actually roll back + // (#535): the timer would fire, fail to parse or apply the snapshot, + // and leave the operator believing a revert happened. Parse and + // validate it with the same checks the apply path uses. + match serde_json::from_str::(&snapshot_json) { + Ok(snapshot) => { + if let Err(e) = prevalidate_config(&snapshot, &InterfaceMap::new()) { + tracing::error!(error = %e, "refusing to arm commit-confirm: snapshot fails validation"); + return Err(StatusCode::INTERNAL_SERVER_ERROR); + } + } + Err(e) => { + tracing::error!(error = %e, "refusing to arm commit-confirm: snapshot is not a valid FirewallConfig"); + return Err(StatusCode::INTERNAL_SERVER_ERROR); + } + } { let store_read = commit_store().read().await; if store_read.is_some() { @@ -493,14 +509,23 @@ pub(crate) async fn commit_confirm_arm_with_snapshot( _ = tokio::time::sleep(std::time::Duration::from_secs(timeout_secs)) => { // Timer expired — rollback! tracing::warn!("Commit confirm expired after {timeout_secs}s — rolling back"); - if let Some(inner) = store.write().await.take() - && let Ok(config) = serde_json::from_str::(&inner.rollback_config) { - if let Err(e) = apply_firewall_config(&rollback_state, &config, &InterfaceMap::new()).await { - tracing::error!(error = ?e, "commit confirm ROLLBACK FAILED — system may be partially configured"); - } else { - tracing::info!("Config rolled back successfully"); + if let Some(inner) = store.write().await.take() { + // Parse validated at arm time; a failure here means the + // stored snapshot was corrupted in memory — log loudly, + // never silently skip the rollback (#535). + match serde_json::from_str::(&inner.rollback_config) { + Ok(config) => { + if let Err(e) = apply_firewall_config(&rollback_state, &config, &InterfaceMap::new()).await { + tracing::error!(error = ?e, "commit confirm ROLLBACK FAILED — system may be partially configured"); + } else { + tracing::info!("Config rolled back successfully"); + } + } + Err(e) => { + tracing::error!(error = %e, "commit confirm ROLLBACK FAILED — stored snapshot unparseable; system keeps the unconfirmed config"); } } + } } _ = cancel_rx => { // Confirmed — do nothing, config stays @@ -636,6 +661,7 @@ pub(crate) async fn build_current_config(state: &AppState) -> Result Result Result StatusCode { StatusCode::INTERNAL_SERVER_ERROR } +/// Pre-apply validation (#535): run the target config through the same +/// converters and validators the apply path uses, *before* the first +/// destructive DELETE. Entries the apply loop would silently skip +/// (unparseable rows, interfaces the user chose to drop) are skipped here +/// too — this only rejects configs that would abort mid-apply. +pub(crate) fn prevalidate_config( + config: &FirewallConfig, + iface_map: &InterfaceMap, +) -> Result<(), String> { + config.validate()?; + + for rc in &config.rules { + let iface_after = match rc.interface.as_deref() { + Some(name) => match map_iface(name, iface_map) { + Some(mapped) => Some(mapped), + None => continue, + }, + None => None, + }; + let mut rc = rc.clone(); + rc.interface = iface_after; + if let Some(rule) = rule_from_config(&rc) { + aifw_core::validation::validate_rule(&rule) + .map_err(|e| format!("rule {}: {e}", rc.id))?; + } + } + + for nc in &config.nat { + let Some(mapped) = map_iface(&nc.interface, iface_map) else { + continue; + }; + let mut nc = nc.clone(); + nc.interface = mapped; + if let Some(nat) = nat_from_config(&nc) { + aifw_core::nat::validate_nat_rule(&nat) + .map_err(|e| format!("nat rule {}: {e}", nc.id))?; + } + } + + for ac in &config.aliases { + if aifw_common::AliasType::parse(&ac.alias_type).is_none() { + continue; + } + aifw_core::AliasEngine::validate_name(&ac.name) + .map_err(|e| format!("alias {}: {e}", ac.name))?; + } + + // WireGuard: mirror the engine's add-time checks, plus duplicate listen + // ports *within* the config (the tables are wiped before re-insert, so + // only intra-config duplicates can collide). + let mut wg_ports = std::collections::HashSet::new(); + for wg in &config.vpn.wireguard { + if aifw_common::Address::parse(&wg.address).is_err() + || map_iface(&wg.interface, iface_map).is_none() + { + continue; + } + if wg.name.is_empty() { + return Err(format!("wg tunnel {}: tunnel name required", wg.id)); + } + if wg.listen_port == 0 { + return Err(format!("wg tunnel {}: listen port required", wg.name)); + } + if !wg_ports.insert(wg.listen_port) { + return Err(format!( + "wg tunnel {}: listen port {} used by another tunnel in this config", + wg.name, wg.listen_port + )); + } + for p in &wg.peers { + if p.public_key.is_empty() { + return Err(format!( + "wg peer {} on {}: public key required", + p.id, wg.name + )); + } + } + } + + for sac in &config.vpn.ipsec { + if aifw_common::Address::parse(&sac.src_addr).is_err() + || aifw_common::Address::parse(&sac.dst_addr).is_err() + { + continue; + } + if sac.name.is_empty() { + return Err(format!("ipsec SA {}: name required", sac.id)); + } + } + + for tunnel in &config.vpn.ipsec_tunnels { + tunnel + .validate() + .map_err(|e| format!("ipsec tunnel {}: {e}", tunnel.name))?; + } + + for rc in &config.rate_limits { + if rate_limit_from_config(rc).is_some() { + if rc.max_connections == 0 { + return Err(format!( + "rate limit {}: max_connections must be > 0", + rc.name + )); + } + if rc.window_secs == 0 { + return Err(format!("rate limit {}: window_secs must be > 0", rc.name)); + } + } + } + + for sc in &config.tls.sni_rules { + if sc.pattern.is_empty() { + return Err(format!("sni rule {}: pattern required", sc.id)); + } + } + + for vc in &config.ha.carp_vips { + if map_iface(&vc.interface, iface_map).is_none() || carp_vip_from_config(vc).is_none() { + continue; + } + if vc.vhid == 0 { + return Err(format!("carp vip {}: VHID must be > 0", vc.virtual_ip)); + } + if vc.password.is_empty() { + return Err(format!( + "carp vip {}: CARP password required", + vc.virtual_ip + )); + } + } + + for nc in &config.ha.nodes { + if cluster_node_from_config(nc).is_some() && nc.name.is_empty() { + return Err(format!("cluster node {}: name required", nc.id)); + } + } + + Ok(()) +} + /// Restore-with-rollback wrapper around [`apply_firewall_config`] (#535). /// /// Captures the current running config first, applies the target strictly, @@ -1529,6 +1697,12 @@ pub(crate) async fn apply_firewall_config_or_rollback( config: &FirewallConfig, iface_map: &InterfaceMap, ) -> Result<(), StatusCode> { + // Validate before snapshotting: a config that can't apply must be + // rejected with nothing mutated, not "applied" and rolled back. + prevalidate_config(config, iface_map).map_err(|e| { + tracing::warn!(error = %e, "config apply: pre-validation rejected target config"); + StatusCode::BAD_REQUEST + })?; let snapshot = build_current_config(state).await?; let Err(apply_err) = apply_firewall_config(state, config, iface_map).await else { return Ok(()); @@ -1580,12 +1754,54 @@ pub(crate) async fn apply_firewall_config( Address, CountryCode, GeoIpRule, Interface, IpsecSa, VpnStatus, WgPeer, WgTunnel, }; - // Restore preludes — clear existing rows before re-populating from the - // imported config. A failed DELETE (locked DB, FK violation, schema - // drift) used to warn and continue, leaving stale rows in a half-imported - // firewall; since #535 it aborts the restore instead. Parse/mapping skips - // (`continue`) below are intentional drops the import preview already - // surfaced; operational failures are errors. + // Direct callers (commit-confirm rollback timer, cluster snapshot sync) + // don't go through the wrapper — validate here too before any DELETE. + prevalidate_config(config, iface_map).map_err(|e| { + tracing::error!(error = %e, "config apply: pre-validation failed — nothing changed"); + StatusCode::BAD_REQUEST + })?; + + // Engines whose tables may not exist yet. Their migrates are idempotent + // DDL — run them before the transaction so the write transaction below + // holds only data statements. + let shaping = aifw_core::shaping::ShapingEngine::new(state.pool.clone(), state.pf.clone()); + shaping + .migrate() + .await + .map_err(|e| apply_fail("shaping migrate", e))?; + let tls_engine = aifw_core::tls::TlsEngine::new(state.pool.clone(), state.pf.clone()); + tls_engine + .migrate() + .await + .map_err(|e| apply_fail("tls migrate", e))?; + let ha_engine = aifw_core::ha::ClusterEngine::new(state.pool.clone(), state.pf.clone()); + ha_engine + .migrate() + .await + .map_err(|e| apply_fail("ha migrate", e))?; + + // Single transaction for ALL database mutations (#158/#535): the + // DELETE-then-reinsert of every section below either commits wholesale + // or rolls back automatically on the first error, so the DB can never + // end up half-restored. pf/kernel/service state can't ride in a SQL + // transaction — those applies run after commit, with the snapshot + // wrapper (`apply_firewall_config_or_rollback`) as their recovery path. + // Parse/mapping skips (`continue`) below are intentional drops the + // import preview already surfaced; operational failures are errors. + // Rows actually inserted per table, checked against committed counts + // after the transaction lands (#535 post-apply verification). + let mut inserted = std::collections::BTreeMap::<&str, i64>::new(); + + let mut tx = state + .pool + .begin() + .await + .map_err(|e| apply_fail("begin restore transaction", e))?; + + // NB: "queue_configs"/"rate_limit_rules" are the real shaping table + // names — the pre-#535 code deleted from "queues"/"rate_limits" (which + // don't exist) and swallowed the error, so shaping rows were never + // actually cleared before re-insert. for table in [ "wg_peers", "wg_tunnels", @@ -1596,9 +1812,16 @@ pub(crate) async fn apply_firewall_config( "nat_rules", "aliases", "static_routes", + "queue_configs", + "rate_limit_rules", + "sni_rules", + "ja3_blocklist", + "carp_vips", + "pfsync_config", + "cluster_nodes", ] { sqlx::query(sqlx::AssertSqlSafe(format!("DELETE FROM {table}"))) - .execute(&state.pool) + .execute(&mut *tx) .await .map_err(|e| apply_fail(&format!("clearing {table}"), e))?; } @@ -1615,11 +1838,10 @@ pub(crate) async fn apply_firewall_config( rc.interface = iface_after; if let Some(rule) = rule_from_config(&rc) { let rule_id = rule.id; - state - .rule_engine - .add_rule(rule) + aifw_core::Database::insert_rule_on(&mut *tx, &rule) .await .map_err(|e| apply_fail(&format!("rule {rule_id} restore"), e))?; + *inserted.entry("rules").or_default() += 1; } else { tracing::warn!(rule_id = %rc.id, "import: skipping unparseable rule entry"); } @@ -1633,19 +1855,17 @@ pub(crate) async fn apply_firewall_config( nc.interface = mapped_iface; if let Some(nat) = nat_from_config(&nc) { let nat_id = nat.id; - state - .nat_engine - .add_rule(nat) + aifw_core::nat::NatEngine::insert_rule_on(&mut *tx, &nat) .await .map_err(|e| apply_fail(&format!("nat rule {nat_id} restore"), e))?; + *inserted.entry("nat_rules").or_default() += 1; } else { tracing::warn!(nat_id = %nc.id, "import: skipping unparseable nat entry"); } } - // Aliases — restored from snapshot. AliasEngine.add validates name + - // resyncs the pf table; failures here just skip the row (same pattern as - // rules/NAT above). + // Aliases — rows insert in the transaction; pf tables re-sync after + // commit (sync_all_strict below). for ac in &config.aliases { use aifw_common::{Alias, AliasType}; let Some(alias_type) = AliasType::parse(&ac.alias_type) else { @@ -1662,17 +1882,17 @@ pub(crate) async fn apply_firewall_config( created_at: chrono::Utc::now(), updated_at: chrono::Utc::now(), }; - let alias_name = alias.name.clone(); - state - .alias_engine - .add(alias) + aifw_core::AliasEngine::insert_on(&mut *tx, &alias) .await - .map_err(|e| apply_fail(&format!("alias {alias_name} restore"), e))?; + .map_err(|e| apply_fail(&format!("alias {} restore", alias.name), e))?; + *inserted.entry("aliases").or_default() += 1; } - // Static routes — restored via direct INSERT + apply_route_to_system, - // matching what /api/v1/routes does for manual creates. Interface map - // applies if the snapshot pinned a specific iface. + // Static routes — restored via direct INSERT, matching what + // /api/v1/routes does for manual creates. Interface map applies if the + // snapshot pinned a specific iface. Kernel route application is deferred + // to after commit (collected here). + let mut kernel_routes: Vec<(aifw_core::config::StaticRouteConfig, Option)> = Vec::new(); for rc in &config.static_routes { let iface_after = match rc.interface.as_deref() { Some(name) => match map_iface(name, iface_map) { @@ -1681,7 +1901,7 @@ pub(crate) async fn apply_firewall_config( }, None => None, }; - let insert_res = sqlx::query( + sqlx::query( "INSERT INTO static_routes (id, destination, gateway, interface, metric, enabled, description, created_at, fib) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", ) .bind(&rc.id) @@ -1693,21 +1913,12 @@ pub(crate) async fn apply_firewall_config( .bind(rc.description.as_deref()) .bind(chrono::Utc::now().to_rfc3339()) .bind(rc.fib as i64) - .execute(&state.pool) - .await; - insert_res - .map_err(|e| apply_fail(&format!("static route {} restore", rc.destination), e))?; - // Kernel route application stays best-effort: it shells out to - // `route`, which is absent/unprivileged on dev hosts, and the row is - // reapplied at boot. + .execute(&mut *tx) + .await + .map_err(|e| apply_fail(&format!("static route {} restore", rc.destination), e))?; + *inserted.entry("static_routes").or_default() += 1; if rc.enabled { - crate::routes::apply_route_to_system( - &rc.destination, - &rc.gateway, - iface_after.as_deref(), - rc.fib, - ) - .await; + kernel_routes.push((rc.clone(), iface_after)); } } @@ -1721,11 +1932,10 @@ pub(crate) async fn apply_firewall_config( rule.label = gc.label.clone(); rule.status = gc.status; let country = rule.country.0.clone(); - state - .geoip_engine - .add_rule(rule) + aifw_core::geoip::GeoIpEngine::insert_rule_on(&mut *tx, &rule) .await .map_err(|e| apply_fail(&format!("geo-ip rule {country} restore"), e))?; + *inserted.entry("geoip_rules").or_default() += 1; } for wg in &config.vpn.wireguard { @@ -1745,6 +1955,7 @@ pub(crate) async fn apply_firewall_config( public_key: wg.public_key.clone(), listen_port: wg.listen_port, address, + address6: wg.address6.as_deref().and_then(|s| Address::parse(s).ok()), dns: wg.dns.clone(), mtu: wg.mtu, listen_interface: None, @@ -1753,12 +1964,10 @@ pub(crate) async fn apply_firewall_config( created_at: now, updated_at: now, }; - let tunnel_name = tunnel.name.clone(); - state - .vpn_engine - .add_wg_tunnel(tunnel) + aifw_core::vpn::VpnEngine::insert_wg_tunnel_on(&mut *tx, &tunnel) .await - .map_err(|e| apply_fail(&format!("wg tunnel {tunnel_name} restore"), e))?; + .map_err(|e| apply_fail(&format!("wg tunnel {} restore", tunnel.name), e))?; + *inserted.entry("wg_tunnels").or_default() += 1; for p in &wg.peers { let peer_id = uuid::Uuid::parse_str(&p.id).unwrap_or_else(|_| uuid::Uuid::new_v4()); let allowed_ips: Vec
= p @@ -1779,12 +1988,10 @@ pub(crate) async fn apply_firewall_config( created_at: now, updated_at: now, }; - let peer_name = peer.name.clone(); - state - .vpn_engine - .add_wg_peer(peer) + aifw_core::vpn::VpnEngine::insert_wg_peer_on(&mut *tx, &peer) .await - .map_err(|e| apply_fail(&format!("wg peer {peer_name} restore"), e))?; + .map_err(|e| apply_fail(&format!("wg peer {} restore", peer.name), e))?; + *inserted.entry("wg_peers").or_default() += 1; } } @@ -1800,43 +2007,19 @@ pub(crate) async fn apply_firewall_config( sa.id = id; sa.enc_algo = sac.enc_algo.clone(); sa.auth_algo = sac.auth_algo.clone(); - let sa_name = sa.name.clone(); - state - .vpn_engine - .add_ipsec_sa(sa) + aifw_core::vpn::VpnEngine::insert_ipsec_sa_on(&mut *tx, &sa) .await - .map_err(|e| apply_fail(&format!("ipsec SA {sa_name} restore"), e))?; + .map_err(|e| apply_fail(&format!("ipsec SA {} restore", sa.name), e))?; + *inserted.entry("ipsec_sas").or_default() += 1; } - // Real IPsec tunnels (#530): restore records first, then one apply to - // re-render swanctl config and reload charon. + // Real IPsec tunnels (#530): restore records in the transaction; the + // swanctl re-render + charon reload runs after commit. for tunnel in &config.vpn.ipsec_tunnels { - let name = tunnel.name.clone(); - state - .ipsec_engine - .add_tunnel(tunnel.clone()) + aifw_core::ipsec::IpsecEngine::insert_tunnel_on(&mut *tx, tunnel) .await - .map_err(|e| apply_fail(&format!("ipsec tunnel {name} restore"), e))?; - } - if !config.vpn.ipsec_tunnels.is_empty() - && let Err(e) = state.ipsec_engine.apply_all().await - { - tracing::warn!(error = %e, "restored IPsec tunnels but swanctl apply failed — tunnels stay down until re-applied"); - } - - if !config.system.dns_servers.is_empty() { - let content: String = config - .system - .dns_servers - .iter() - .map(|s| format!("nameserver {s}")) - .collect::>() - .join("\n"); - // Best-effort: /etc/resolv.conf is a root-owned system file the aifw - // user may not be able to write; the DB remains the source of truth. - if let Err(e) = tokio::fs::write("/etc/resolv.conf", &content).await { - tracing::warn!(error = %e, "import: /etc/resolv.conf write failed"); - } + .map_err(|e| apply_fail(&format!("ipsec tunnel {} restore", tunnel.name), e))?; + *inserted.entry("ipsec_tunnels").or_default() += 1; } let auth = &config.auth; @@ -1857,26 +2040,12 @@ pub(crate) async fn apply_firewall_config( sqlx::query("INSERT OR REPLACE INTO auth_config (key, value) VALUES (?1, ?2)") .bind(key) .bind(value) - .execute(&state.pool) + .execute(&mut *tx) .await .map_err(|e| apply_fail(&format!("auth config {key} restore"), e))?; } - // Traffic shaping: queues + per-IP rate limits - let shaping = aifw_core::shaping::ShapingEngine::new(state.pool.clone(), state.pf.clone()); - shaping - .migrate() - .await - .map_err(|e| apply_fail("shaping migrate", e))?; - // NB: the pre-#535 code deleted from "queues"/"rate_limits" — tables that - // don't exist — and swallowed the error, so shaping rows were never - // actually cleared before re-insert. These are the real table names. - for table in ["queue_configs", "rate_limit_rules"] { - sqlx::query(sqlx::AssertSqlSafe(format!("DELETE FROM {table}"))) - .execute(&state.pool) - .await - .map_err(|e| apply_fail(&format!("clearing {table}"), e))?; - } + // Traffic shaping: queues + per-IP rate limits (tables cleared above) for qc in &config.queues { let Some(mapped) = map_iface(&qc.interface, iface_map) else { continue; @@ -1884,10 +2053,10 @@ pub(crate) async fn apply_firewall_config( let mut qc = qc.clone(); qc.interface = mapped; if let Some(q) = queue_from_config(&qc) { - shaping - .add_queue(q) + aifw_core::shaping::ShapingEngine::insert_queue_on(&mut *tx, &q) .await .map_err(|e| apply_fail(&format!("shaping queue {} restore", qc.name), e))?; + *inserted.entry("queue_configs").or_default() += 1; } } for rc in &config.rate_limits { @@ -1901,60 +2070,29 @@ pub(crate) async fn apply_firewall_config( let mut rc = rc.clone(); rc.interface = iface_after; if let Some(r) = rate_limit_from_config(&rc) { - shaping - .add_rate_limit(r) + aifw_core::shaping::ShapingEngine::insert_rate_limit_on(&mut *tx, &r) .await .map_err(|e| apply_fail("rate limit restore", e))?; + *inserted.entry("rate_limit_rules").or_default() += 1; } } - shaping - .apply_queues() - .await - .map_err(|e| apply_fail("shaping queues apply", e))?; - shaping - .apply_rate_limits() - .await - .map_err(|e| apply_fail("rate limits apply", e))?; - // TLS: SNI rules + JA3 blocklist - let tls_engine = aifw_core::tls::TlsEngine::new(state.pool.clone(), state.pf.clone()); - tls_engine - .migrate() - .await - .map_err(|e| apply_fail("tls migrate", e))?; - for table in ["sni_rules", "ja3_blocklist"] { - sqlx::query(sqlx::AssertSqlSafe(format!("DELETE FROM {table}"))) - .execute(&state.pool) - .await - .map_err(|e| apply_fail(&format!("clearing {table}"), e))?; - } + // TLS: SNI rules + JA3 blocklist (tables cleared above) for sc in &config.tls.sni_rules { if let Some(sni) = sni_rule_from_config(sc) { - tls_engine - .add_sni_rule(sni) + aifw_core::tls::TlsEngine::insert_sni_rule_on(&mut *tx, &sni) .await .map_err(|e| apply_fail(&format!("sni rule {} restore", sc.pattern), e))?; + *inserted.entry("sni_rules").or_default() += 1; } } for hash in &config.tls.blocked_ja3 { - tls_engine - .add_ja3_block(hash, "restored from backup") + aifw_core::tls::TlsEngine::insert_ja3_block_on(&mut *tx, hash, "restored from backup") .await .map_err(|e| apply_fail(&format!("ja3 block {hash} restore"), e))?; } - // HA: CARP VIPs + pfsync + cluster nodes - let ha_engine = aifw_core::ha::ClusterEngine::new(state.pool.clone(), state.pf.clone()); - ha_engine - .migrate() - .await - .map_err(|e| apply_fail("ha migrate", e))?; - for table in ["carp_vips", "pfsync_config", "cluster_nodes"] { - sqlx::query(sqlx::AssertSqlSafe(format!("DELETE FROM {table}"))) - .execute(&state.pool) - .await - .map_err(|e| apply_fail(&format!("clearing {table}"), e))?; - } + // HA: CARP VIPs + pfsync + cluster nodes (tables cleared above) for vc in &config.ha.carp_vips { let Some(mapped) = map_iface(&vc.interface, iface_map) else { continue; @@ -1962,10 +2100,10 @@ pub(crate) async fn apply_firewall_config( let mut vc = vc.clone(); vc.interface = mapped; if let Some(vip) = carp_vip_from_config(&vc) { - ha_engine - .add_carp_vip(vip) + aifw_core::ha::ClusterEngine::insert_carp_vip_on(&mut *tx, &vip) .await .map_err(|e| apply_fail(&format!("carp vip {} restore", vc.virtual_ip), e))?; + *inserted.entry("carp_vips").or_default() += 1; } } if let Some(pc) = &config.ha.pfsync @@ -1974,18 +2112,143 @@ pub(crate) async fn apply_firewall_config( let mut pc = pc.clone(); pc.sync_interface = mapped_sync; if let Some(pfsync) = pfsync_from_config(&pc) { - ha_engine - .set_pfsync(pfsync) + aifw_core::ha::ClusterEngine::set_pfsync_on(&mut tx, &pfsync) .await .map_err(|e| apply_fail("pfsync restore", e))?; } } for nc in &config.ha.nodes { if let Some(node) = cluster_node_from_config(nc) { - ha_engine - .add_node(node) + aifw_core::ha::ClusterEngine::insert_node_on(&mut *tx, &node) .await .map_err(|e| apply_fail("cluster node restore", e))?; + *inserted.entry("cluster_nodes").or_default() += 1; + } + } + + // DHCP: subnets, reservations, global/DDNS/HA config (DB rows only; the + // rDHCP service regen runs after commit) + apply_dhcp_section_db(&mut tx, &config.dhcp).await?; + + // DNS resolver settings (#589). `None` = backup predates the section; + // leave the box's resolver config untouched instead of resetting it to + // defaults. Service regen runs after commit. + if let Some(resolver) = &config.dns_resolver { + crate::dns_resolver::save_config_on(&mut tx, resolver) + .await + .map_err(|e| apply_fail("dns resolver config restore", e))?; + } + + // One audit row for the whole restore, committed atomically with it. + // (Pre-#158 each engine `add` wrote a per-row audit entry; a restore is + // one operator action, not N rule additions.) + aifw_core::AuditLog::log_on( + &mut *tx, + aifw_core::AuditAction::ConfigChanged, + None, + &format!( + "config restore applied: {} rules, {} nat, {} aliases, {} geoip, {} wg, {} ipsec tunnels", + config.rules.len(), + config.nat.len(), + config.aliases.len(), + config.geoip.len(), + config.vpn.wireguard.len(), + config.vpn.ipsec_tunnels.len(), + ), + "restore", + ) + .await + .map_err(|e| apply_fail("restore audit entry", e))?; + + tx.commit() + .await + .map_err(|e| apply_fail("commit restore transaction", e))?; + + // Post-commit DB verification (#535): the transaction guarantees + // atomicity, but confirm the committed row counts match what the + // restore inserted before touching the data plane. (Key/value config + // tables and INSERT OR REPLACE targets are excluded — duplicates + // legitimately collapse there.) + for table in [ + "rules", + "nat_rules", + "aliases", + "static_routes", + "geoip_rules", + "wg_tunnels", + "wg_peers", + "ipsec_sas", + "ipsec_tunnels", + "queue_configs", + "rate_limit_rules", + "sni_rules", + "carp_vips", + "cluster_nodes", + ] { + let expected = inserted.get(table).copied().unwrap_or(0); + let (count,): (i64,) = + sqlx::query_as(sqlx::AssertSqlSafe(format!("SELECT COUNT(*) FROM {table}"))) + .fetch_one(&state.pool) + .await + .map_err(|e| apply_fail(&format!("verifying {table}"), e))?; + if count != expected { + return Err(apply_fail( + &format!("verifying {table}"), + format!("{count} rows committed but {expected} were inserted"), + )); + } + } + + // ============================================================ + // Post-commit: pf / kernel / service applies. The DB is now fully + // consistent; a failure below is a data-plane mismatch that must + // surface as an error (#535) — the snapshot wrapper re-applies the + // prior config to recover. Steps marked best-effort are reapplied at + // boot and degrade capacity, not policy. + // ============================================================ + + // Kernel route application. Best-effort: it shells out to `route`, + // which is absent/unprivileged on dev hosts; rows are reapplied at boot. + for (rc, iface_after) in &kernel_routes { + crate::routes::apply_route_to_system( + &rc.destination, + &rc.gateway, + iface_after.as_deref(), + rc.fib, + ) + .await; + } + + // Alias pf tables — required; a stale table means rules referencing the + // alias match the wrong addresses. + state + .alias_engine + .sync_all_strict() + .await + .map_err(|e| apply_fail("alias pf table sync", e))?; + + // swanctl re-render + charon reload. Warn-only: strongSwan is a + // FreeBSD-side companion service absent on dev hosts; tunnels stay down + // until re-applied. + if !config.vpn.ipsec_tunnels.is_empty() + && let Err(e) = state.ipsec_engine.apply_all().await + { + tracing::warn!(error = %e, "restored IPsec tunnels but swanctl apply failed — tunnels stay down until re-applied"); + } + + if !config.system.dns_servers.is_empty() { + let content: String = config + .system + .dns_servers + .iter() + .map(|s| format!("nameserver {s}\n")) + .collect(); + // /etc/resolv.conf is root-owned, so a direct write as the aifw user + // fails on FreeBSD (#307); stage in /tmp and go through the + // `aifw-sudo-install` allowlist instead. Warn-only like the other + // post-commit system applies: the helper is absent on dev hosts. + if let Err(e) = install_resolv_conf(&content).await { + tracing::warn!(error = %e, "import: /etc/resolv.conf install failed — restored DNS servers not applied to the system resolver"); } } @@ -2003,11 +2266,37 @@ pub(crate) async fn apply_firewall_config( } } - // DHCP: subnets, reservations, global/DDNS/HA config - apply_dhcp_section(state, &config.dhcp).await?; + // Regenerate rDHCP TOML + restart service so the restored config takes + // effect. Best-effort: the companion service may be absent (dev hosts); + // the DB rows are authoritative and reapplied at boot. + crate::dhcp::auto_apply(state).await; + + // Regenerate resolver config + restart the DNS backend so the restored + // settings take effect (#589). Best-effort like rDHCP above: the backend + // service may be absent (dev hosts); the DB rows are authoritative and + // switch_backend probes + auto-rolls-back on a failed start. + if let Some(resolver) = &config.dns_resolver { + let report = crate::dns_resolver::switch_backend(state, &resolver.backend, resolver).await; + if report.rolled_back || (resolver.enabled && !report.probe_udp) { + tracing::warn!( + backend = %resolver.backend, + rolled_back = report.rolled_back, + message = %report.message, + "import: DNS resolver apply did not come up healthy — settings are restored in the DB, re-apply via /dns/resolver/apply" + ); + } + } // Final data-plane applies — a failure here means the kernel does NOT // match the restored DB, so it must not report success (#535). + shaping + .apply_queues() + .await + .map_err(|e| apply_fail("shaping queues apply", e))?; + shaping + .apply_rate_limits() + .await + .map_err(|e| apply_fail("rate limits apply", e))?; let vpn_rules = state .vpn_engine .collect_vpn_rules() @@ -2030,16 +2319,63 @@ pub(crate) async fn apply_firewall_config( .await .map_err(|e| apply_fail("geoip rules apply", e))?; + // Post-apply data-plane verification (#535): pf must hold exactly the + // rulesets the engines just rendered. Catches a backend that reported + // success but didn't take the rules. + state + .rule_engine + .verify_applied() + .await + .map_err(|e| apply_fail("firewall rules verification", e))?; + state + .nat_engine + .verify_applied() + .await + .map_err(|e| apply_fail("nat rules verification", e))?; + state + .geoip_engine + .verify_applied() + .await + .map_err(|e| apply_fail("geoip rules verification", e))?; + Ok(()) } -async fn apply_dhcp_section( - state: &AppState, +/// Stage `content` in /tmp and atomically install it as `/etc/resolv.conf` +/// via `aifw_core::sudo::install`. The destination is on the +/// `aifw-sudo-install` allowlist; a direct `tokio::fs::write` silently +/// fails as the unprivileged aifw user on FreeBSD (#307). +async fn install_resolv_conf(content: &str) -> Result<(), String> { + use std::time::{SystemTime, UNIX_EPOCH}; + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + let tmp = format!("/tmp/aifw.resolv.conf.{nanos}.tmp"); + tokio::fs::write(&tmp, content) + .await + .map_err(|e| format!("stage tmp: {e}"))?; + let result = aifw_core::sudo::install(Some("0644"), None, None, &tmp, "/etc/resolv.conf") + .await + .map_err(|e| format!("spawn sudo install: {e}")); + // Best-effort tmp cleanup; the install result below is what matters. + let _ = tokio::fs::remove_file(&tmp).await; + let out = result?; + if !out.status.success() { + return Err(String::from_utf8_lossy(&out.stderr).trim().to_string()); + } + Ok(()) +} + +/// DHCP section of a restore — DB rows only, on the caller's transaction +/// connection (#158/#535). The rDHCP service regen (`dhcp::auto_apply`) +/// runs post-commit in `apply_firewall_config`. +async fn apply_dhcp_section_db( + conn: &mut sqlx::SqliteConnection, dhcp: &aifw_core::config::DhcpSection, ) -> Result<(), StatusCode> { - // Wipe + re-insert for a clean restore. `auto_apply` at the end regenerates - // the rDHCP TOML config and restarts the service. DB mutations are - // required steps (#535); only the service regeneration is best-effort. + // Wipe + re-insert for a clean restore. DB mutations are required + // steps (#535). for table in [ "dhcp_subnets", "dhcp_reservations", @@ -2048,7 +2384,7 @@ async fn apply_dhcp_section( "dhcp_ha_config", ] { sqlx::query(sqlx::AssertSqlSafe(format!("DELETE FROM {table}"))) - .execute(&state.pool) + .execute(&mut *conn) .await .map_err(|e| apply_fail(&format!("clearing {table}"), e))?; } @@ -2103,7 +2439,7 @@ async fn apply_dhcp_section( sqlx::query("INSERT OR REPLACE INTO dhcp_config (key, value) VALUES (?1, ?2)") .bind(k) .bind(v) - .execute(&state.pool) + .execute(&mut *conn) .await .map_err(|e| apply_fail(&format!("dhcp config {k} restore"), e))?; } @@ -2180,7 +2516,7 @@ async fn apply_dhcp_section( .bind(&s.ntp_servers) .bind(&options_json) .bind(&s.created_at) - .execute(&state.pool) + .execute(&mut *conn) .await .map_err(|e| apply_fail(&format!("dhcp subnet {} restore", s.network), e))?; } @@ -2193,7 +2529,7 @@ async fn apply_dhcp_section( ) .bind(&r.id).bind(&r.subnet_id).bind(&r.mac_address).bind(&r.ip_address) .bind(&r.hostname).bind(&r.client_id).bind(&r.description).bind(&r.created_at) - .execute(&state.pool).await + .execute(&mut *conn).await .map_err(|e| apply_fail(&format!("dhcp reservation {} restore", r.ip_address), e))?; } @@ -2220,7 +2556,7 @@ async fn apply_dhcp_section( sqlx::query("INSERT OR REPLACE INTO dhcp_ddns_config (key, value) VALUES (?1, ?2)") .bind(k) .bind(v) - .execute(&state.pool) + .execute(&mut *conn) .await .map_err(|e| apply_fail(&format!("dhcp ddns config {k} restore"), e))?; } @@ -2257,15 +2593,11 @@ async fn apply_dhcp_section( sqlx::query("INSERT OR REPLACE INTO dhcp_ha_config (key, value) VALUES (?1, ?2)") .bind(k) .bind(v) - .execute(&state.pool) + .execute(&mut *conn) .await .map_err(|e| apply_fail(&format!("dhcp ha config {k} restore"), e))?; } - // Regenerate rDHCP TOML + restart service so the restored config takes - // effect. Best-effort: the companion service may be absent (dev hosts); - // the DB rows above are authoritative and reapplied at boot. - crate::dhcp::auto_apply(state).await; Ok(()) } @@ -2415,6 +2747,7 @@ fn queue_from_config(qc: &aifw_core::config::QueueConfigEntry) -> Option String { // Types // ============================================================ -#[derive(Debug, Serialize, Deserialize, Clone)] -pub struct ResolverConfig { - pub backend: String, // "unbound" or "rdns" - pub enabled: bool, - pub listen_interfaces: Vec, - pub port: u16, - pub dnssec: bool, - pub dns64: bool, - pub register_dhcp: bool, - pub dhcp_domain: String, // domain for DHCP lease DNS registration (e.g. "local") - pub local_zone_type: String, // transparent, static, redirect, etc. - pub outgoing_interface: Option, - // Advanced - pub num_threads: u32, - pub msg_cache_size: String, - pub rrset_cache_size: String, - pub cache_max_ttl: u32, - pub cache_min_ttl: u32, - pub prefetch: bool, - pub prefetch_key: bool, - pub infra_host_ttl: u32, - pub unwanted_reply_threshold: u32, - pub log_queries: bool, - pub log_replies: bool, - pub log_verbosity: u32, - /// Ceiling on a single query's resolution (ms). 0 = rDNS built-in - /// per-transport defaults (UDP 3000, TCP/DoT 30000). - #[serde(default)] - pub query_timeout_ms: u32, - pub hide_identity: bool, - pub hide_version: bool, - pub rebind_protection: bool, - pub private_addresses: Vec, - // Forwarding - pub forwarding_enabled: bool, - pub forwarding_servers: Vec, // plain upstream DNS IPs (e.g. "8.8.8.8", "1.1.1.1") - pub use_system_nameservers: bool, // also forward to /etc/resolv.conf nameservers - // DoT - pub dot_enabled: bool, - pub dot_upstream: Vec, // "1.1.1.1@853#cloudflare-dns.com" - // Blocklists - pub blocklists_enabled: bool, - pub blocklist_urls: Vec, - pub whitelist: Vec, - pub blocklist_action: String, // "nxdomain" | "redirect" - pub blocklist_redirect_ip: Option, - // Custom - pub custom_options: String, - // Safety - /// Probe :53 after a resolver switch and auto-rollback on failure. - /// Default: true. Disable to restore the old fire-and-forget behavior - /// (e.g. for debugging, or on boxes where the probe misfires). - #[serde(default = "default_true")] - pub probe_enabled: bool, -} - -fn default_true() -> bool { - true -} - -impl Default for ResolverConfig { - fn default() -> Self { - Self { - backend: "rdns".to_string(), - enabled: false, - listen_interfaces: vec!["0.0.0.0".to_string()], - port: 53, - dnssec: true, - dns64: false, - register_dhcp: true, - dhcp_domain: "local".to_string(), - local_zone_type: "transparent".to_string(), - outgoing_interface: None, - num_threads: 2, - msg_cache_size: "8m".to_string(), - rrset_cache_size: "16m".to_string(), - cache_max_ttl: 86400, - cache_min_ttl: 0, - prefetch: true, - prefetch_key: true, - infra_host_ttl: 900, - unwanted_reply_threshold: 10000, - log_queries: false, - log_replies: false, - log_verbosity: 1, - query_timeout_ms: 0, - hide_identity: true, - hide_version: true, - rebind_protection: true, - private_addresses: vec![ - "10.0.0.0/8".into(), - "172.16.0.0/12".into(), - "192.168.0.0/16".into(), - "169.254.0.0/16".into(), - "fd00::/8".into(), - "fe80::/10".into(), - ], - // Default ON. Iterative recursion in rDNS 1.12.8 returns referrals - // instead of following them to completion, leaving clients with - // 0-answer responses for anything not already cached. Forwarding - // to public resolvers is the battle-tested fallback and keeps DNS - // working out of the box. Operators who want pure recursion can - // flip this off in the UI. - forwarding_enabled: true, - forwarding_servers: vec!["1.1.1.1".into(), "8.8.8.8".into()], - use_system_nameservers: false, - dot_enabled: false, - dot_upstream: vec![ - "1.1.1.1@853#cloudflare-dns.com".into(), - "1.0.0.1@853#cloudflare-dns.com".into(), - ], - blocklists_enabled: false, - blocklist_urls: vec![], - whitelist: vec![], - blocklist_action: "nxdomain".to_string(), - blocklist_redirect_ip: None, - custom_options: String::new(), - probe_enabled: true, - } - } -} +/// The resolver settings struct lives in aifw-core so config snapshots can +/// round-trip it as `FirewallConfig::dns_resolver` (#589). Same JSON shape +/// the `/api/v1/dns/resolver/config` endpoint has always spoken. +pub use aifw_core::config::DnsResolverSection as ResolverConfig; #[derive(Debug, Serialize, Deserialize, Clone)] pub struct HostOverride { @@ -516,7 +399,7 @@ pub async fn migrate(pool: &SqlitePool) -> Result<(), sqlx::Error> { // Config helpers // ============================================================ -async fn load_config(pool: &SqlitePool) -> ResolverConfig { +pub(crate) async fn load_config(pool: &SqlitePool) -> ResolverConfig { let rows = sqlx::query_as::<_, (String, String)>("SELECT key, value FROM dns_resolver_config") .fetch_all(pool) .await @@ -537,6 +420,11 @@ async fn load_config(pool: &SqlitePool) -> ResolverConfig { "port" => c.port = v.parse().unwrap_or(53), "dnssec" => c.dnssec = v == "true", "dns64" => c.dns64 = v == "true", + "dns64_prefix" => { + if !v.is_empty() { + c.dns64_prefix = v + } + } "register_dhcp" => c.register_dhcp = v == "true", "local_zone_type" => c.local_zone_type = v, "outgoing_interface" => { @@ -974,6 +862,21 @@ async fn generate_rdns_conf(pool: &SqlitePool) -> String { "dnssec = {}\nqname_minimization = true\n", c.dnssec )); + // DNS64 (RFC 6147, #531): emit only when enabled; the prefix must match + // the NAT64 rule prefix for the combined workflow to function. Re-check + // the prefix here too — the handler validates on save, but a restored + // legacy value must never reach the root-managed TOML unparsed. + if c.dns64 { + match validate_dns64_prefix(&c.dns64_prefix) { + Ok(()) => toml.push_str(&format!( + "dns64 = true\ndns64_prefix = \"{}\"\n", + c.dns64_prefix + )), + Err(e) => { + tracing::warn!(error = %e, "dns64 enabled but prefix invalid; omitting from rdns.toml") + } + } + } if c.query_timeout_ms > 0 { toml.push_str(&format!("query_timeout_ms = {}\n", c.query_timeout_ms)); } @@ -1510,76 +1413,140 @@ pub async fn get_config_handler( Ok(Json(load_config(&state.pool).await)) } +/// Validate a `dns64_prefix` value: an IPv6 address or `addr/96` (only /96 +/// is supported — the same rule rDNS enforces). The value is interpolated +/// into the root-managed rdns.toml, so anything that doesn't parse is +/// rejected outright rather than escaped (#531 review M1: a quote+newline +/// payload could otherwise inject arbitrary config sections). +pub(crate) fn validate_dns64_prefix(s: &str) -> Result<(), String> { + let (addr, len) = match s.split_once('/') { + Some((a, l)) => (a, l), + None => (s, "96"), + }; + if len.trim() != "96" { + return Err(format!( + "dns64_prefix must be an IPv6 /96 prefix (e.g. 64:ff9b::/96), got '{s}'" + )); + } + if addr.trim().parse::().is_err() { + return Err(format!("dns64_prefix is not a valid IPv6 prefix: '{s}'")); + } + Ok(()) +} + pub async fn update_config_handler( State(state): State, Json(c): Json, -) -> Result, StatusCode> { - let pool = &state.pool; - save_key(pool, "backend", &c.backend).await; - save_key(pool, "enabled", bool_str(c.enabled)).await; - save_key(pool, "dhcp_domain", &c.dhcp_domain).await; - save_key(pool, "listen_interfaces", &c.listen_interfaces.join(",")).await; - save_key(pool, "port", &c.port.to_string()).await; - save_key(pool, "dnssec", bool_str(c.dnssec)).await; - save_key(pool, "dns64", bool_str(c.dns64)).await; - save_key(pool, "register_dhcp", bool_str(c.register_dhcp)).await; - save_key(pool, "local_zone_type", &c.local_zone_type).await; - save_key( - pool, - "outgoing_interface", - c.outgoing_interface.as_deref().unwrap_or(""), - ) - .await; - save_key(pool, "num_threads", &c.num_threads.to_string()).await; - save_key(pool, "msg_cache_size", &c.msg_cache_size).await; - save_key(pool, "rrset_cache_size", &c.rrset_cache_size).await; - save_key(pool, "cache_max_ttl", &c.cache_max_ttl.to_string()).await; - save_key(pool, "cache_min_ttl", &c.cache_min_ttl.to_string()).await; - save_key(pool, "prefetch", bool_str(c.prefetch)).await; - save_key(pool, "prefetch_key", bool_str(c.prefetch_key)).await; - save_key(pool, "infra_host_ttl", &c.infra_host_ttl.to_string()).await; - save_key( - pool, - "unwanted_reply_threshold", - &c.unwanted_reply_threshold.to_string(), - ) - .await; - save_key(pool, "log_queries", bool_str(c.log_queries)).await; - save_key(pool, "log_replies", bool_str(c.log_replies)).await; - save_key(pool, "log_verbosity", &c.log_verbosity.to_string()).await; - save_key(pool, "query_timeout_ms", &c.query_timeout_ms.to_string()).await; - save_key(pool, "hide_identity", bool_str(c.hide_identity)).await; - save_key(pool, "hide_version", bool_str(c.hide_version)).await; - save_key(pool, "rebind_protection", bool_str(c.rebind_protection)).await; - save_key(pool, "private_addresses", &c.private_addresses.join(",")).await; - save_key(pool, "forwarding_enabled", bool_str(c.forwarding_enabled)).await; - save_key(pool, "forwarding_servers", &c.forwarding_servers.join(",")).await; - save_key( - pool, - "use_system_nameservers", - bool_str(c.use_system_nameservers), - ) - .await; - save_key(pool, "dot_enabled", bool_str(c.dot_enabled)).await; - save_key(pool, "dot_upstream", &c.dot_upstream.join(",")).await; - save_key(pool, "blocklists_enabled", bool_str(c.blocklists_enabled)).await; - save_key(pool, "blocklist_urls", &c.blocklist_urls.join("\n")).await; - save_key(pool, "whitelist", &c.whitelist.join("\n")).await; - save_key(pool, "blocklist_action", &c.blocklist_action).await; - save_key( - pool, - "blocklist_redirect_ip", - c.blocklist_redirect_ip.as_deref().unwrap_or(""), - ) - .await; - save_key(pool, "custom_options", &c.custom_options).await; - save_key(pool, "probe_enabled", bool_str(c.probe_enabled)).await; +) -> Result, (StatusCode, Json)> { + if let Err(msg) = validate_dns64_prefix(&c.dns64_prefix) { + return Err(( + StatusCode::BAD_REQUEST, + Json(MessageResponse { message: msg }), + )); + } + let mut conn = state.pool.acquire().await.map_err(|_| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(MessageResponse { + message: "database unavailable".to_string(), + }), + ) + })?; + save_config_on(&mut conn, &c).await.map_err(|e| { + tracing::error!(error = %e, "resolver config save failed"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(MessageResponse { + message: "resolver config save failed".to_string(), + }), + ) + })?; state.set_pending(|p| p.dns = true).await; Ok(Json(MessageResponse { message: "DNS resolver config saved".to_string(), })) } +/// Persist every `ResolverConfig` field into `dns_resolver_config`. Shared +/// by the `PUT /dns/resolver/config` handler and the backup restore path +/// (#589) — the latter runs it on the restore transaction, so this takes a +/// connection rather than the pool. +pub(crate) async fn save_config_on( + conn: &mut sqlx::SqliteConnection, + c: &ResolverConfig, +) -> Result<(), sqlx::Error> { + for (k, v) in [ + ("backend", c.backend.clone()), + ("enabled", bool_str(c.enabled).to_string()), + ("dhcp_domain", c.dhcp_domain.clone()), + ("listen_interfaces", c.listen_interfaces.join(",")), + ("port", c.port.to_string()), + ("dnssec", bool_str(c.dnssec).to_string()), + ("dns64", bool_str(c.dns64).to_string()), + ("dns64_prefix", c.dns64_prefix.clone()), + ("register_dhcp", bool_str(c.register_dhcp).to_string()), + ("local_zone_type", c.local_zone_type.clone()), + ( + "outgoing_interface", + c.outgoing_interface.clone().unwrap_or_default(), + ), + ("num_threads", c.num_threads.to_string()), + ("msg_cache_size", c.msg_cache_size.clone()), + ("rrset_cache_size", c.rrset_cache_size.clone()), + ("cache_max_ttl", c.cache_max_ttl.to_string()), + ("cache_min_ttl", c.cache_min_ttl.to_string()), + ("prefetch", bool_str(c.prefetch).to_string()), + ("prefetch_key", bool_str(c.prefetch_key).to_string()), + ("infra_host_ttl", c.infra_host_ttl.to_string()), + ( + "unwanted_reply_threshold", + c.unwanted_reply_threshold.to_string(), + ), + ("log_queries", bool_str(c.log_queries).to_string()), + ("log_replies", bool_str(c.log_replies).to_string()), + ("log_verbosity", c.log_verbosity.to_string()), + ("query_timeout_ms", c.query_timeout_ms.to_string()), + ("hide_identity", bool_str(c.hide_identity).to_string()), + ("hide_version", bool_str(c.hide_version).to_string()), + ( + "rebind_protection", + bool_str(c.rebind_protection).to_string(), + ), + ("private_addresses", c.private_addresses.join(",")), + ( + "forwarding_enabled", + bool_str(c.forwarding_enabled).to_string(), + ), + ("forwarding_servers", c.forwarding_servers.join(",")), + ( + "use_system_nameservers", + bool_str(c.use_system_nameservers).to_string(), + ), + ("dot_enabled", bool_str(c.dot_enabled).to_string()), + ("dot_upstream", c.dot_upstream.join(",")), + ( + "blocklists_enabled", + bool_str(c.blocklists_enabled).to_string(), + ), + ("blocklist_urls", c.blocklist_urls.join("\n")), + ("whitelist", c.whitelist.join("\n")), + ("blocklist_action", c.blocklist_action.clone()), + ( + "blocklist_redirect_ip", + c.blocklist_redirect_ip.clone().unwrap_or_default(), + ), + ("custom_options", c.custom_options.clone()), + ("probe_enabled", bool_str(c.probe_enabled).to_string()), + ] { + sqlx::query("INSERT OR REPLACE INTO dns_resolver_config (key, value) VALUES (?1, ?2)") + .bind(k) + .bind(v) + .execute(&mut *conn) + .await?; + } + Ok(()) +} + // ============================================================ // Backend switch — safe, probed, auto-rollback // ============================================================ diff --git a/aifw-api/src/ids.rs b/aifw-api/src/ids.rs index e0b8cec9..f69eb088 100644 --- a/aifw-api/src/ids.rs +++ b/aifw-api/src/ids.rs @@ -243,8 +243,38 @@ pub async fn purge_alerts( .execute(&state.pool) .await .map_err(|_| internal())?; + + // Reclaim the space: a bare DELETE only moves pages to the freelist — + // seen live as a 2.9GB DB file with 34 rows (#601). VACUUM rewrites the + // file; the TRUNCATE checkpoint then shrinks the WAL that the rewrite + // itself produced (the rewrite streams through the WAL, so without this + // the reclaimed gigabytes just move into aifw.db-wal). Failures are + // non-fatal — the rows are gone either way. + let mut reclaimed = " and space reclaimed"; + if let Err(e) = sqlx::query("VACUUM").execute(&state.pool).await { + tracing::warn!(error = %e, "ids purge: vacuum failed; space not reclaimed"); + reclaimed = " (space reclaim deferred: vacuum busy)"; + } else if let Err(e) = sqlx::query("PRAGMA wal_checkpoint(TRUNCATE)") + .fetch_all(&state.pool) + .await + { + tracing::warn!(error = %e, "ids purge: wal checkpoint failed"); + } + + if let Err(e) = aifw_core::audit::AuditLog::new(state.pool.clone()) + .log( + aifw_core::audit::AuditAction::ConfigChanged, + None, + &format!("ids: purged all alerts ({} rows)", res.rows_affected()), + "ids_api", + ) + .await + { + tracing::warn!(error = %e, "ids purge: audit log write failed"); + } + Ok(Json(MessageResponse { - message: format!("{} alerts purged", res.rows_affected()), + message: format!("{} alerts purged{reclaimed}", res.rows_affected()), })) } diff --git a/aifw-api/src/main.rs b/aifw-api/src/main.rs index e2d3ced8..97f41478 100644 --- a/aifw-api/src/main.rs +++ b/aifw-api/src/main.rs @@ -179,6 +179,10 @@ pub struct AppState { /// Never changes without a config write, so a one-shot read is correct. pub cluster_enabled: Arc, pub cluster_events: aifw_common::ClusterEventBus, + /// Whether the API is serving TLS — controls the `Secure` attribute on + /// the session cookies (SEC-M7 #304). Set from `!args.no_tls` in `main`; + /// defaults to `false` in tests. + pub tls_enabled: bool, /// Ring buffer of deflate-compressed `WsHistoryEntry` JSON frames /// (PERF-M5). ~2-3 KB of repetitive JSON per tick compresses 4-8x, so a /// 1800-entry ring costs ~1 MB instead of ~5 MB. Compress/decompress @@ -515,6 +519,8 @@ pub fn build_router( .merge(router::rules_write()) .merge(router::nat_read()) .merge(router::nat_write()) + .merge(router::shaper_read()) + .merge(router::shaper_write()) .merge(router::vpn_read()) .merge(router::vpn_write()) .merge(router::geoip_read()) @@ -961,21 +967,18 @@ async fn create_state_from_db( .await .unwrap_or_default(); for (name,) in &enabled_plugins { - // Unload disabled version and re-register as enabled - let _ = plugin_mgr.unload(name).await; - let plugin: Option> = match name.as_str() { - "logging" => Some(Box::new(aifw_plugins::examples::LoggingPlugin::new())), - "ip_reputation" => Some(Box::new(aifw_plugins::examples::IpReputationPlugin::new())), - "webhook" => Some(Box::new(aifw_plugins::examples::WebhookPlugin::new())), - _ => None, - }; - if let Some(p) = plugin { + // Unload disabled version and re-register as enabled, with the + // persisted settings — plugins only read settings in init() (#586). + let reg_name = plugins::canonical_builtin(name).unwrap_or(name); + let _ = plugin_mgr.unload(reg_name).await; + if let Some(p) = plugins::instantiate_builtin(name) { + let settings = plugins::load_settings(&pool, name).await; let _ = plugin_mgr .register( p, aifw_plugins::PluginConfig { enabled: true, - ..Default::default() + settings, }, ) .await; @@ -1039,6 +1042,7 @@ async fn create_state_from_db( tls_engine, cluster_enabled, cluster_events, + tls_enabled: false, metrics_history: Arc::new(RwLock::new(VecDeque::with_capacity(history_max.min(86400)))), metrics_history_max: Arc::new(std::sync::atomic::AtomicUsize::new(history_max)), redis: None, @@ -1241,6 +1245,16 @@ async fn ensure_rdr_anchor() { let mut changed = false; let has_rdr = lines.iter().any(|l| l.trim() == "rdr-anchor \"aifw-nat\""); + // Filter-class hook for the NAT anchor: cross-family af-to rules are + // pass rules, so an install predating `anchor "aifw-nat"` would load + // them but never evaluate them (#531). + let has_nat_filter = lines.iter().any(|l| l.trim() == "anchor \"aifw-nat\""); + // ICMPv6 neighbor discovery: aifw-setup only emits this into NEWLY + // generated pf.conf files, so upgraded appliances keep dropping ND and + // all IPv6 (incl. NAT64) stays broken until healed here (#601). + const ND_RULE: &str = + "pass quick inet6 proto icmp6 icmp6-type { routersol, routeradv, neighbrsol, neighbradv }"; + let has_nd = lines.iter().any(|l| l.trim() == ND_RULE); let mwan_anchors = ["aifw-pbr", "aifw-mwan-leak", "aifw-mwan-reply"]; let has_mwan: Vec = mwan_anchors .iter() @@ -1251,6 +1265,7 @@ async fn ensure_rdr_anchor() { .collect(); let mut mwan_inserted = false; + let mut nd_inserted = false; for line in lines.iter() { let t = line.trim(); @@ -1267,6 +1282,20 @@ async fn ensure_rdr_anchor() { // 2. Before the filter-section `anchor "aifw"` (trimmed EXACTLY — won't // match nat-anchor or rdr-anchor), inject any missing mwan anchors. + // ND pass must precede the FIRST filter anchor — anchors hold + // `block quick` rules that would otherwise eat neighbor + // solicitations (#601). Checked against every filter-anchor form so + // it lands before aifw-pbr when the mwan anchors already exist. + if !has_nd + && !nd_inserted + && (t == "anchor \"aifw\"" + || mwan_anchors.iter().any(|a| t == format!("anchor \"{a}\""))) + { + out.push(ND_RULE.to_string()); + nd_inserted = true; + changed = true; + } + if !mwan_inserted && t == "anchor \"aifw\"" { for (i, a) in mwan_anchors.iter().enumerate() { if !has_mwan[i] { @@ -1276,6 +1305,12 @@ async fn ensure_rdr_anchor() { } mwan_inserted = true; out.push((*line).to_string()); + // 3. After the filter `anchor "aifw"`, inject the filter-class + // `anchor "aifw-nat"` hook if absent (af-to rules, #531). + if !has_nat_filter { + out.push("anchor \"aifw-nat\"".to_string()); + changed = true; + } continue; } @@ -1688,6 +1723,12 @@ async fn main() -> anyhow::Result<()> { let mem_state = state.clone(); tokio::spawn(async move { let mut interval = tokio::time::interval(std::time::Duration::from_secs(60)); + // 24h alert count cache (#601): the COUNT rides an index but + // still costs >1s on multi-million-row tables — too expensive + // for a per-minute heartbeat. Refresh every 10th tick; the + // heartbeat is a health signal, not a live stat. + let mut alerts_24h_cache: i64 = 0; + let mut heartbeat_ticks: u32 = 0; loop { interval.tick().await; let hist_entries = mem_state.metrics_history.read().await.len(); @@ -1731,13 +1772,19 @@ async fn main() -> anyhow::Result<()> { // rides idx_ids_alerts_ts instead of full-scanning a // multi-million-row table every tick (and contending with // ingestion). This is a health signal — recent persistence, - // not lifetime total — hence the _24h field name. - let (ids_alerts_db_24h,): (i64,) = sqlx::query_as( - "SELECT COUNT(*) FROM ids_alerts WHERE timestamp >= datetime('now', '-1 day')", - ) - .fetch_one(&mem_state.pool) - .await - .unwrap_or((0,)); + // not lifetime total — hence the _24h field name. Refreshed + // every 10 minutes, cached between (#601). + if heartbeat_ticks.is_multiple_of(10) { + let (count,): (i64,) = sqlx::query_as( + "SELECT COUNT(*) FROM ids_alerts WHERE timestamp >= datetime('now', '-1 day')", + ) + .fetch_one(&mem_state.pool) + .await + .unwrap_or((0,)); + alerts_24h_cache = count; + } + heartbeat_ticks = heartbeat_ticks.wrapping_add(1); + let ids_alerts_db_24h = alerts_24h_cache; // PERF-H12: native RSS syscall, not a `ps` fork (finishes the // main.rs shell-out #356 also cited). let rss_mb = @@ -1765,6 +1812,7 @@ async fn main() -> anyhow::Result<()> { } let tls_enabled = !args.no_tls; + state.tls_enabled = tls_enabled; let app = build_router( state, args.ui_dir.as_deref(), diff --git a/aifw-api/src/opnsense/importer.rs b/aifw-api/src/opnsense/importer.rs index 08aceac4..15875a61 100644 --- a/aifw-api/src/opnsense/importer.rs +++ b/aifw-api/src/opnsense/importer.rs @@ -580,6 +580,19 @@ pub async fn import_opnsense( let pre_apply_snapshot_json = match crate::backup::capture_runtime_snapshot(&state).await { Ok(snap) => Some(snap), Err(e) => { + // The caller asked for a commit-confirm safety net; importing + // without a rollback target would silently strip it (#535). + // Refuse up front rather than apply unprotected. + if req.commit_confirm { + tracing::error!( + ?e, + "aborting import: commit-confirm requested but pre-apply snapshot capture failed" + ); + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + "could not capture the pre-import snapshot needed for commit-confirm rollback — import aborted; retry or disable commit_confirm".into(), + )); + } tracing::warn!( ?e, "failed to capture pre-apply snapshot; commit-confirm rollback target unavailable" diff --git a/aifw-api/src/plugins.rs b/aifw-api/src/plugins.rs index abdd5614..c5b1d186 100644 --- a/aifw-api/src/plugins.rs +++ b/aifw-api/src/plugins.rs @@ -32,6 +32,45 @@ pub struct MessageResponse { pub message: String, } +/// Canonical registered name (`PluginInfo::name`) of a built-in plugin. +/// Accepts both the registered name (what the UI sends, taken from the +/// plugin list) and the legacy short keys the API historically matched on. +pub(crate) fn canonical_builtin(name: &str) -> Option<&'static str> { + match name { + "custom-logger" | "logging" => Some("custom-logger"), + "ip-reputation" | "ip_reputation" => Some("ip-reputation"), + "webhook-notifier" | "webhook" => Some("webhook-notifier"), + _ => None, + } +} + +/// Create a fresh instance of a built-in plugin by name (either spelling). +pub(crate) fn instantiate_builtin(name: &str) -> Option> { + match canonical_builtin(name)? { + "custom-logger" => Some(Box::new(aifw_plugins::examples::LoggingPlugin::new())), + "ip-reputation" => Some(Box::new(aifw_plugins::examples::IpReputationPlugin::new())), + "webhook-notifier" => Some(Box::new(aifw_plugins::examples::WebhookPlugin::new())), + _ => None, + } +} + +/// Load a plugin's persisted settings from the DB. Missing rows or +/// malformed JSON yield an empty map. +pub(crate) async fn load_settings( + pool: &SqlitePool, + name: &str, +) -> std::collections::HashMap { + let row: Option<(Option,)> = + sqlx::query_as("SELECT settings FROM plugin_config WHERE name = ?1") + .bind(name) + .fetch_optional(pool) + .await + .unwrap_or(None); + row.and_then(|(s,)| s) + .and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or_default() +} + pub async fn list_plugins( State(state): State, ) -> Result, StatusCode> { @@ -78,9 +117,12 @@ pub async fn enable_plugin( .execute(&state.pool).await; let mut mgr = state.plugin_manager.write().await; + // Plugins are registered under their PluginInfo::name — normalize so + // legacy short keys ("logging") hit the same instance ("custom-logger"). + let reg_name = canonical_builtin(name).unwrap_or(name); if !enabled { - let _ = mgr.unload(name).await; + let _ = mgr.unload(reg_name).await; // Sync the atomic shadow counter (PERF-C12). state .plugin_running_count @@ -90,21 +132,18 @@ pub async fn enable_plugin( })) } else { // Re-register with enabled=true — need to create a new instance - let plugin: Option> = match name { - "logging" => Some(Box::new(aifw_plugins::examples::LoggingPlugin::new())), - "ip_reputation" => Some(Box::new(aifw_plugins::examples::IpReputationPlugin::new())), - "webhook" => Some(Box::new(aifw_plugins::examples::WebhookPlugin::new())), - _ => None, - }; - if let Some(p) = plugin { + if let Some(p) = instantiate_builtin(name) { + // Plugins read settings only in init(), so pass the persisted + // settings — otherwise a saved webhook URL etc. never applies (#586). + let settings = load_settings(&state.pool, name).await; // Unload old instance if exists - let _ = mgr.unload(name).await; + let _ = mgr.unload(reg_name).await; let _ = mgr .register( p, aifw_plugins::PluginConfig { enabled: true, - ..Default::default() + settings, }, ) .await; @@ -160,13 +199,68 @@ pub async fn update_plugin_config( .unwrap_or(serde_json::json!({})); let settings_str = serde_json::to_string(&settings).unwrap_or_default(); - let _ = sqlx::query( + sqlx::query( "INSERT INTO plugin_config (name, enabled, settings) VALUES (?1, 0, ?2) ON CONFLICT(name) DO UPDATE SET settings=excluded.settings" - ).bind(&name).bind(&settings_str).execute(&state.pool).await; + ).bind(&name).bind(&settings_str).execute(&state.pool).await + .map_err(|e| { + tracing::error!(error = %e, plugin = %name, "plugins: failed to persist config"); + StatusCode::INTERNAL_SERVER_ERROR + })?; - Ok(Json(MessageResponse { - message: format!("Plugin '{name}' config updated."), - })) + // Plugins read settings only in init(), so a running instance keeps its + // old config until re-registered — restart it with the new settings (#586). + let mut mgr = state.plugin_manager.write().await; + let reg_name = canonical_builtin(&name).unwrap_or(&name); + let running = mgr + .list_plugins() + .iter() + .any(|(info, s)| info.name == reg_name && *s == aifw_plugins::PluginState::Running); + if !running { + return Ok(Json(MessageResponse { + message: format!("Plugin '{name}' config updated."), + })); + } + + let Some(plugin) = instantiate_builtin(&name) else { + return Ok(Json(MessageResponse { + message: format!("Plugin '{name}' config updated."), + })); + }; + let settings_map: std::collections::HashMap = + serde_json::from_value(settings).unwrap_or_default(); + + if let Err(e) = mgr.unload(reg_name).await { + tracing::warn!(error = %e, plugin = %name, "plugins: unload before config reload failed"); + } + let message = match mgr + .register( + plugin, + aifw_plugins::PluginConfig { + enabled: true, + settings: settings_map, + }, + ) + .await + { + Ok(()) => format!("Plugin '{name}' config updated and applied."), + Err(e) => { + tracing::error!(error = %e, plugin = %name, "plugins: restart with new config failed"); + // Keep the plugin listed (stopped) so the UI can still show it. + if let Some(p) = instantiate_builtin(&name) + && let Err(e2) = mgr.register(p, aifw_plugins::PluginConfig::default()).await + { + tracing::warn!(error = %e2, plugin = %name, "plugins: fallback re-register failed"); + } + format!( + "Plugin '{name}' config saved, but restarting it with the new settings failed: {e}" + ) + } + }; + state + .plugin_running_count + .store(mgr.running_count(), std::sync::atomic::Ordering::Relaxed); + + Ok(Json(MessageResponse { message })) } pub async fn get_plugin_logs( @@ -434,6 +528,114 @@ mod dispatch_tests { } } + /// Drift guard: `canonical_builtin` must return exactly the name each + /// built-in registers under (`PluginInfo::name`) — the UI keys every + /// toggle/config call on that name. + #[test] + fn canonical_builtin_matches_plugin_info_names() { + for key in ["custom-logger", "ip-reputation", "webhook-notifier"] { + let canonical = canonical_builtin(key).expect("builtin key must resolve"); + let info = instantiate_builtin(key) + .expect("builtin must instantiate") + .info(); + assert_eq!(canonical, info.name, "canonical name drifted for '{key}'"); + } + } + + #[tokio::test] + async fn update_config_restarts_running_plugin() { + let state = crate::create_app_state_in_memory(test_auth_settings()) + .await + .unwrap(); + + // Enable the built-in logging plugin through the handler, using the + // registered name the UI sends (from the plugin list). + let resp = enable_plugin( + axum::extract::State(state.clone()), + Json(serde_json::json!({"name": "custom-logger", "enabled": true})), + ) + .await + .unwrap(); + assert!( + resp.0.message.contains("enabled"), + "unexpected enable message: {}", + resp.0.message + ); + + // Save new settings — the running instance must be restarted with them. + let resp = update_plugin_config( + axum::extract::State(state.clone()), + axum::extract::Path("custom-logger".to_string()), + Json(serde_json::json!({"settings": {"max_entries": 5}})), + ) + .await + .unwrap(); + assert!( + resp.0.message.contains("applied"), + "expected applied message, got: {}", + resp.0.message + ); + + let mgr = state.plugin_manager.read().await; + let running = mgr.list_plugins().iter().any(|(info, s)| { + info.name == "custom-logger" && *s == aifw_plugins::PluginState::Running + }); + assert!(running, "plugin must still be running after config update"); + assert_eq!( + state + .plugin_running_count + .load(std::sync::atomic::Ordering::Relaxed), + mgr.running_count(), + "shadow counter must stay in sync" + ); + } + + #[tokio::test] + async fn update_config_on_stopped_plugin_only_persists() { + let state = crate::create_app_state_in_memory(test_auth_settings()) + .await + .unwrap(); + + let resp = update_plugin_config( + axum::extract::State(state.clone()), + axum::extract::Path("custom-logger".to_string()), + Json(serde_json::json!({"settings": {"max_entries": 7}})), + ) + .await + .unwrap(); + assert!( + !resp.0.message.contains("applied"), + "stopped plugin must not report an applied restart: {}", + resp.0.message + ); + + // Settings persisted and readable back. + let cfg = get_plugin_config( + axum::extract::State(state.clone()), + axum::extract::Path("custom-logger".to_string()), + ) + .await + .unwrap(); + assert_eq!(cfg.0["settings"]["max_entries"], 7); + + // Enabling afterwards must pick the saved settings up (init-time read). + let resp = enable_plugin( + axum::extract::State(state.clone()), + Json(serde_json::json!({"name": "custom-logger", "enabled": true})), + ) + .await + .unwrap(); + assert!( + resp.0.message.contains("enabled"), + "unexpected enable message: {}", + resp.0.message + ); + let mgr = state.plugin_manager.read().await; + assert!(mgr.list_plugins().iter().any(|(info, s)| { + info.name == "custom-logger" && *s == aifw_plugins::PluginState::Running + })); + } + #[tokio::test] async fn dispatch_hook_short_circuits_with_no_plugins() { let state = crate::create_app_state_in_memory(test_auth_settings()) diff --git a/aifw-api/src/router.rs b/aifw-api/src/router.rs index 6b2ca1d2..ad6c02b7 100644 --- a/aifw-api/src/router.rs +++ b/aifw-api/src/router.rs @@ -95,6 +95,24 @@ pub(crate) fn nat_write() -> Router { .layer(middleware::from_fn(perm_check!(Permission::NatWrite))) } +pub(crate) fn shaper_read() -> Router { + Router::new() + .route("/api/v1/shaper/queues", get(routes::list_shaper_queues)) + .route("/api/v1/shaper/status", get(routes::shaper_status)) + .layer(middleware::from_fn(perm_check!(Permission::RulesRead))) +} + +pub(crate) fn shaper_write() -> Router { + Router::new() + .route("/api/v1/shaper/queues", post(routes::create_shaper_queue)) + .route( + "/api/v1/shaper/queues/{id}", + put(routes::update_shaper_queue).delete(routes::delete_shaper_queue), + ) + .route("/api/v1/shaper/apply", post(routes::apply_shaper)) + .layer(middleware::from_fn(perm_check!(Permission::RulesWrite))) +} + pub(crate) fn vpn_read() -> Router { Router::new() .route("/api/v1/vpn/wg", get(routes::list_wg_tunnels)) diff --git a/aifw-api/src/routes/auth.rs b/aifw-api/src/routes/auth.rs index 0f1d39ca..d249e36a 100644 --- a/aifw-api/src/routes/auth.rs +++ b/aifw-api/src/routes/auth.rs @@ -8,11 +8,23 @@ use super::*; use crate::auth; +/// Build the `Set-Cookie` response headers installing a token pair as the +/// browser session (SEC-M7 #304). Non-browser clients keep using the tokens +/// from the JSON body; the cookies are additive. +fn session_cookie_headers(state: &AppState, tokens: &auth::TokenPair) -> HeaderMap { + let mut h = HeaderMap::new(); + auth::cookies::append_set_cookies( + &mut h, + auth::cookies::session_cookies(tokens, &state.auth_settings, state.tls_enabled), + ); + h +} + pub async fn login( State(state): State, headers: HeaderMap, Json(req): Json, -) -> Result, StatusCode> { +) -> Result<(HeaderMap, Json), StatusCode> { // Rate-limit on two axes: the X-Forwarded-For-ish client IP (best // effort — spoofable without a trusted-proxies allow-list, tracked // as a follow-up) AND the username. The username axis guarantees a @@ -74,18 +86,24 @@ pub async fn login( // Check if TOTP is required if user.totp_enabled { - return Ok(Json(auth::LoginResponse { - tokens: None, - totp_required: true, - })); + return Ok(( + HeaderMap::new(), + Json(auth::LoginResponse { + tokens: None, + totp_required: true, + }), + )); } // Check if TOTP enforcement is on but user hasn't set it up if state.auth_settings.require_totp && !user.totp_enabled { - return Ok(Json(auth::LoginResponse { - tokens: None, - totp_required: true, - })); + return Ok(( + HeaderMap::new(), + Json(auth::LoginResponse { + tokens: None, + totp_required: true, + }), + )); } // Resolve permissions for the JWT @@ -114,16 +132,20 @@ pub async fn login( ) .await; - Ok(Json(auth::LoginResponse { - tokens: Some(tokens), - totp_required: false, - })) + let cookies = session_cookie_headers(&state, &tokens); + Ok(( + cookies, + Json(auth::LoginResponse { + tokens: Some(tokens), + totp_required: false, + }), + )) } pub async fn totp_login( State(state): State, Json(req): Json, -) -> Result, StatusCode> { +) -> Result<(HeaderMap, Json), StatusCode> { let user = auth::get_user_by_username(&state.pool, &req.username) .await? .ok_or(StatusCode::UNAUTHORIZED)?; @@ -171,20 +193,27 @@ pub async fn totp_login( .await .map_err(|_| internal())?; - Ok(Json(tokens)) + let cookies = session_cookie_headers(&state, &tokens); + Ok((cookies, Json(tokens))) } pub async fn refresh_token( State(state): State, headers: HeaderMap, - Json(req): Json, -) -> Result, StatusCode> { + body: Option>, +) -> Result<(HeaderMap, Json), StatusCode> { + // Token comes from the JSON body (header-auth clients) or, for the + // browser UI, from the HttpOnly refresh cookie (SEC-M7 #304). + let refresh_token = body + .map(|Json(r)| r.refresh_token) + .or_else(|| auth::cookies::cookie_value(&headers, auth::cookies::REFRESH_COOKIE)) + .ok_or(bad_request())?; // SEC-H8: rate-limit refresh like login. Key on the client IP (best // effort, spoofable without a trusted-proxy allow-list) and on the // token prefix so a spray of forged tokens from one source is capped. // Falling back to the prefix as the IP key when there's no XFF avoids a // single shared global bucket that would block unrelated clients. - let token_prefix = auth::tokens::refresh_prefix(&req.refresh_token).to_string(); + let token_prefix = auth::tokens::refresh_prefix(&refresh_token).to_string(); let client_ip = headers .get("x-forwarded-for") .and_then(|v| v.to_str().ok()) @@ -200,40 +229,56 @@ pub async fn refresh_token( return Err(StatusCode::TOO_MANY_REQUESTS); } - let tokens = match auth::tokens::rotate_refresh_token( - &state.pool, - &req.refresh_token, - &state.auth_settings, - ) - .await - { - Ok(t) => t, - Err(_) => { - state - .login_limiter - .record_failure(&client_ip, &token_prefix) - .await; - return Err(StatusCode::UNAUTHORIZED); - } - }; + let tokens = + match auth::tokens::rotate_refresh_token(&state.pool, &refresh_token, &state.auth_settings) + .await + { + Ok(t) => t, + Err(_) => { + state + .login_limiter + .record_failure(&client_ip, &token_prefix) + .await; + return Err(StatusCode::UNAUTHORIZED); + } + }; state.login_limiter.clear(&client_ip, &token_prefix).await; - Ok(Json(tokens)) + let cookies = session_cookie_headers(&state, &tokens); + Ok((cookies, Json(tokens))) } pub async fn logout( State(state): State, headers: HeaderMap, - Json(req): Json, -) -> Result, StatusCode> { - // Revoke the refresh token - auth::tokens::revoke_refresh_token(&state.pool, &req.refresh_token) - .await - .map_err(|_| bad_request())?; - // Also revoke the current access token if present - if let Some(auth_header) = headers.get("authorization").and_then(|v| v.to_str().ok()) - && let Some(token) = auth_header.strip_prefix("Bearer ") - && let Ok(data) = auth::verify_access_token(token, &state.auth_settings) + body: Option>, +) -> Result<(HeaderMap, Json), StatusCode> { + // Revoke the refresh token. Body token (header-auth clients) keeps the + // strict 400-on-unknown contract; the cookie fallback is best-effort so + // a stale cookie can't wedge the browser in a can't-log-out state. + match body { + Some(Json(req)) => { + auth::tokens::revoke_refresh_token(&state.pool, &req.refresh_token) + .await + .map_err(|_| bad_request())?; + } + None => { + if let Some(rt) = auth::cookies::cookie_value(&headers, auth::cookies::REFRESH_COOKIE) + && let Err(e) = auth::tokens::revoke_refresh_token(&state.pool, &rt).await + { + tracing::warn!(error = %e, "logout: refresh cookie revocation failed"); + } + } + } + // Also revoke the current access token if present (header or cookie) + let access_token = headers + .get("authorization") + .and_then(|v| v.to_str().ok()) + .and_then(|h| h.strip_prefix("Bearer ")) + .map(str::to_string) + .or_else(|| auth::cookies::cookie_value(&headers, auth::cookies::ACCESS_COOKIE)); + if let Some(token) = access_token + && let Ok(data) = auth::verify_access_token(&token, &state.auth_settings) { let exp = chrono::DateTime::from_timestamp(data.claims.exp, 0) .map(|d| d.to_rfc3339()) @@ -243,9 +288,18 @@ pub async fn logout( // can't ride the positive-cache TTL. state.auth_jti_cache.remove(&data.claims.jti); } - Ok(Json(MessageResponse { - message: "Logged out".to_string(), - })) + // Expire the session cookies regardless of revocation outcome. + let mut cookies = HeaderMap::new(); + auth::cookies::append_set_cookies( + &mut cookies, + auth::cookies::clear_cookies(state.tls_enabled), + ); + Ok(( + cookies, + Json(MessageResponse { + message: "Logged out".to_string(), + }), + )) } pub async fn totp_setup( @@ -564,19 +618,19 @@ pub async fn create_api_key( Ok((StatusCode::CREATED, Json(response))) } -/// Pull the authenticated user's id out of a `Bearer` token. Shared with -/// `routes::users`, which needs the actor id for audit entries. +/// Pull the authenticated user's id out of a `Bearer` token or the session +/// cookie (SEC-M7 #304). Shared with `routes::users`, which needs the actor +/// id for audit entries. pub(super) fn extract_user_id(headers: &HeaderMap, state: &AppState) -> Result { - let auth_header = headers + let token = headers .get("authorization") .and_then(|v| v.to_str().ok()) + .and_then(|h| h.strip_prefix("Bearer ")) + .map(str::to_string) + .or_else(|| auth::cookies::cookie_value(headers, auth::cookies::ACCESS_COOKIE)) .ok_or(StatusCode::UNAUTHORIZED)?; - if let Some(token) = auth_header.strip_prefix("Bearer ") { - let data = auth::verify_access_token(token, &state.auth_settings) - .map_err(|_| StatusCode::UNAUTHORIZED)?; - Ok(data.claims.sub) - } else { - Err(StatusCode::UNAUTHORIZED) - } + let data = auth::verify_access_token(&token, &state.auth_settings) + .map_err(|_| StatusCode::UNAUTHORIZED)?; + Ok(data.claims.sub) } diff --git a/aifw-api/src/routes/mod.rs b/aifw-api/src/routes/mod.rs index 0cde9ad7..cc0cadfb 100644 --- a/aifw-api/src/routes/mod.rs +++ b/aifw-api/src/routes/mod.rs @@ -34,6 +34,7 @@ pub mod response; pub mod rules; pub mod schedules; pub mod settings; +pub mod shaper; pub mod static_routes; pub mod status; pub mod users; @@ -51,6 +52,7 @@ pub use response::*; pub use rules::*; pub use schedules::*; pub use settings::*; +pub use shaper::*; pub use static_routes::*; pub use status::*; pub use users::*; diff --git a/aifw-api/src/routes/nat.rs b/aifw-api/src/routes/nat.rs index c24eb66e..b92d82fa 100644 --- a/aifw-api/src/routes/nat.rs +++ b/aifw-api/src/routes/nat.rs @@ -34,19 +34,50 @@ pub async fn list_nat_rules( Ok(Json(ApiResponse { data: rules })) } +/// Error payload for NAT create/update: status + human-readable message so +/// engine/pf validation feedback (e.g. af-to family errors) reaches the UI +/// instead of a bare status code (#531). +type NatError = (StatusCode, Json); + +fn nat_bad_request(msg: impl Into) -> NatError { + ( + StatusCode::BAD_REQUEST, + Json(MessageResponse { + message: msg.into(), + }), + ) +} + +fn nat_engine_error(e: aifw_common::AifwError) -> NatError { + use aifw_common::AifwError; + let code = match &e { + AifwError::Validation(_) => StatusCode::BAD_REQUEST, + AifwError::NotFound(_) => StatusCode::NOT_FOUND, + _ => StatusCode::INTERNAL_SERVER_ERROR, + }; + ( + code, + Json(MessageResponse { + message: e.to_string(), + }), + ) +} + pub async fn create_nat_rule( State(state): State, Json(req): Json, -) -> Result<(StatusCode, Json>), StatusCode> { - let nat_type = NatType::parse(&req.nat_type).map_err(|_| bad_request())?; - let protocol = Protocol::parse(&req.protocol).map_err(|_| bad_request())?; +) -> Result<(StatusCode, Json>), NatError> { + let nat_type = NatType::parse(&req.nat_type) + .map_err(|_| nat_bad_request(format!("unknown NAT type '{}'", req.nat_type)))?; + let protocol = Protocol::parse(&req.protocol) + .map_err(|_| nat_bad_request(format!("unknown protocol '{}'", req.protocol)))?; let src_addr = req .src_addr .as_deref() .map(Address::parse) .transpose() - .map_err(|_| bad_request())? + .map_err(|e| nat_bad_request(format!("invalid source address: {e}")))? .unwrap_or(Address::Any); let dst_addr = req @@ -54,15 +85,17 @@ pub async fn create_nat_rule( .as_deref() .map(Address::parse) .transpose() - .map_err(|_| bad_request())? + .map_err(|e| nat_bad_request(format!("invalid destination address: {e}")))? .unwrap_or(Address::Any); - let redirect_addr = Address::parse(&req.redirect_addr).map_err(|_| bad_request())?; + let redirect_addr = Address::parse(&req.redirect_addr) + .map_err(|e| nat_bad_request(format!("invalid redirect address: {e}")))?; // Validate interface and label to prevent pf rule injection - aifw_core::validation::validate_interface_name(&req.interface).map_err(|_| bad_request())?; + aifw_core::validation::validate_interface_name(&req.interface) + .map_err(|e| nat_bad_request(e.to_string()))?; if let Some(ref label) = req.label { - aifw_core::validation::validate_label(label).map_err(|_| bad_request())?; + aifw_core::validation::validate_label(label).map_err(|e| nat_bad_request(e.to_string()))?; } let mut rule = NatRule::new( @@ -84,7 +117,7 @@ pub async fn create_nat_rule( .nat_engine .add_rule(rule) .await - .map_err(|_| bad_request())?; + .map_err(nat_engine_error)?; state.set_pending(|p| p.nat = true).await; Ok((StatusCode::CREATED, Json(ApiResponse { data: rule }))) } @@ -93,29 +126,32 @@ pub async fn update_nat_rule( State(state): State, Path(id): Path, Json(req): Json, -) -> Result>, StatusCode> { - let uuid = Uuid::parse_str(&id).map_err(|_| bad_request())?; +) -> Result>, NatError> { + let uuid = Uuid::parse_str(&id).map_err(|_| nat_bad_request("invalid rule id"))?; let mut rule = state .nat_engine .get_rule(uuid) .await - .map_err(|_| StatusCode::NOT_FOUND)?; + .map_err(nat_engine_error)?; - rule.nat_type = NatType::parse(&req.nat_type).map_err(|_| bad_request())?; + rule.nat_type = NatType::parse(&req.nat_type) + .map_err(|_| nat_bad_request(format!("unknown NAT type '{}'", req.nat_type)))?; // Validate interface and label to prevent pf rule injection - aifw_core::validation::validate_interface_name(&req.interface).map_err(|_| bad_request())?; + aifw_core::validation::validate_interface_name(&req.interface) + .map_err(|e| nat_bad_request(e.to_string()))?; if let Some(ref label) = req.label { - aifw_core::validation::validate_label(label).map_err(|_| bad_request())?; + aifw_core::validation::validate_label(label).map_err(|e| nat_bad_request(e.to_string()))?; } rule.interface = Interface(req.interface); - rule.protocol = Protocol::parse(&req.protocol).map_err(|_| bad_request())?; + rule.protocol = Protocol::parse(&req.protocol) + .map_err(|_| nat_bad_request(format!("unknown protocol '{}'", req.protocol)))?; rule.src_addr = req .src_addr .as_deref() .map(Address::parse) .transpose() - .map_err(|_| bad_request())? + .map_err(|e| nat_bad_request(format!("invalid source address: {e}")))? .unwrap_or(Address::Any); rule.src_port = port_range(req.src_port_start, req.src_port_end); rule.dst_addr = req @@ -123,11 +159,12 @@ pub async fn update_nat_rule( .as_deref() .map(Address::parse) .transpose() - .map_err(|_| bad_request())? + .map_err(|e| nat_bad_request(format!("invalid destination address: {e}")))? .unwrap_or(Address::Any); rule.dst_port = port_range(req.dst_port_start, req.dst_port_end); rule.redirect = NatRedirect { - address: Address::parse(&req.redirect_addr).map_err(|_| bad_request())?, + address: Address::parse(&req.redirect_addr) + .map_err(|e| nat_bad_request(format!("invalid redirect address: {e}")))?, port: port_range(req.redirect_port_start, req.redirect_port_end), }; rule.label = req.label; @@ -135,7 +172,7 @@ pub async fn update_nat_rule( rule.status = match s.as_str() { "active" => NatStatus::Active, "disabled" => NatStatus::Disabled, - _ => return Err(bad_request()), + _ => return Err(nat_bad_request(format!("unknown status '{s}'"))), }; } rule.updated_at = chrono::Utc::now(); @@ -144,7 +181,7 @@ pub async fn update_nat_rule( .nat_engine .update_rule(&rule) .await - .map_err(|_| internal())?; + .map_err(nat_engine_error)?; state.set_pending(|p| p.nat = true).await; Ok(Json(ApiResponse { data: rule })) } @@ -168,16 +205,21 @@ pub async fn delete_nat_rule( pub async fn get_nat_pf_output( State(state): State, ) -> Result>>, StatusCode> { - let nat_rules = state.pf.get_nat_rules("aifw").await.unwrap_or_default(); - let filter_rules = state.pf.get_rules("aifw").await.unwrap_or_default(); + // The NAT engine loads into "aifw-nat" (this endpoint used to read the + // "aifw" filter anchor and always came back empty). Cross-family af-to + // rules are filter-class, so both rulesets of the anchor are shown. + let nat_rules = state.pf.get_nat_rules("aifw-nat").await.unwrap_or_default(); + let filter_rules = state.pf.get_rules("aifw-nat").await.unwrap_or_default(); let mut output = Vec::new(); if !nat_rules.is_empty() { - output.push("# NAT Rules (anchor: aifw)".to_string()); + output.push("# NAT rules (anchor: aifw-nat)".to_string()); output.extend(nat_rules); } if !filter_rules.is_empty() { - output.push("".to_string()); - output.push("# Filter Rules (anchor: aifw)".to_string()); + if !output.is_empty() { + output.push("".to_string()); + } + output.push("# Translation pass rules — af-to (anchor: aifw-nat)".to_string()); output.extend(filter_rules); } Ok(Json(ApiResponse { data: output })) diff --git a/aifw-api/src/routes/shaper.rs b/aifw-api/src/routes/shaper.rs new file mode 100644 index 00000000..6fe6f521 --- /dev/null +++ b/aifw-api/src/routes/shaper.rs @@ -0,0 +1,128 @@ +//! FQ-CoDel shaper API: CRUD plus explicit apply/status operations. + +use super::*; +use aifw_common::QueueConfig; + +#[derive(Debug, Serialize)] +pub struct ShaperStatus { + pub configured: usize, + pub active: usize, + pub backend: &'static str, + pub verified: bool, +} + +pub async fn list_shaper_queues( + State(state): State, +) -> Result>>, StatusCode> { + let queues = state + .shaping_engine + .list_queues() + .await + .map_err(|_| internal())?; + Ok(Json(ApiResponse { data: queues })) +} + +pub async fn create_shaper_queue( + State(state): State, + Json(queue): Json, +) -> Result<(StatusCode, Json>), StatusCode> { + let created = state + .shaping_engine + .add_queue(queue) + .await + .map_err(|_| bad_request())?; + state + .shaping_engine + .apply_queues() + .await + .map_err(|_| internal())?; + Ok((StatusCode::CREATED, Json(ApiResponse { data: created }))) +} + +pub async fn delete_shaper_queue( + State(state): State, + Path(id): Path, +) -> Result, StatusCode> { + let id = Uuid::parse_str(&id).map_err(|_| bad_request())?; + state + .shaping_engine + .delete_queue(id) + .await + .map_err(|_| StatusCode::NOT_FOUND)?; + state + .shaping_engine + .apply_queues() + .await + .map_err(|_| internal())?; + Ok(Json(MessageResponse { + message: "shaper queue deleted and live state reapplied".into(), + })) +} + +pub async fn update_shaper_queue( + State(state): State, + Path(id): Path, + Json(mut queue): Json, +) -> Result>, StatusCode> { + queue.id = Uuid::parse_str(&id).map_err(|_| bad_request())?; + let updated = state + .shaping_engine + .update_queue(queue) + .await + .map_err(|_| bad_request())?; + state + .shaping_engine + .apply_queues() + .await + .map_err(|_| internal())?; + Ok(Json(ApiResponse { data: updated })) +} + +pub async fn apply_shaper( + State(state): State, +) -> Result, StatusCode> { + state + .shaping_engine + .apply_queues() + .await + .map_err(|_| internal())?; + Ok(Json(MessageResponse { + message: "shaper configuration applied and verified".into(), + })) +} + +pub async fn shaper_status( + State(state): State, +) -> Result>, StatusCode> { + let queues = state + .shaping_engine + .list_queues() + .await + .map_err(|_| internal())?; + let active = queues + .iter() + .filter(|queue| queue.status == aifw_common::QueueStatus::Active) + .count(); + let verified = if cfg!(target_os = "freebsd") && active > 0 { + tokio::process::Command::new("dnctl") + .args(["pipe", "list"]) + .output() + .await + .map(|output| output.status.success()) + .unwrap_or(false) + } else { + active == 0 + }; + Ok(Json(ApiResponse { + data: ShaperStatus { + configured: queues.len(), + active, + backend: if cfg!(target_os = "freebsd") { + "dummynet" + } else { + "mock" + }, + verified, + }, + })) +} diff --git a/aifw-api/src/routes/vpn.rs b/aifw-api/src/routes/vpn.rs index bd4f2472..558d122d 100644 --- a/aifw-api/src/routes/vpn.rs +++ b/aifw-api/src/routes/vpn.rs @@ -7,6 +7,9 @@ pub struct CreateWgTunnelRequest { pub name: String, pub listen_port: u16, pub address: String, + /// Optional IPv6 tunnel address for dual-stack tunnels (#471), + /// e.g. `fd00:a1f0::1/64`. Empty/omitted means IPv4-only. + pub address6: Option, pub private_key: Option, pub dns: Option, pub mtu: Option, @@ -16,6 +19,14 @@ pub struct CreateWgTunnelRequest { pub split_routes: Option, } +/// Parse the optional IPv6 tunnel address; empty/whitespace means None. +fn parse_address6(raw: Option<&str>) -> Result, StatusCode> { + match raw.map(str::trim).filter(|s| !s.is_empty()) { + Some(s) => Address::parse(s).map(Some).map_err(|_| bad_request()), + None => Ok(None), + } +} + pub async fn list_wg_tunnels( State(state): State, ) -> Result>>, StatusCode> { @@ -32,6 +43,7 @@ pub async fn create_wg_tunnel( Json(req): Json, ) -> Result<(StatusCode, Json>), StatusCode> { let address = Address::parse(&req.address).map_err(|_| bad_request())?; + let address6 = parse_address6(req.address6.as_deref())?; // FreeBSD requires short interface names: wg0, wg1, etc. (not wg51820) let existing = state.vpn_engine.list_wg_tunnels().await.unwrap_or_default(); let used_indices: std::collections::HashSet = existing @@ -52,6 +64,7 @@ pub async fn create_wg_tunnel( tunnel.public_key = aifw_common::vpn::derive_wg_pubkey(pk).map_err(|_| bad_request())?; tunnel.private_key = pk.clone(); } + tunnel.address6 = address6; tunnel.dns = req.dns; tunnel.mtu = req.mtu; tunnel.listen_interface = req.listen_interface; @@ -83,6 +96,7 @@ pub async fn update_wg_tunnel( tunnel.name = req.name; tunnel.listen_port = req.listen_port; tunnel.address = Address::parse(&req.address).map_err(|_| bad_request())?; + tunnel.address6 = parse_address6(req.address6.as_deref())?; tunnel.dns = req.dns; tunnel.mtu = req.mtu; tunnel.listen_interface = req.listen_interface; diff --git a/aifw-api/src/tests.rs b/aifw-api/src/tests.rs index 61d88fc4..dd0e403c 100644 --- a/aifw-api/src/tests.rs +++ b/aifw-api/src/tests.rs @@ -442,6 +442,73 @@ mod tests { resp.assert_status_ok(); } + #[tokio::test] + async fn test_nat64_create_happy_path() { + let (server, _) = test_app().await; + let token = create_user_and_login(&server).await; + + let resp = server + .post("/api/v1/nat") + .authorization_bearer(&token) + .json(&json!({ + "nat_type": "nat64", + "interface": "em0", + "protocol": "any", + "src_addr": "2001:db8:1::/64", + "dst_addr": "64:ff9b::/96", + "redirect_addr": "203.0.113.1", + })) + .await; + + resp.assert_status(StatusCode::CREATED); + let body: Value = resp.json(); + assert_eq!(body["data"]["nat_type"], "nat64"); + } + + #[tokio::test] + async fn test_nat64_create_wrong_family_gets_message() { + let (server, _) = test_app().await; + let token = create_user_and_login(&server).await; + + // IPv4 redirect required for nat64 — an IPv6 one must 400 with a + // human-readable message (surfaced to the UI banner, #531). + let resp = server + .post("/api/v1/nat") + .authorization_bearer(&token) + .json(&json!({ + "nat_type": "nat64", + "interface": "em0", + "protocol": "any", + "dst_addr": "64:ff9b::/96", + "redirect_addr": "2001:db8::1", + })) + .await; + + resp.assert_status(StatusCode::BAD_REQUEST); + let body: Value = resp.json(); + let msg = body["message"].as_str().unwrap(); + assert!( + msg.contains("nat64"), + "message should name the rule type: {msg}" + ); + + // Missing /96 prefix on the destination + let resp = server + .post("/api/v1/nat") + .authorization_bearer(&token) + .json(&json!({ + "nat_type": "nat64", + "interface": "em0", + "protocol": "any", + "dst_addr": "64:ff9b::/64", + "redirect_addr": "203.0.113.1", + })) + .await; + resp.assert_status(StatusCode::BAD_REQUEST); + let body: Value = resp.json(); + assert!(body["message"].as_str().unwrap().contains("/96")); + } + // --- New auth system tests --- #[tokio::test] @@ -2279,6 +2346,89 @@ mod tests { .expect("restoring the current config must succeed"); } + #[tokio::test] + async fn test_restore_round_trips_dns_resolver_config() { + // #589: resolver settings live in dns_resolver_config, not + // /etc/resolv.conf — a backup/restore must carry them. + let state = crate::create_app_state_in_memory(plain_auth_settings()) + .await + .unwrap(); + + let resolver = crate::dns_resolver::ResolverConfig { + forwarding_servers: vec!["9.9.9.9".to_string()], + blocklists_enabled: true, + ..Default::default() + }; + let mut conn = state.pool.acquire().await.unwrap(); + crate::dns_resolver::save_config_on(&mut conn, &resolver) + .await + .unwrap(); + drop(conn); + + let config = crate::backup::build_current_config(&state).await.unwrap(); + let exported = config + .dns_resolver + .as_ref() + .expect("export must include resolver"); + assert_eq!(exported.forwarding_servers, vec!["9.9.9.9".to_string()]); + assert!(exported.blocklists_enabled); + + // Simulate drift after the backup was taken. + sqlx::query( + "INSERT OR REPLACE INTO dns_resolver_config (key, value) VALUES ('forwarding_servers', '8.8.4.4')", + ) + .execute(&state.pool) + .await + .unwrap(); + + crate::backup::apply_firewall_config(&state, &config, &Default::default()) + .await + .expect("restore must succeed"); + + let (servers,): (String,) = sqlx::query_as( + "SELECT value FROM dns_resolver_config WHERE key = 'forwarding_servers'", + ) + .fetch_one(&state.pool) + .await + .unwrap(); + assert_eq!( + servers, "9.9.9.9", + "restore must reinstate the backed-up forwarders" + ); + } + + #[tokio::test] + async fn test_restore_without_resolver_section_leaves_config_untouched() { + // A pre-#589 backup has no dns_resolver section — restoring it must + // not reset the box's resolver config to defaults. + let state = crate::create_app_state_in_memory(plain_auth_settings()) + .await + .unwrap(); + sqlx::query( + "INSERT OR REPLACE INTO dns_resolver_config (key, value) VALUES ('forwarding_servers', '9.9.9.9')", + ) + .execute(&state.pool) + .await + .unwrap(); + + let mut config = crate::backup::build_current_config(&state).await.unwrap(); + config.dns_resolver = None; + crate::backup::apply_firewall_config(&state, &config, &Default::default()) + .await + .expect("restore must succeed"); + + let (servers,): (String,) = sqlx::query_as( + "SELECT value FROM dns_resolver_config WHERE key = 'forwarding_servers'", + ) + .fetch_one(&state.pool) + .await + .unwrap(); + assert_eq!( + servers, "9.9.9.9", + "legacy restore must not clobber resolver config" + ); + } + #[tokio::test] async fn test_restore_fails_instead_of_partial_apply() { // A required step failing (here: clearing a table that no longer @@ -2296,6 +2446,397 @@ mod tests { assert!(res.is_err(), "partial apply must not report success"); } + #[tokio::test] + async fn test_mid_apply_failure_rolls_back_db_transaction() { + // Force a failure LATE in the apply (the DHCP clear runs after every + // rules/NAT/alias insert) and verify the single restore transaction + // (#158) rewinds everything — the pre-restore rows must survive + // untouched even without the snapshot-reapply wrapper. + let state = crate::create_app_state_in_memory(plain_auth_settings()) + .await + .unwrap(); + let rule = aifw_common::Rule::new( + aifw_common::Action::Pass, + aifw_common::Direction::In, + aifw_common::Protocol::Tcp, + aifw_common::RuleMatch { + src_addr: aifw_common::Address::Any, + src_port: None, + dst_addr: aifw_common::Address::Any, + dst_port: None, + }, + ); + let rule_id = state.rule_engine.add_rule(rule).await.unwrap().id; + + let config = crate::backup::build_current_config(&state).await.unwrap(); + sqlx::query("DROP TABLE dhcp_subnets") + .execute(&state.pool) + .await + .unwrap(); + let res = crate::backup::apply_firewall_config(&state, &config, &Default::default()).await; + assert!(res.is_err(), "late failure must abort the restore"); + + let rules = state.rule_engine.list_rules().await.unwrap(); + assert_eq!(rules.len(), 1, "transaction rollback must keep prior rows"); + assert_eq!(rules[0].id, rule_id); + } + + #[tokio::test] + async fn test_restore_1k_rules_round_trips() { + // #158 acceptance: a large config restores through the single + // transaction and every row survives the round trip. + let state = crate::create_app_state_in_memory(plain_auth_settings()) + .await + .unwrap(); + let mut config = crate::backup::build_current_config(&state).await.unwrap(); + for i in 0..1000 { + config.rules.push(aifw_core::config::RuleConfig { + id: uuid::Uuid::new_v4().to_string(), + priority: i, + action: aifw_common::Action::Pass, + direction: aifw_common::Direction::In, + protocol: aifw_common::Protocol::Tcp, + interface: None, + src_addr: Some("any".to_string()), + src_port_start: None, + src_port_end: None, + dst_addr: Some("any".to_string()), + dst_port_start: Some(1000 + i as u16), + dst_port_end: Some(1000 + i as u16), + log: false, + quick: true, + label: Some(format!("bulk-{i}")), + state_tracking: aifw_common::StateTracking::KeepState, + status: aifw_common::RuleStatus::Active, + ip_version: aifw_common::IpVersion::Both, + src_invert: false, + dst_invert: false, + schedule_id: None, + gateway: None, + }); + } + let started = std::time::Instant::now(); + crate::backup::apply_firewall_config(&state, &config, &Default::default()) + .await + .expect("bulk restore must succeed"); + let elapsed = started.elapsed(); + let rules = state.rule_engine.list_rules().await.unwrap(); + assert_eq!(rules.len(), 1000, "every rule must survive the round trip"); + // Generous bound — the point is one transaction, not per-row fsyncs. + assert!(elapsed.as_secs() < 30, "bulk restore took {elapsed:?}"); + } + + fn any_tcp_rule() -> aifw_common::Rule { + aifw_common::Rule::new( + aifw_common::Action::Pass, + aifw_common::Direction::In, + aifw_common::Protocol::Tcp, + aifw_common::RuleMatch { + src_addr: aifw_common::Address::Any, + src_port: None, + dst_addr: aifw_common::Address::Any, + dst_port: None, + }, + ) + } + + #[tokio::test] + async fn test_restore_failure_injection_every_db_stage() { + // #535: for every table the restore touches, a failure at that stage + // must abort the restore with the transaction rolled back — the + // pre-restore rules must survive untouched at every injection point. + let _ = tracing_subscriber::fmt() + .with_max_level(tracing::Level::ERROR) + .try_init(); + for table in [ + "nat_rules", + "aliases", + "static_routes", + "geoip_rules", + "wg_tunnels", + "wg_peers", + "ipsec_sas", + "ipsec_tunnels", + "queue_configs", + "rate_limit_rules", + "sni_rules", + "ja3_blocklist", + "carp_vips", + "pfsync_config", + "cluster_nodes", + "auth_config", + "dhcp_subnets", + "dhcp_reservations", + "dhcp_config", + "dhcp_ddns_config", + "dhcp_ha_config", + ] { + let state = crate::create_app_state_in_memory(plain_auth_settings()) + .await + .unwrap(); + state.rule_engine.add_rule(any_tcp_rule()).await.unwrap(); + let config = crate::backup::build_current_config(&state).await.unwrap(); + // Replace the table with a read-only VIEW of the same name: the + // engine migrates' CREATE TABLE IF NOT EXISTS no-op on it (so + // pre-tx migrates can't undo the injection, unlike a plain DROP) + // and the restore's DELETE/INSERT then fails at exactly this + // stage. + sqlx::query(sqlx::AssertSqlSafe(format!("DROP TABLE {table}"))) + .execute(&state.pool) + .await + .unwrap(); + sqlx::query(sqlx::AssertSqlSafe(format!( + "CREATE VIEW {table} AS SELECT 1 AS x" + ))) + .execute(&state.pool) + .await + .unwrap(); + let res = + crate::backup::apply_firewall_config(&state, &config, &Default::default()).await; + assert!(res.is_err(), "failure at {table} must abort the restore"); + let rules = state.rule_engine.list_rules().await.unwrap(); + assert_eq!( + rules.len(), + 1, + "failure at {table} must leave prior rules untouched" + ); + } + } + + #[tokio::test] + async fn test_restore_mid_tx_failure_rolls_back_and_audits() { + // Outcome 2 of the #535 contract: apply fails (duplicate alias name + // violates the UNIQUE constraint mid-transaction), prior state is + // restored, and the rollback is audited. + let state = crate::create_app_state_in_memory(plain_auth_settings()) + .await + .unwrap(); + state.rule_engine.add_rule(any_tcp_rule()).await.unwrap(); + state + .alias_engine + .add(aifw_common::Alias { + id: uuid::Uuid::new_v4(), + name: "keepme".to_string(), + alias_type: aifw_common::AliasType::Host, + entries: vec!["192.0.2.1".to_string()], + description: None, + enabled: true, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + }) + .await + .unwrap(); + + let mut config = crate::backup::build_current_config(&state).await.unwrap(); + for _ in 0..2 { + config.aliases.push(aifw_core::config::AliasConfig { + id: uuid::Uuid::new_v4().to_string(), + name: "dup_name".to_string(), + alias_type: "host".to_string(), + entries: vec!["198.51.100.1".to_string()], + description: None, + enabled: false, + }); + } + + let res = + crate::backup::apply_firewall_config_or_rollback(&state, &config, &Default::default()) + .await; + assert!(res.is_err(), "duplicate alias must abort the restore"); + + let aliases = state.alias_engine.list().await.unwrap(); + assert_eq!(aliases.len(), 1, "prior alias set must be restored"); + assert_eq!(aliases[0].name, "keepme"); + assert_eq!(state.rule_engine.list_rules().await.unwrap().len(), 1); + + let (audits,): (i64,) = sqlx::query_as( + "SELECT COUNT(*) FROM audit_log WHERE details LIKE '%rolled back to pre-restore state%'", + ) + .fetch_one(&state.pool) + .await + .unwrap(); + assert!(audits >= 1, "rollback must be audited"); + } + + #[tokio::test] + async fn test_restore_pf_failure_after_commit_rolls_back_cleanly() { + // Outcome 2 via the data plane: the target config needs a pf op the + // snapshot doesn't (geo-IP table populate), so injecting that + // failure aborts the target apply and the snapshot re-applies clean. + let state = crate::create_app_state_in_memory(plain_auth_settings()) + .await + .unwrap(); + state.rule_engine.add_rule(any_tcp_rule()).await.unwrap(); + let mock = state + .pf + .as_any() + .downcast_ref::() + .expect("tests run on the mock backend"); + mock.fail_op("replace_table_entries").await; + + let mut config = crate::backup::build_current_config(&state).await.unwrap(); + config.geoip.push(aifw_core::config::GeoIpEntry { + id: uuid::Uuid::new_v4().to_string(), + country: "CN".to_string(), + action: aifw_common::GeoIpAction::Block, + label: None, + status: aifw_common::GeoIpRuleStatus::Active, + }); + + let res = + crate::backup::apply_firewall_config_or_rollback(&state, &config, &Default::default()) + .await; + assert!(res.is_err(), "pf failure must abort the restore"); + assert_eq!( + state.geoip_engine.list_rules().await.unwrap().len(), + 0, + "geo-ip rows from the failed target must be rolled back" + ); + assert_eq!(state.rule_engine.list_rules().await.unwrap().len(), 1); + } + + #[tokio::test] + async fn test_restore_rollback_failure_is_audited_high_severity() { + // Outcome 3 of the #535 contract: the apply fails AND the rollback + // fails (persistent pf failure hits both); the operator gets an + // explicit rollback-failed audit row, never silent partial success. + let state = crate::create_app_state_in_memory(plain_auth_settings()) + .await + .unwrap(); + state.rule_engine.add_rule(any_tcp_rule()).await.unwrap(); + let mock = state + .pf + .as_any() + .downcast_ref::() + .expect("tests run on the mock backend"); + mock.fail_op("load_rules").await; + + let config = crate::backup::build_current_config(&state).await.unwrap(); + let res = + crate::backup::apply_firewall_config_or_rollback(&state, &config, &Default::default()) + .await; + assert!(res.is_err()); + + let (audits,): (i64,) = + sqlx::query_as("SELECT COUNT(*) FROM audit_log WHERE details LIKE '%rollback failed%'") + .fetch_one(&state.pool) + .await + .unwrap(); + assert!(audits >= 1, "failed rollback must be audited"); + mock.clear_fail("load_rules").await; + } + + #[tokio::test] + async fn test_commit_confirm_refuses_invalid_rollback_snapshot() { + // Arming with a snapshot that can't roll back would leave the timer + // to fail silently at expiry (#535) — it must be refused up front. + let state = crate::create_app_state_in_memory(plain_auth_settings()) + .await + .unwrap(); + let res = crate::backup::commit_confirm_arm_with_snapshot( + state, + "{not valid json".to_string(), + "test".to_string(), + 5, + ) + .await; + assert_eq!(res, Err(axum::http::StatusCode::INTERNAL_SERVER_ERROR)); + } + + #[tokio::test] + async fn test_post_apply_verification_detects_pf_drift() { + // verify_applied must fail when the pf anchor no longer matches the + // database (#535 post-apply verification). + let state = crate::create_app_state_in_memory(plain_auth_settings()) + .await + .unwrap(); + let rule = aifw_common::Rule::new( + aifw_common::Action::Pass, + aifw_common::Direction::In, + aifw_common::Protocol::Tcp, + aifw_common::RuleMatch { + src_addr: aifw_common::Address::Any, + src_port: None, + dst_addr: aifw_common::Address::Any, + dst_port: None, + }, + ); + state.rule_engine.add_rule(rule).await.unwrap(); + state.rule_engine.apply_rules().await.unwrap(); + state + .rule_engine + .verify_applied() + .await + .expect("freshly applied ruleset must verify"); + + state.pf.flush_rules("aifw").await.unwrap(); + assert!( + state.rule_engine.verify_applied().await.is_err(), + "flushed anchor must fail verification" + ); + } + + #[tokio::test] + async fn test_prevalidation_rejects_bad_config_without_mutation() { + // A config that would abort mid-apply (rule priority out of range) + // must be rejected up front with 400 and zero rows touched (#535). + let state = crate::create_app_state_in_memory(plain_auth_settings()) + .await + .unwrap(); + let rule = aifw_common::Rule::new( + aifw_common::Action::Pass, + aifw_common::Direction::In, + aifw_common::Protocol::Tcp, + aifw_common::RuleMatch { + src_addr: aifw_common::Address::Any, + src_port: None, + dst_addr: aifw_common::Address::Any, + dst_port: None, + }, + ); + state.rule_engine.add_rule(rule).await.unwrap(); + + let mut config = crate::backup::build_current_config(&state).await.unwrap(); + config.rules[0].priority = 20_000; // validate_rule caps at 10000 + + let res = + crate::backup::apply_firewall_config_or_rollback(&state, &config, &Default::default()) + .await; + assert_eq!(res, Err(axum::http::StatusCode::BAD_REQUEST)); + let rules = state.rule_engine.list_rules().await.unwrap(); + assert_eq!(rules.len(), 1, "prevalidation failure must not touch rows"); + } + + #[tokio::test] + async fn test_prevalidation_rejects_duplicate_wg_ports() { + let state = crate::create_app_state_in_memory(plain_auth_settings()) + .await + .unwrap(); + let mut config = crate::backup::build_current_config(&state).await.unwrap(); + for name in ["wg-a", "wg-b"] { + config + .vpn + .wireguard + .push(aifw_core::config::WireguardTunnelConfig { + id: uuid::Uuid::new_v4().to_string(), + name: name.to_string(), + interface: "wg0".to_string(), + listen_port: 51820, + address: "10.9.0.1/24".to_string(), + address6: None, + private_key: "k".into(), + public_key: "K".into(), + dns: None, + mtu: None, + peers: vec![], + }); + } + let res = + crate::backup::apply_firewall_config_or_rollback(&state, &config, &Default::default()) + .await; + assert_eq!(res, Err(axum::http::StatusCode::BAD_REQUEST)); + } + // --- IPsec tunnels (#530) --- fn ipsec_tunnel_body() -> Value { @@ -2445,4 +2986,206 @@ mod tests { .starts_with("aifw-") ); } + + // ============ Session-cookie auth (SEC-M7 #304) ============ + + /// Collect the `Set-Cookie` header values from a response. + fn set_cookies(resp: &axum_test::TestResponse) -> Vec { + resp.headers() + .get_all(axum::http::header::SET_COOKIE) + .iter() + .filter_map(|v| v.to_str().ok().map(str::to_string)) + .collect() + } + + /// Extract `=` (value only) from a `Set-Cookie` list. + fn cookie_from(cookies: &[String], name: &str) -> String { + cookies + .iter() + .find(|c| c.starts_with(&format!("{name}="))) + .and_then(|c| c.split(';').next()) + .and_then(|kv| kv.split('=').nth(1)) + .unwrap_or_else(|| panic!("cookie {name} not found in {cookies:?}")) + .to_string() + } + + /// Register the first user and log in, returning the raw `Set-Cookie` + /// values from the login response. + async fn login_cookies(server: &TestServer) -> Vec { + server + .post("/api/v1/auth/register") + .json(&json!({"username": "admin", "password": "TestPass123"})) + .await; + let resp = server + .post("/api/v1/auth/login") + .json(&json!({"username": "admin", "password": "TestPass123"})) + .await; + resp.assert_status_ok(); + set_cookies(&resp) + } + + #[tokio::test] + async fn test_login_sets_httponly_session_cookies() { + let (server, _) = test_app().await; + let cookies = login_cookies(&server).await; + + assert_eq!(cookies.len(), 2, "expected access + refresh cookies"); + for c in &cookies { + assert!(c.contains("HttpOnly"), "{c}"); + assert!(c.contains("SameSite=Strict"), "{c}"); + } + let access = cookies.iter().find(|c| c.starts_with("aifw_at=")).unwrap(); + assert!(access.contains("Path=/;"), "{access}"); + let refresh = cookies.iter().find(|c| c.starts_with("aifw_rt=")).unwrap(); + assert!(refresh.contains("Path=/api/v1/auth;"), "{refresh}"); + } + + #[tokio::test] + async fn test_cookie_authenticates_requests() { + let (server, _) = test_app().await; + let cookies = login_cookies(&server).await; + let access = cookie_from(&cookies, "aifw_at"); + + // No Authorization header — the cookie alone must authenticate. + let resp = server + .get("/api/v1/auth/me") + .add_header("cookie", format!("aifw_at={access}")) + .await; + resp.assert_status_ok(); + let body: Value = resp.json(); + assert_eq!(body["username"], "admin"); + } + + #[tokio::test] + async fn test_cookie_write_requires_csrf_header() { + let (server, _) = test_app().await; + let cookies = login_cookies(&server).await; + let access = cookie_from(&cookies, "aifw_at"); + + // Unsafe method with cookie auth but no CSRF header → 403. + let resp = server + .post("/api/v1/auth/ws-ticket") + .add_header("cookie", format!("aifw_at={access}")) + .await; + resp.assert_status(StatusCode::FORBIDDEN); + + // Same request with the custom header succeeds. + let resp = server + .post("/api/v1/auth/ws-ticket") + .add_header("cookie", format!("aifw_at={access}")) + .add_header("x-aifw-csrf", "1") + .await; + resp.assert_status_ok(); + } + + #[tokio::test] + async fn test_bearer_writes_do_not_need_csrf_header() { + let (server, _) = test_app().await; + let token = create_user_and_login(&server).await; + + // Header-auth clients are CSRF-immune; no custom header required. + let resp = server + .post("/api/v1/auth/ws-ticket") + .authorization_bearer(&token) + .await; + resp.assert_status_ok(); + } + + #[tokio::test] + async fn test_refresh_via_cookie_rotates_session() { + let (server, _) = test_app().await; + let cookies = login_cookies(&server).await; + let refresh = cookie_from(&cookies, "aifw_rt"); + + // No JSON body — the refresh token rides the cookie. + let resp = server + .post("/api/v1/auth/refresh") + .add_header("cookie", format!("aifw_rt={refresh}")) + .await; + resp.assert_status_ok(); + + let rotated = set_cookies(&resp); + let new_access = cookie_from(&rotated, "aifw_at"); + let new_refresh = cookie_from(&rotated, "aifw_rt"); + assert_ne!(new_refresh, refresh, "refresh token must rotate"); + + // The rotated access cookie authenticates. + server + .get("/api/v1/auth/me") + .add_header("cookie", format!("aifw_at={new_access}")) + .await + .assert_status_ok(); + + // The old refresh token was revoked by the rotation. + let resp = server + .post("/api/v1/auth/refresh") + .add_header("cookie", format!("aifw_rt={refresh}")) + .await; + resp.assert_status(StatusCode::UNAUTHORIZED); + } + + #[tokio::test] + async fn test_logout_via_cookies_clears_and_revokes() { + let (server, _) = test_app().await; + let cookies = login_cookies(&server).await; + let access = cookie_from(&cookies, "aifw_at"); + let refresh = cookie_from(&cookies, "aifw_rt"); + + // Cookie-only logout: no body, no Authorization header. + let resp = server + .post("/api/v1/auth/logout") + .add_header("cookie", format!("aifw_at={access}; aifw_rt={refresh}")) + .add_header("x-aifw-csrf", "1") + .await; + resp.assert_status_ok(); + for c in set_cookies(&resp) { + assert!(c.contains("Max-Age=0"), "logout must expire cookies: {c}"); + } + + // Access token was revoked (JTI) — cookie no longer authenticates. + let resp = server + .get("/api/v1/auth/me") + .add_header("cookie", format!("aifw_at={access}")) + .await; + resp.assert_status(StatusCode::UNAUTHORIZED); + + // Refresh token was revoked too. + let resp = server + .post("/api/v1/auth/refresh") + .add_header("cookie", format!("aifw_rt={refresh}")) + .await; + resp.assert_status(StatusCode::UNAUTHORIZED); + } + + #[tokio::test] + async fn test_totp_required_login_sets_no_cookies() { + let (server, _) = test_app().await; + // First login normally to create the user, then enable TOTP. + let token = create_user_and_login(&server).await; + let resp = server + .post("/api/v1/auth/totp/setup") + .authorization_bearer(&token) + .await; + resp.assert_status_ok(); + let body: Value = resp.json(); + let secret = body["secret"].as_str().unwrap().to_string(); + let code = crate::auth::totp::generate_current(&secret).unwrap(); + server + .post("/api/v1/auth/totp/verify") + .authorization_bearer(&token) + .json(&json!({"code": code})) + .await + .assert_status_ok(); + + // Password-only login now returns totp_required and must NOT install + // session cookies. + let resp = server + .post("/api/v1/auth/login") + .json(&json!({"username": "admin", "password": "TestPass123"})) + .await; + resp.assert_status_ok(); + let body: Value = resp.json(); + assert_eq!(body["totp_required"], true); + assert!(set_cookies(&resp).is_empty(), "no cookies before 2FA"); + } } diff --git a/aifw-api/src/updates.rs b/aifw-api/src/updates.rs index d0605ab7..d7810803 100644 --- a/aifw-api/src/updates.rs +++ b/aifw-api/src/updates.rs @@ -565,6 +565,7 @@ pub async fn aifw_update_status( published_at: String::new(), tarball_url: None, checksum_url: None, + checksum_signature_url: None, has_backup: std::path::Path::new("/usr/local/share/aifw/backup/version").exists(), backup_version: tokio::fs::read_to_string("/usr/local/share/aifw/backup/version") .await diff --git a/aifw-cli/src/commands.rs b/aifw-cli/src/commands.rs index 68eea8a6..5776910d 100644 --- a/aifw-cli/src/commands.rs +++ b/aifw-cli/src/commands.rs @@ -303,7 +303,9 @@ pub async fn reload(db_path: &Path) -> anyhow::Result<()> { async fn create_nat_engine(db_path: &Path) -> anyhow::Result { let db = Database::new(db_path).await?; let pf = Arc::from(aifw_pf::create_backend()); - Ok(NatEngine::new(db.pool().clone(), pf)) + // "aifw-nat" — must match the API/daemon anchor; NAT loads replace every + // rule class in their anchor since #531. + Ok(NatEngine::new(db.pool().clone(), pf).with_anchor("aifw-nat".to_string())) } /// Heal drift between running kernel state and the source of truth @@ -430,9 +432,57 @@ pub async fn nat_add( rule.dst_port = dst_port.map(parse_port).transpose()?; rule.label = label.map(String::from); - let rule = nat.add_rule(rule).await?; + let rule = nat.add_rule(rule).await.map_err(|e| { + let msg = e.to_string(); + match nat_flag_hint(&msg) { + Some(hint) => anyhow::anyhow!("{msg}\n hint: {hint}"), + None => anyhow::anyhow!(msg), + } + })?; println!("Added NAT rule {}", rule.id); println!(" pf: {}", rule.to_pf_rule()); + if rule.nat_type == NatType::Nat64 + && let aifw_common::Address::Network(std::net::IpAddr::V6(p6), 96) = rule.dst_addr + { + let example = aifw_common::embed_rfc6052(p6, std::net::Ipv4Addr::new(10, 0, 0, 1)); + println!(" IPv6 clients reach IPv4 hosts via {p6}/96 (e.g. 10.0.0.1 -> {example})"); + } + Ok(()) +} + +/// Map engine validation messages to the CLI flag the user should fix. +fn nat_flag_hint(msg: &str) -> Option<&'static str> { + if msg.contains("nat64 destination") { + Some( + "set --dst to an IPv6 /96 prefix, e.g. --dst 64:ff9b::/96 (or omit --dst for the default)", + ) + } else if msg.contains("nat46 destination") { + Some("set --dst to the IPv4 address being reached, e.g. --dst 10.99.1.1") + } else if msg.contains("translation source") { + Some( + "set --redirect to a single address the firewall owns in the translated family (IPv4 for nat64, IPv6 for nat46)", + ) + } else if msg.contains("redirect port") { + Some("drop --redirect-port — af-to translates addresses, not ports") + } else if msg.contains("source address must be") { + Some("fix --src: it must match the rule's ingress family (IPv6 for nat64, IPv4 for nat46)") + } else { + None + } +} + +/// Print the RFC 6052 embedded address for a prefix + IPv4 pair. +pub fn nat_embed(prefix: &str, ipv4: &str) -> anyhow::Result<()> { + let p: std::net::Ipv6Addr = prefix + .split('/') + .next() + .unwrap_or(prefix) + .parse() + .map_err(|_| anyhow::anyhow!("'{prefix}' is not a valid IPv6 prefix"))?; + let v4: std::net::Ipv4Addr = ipv4 + .parse() + .map_err(|_| anyhow::anyhow!("'{ipv4}' is not a valid IPv4 address"))?; + println!("{}", aifw_common::embed_rfc6052(p, v4)); Ok(()) } @@ -481,7 +531,12 @@ pub async fn nat_list(db_path: &Path, json: bool) -> anyhow::Result<()> { .map(|p| format!(":{p}")) .unwrap_or_default() ); - let redir = format!("{}", rule.redirect); + // Cross-family rules read as a direction, not an address rewrite. + let redir = match rule.nat_type { + NatType::Nat64 => format!("v6->v4 via {}", rule.redirect.address), + NatType::Nat46 => format!("v4->v6 via {}", rule.redirect.address), + _ => format!("{}", rule.redirect), + }; let status = match rule.status { aifw_common::NatStatus::Active => "", aifw_common::NatStatus::Disabled => " [disabled]", @@ -537,6 +592,7 @@ pub async fn queue_add( config.default = default; let config = engine.add_queue(config).await?; + engine.apply_queues().await?; println!("Added queue {}", config.id); println!(" pf: {}", config.to_pf_queue()); Ok(()) @@ -546,6 +602,7 @@ pub async fn queue_remove(db_path: &Path, id: &str) -> anyhow::Result<()> { let engine = create_shaping_engine(db_path).await?; let uuid = Uuid::parse_str(id)?; engine.delete_queue(uuid).await?; + engine.apply_queues().await?; println!("Removed queue {id}"); Ok(()) } @@ -3369,3 +3426,102 @@ pub async fn cluster_verify(as_json: bool) -> anyhow::Result<()> { std::process::exit(1); } } + +/// Delete every stored IDS alert and reclaim the disk space. A bare DELETE +/// only frees pages to SQLite's freelist (#601: a 2.9GB file holding 34 +/// rows), so this follows up with VACUUM + a TRUNCATE WAL checkpoint. +/// "no such table" means the IDS subsystem has never initialized this DB — +/// for read/purge paths that's simply "nothing stored", not an error. +fn ids_table_missing(e: &sqlx::Error) -> bool { + e.to_string().contains("no such table") +} + +pub async fn ids_purge_alerts(db_path: &Path, yes: bool) -> anyhow::Result<()> { + let db = Database::new(db_path).await?; + let pool = db.pool(); + let (count,): (i64,) = match sqlx::query_as("SELECT COUNT(*) FROM ids_alerts") + .fetch_one(pool) + .await + { + Ok(row) => row, + Err(e) if ids_table_missing(&e) => { + println!("No IDS alerts stored."); + return Ok(()); + } + Err(e) => return Err(e.into()), + }; + if count == 0 { + println!("No IDS alerts stored."); + return Ok(()); + } + if !yes { + use std::io::Write; + print!("Delete ALL {count} IDS alerts? This cannot be undone. [y/N] "); + std::io::stdout().flush()?; + let mut line = String::new(); + std::io::stdin().read_line(&mut line)?; + if !matches!(line.trim(), "y" | "Y" | "yes") { + println!("Aborted."); + return Ok(()); + } + } + sqlx::query("DELETE FROM ids_alerts").execute(pool).await?; + println!("Deleted {count} alerts; reclaiming disk space (may take a moment)..."); + sqlx::query("VACUUM").execute(pool).await?; + sqlx::query("PRAGMA wal_checkpoint(TRUNCATE)") + .fetch_all(pool) + .await?; + println!("Done."); + Ok(()) +} + +/// Show or set the alert retention window (days). The hourly retention +/// sweep in aifw-ids prunes past it and reclaims space after big purges. +pub async fn ids_retention(db_path: &Path, days: Option) -> anyhow::Result<()> { + let db = Database::new(db_path).await?; + let pool = db.pool(); + match days { + None => { + let cur: Option<(String,)> = match sqlx::query_as( + "SELECT value FROM ids_config WHERE key = 'alert_retention_days'", + ) + .fetch_optional(pool) + .await + { + Ok(row) => row, + Err(e) if ids_table_missing(&e) => None, + Err(e) => return Err(e.into()), + }; + match cur { + Some((v,)) => println!("Alert retention: {v} days"), + None => println!("Alert retention: 7 days (default)"), + } + } + Some(d) => { + anyhow::ensure!( + (1..=365).contains(&d), + "retention must be between 1 and 365 days" + ); + if let Err(e) = sqlx::query( + "INSERT INTO ids_config (key, value, updated_at) VALUES ('alert_retention_days', ?1, datetime('now')) \ + ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = datetime('now')", + ) + .bind(d.to_string()) + .execute(pool) + .await + { + if ids_table_missing(&e) { + anyhow::bail!( + "IDS database not initialized yet — start aifw-api/aifw-ids once, then retry" + ); + } + return Err(e.into()); + } + println!("Alert retention set to {d} day(s)."); + println!( + " The running aifw-ids applies it after an IDS reload (UI) or 'service aifw_ids restart'." + ); + } + } + Ok(()) +} diff --git a/aifw-cli/src/main.rs b/aifw-cli/src/main.rs index 505f41a6..b4ca58e7 100644 --- a/aifw-cli/src/main.rs +++ b/aifw-cli/src/main.rs @@ -36,6 +36,11 @@ enum Commands { #[command(subcommand)] action: NatAction, }, + /// Manage the IDS engine (alerts, retention) + Ids { + #[command(subcommand)] + action: IdsAction, + }, /// Manage traffic queues Queue { #[command(subcommand)] @@ -747,11 +752,52 @@ enum DhcpAction { // One variant carries many large optional fields; boxing each variant would // add allocator overhead for a CLI command type that lives briefly on the stack. #[allow(clippy::large_enum_variant)] +#[derive(Subcommand)] +enum IdsAction { + /// Delete ALL stored IDS alerts and reclaim the disk space + #[command(after_help = "\ +EXAMPLES: + aifw ids purge-alerts # asks for confirmation first + aifw ids purge-alerts --yes # non-interactive (scripts/cron)")] + PurgeAlerts { + /// Skip the confirmation prompt + #[arg(long)] + yes: bool, + }, + /// Show or set how long alerts are kept (pruned hourly by aifw-ids) + #[command(after_help = "\ +EXAMPLES: + aifw ids retention # show the current setting + aifw ids retention 7 # keep one week (the default) + aifw ids retention 30 # keep one month")] + Retention { + /// Days to keep alerts (1-365). Omit to show the current value. + days: Option, + }, +} + #[derive(Subcommand)] enum NatAction { /// Add a NAT rule + #[command(after_help = "\ +EXAMPLES: + # Source NAT a LAN behind a WAN address + aifw nat add --nat-type snat --interface em0 --src 192.168.1.0/24 --redirect 203.0.113.1 + + # Port-forward WAN :80 to an internal host + aifw nat add --nat-type dnat --interface em0 --dst-port 80 --redirect 10.0.0.5 --redirect-port 8080 + + # NAT64: IPv6-only clients reach IPv4 hosts (pf af-to, FreeBSD 15+). + # --dst defaults to the well-known prefix 64:ff9b::/96; --redirect is + # the IPv4 the firewall sources translated traffic from. + aifw nat add --nat-type nat64 --interface em1 --redirect 203.0.113.1 + + # NAT46: IPv4-only clients reach an IPv6 service. The v6 server must + # hold the RFC 6052 embedding of --dst in --redirect's /96 subnet + # (print it with: aifw nat embed ). + aifw nat add --nat-type nat46 --interface em1 --dst 10.99.1.1 --redirect 2001:db8:2::1")] Add { - /// NAT type: snat, dnat, masquerade, binat, nat64, nat46 + /// NAT type: snat, dnat, masquerade, binat, nat64 (IPv6→IPv4 af-to), nat46 (IPv4→IPv6 af-to) #[arg(long, name = "type")] nat_type: String, @@ -759,7 +805,7 @@ enum NatAction { #[arg(long)] interface: String, - /// Protocol: tcp, udp, any + /// Protocol: tcp, udp, icmp, any (icmp auto-normalizes to icmp6 on nat64) #[arg(long, default_value = "any")] proto: String, @@ -771,19 +817,21 @@ enum NatAction { #[arg(long)] src_port: Option, - /// Destination address - #[arg(long, default_value = "any")] - dst: String, + /// Destination address. nat64: the /96 translation prefix + /// (defaults to 64:ff9b::/96). nat46: the IPv4 destination (required). + #[arg(long)] + dst: Option, /// Destination port #[arg(long)] dst_port: Option, - /// Redirect target address + /// Redirect target. nat64/nat46: the translation source address the + /// firewall owns in the translated family (IPv4 for nat64, IPv6 for nat46) #[arg(long)] redirect: String, - /// Redirect target port + /// Redirect target port (not valid for nat64/nat46) #[arg(long)] redirect_port: Option, @@ -802,6 +850,18 @@ enum NatAction { #[arg(long)] json: bool, }, + /// Print the RFC 6052 NAT64 address embedding an IPv4 host in a /96 prefix + #[command(after_help = "\ +EXAMPLES: + aifw nat embed 64:ff9b::/96 8.8.8.8 # -> 64:ff9b::808:808 + aifw nat embed 2001:db8:2::1 10.99.1.1 # -> 2001:db8:2::a63:101")] + Embed { + /// IPv6 /96 prefix (or an address whose /96 subnet is used) + prefix: String, + + /// IPv4 address to embed + ipv4: String, + }, } #[derive(Subcommand)] @@ -917,6 +977,14 @@ async fn main() -> anyhow::Result<()> { commands::rules_list(&cli.db, json).await?; } }, + Commands::Ids { action } => match action { + IdsAction::PurgeAlerts { yes } => { + commands::ids_purge_alerts(&cli.db, yes).await?; + } + IdsAction::Retention { days } => { + commands::ids_retention(&cli.db, days).await?; + } + }, Commands::Nat { action } => match action { NatAction::Add { nat_type, @@ -930,6 +998,15 @@ async fn main() -> anyhow::Result<()> { redirect_port, label, } => { + // Smart default: nat64 practically always matches the + // well-known prefix; every other type keeps "any". + let dst = dst.unwrap_or_else(|| { + if nat_type == "nat64" { + "64:ff9b::/96".to_string() + } else { + "any".to_string() + } + }); commands::nat_add( &cli.db, &nat_type, @@ -951,6 +1028,9 @@ async fn main() -> anyhow::Result<()> { NatAction::List { json } => { commands::nat_list(&cli.db, json).await?; } + NatAction::Embed { prefix, ipv4 } => { + commands::nat_embed(&prefix, &ipv4)?; + } }, Commands::Queue { action } => match action { QueueAction::Add { diff --git a/aifw-common/src/ids.rs b/aifw-common/src/ids.rs index 6d50ba39..128f9f8c 100644 --- a/aifw-common/src/ids.rs +++ b/aifw-common/src/ids.rs @@ -394,7 +394,7 @@ impl Default for IdsConfig { ], external_net: vec!["!$HOME_NET".into()], interfaces: Vec::new(), - alert_retention_days: 30, + alert_retention_days: 7, eve_log_enabled: false, eve_log_path: None, syslog_target: None, diff --git a/aifw-common/src/lib.rs b/aifw-common/src/lib.rs index b2bb7d08..c581ddef 100644 --- a/aifw-common/src/lib.rs +++ b/aifw-common/src/lib.rs @@ -75,11 +75,11 @@ pub use multiwan::{ LeakDirection, MwIpVersion, MwProtocol, PolicyRule, PolicyStatus, RouteAction, RouteLeak, RoutingInstance, StickyMode, }; -pub use nat::{NatRedirect, NatRule, NatStatus, NatType}; +pub use nat::{NatRedirect, NatRule, NatStatus, NatType, embed_rfc6052}; pub use permission::{ALL_PERMISSIONS, Permission, PermissionSet, builtin_role_permissions}; pub use ratelimit::{ - Bandwidth, BandwidthUnit, QueueConfig, QueueStatus, QueueType, RateLimitRule, RateLimitStatus, - SynFloodConfig, TrafficClass, + Bandwidth, BandwidthUnit, FqCodelConfig, QueueConfig, QueueStatus, QueueType, RateLimitRule, + RateLimitStatus, SynFloodConfig, TrafficClass, }; pub use rule::{ Action, AdaptiveTimeouts, Direction, IpVersion, Protocol, Rule, RuleMatch, RuleStatus, diff --git a/aifw-common/src/nat.rs b/aifw-common/src/nat.rs index ff345463..e9163ffb 100644 --- a/aifw-common/src/nat.rs +++ b/aifw-common/src/nat.rs @@ -231,23 +231,34 @@ impl NatRule { parts.join(" ") } - /// NAT64: `nat on inet6 from to -> ` + /// NAT64 (IPv6→IPv4), pf `af-to` address-family translation (FreeBSD 15+): + /// `pass in quick on inet6 [proto

] from to af-to inet from [label "..."]` + /// + /// `af-to` is a filter-class rule and only valid on inbound rules; `quick` + /// is required because the main ruleset's default policy follows the + /// anchor hooks and would otherwise override the pass (last-match wins). + /// The translated destination is the IPv4 embedded in the low 32 bits of + /// the matched /96 prefix (RFC 6052). fn to_pf_nat64(&self) -> String { - let mut parts = vec![format!("nat on {} inet6", self.interface)]; - self.push_proto(&mut parts); + let mut parts = vec![format!("pass in quick on {} inet6", self.interface)]; + self.push_af_proto(&mut parts, true); self.push_from_to(&mut parts); - parts.push(format!("-> {}", self.redirect)); - self.push_label(&mut parts); + parts.push(format!("af-to inet from {}", self.redirect.address)); + self.push_filter_label(&mut parts); parts.join(" ") } - /// NAT46: `nat on inet from to -> ` + /// NAT46 (IPv4→IPv6), pf `af-to` address-family translation (FreeBSD 15+): + /// `pass in quick on inet [proto

] from to af-to inet6 from [label "..."]` + /// + /// The translated destination defaults to the RFC 6052 embedding of the + /// original IPv4 destination in the /96 subnet of the new IPv6 source. fn to_pf_nat46(&self) -> String { - let mut parts = vec![format!("nat on {} inet", self.interface)]; - self.push_proto(&mut parts); + let mut parts = vec![format!("pass in quick on {} inet", self.interface)]; + self.push_af_proto(&mut parts, false); self.push_from_to(&mut parts); - parts.push(format!("-> {}", self.redirect)); - self.push_label(&mut parts); + parts.push(format!("af-to inet6 from {}", self.redirect.address)); + self.push_filter_label(&mut parts); parts.join(" ") } @@ -257,6 +268,27 @@ impl NatRule { } } + /// Protocol clause for `af-to` rules. The keyword must match the + /// pre-translation family (a NAT64 rule matches IPv6 ingress), so + /// `icmp`/`icmp6` are normalized to the matching side's family. + fn push_af_proto(&self, parts: &mut Vec, inet6_match: bool) { + let proto = match (self.protocol, inet6_match) { + (crate::Protocol::Any, _) => return, + (crate::Protocol::Icmp, true) => "icmp6".to_string(), + (crate::Protocol::Icmp6, false) => "icmp".to_string(), + (p, _) => p.to_string(), + }; + parts.push(format!("proto {proto}")); + } + + /// Label clause for filter-class (`af-to`) rules — unlike nat-class + /// rules, pass rules support pf labels for per-rule counters. + fn push_filter_label(&self, parts: &mut Vec) { + if let Some(ref label) = self.label { + parts.push(format!("label \"{label}\"")); + } + } + fn push_from_to(&self, parts: &mut Vec) { // source let mut from = format!("from {}", self.src_addr); @@ -278,3 +310,13 @@ impl NatRule { // The label is stored in the DB for UI display purposes only. } } + +/// Embed an IPv4 address into an IPv6 /96 prefix per RFC 6052 §2.2 — the +/// NAT64 address mapping (e.g. `64:ff9b::/96` + `10.99.2.2` → +/// `64:ff9b::a63:202`). This is the address an IPv6-only client uses to +/// reach an IPv4 host through a NAT64 rule. +pub fn embed_rfc6052(prefix: std::net::Ipv6Addr, v4: std::net::Ipv4Addr) -> std::net::Ipv6Addr { + let mut octets = prefix.octets(); + octets[12..16].copy_from_slice(&v4.octets()); + std::net::Ipv6Addr::from(octets) +} diff --git a/aifw-common/src/ratelimit.rs b/aifw-common/src/ratelimit.rs index f889d47e..6b372c37 100644 --- a/aifw-common/src/ratelimit.rs +++ b/aifw-common/src/ratelimit.rs @@ -17,6 +17,59 @@ pub enum QueueType { Priq, } +/// dummynet scheduler configuration for the FQ-CoDel backend. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +pub struct FqCodelConfig { + /// Target queueing delay in milliseconds. + pub target_ms: u32, + /// CoDel interval in milliseconds. + pub interval_ms: u32, + /// Per-flow quantum in bytes. + pub quantum_bytes: u32, + /// Maximum queue length in packets. + pub limit_packets: u32, + /// Number of flow buckets. + pub flows: u32, + /// Enable ECN marking. + pub ecn: bool, +} + +impl Default for FqCodelConfig { + fn default() -> Self { + Self { + target_ms: 5, + interval_ms: 100, + quantum_bytes: 1514, + limit_packets: 10240, + flows: 1024, + ecn: true, + } + } +} + +impl FqCodelConfig { + /// Reject values outside FreeBSD dummynet's safe scheduler bounds. + pub fn validate(&self) -> crate::Result<()> { + if !(1..=1_000).contains(&self.target_ms) + || self.interval_ms < self.target_ms + || self.interval_ms > 10_000 + { + return Err(crate::AifwError::Validation( + "invalid FQ-CoDel target/interval".to_string(), + )); + } + if !(64..=9_000).contains(&self.quantum_bytes) + || !(1..=20_480).contains(&self.limit_packets) + || !(1..=65_536).contains(&self.flows) + { + return Err(crate::AifwError::Validation( + "invalid FQ-CoDel quantum, limit, or flow count".to_string(), + )); + } + Ok(()) + } +} + impl std::fmt::Display for QueueType { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { @@ -195,6 +248,9 @@ pub struct QueueConfig { pub created_at: DateTime, /// Last modification timestamp (UTC) pub updated_at: DateTime, + /// FQ-CoDel parameters (used when queue_type is Codel). + #[serde(default)] + pub fq_codel: FqCodelConfig, } /// Enabled/disabled state of a queue @@ -230,6 +286,7 @@ impl QueueConfig { status: QueueStatus::Active, created_at: now, updated_at: now, + fq_codel: FqCodelConfig::default(), } } @@ -248,9 +305,10 @@ impl QueueConfig { } match self.queue_type { - QueueType::Codel => { - parts.push("flows 1024 quantum 1514 target 5 interval 100".to_string()) - } + // FQ-CoDel is rendered by the dummynet backend. Returning no PF + // queue syntax here prevents callers from accidentally loading a + // same-family ALTQ approximation into pf. + QueueType::Codel => return String::new(), QueueType::Hfsc => {} QueueType::Priq => parts.push(format!("priority {}", self.traffic_class.priority())), } diff --git a/aifw-common/src/tests.rs b/aifw-common/src/tests.rs index be68e593..608a7544 100644 --- a/aifw-common/src/tests.rs +++ b/aifw-common/src/tests.rs @@ -363,19 +363,99 @@ mod tests { #[test] fn test_nat64_pf_rule() { + // NAT64: v6 clients (src) hitting the /96 prefix (dst) are translated + // to IPv4 sourced from the redirect address. let rule = NatRule::new( NatType::Nat64, Interface("em0".to_string()), Protocol::Any, - Address::Network(IpAddr::V6("64:ff9b::".parse().unwrap()), 96), Address::Any, + Address::Network(IpAddr::V6("64:ff9b::".parse().unwrap()), 96), NatRedirect { - address: Address::Single(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 0))), + address: Address::Single(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 1))), port: None, }, ); let pf = rule.to_pf_rule(); - assert!(pf.starts_with("nat on em0 inet6")); + assert_eq!( + pf, + "pass in quick on em0 inet6 from any to 64:ff9b::/96 af-to inet from 203.0.113.1" + ); + } + + #[test] + fn test_nat64_pf_rule_proto_normalization_and_label() { + // `icmp` on a NAT64 rule must render as `icmp6` (the rule matches + // IPv6 ingress); af-to rules are filter-class so labels are emitted. + let mut rule = NatRule::new( + NatType::Nat64, + Interface("em0".to_string()), + Protocol::Icmp, + Address::Network(IpAddr::V6("2001:db8:1::".parse().unwrap()), 64), + Address::Network(IpAddr::V6("64:ff9b::".parse().unwrap()), 96), + NatRedirect { + address: Address::Single(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 1))), + port: None, + }, + ); + rule.label = Some("nat64-icmp".to_string()); + assert_eq!( + rule.to_pf_rule(), + "pass in quick on em0 inet6 proto icmp6 from 2001:db8:1::/64 to 64:ff9b::/96 af-to inet from 203.0.113.1 label \"nat64-icmp\"" + ); + // tcp passes through untouched, with port clauses + rule.protocol = Protocol::Tcp; + rule.label = None; + rule.dst_port = Some(PortRange { start: 80, end: 80 }); + assert_eq!( + rule.to_pf_rule(), + "pass in quick on em0 inet6 proto tcp from 2001:db8:1::/64 to 64:ff9b::/96 port 80 af-to inet from 203.0.113.1" + ); + } + + #[test] + fn test_nat46_pf_rule() { + // NAT46: v4 clients (src) hitting a v4 destination are translated to + // IPv6 sourced from the redirect address; translated destination is + // the RFC 6052 embedding in the new source's /96 subnet (pf default). + let mut rule = NatRule::new( + NatType::Nat46, + Interface("em1".to_string()), + Protocol::Icmp6, + Address::Any, + Address::Single(IpAddr::V4(Ipv4Addr::new(10, 99, 1, 1))), + NatRedirect { + address: Address::Single(IpAddr::V6("2001:db8:2::1".parse().unwrap())), + port: None, + }, + ); + // `icmp6` normalizes to `icmp` (the rule matches IPv4 ingress) + assert_eq!( + rule.to_pf_rule(), + "pass in quick on em1 inet proto icmp from any to 10.99.1.1 af-to inet6 from 2001:db8:2::1" + ); + rule.protocol = Protocol::Any; + assert_eq!( + rule.to_pf_rule(), + "pass in quick on em1 inet from any to 10.99.1.1 af-to inet6 from 2001:db8:2::1" + ); + } + + #[test] + fn test_embed_rfc6052() { + let prefix: std::net::Ipv6Addr = "64:ff9b::".parse().unwrap(); + let embedded = crate::nat::embed_rfc6052(prefix, Ipv4Addr::new(10, 99, 2, 2)); + assert_eq!( + embedded, + "64:ff9b::a63:202".parse::().unwrap() + ); + // embedding into a non-zero-host prefix overwrites only the low 32 bits + let prefix: std::net::Ipv6Addr = "2001:db8:2::1".parse().unwrap(); + let embedded = crate::nat::embed_rfc6052(prefix, Ipv4Addr::new(10, 99, 1, 1)); + assert_eq!( + embedded, + "2001:db8:2::a63:101".parse::().unwrap() + ); } // --- Rate limiting / queue tests --- @@ -456,8 +536,7 @@ mod tests { TrafficClass::Default, ); q.default = true; - let pf = q.to_pf_queue(); - assert!(pf.contains("default")); + assert!(q.to_pf_queue().is_empty()); } #[test] diff --git a/aifw-common/src/types.rs b/aifw-common/src/types.rs index 2fcff853..7c94e883 100644 --- a/aifw-common/src/types.rs +++ b/aifw-common/src/types.rs @@ -69,6 +69,21 @@ impl fmt::Display for Address { } impl Address { + /// The concrete IP behind this address, if it has one (`Single`/`Network`). + /// `Any` and `Table` return `None` — their family is indeterminate. + pub fn ip(&self) -> Option { + match self { + Address::Single(ip) | Address::Network(ip, _) => Some(*ip), + Address::Any | Address::Table(_) => None, + } + } + + /// `Some(true)` for a concrete IPv6 address/network, `Some(false)` for + /// IPv4, `None` when the family is indeterminate (`Any`/`Table`). + pub fn is_ipv6(&self) -> Option { + self.ip().map(|ip| ip.is_ipv6()) + } + /// Validate a pf table name. pf table names are letters, digits, `_` and /// `-`, 1-31 characters. Rejecting anything else (notably whitespace and /// newlines) prevents pf rule injection: `Display` renders a table as diff --git a/aifw-common/src/vpn.rs b/aifw-common/src/vpn.rs index 72d349c1..fc7b1476 100644 --- a/aifw-common/src/vpn.rs +++ b/aifw-common/src/vpn.rs @@ -24,6 +24,10 @@ pub struct WgTunnel { pub listen_port: u16, /// Server's tunnel address with prefix (e.g. 10.10.0.1/24); defines the tunnel subnet pub address: Address, + /// Server's IPv6 tunnel address with prefix (e.g. fd00:a1f0::1/64) for + /// dual-stack tunnels (#471); None means the tunnel carries no inner IPv6 + #[serde(default)] + pub address6: Option

, /// DNS server(s) pushed to clients in generated configs; None omits the DNS line pub dns: Option, /// Interface MTU; None uses the system default @@ -63,6 +67,7 @@ impl WgTunnel { public_key, listen_port, address, + address6: None, dns: None, mtu: None, listen_interface: None, @@ -73,18 +78,48 @@ impl WgTunnel { }) } - /// Generate ifconfig commands to create the WireGuard interface + /// Generate ifconfig commands to create the WireGuard interface. + /// Uses the address-family-correct keyword (`inet`/`inet6`) — configuring + /// an IPv6 address with `inet` silently fails on FreeBSD (#471). pub fn to_ifconfig_cmds(&self) -> Vec { + let family = if address_is_v6(&self.address) { + "inet6" + } else { + "inet" + }; let mut cmds = vec![ format!("ifconfig {} create", self.interface), - format!("ifconfig {} inet {} up", self.interface, self.address), + format!("ifconfig {} {} {} up", self.interface, family, self.address), ]; + if let Some(ref addr6) = self.address6 { + cmds.push(format!("ifconfig {} inet6 {}", self.interface, addr6)); + } if let Some(mtu) = self.mtu { cmds.push(format!("ifconfig {} mtu {mtu}", self.interface)); } cmds } + /// Validate the address/address6 pairing: `address6` must be IPv6, and + /// when it is set `address` must be IPv4 (dual-stack means one of each — + /// two v6 addresses belong in a differently shaped config). + pub fn validate_addresses(&self) -> crate::Result<()> { + if let Some(ref a6) = self.address6 { + if !address_is_v6(a6) { + return Err(crate::AifwError::Validation( + "IPv6 tunnel address must be an IPv6 address (e.g. fd00:a1f0::1/64)" + .to_string(), + )); + } + if address_is_v6(&self.address) { + return Err(crate::AifwError::Validation( + "with an IPv6 tunnel address set, the primary address must be IPv4".to_string(), + )); + } + } + Ok(()) + } + /// Generate pf rules to allow WireGuard traffic pub fn to_pf_rules(&self) -> Vec { // If listen_interface is set, bind the UDP rule to that interface @@ -112,28 +147,69 @@ impl WgTunnel { address_network_cidr(&self.address) } - /// Outbound NAT rule so tunnel clients reach the internet through the - /// WAN (#469). Only IPv4 tunnel subnets are NAT'd — returns None for - /// IPv6 / alias / any addresses so we never masquerade 0.0.0.0/0. - pub fn to_nat_rule(&self, wan_if: &str) -> Option { + /// Network CIDR of the IPv6 tunnel subnet, masked to the network boundary + /// (e.g. address6 fd00:a1f0::1/64 → "fd00:a1f0::/64"); None when the + /// tunnel has no inner IPv6. + pub fn network_cidr6(&self) -> Option { + self.address6.as_ref().map(address_network_cidr) + } + + /// Outbound NAT rules so tunnel clients reach the internet through the + /// WAN: the #469 IPv4 masquerade plus, for tunnels carrying inner IPv6, + /// an NPTv6-style NAT66 masquerade (#471) — same shape pf uses for + /// NAT64 (`nat on inet6`). Only Single/Network tunnel subnets are + /// NAT'd — alias/any addresses emit nothing so we never masquerade a + /// whole address family. + pub fn to_nat_rules(&self, wan_if: &str) -> Vec { use std::net::IpAddr; + let mut rules = Vec::new(); match &self.address { - Address::Single(IpAddr::V4(_)) | Address::Network(IpAddr::V4(_), _) => Some(format!( - "nat on {wan_if} from {} to any -> ({wan_if})", - self.network_cidr() - )), - _ => None, + Address::Single(IpAddr::V4(_)) | Address::Network(IpAddr::V4(_), _) => { + rules.push(format!( + "nat on {wan_if} from {} to any -> ({wan_if})", + self.network_cidr() + )); + } + Address::Single(IpAddr::V6(_)) | Address::Network(IpAddr::V6(_), _) => { + rules.push(format!( + "nat on {wan_if} inet6 from {} to any -> ({wan_if})", + self.network_cidr() + )); + } + _ => {} + } + if let Some(net6) = self.network_cidr6() { + rules.push(format!( + "nat on {wan_if} inet6 from {net6} to any -> ({wan_if})" + )); } + rules } } +/// Whether an Address is IPv6 (Single or Network variant). +fn address_is_v6(addr: &Address) -> bool { + use std::net::IpAddr; + matches!( + addr, + Address::Single(IpAddr::V6(_)) | Address::Network(IpAddr::V6(_), _) + ) +} + /// AllowedIPs for a full-tunnel client config, scoped to the address -/// families the tunnel's inner network can carry. -fn full_tunnel_allowed_ips(addr: &Address) -> &'static str { +/// families the tunnel's inner network can carry. A dual-stack tunnel +/// (v4 primary + address6) routes both families (#471); emitting `::/0` +/// for a v4-only tunnel blackholes client IPv6 (#469). +fn full_tunnel_allowed_ips(tunnel: &WgTunnel) -> &'static str { use std::net::IpAddr; - match addr { - Address::Single(IpAddr::V4(_)) | Address::Network(IpAddr::V4(_), _) => "0.0.0.0/0", - Address::Single(IpAddr::V6(_)) | Address::Network(IpAddr::V6(_), _) => "::/0", + let has_v6 = tunnel.address6.is_some() || address_is_v6(&tunnel.address); + let has_v4 = matches!( + &tunnel.address, + Address::Single(IpAddr::V4(_)) | Address::Network(IpAddr::V4(_), _) + ); + match (has_v4, has_v6) { + (true, false) => "0.0.0.0/0", + (false, true) => "::/0", _ => "0.0.0.0/0, ::/0", } } @@ -213,9 +289,11 @@ impl WgPeer { } else { conf.push_str("PrivateKey = \n"); } - // Use the first allowed_ip as the client's address - if let Some(addr) = self.allowed_ips.first() { - conf.push_str(&format!("Address = {addr}\n")); + // The allowed_ips double as the client's addresses — emit them all + // so a dual-stack peer (v4/32 + v6/128) gets both families (#471). + if !self.allowed_ips.is_empty() { + let addrs: Vec = self.allowed_ips.iter().map(|a| a.to_string()).collect(); + conf.push_str(&format!("Address = {}\n", addrs.join(", "))); } if let Some(ref dns) = tunnel.dns && !dns.is_empty() @@ -235,16 +313,20 @@ impl WgPeer { )); if split_tunnel { // Prefer admin-configured split routes; otherwise derive the - // network CIDR from the tunnel address (so 172.29.240.1/24 yields - // 172.29.240.0/24 instead of a host/prefix that confuses some - // clients). + // network CIDR(s) from the tunnel addresses (so 172.29.240.1/24 + // yields 172.29.240.0/24 instead of a host/prefix that confuses + // some clients; dual-stack tunnels include the v6 subnet too). let routes = tunnel .split_routes .as_deref() .map(|s| s.trim()) .filter(|s| !s.is_empty()) .map(|s| s.to_string()) - .unwrap_or_else(|| address_network_cidr(&tunnel.address)); + .unwrap_or_else(|| { + let mut nets = vec![address_network_cidr(&tunnel.address)]; + nets.extend(tunnel.network_cidr6()); + nets.join(", ") + }); conf.push_str(&format!("AllowedIPs = {routes}\n")); } else { // Route all traffic through the VPN — but only the address @@ -253,7 +335,7 @@ impl WgPeer { // dual-stack clients (#469). conf.push_str(&format!( "AllowedIPs = {}\n", - full_tunnel_allowed_ips(&tunnel.address) + full_tunnel_allowed_ips(tunnel) )); } if let Some(ka) = self.persistent_keepalive { @@ -787,6 +869,7 @@ mod split_tunnel_tests { public_key: "y".into(), listen_port: 51820, address: Address::Network("172.29.240.1".parse().unwrap(), 24), + address6: None, dns: None, mtu: None, listen_interface: None, @@ -822,6 +905,7 @@ mod split_tunnel_tests { public_key: "y".into(), listen_port: 51820, address: Address::Network("172.29.240.1".parse().unwrap(), 24), + address6: None, dns: None, mtu: None, listen_interface: None, @@ -857,6 +941,7 @@ mod split_tunnel_tests { public_key: "y".into(), listen_port: 51820, address, + address6: None, dns: None, mtu: None, listen_interface: None, @@ -899,20 +984,95 @@ mod split_tunnel_tests { assert!(!cfg.contains("0.0.0.0/0")); } + #[test] + fn full_tunnel_dual_stack_routes_both_families() { + let mut tunnel = test_tunnel(Address::Network("10.10.0.1".parse().unwrap(), 24)); + tunnel.address6 = Some(Address::Network("fd00:a1f0::1".parse().unwrap(), 64)); + let mut peer = test_peer(tunnel.id); + peer.allowed_ips = vec![ + Address::Network("10.10.0.2".parse().unwrap(), 32), + Address::Network("fd00:a1f0::2".parse().unwrap(), 128), + ]; + let cfg = peer.to_client_config(&tunnel, "vpn.example.com", false); + assert!(cfg.contains("Address = 10.10.0.2/32, fd00:a1f0::2/128\n")); + assert!(cfg.contains("AllowedIPs = 0.0.0.0/0, ::/0\n")); + } + + #[test] + fn split_tunnel_fallback_includes_v6_subnet() { + let mut tunnel = test_tunnel(Address::Network("10.10.0.1".parse().unwrap(), 24)); + tunnel.address6 = Some(Address::Network("fd00:a1f0::1".parse().unwrap(), 64)); + let cfg = test_peer(tunnel.id).to_client_config(&tunnel, "vpn.example.com", true); + assert!(cfg.contains("AllowedIPs = 10.10.0.0/24, fd00:a1f0::/64\n")); + } + + #[test] + fn ifconfig_cmds_dual_stack_configures_both_families() { + let mut tunnel = test_tunnel(Address::Network("10.10.0.1".parse().unwrap(), 24)); + tunnel.address6 = Some(Address::Network("fd00:a1f0::1".parse().unwrap(), 64)); + let cmds = tunnel.to_ifconfig_cmds(); + assert_eq!(cmds[1], "ifconfig wg0 inet 10.10.0.1/24 up"); + assert_eq!(cmds[2], "ifconfig wg0 inet6 fd00:a1f0::1/64"); + } + + #[test] + fn ifconfig_cmds_v6_primary_uses_inet6() { + let tunnel = test_tunnel(Address::Network("fd00:a1f0::1".parse().unwrap(), 64)); + assert!(tunnel.to_ifconfig_cmds()[1].starts_with("ifconfig wg0 inet6 ")); + } + + #[test] + fn validate_addresses_rejects_bad_pairings() { + // v4 in the address6 slot + let mut t = test_tunnel(Address::Network("10.10.0.1".parse().unwrap(), 24)); + t.address6 = Some(Address::Network("192.168.1.1".parse().unwrap(), 24)); + assert!(t.validate_addresses().is_err()); + + // two v6 addresses + let mut t = test_tunnel(Address::Network("fd00:1::1".parse().unwrap(), 64)); + t.address6 = Some(Address::Network("fd00:2::1".parse().unwrap(), 64)); + assert!(t.validate_addresses().is_err()); + + // proper dual-stack is fine + let mut t = test_tunnel(Address::Network("10.10.0.1".parse().unwrap(), 24)); + t.address6 = Some(Address::Network("fd00:a1f0::1".parse().unwrap(), 64)); + assert!(t.validate_addresses().is_ok()); + } + #[test] fn nat_rule_masks_to_network_boundary() { let tunnel = test_tunnel(Address::Network("10.10.0.1".parse().unwrap(), 24)); assert_eq!( - tunnel.to_nat_rule("em0").as_deref(), - Some("nat on em0 from 10.10.0.0/24 to any -> (em0)") + tunnel.to_nat_rules("em0"), + vec!["nat on em0 from 10.10.0.0/24 to any -> (em0)"] + ); + } + + #[test] + fn nat_rules_dual_stack_adds_nat66() { + let mut tunnel = test_tunnel(Address::Network("10.10.0.1".parse().unwrap(), 24)); + tunnel.address6 = Some(Address::Network("fd00:a1f0::1".parse().unwrap(), 64)); + assert_eq!( + tunnel.to_nat_rules("em0"), + vec![ + "nat on em0 from 10.10.0.0/24 to any -> (em0)", + "nat on em0 inet6 from fd00:a1f0::/64 to any -> (em0)", + ] ); } #[test] - fn nat_rule_skipped_for_non_v4_addresses() { + fn nat_rules_v6_only_tunnel_gets_nat66() { let v6 = test_tunnel(Address::Network("fd00:a1f0::1".parse().unwrap(), 64)); - assert_eq!(v6.to_nat_rule("em0"), None); + assert_eq!( + v6.to_nat_rules("em0"), + vec!["nat on em0 inet6 from fd00:a1f0::/64 to any -> (em0)"] + ); + } + + #[test] + fn nat_rules_skipped_for_alias_and_any_addresses() { let any = test_tunnel(Address::Any); - assert_eq!(any.to_nat_rule("em0"), None); + assert!(any.to_nat_rules("em0").is_empty()); } } diff --git a/aifw-core/src/alias.rs b/aifw-core/src/alias.rs index fe12ff14..218841e5 100644 --- a/aifw-core/src/alias.rs +++ b/aifw-core/src/alias.rs @@ -88,15 +88,7 @@ impl AliasEngine { /// 1-31 alphanumeric/`_`/`-` chars or is reserved (`bruteforce`, /// `ai_blocked`). pub async fn add(&self, alias: Alias) -> Result { - self.validate_name(&alias.name)?; - let entries_json = serde_json::to_string(&alias.entries) - .map_err(|e| AifwError::Validation(e.to_string()))?; - - sqlx::query("INSERT INTO aliases (id, name, alias_type, entries, description, enabled, created_at, updated_at) VALUES (?1,?2,?3,?4,?5,?6,?7,?8)") - .bind(alias.id.to_string()).bind(&alias.name).bind(alias.alias_type.as_str()) - .bind(&entries_json).bind(alias.description.as_deref()) - .bind(alias.enabled).bind(alias.created_at.to_rfc3339()).bind(alias.updated_at.to_rfc3339()) - .execute(&self.pool).await.map_err(|e| AifwError::Database(e.to_string()))?; + Self::insert_on(&self.pool, &alias).await?; if alias.enabled { self.sync_to_pf(&alias).await?; @@ -106,11 +98,30 @@ impl AliasEngine { Ok(alias) } + /// Executor-generic validate + insert with no pf side effects. Public so + /// the transactional restore path (#158/#535) can batch alias rows with + /// every other section; pf tables are re-synced after commit. + pub async fn insert_on<'e, E>(exec: E, alias: &Alias) -> Result<()> + where + E: sqlx::Executor<'e, Database = sqlx::Sqlite>, + { + Self::validate_name(&alias.name)?; + let entries_json = serde_json::to_string(&alias.entries) + .map_err(|e| AifwError::Validation(e.to_string()))?; + + sqlx::query("INSERT INTO aliases (id, name, alias_type, entries, description, enabled, created_at, updated_at) VALUES (?1,?2,?3,?4,?5,?6,?7,?8)") + .bind(alias.id.to_string()).bind(&alias.name).bind(alias.alias_type.as_str()) + .bind(&entries_json).bind(alias.description.as_deref()) + .bind(alias.enabled).bind(alias.created_at.to_rfc3339()).bind(alias.updated_at.to_rfc3339()) + .execute(exec).await.map_err(|e| AifwError::Database(e.to_string()))?; + Ok(()) + } + /// Update an alias row, then flush and repopulate its pf table (only /// re-synced when enabled). Fails with `NotFound` if the id doesn't /// exist, or validation on a bad name. pub async fn update(&self, alias: Alias) -> Result { - self.validate_name(&alias.name)?; + Self::validate_name(&alias.name)?; let entries_json = serde_json::to_string(&alias.entries) .map_err(|e| AifwError::Validation(e.to_string()))?; let now = Utc::now().to_rfc3339(); @@ -151,6 +162,20 @@ impl AliasEngine { Ok(()) } + /// Sync all enabled aliases to pf tables, failing on the first error. + /// The restore path uses this after its transaction commits so a pf-side + /// failure surfaces as a required-step error (#535) instead of the + /// warn-and-continue behavior of [`Self::sync_all`]. + pub async fn sync_all_strict(&self) -> Result<()> { + for alias in self.list().await? { + if alias.enabled { + let _ = self.pf.flush_table(&alias.name).await; + self.sync_to_pf(&alias).await?; + } + } + Ok(()) + } + /// Sync all enabled aliases to pf tables. Called during reload. pub async fn sync_all(&self) -> Result<()> { let aliases = self.list().await?; @@ -239,7 +264,10 @@ impl AliasEngine { Ok(()) } - fn validate_name(&self, name: &str) -> Result<()> { + /// Validate an alias name: 1-31 alphanumeric/`_`/`-` chars, not + /// reserved. Associated (not `&self`) so the backup restore path can + /// pre-validate a whole config with the same checks `add` applies. + pub fn validate_name(name: &str) -> Result<()> { if name.is_empty() || name.len() > 31 { return Err(AifwError::Validation( "Alias name must be 1-31 characters".into(), diff --git a/aifw-core/src/config.rs b/aifw-core/src/config.rs index 3eb352ba..df297292 100644 --- a/aifw-core/src/config.rs +++ b/aifw-core/src/config.rs @@ -68,6 +68,12 @@ pub struct FirewallConfig { /// snapshot/restore silently drops every route the user defined. #[serde(default)] pub static_routes: Vec, + /// rDNS/unbound resolver settings (the `dns_resolver_config` table). + /// `None` means the backup predates this section (#589) — restore + /// leaves the box's resolver config untouched rather than resetting + /// it to defaults. + #[serde(default)] + pub dns_resolver: Option, } impl Default for FirewallConfig { @@ -88,6 +94,7 @@ impl Default for FirewallConfig { dhcp: DhcpSection::default(), aliases: Vec::new(), static_routes: Vec::new(), + dns_resolver: None, } } } @@ -222,6 +229,24 @@ impl FirewallConfig { if self.system.api_port == 0 { return Err("system.api_port cannot be 0".to_string()); } + if let Some(r) = &self.dns_resolver { + for s in &r.forwarding_servers { + if s.parse::().is_err() { + return Err(format!( + "dns_resolver.forwarding_servers entry {s} is not a valid IP" + )); + } + } + if r.blocklist_urls.len() > 10_000 + || r.whitelist.len() > 100_000 + || r.forwarding_servers.len() > 100 + || r.dot_upstream.len() > 100 + || r.listen_interfaces.len() > 100 + || r.private_addresses.len() > 1_000 + { + return Err("dns_resolver list length exceeds cap".to_string()); + } + } Ok(()) } } @@ -501,6 +526,9 @@ pub struct QueueConfigEntry { pub default: bool, /// Whether the queue is loaded into pf pub status: QueueStatus, + /// FQ-CoDel scheduler parameters, retained across backup and rollback. + #[serde(default)] + pub fq_codel: aifw_common::FqCodelConfig, } /// A per-source-IP connection rate limit as stored in a config snapshot @@ -558,6 +586,9 @@ pub struct WireguardTunnelConfig { pub listen_port: u16, /// Tunnel interface address (IP/prefix) pub address: String, + /// Optional IPv6 tunnel address for dual-stack tunnels (#471) + #[serde(default)] + pub address6: Option, /// Local WireGuard private key (base64; sensitive) pub private_key: String, /// Local WireGuard public key (base64) @@ -975,6 +1006,173 @@ pub struct DhcpHaSection { pub tls_ca: Option, } +// ============================================================ +// DNS resolver (rDNS / unbound) +// ============================================================ + +/// rDNS/unbound resolver settings — the `dns_resolver_config` key/value +/// table materialized as a struct. This is the same type the +/// `/api/v1/dns/resolver/config` endpoint speaks (aifw-api re-exports it +/// as `ResolverConfig`); it lives here so config snapshots can round-trip +/// it (#589). +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct DnsResolverSection { + /// Resolver backend: `rdns` or `unbound` + pub backend: String, + /// Whether the resolver service runs + pub enabled: bool, + /// Listen addresses (e.g. `0.0.0.0`) + pub listen_interfaces: Vec, + /// DNS listen port (default 53) + pub port: u16, + /// DNSSEC validation + pub dnssec: bool, + /// DNS64 synthesis + pub dns64: bool, + /// DNS64 /96 prefix — must match the NAT64 rule prefix (#531) + pub dns64_prefix: String, + /// Register DHCP leases in DNS + pub register_dhcp: bool, + /// Domain for DHCP lease DNS registration (e.g. `local`) + pub dhcp_domain: String, + /// Local zone type — transparent, static, redirect, etc. + pub local_zone_type: String, + /// Interface for outgoing queries; None = any + pub outgoing_interface: Option, + // Advanced + /// Worker thread count + pub num_threads: u32, + /// Message cache size (e.g. `8m`) + pub msg_cache_size: String, + /// RRset cache size (e.g. `16m`) + pub rrset_cache_size: String, + /// Maximum cache TTL in seconds + pub cache_max_ttl: u32, + /// Minimum cache TTL in seconds + pub cache_min_ttl: u32, + /// Prefetch popular records before expiry + pub prefetch: bool, + /// Prefetch DNSKEY records + pub prefetch_key: bool, + /// Host infrastructure cache TTL in seconds + pub infra_host_ttl: u32, + /// Unwanted-reply threshold before cache flush + pub unwanted_reply_threshold: u32, + /// Log client queries + pub log_queries: bool, + /// Log replies + pub log_replies: bool, + /// Log verbosity level + pub log_verbosity: u32, + /// Ceiling on a single query's resolution (ms). 0 = rDNS built-in + /// per-transport defaults (UDP 3000, TCP/DoT 30000). + #[serde(default)] + pub query_timeout_ms: u32, + /// Refuse id.server/hostname.bind queries + pub hide_identity: bool, + /// Refuse version.server/version.bind queries + pub hide_version: bool, + /// DNS-rebind protection + pub rebind_protection: bool, + /// Private address ranges for rebind protection + pub private_addresses: Vec, + // Forwarding + /// Forward to upstream servers instead of full recursion + pub forwarding_enabled: bool, + /// Plain upstream DNS IPs (e.g. `8.8.8.8`, `1.1.1.1`) + pub forwarding_servers: Vec, + /// Also forward to /etc/resolv.conf nameservers + pub use_system_nameservers: bool, + // DoT + /// DNS-over-TLS forwarding + pub dot_enabled: bool, + /// DoT upstreams (`1.1.1.1@853#cloudflare-dns.com`) + pub dot_upstream: Vec, + // Blocklists + /// Enable domain blocklists + pub blocklists_enabled: bool, + /// Blocklist source URLs + pub blocklist_urls: Vec, + /// Domains exempted from blocklists + pub whitelist: Vec, + /// `nxdomain` or `redirect` + pub blocklist_action: String, + /// Redirect target when blocklist_action is `redirect` + pub blocklist_redirect_ip: Option, + // Custom + /// Free-form extra config lines appended to the generated config + pub custom_options: String, + // Safety + /// Probe :53 after a resolver switch and auto-rollback on failure. + /// Default: true. Disable to restore the old fire-and-forget behavior + /// (e.g. for debugging, or on boxes where the probe misfires). + #[serde(default = "default_true")] + pub probe_enabled: bool, +} + +impl Default for DnsResolverSection { + fn default() -> Self { + Self { + backend: "rdns".to_string(), + enabled: false, + listen_interfaces: vec!["0.0.0.0".to_string()], + port: 53, + dnssec: true, + dns64: false, + dns64_prefix: "64:ff9b::/96".to_string(), + register_dhcp: true, + dhcp_domain: "local".to_string(), + local_zone_type: "transparent".to_string(), + outgoing_interface: None, + num_threads: 2, + msg_cache_size: "8m".to_string(), + rrset_cache_size: "16m".to_string(), + cache_max_ttl: 86400, + cache_min_ttl: 0, + prefetch: true, + prefetch_key: true, + infra_host_ttl: 900, + unwanted_reply_threshold: 10000, + log_queries: false, + log_replies: false, + log_verbosity: 1, + query_timeout_ms: 0, + hide_identity: true, + hide_version: true, + rebind_protection: true, + private_addresses: vec![ + "10.0.0.0/8".into(), + "172.16.0.0/12".into(), + "192.168.0.0/16".into(), + "169.254.0.0/16".into(), + "fd00::/8".into(), + "fe80::/10".into(), + ], + // Default ON. Iterative recursion in rDNS 1.12.8 returns referrals + // instead of following them to completion, leaving clients with + // 0-answer responses for anything not already cached. Forwarding + // to public resolvers is the battle-tested fallback and keeps DNS + // working out of the box. Operators who want pure recursion can + // flip this off in the UI. + forwarding_enabled: true, + forwarding_servers: vec!["1.1.1.1".into(), "8.8.8.8".into()], + use_system_nameservers: false, + dot_enabled: false, + dot_upstream: vec![ + "1.1.1.1@853#cloudflare-dns.com".into(), + "1.0.0.1@853#cloudflare-dns.com".into(), + ], + blocklists_enabled: false, + blocklist_urls: vec![], + whitelist: vec![], + blocklist_action: "nxdomain".to_string(), + blocklist_redirect_ip: None, + custom_options: String::new(), + probe_enabled: true, + } + } +} + // ============================================================ // SHA-256 (pure Rust, for config hashing) // ============================================================ diff --git a/aifw-core/src/db.rs b/aifw-core/src/db.rs index 9e217841..6660575a 100644 --- a/aifw-core/src/db.rs +++ b/aifw-core/src/db.rs @@ -189,8 +189,9 @@ impl Database { } /// Executor-generic variant so engines can run the insert inside a - /// transaction alongside its audit row (PERF-H6 #350). - pub(crate) async fn insert_rule_on<'e, E>(exec: E, rule: &Rule) -> Result<()> + /// transaction alongside its audit row (PERF-H6 #350) and the restore + /// path can batch every section into one transaction (#158/#535). + pub async fn insert_rule_on<'e, E>(exec: E, rule: &Rule) -> Result<()> where E: sqlx::Executor<'e, Database = sqlx::Sqlite>, { diff --git a/aifw-core/src/engine.rs b/aifw-core/src/engine.rs index f9897379..d1024a4d 100644 --- a/aifw-core/src/engine.rs +++ b/aifw-core/src/engine.rs @@ -159,12 +159,9 @@ impl RuleEngine { *self.extra_rules.write().await = rules; } - /// Generate pf rules from active rules and load them into the pf anchor. - /// Rules referencing a schedule are only compiled while inside their - /// active window, evaluated against appliance local time (#537). - /// Extra rules (from VPN, etc.) are inserted just before any block rule - /// so they aren't shadowed by a `block quick` default. - pub async fn apply_rules(&self) -> Result<()> { + /// Render the pf ruleset that [`Self::apply_rules`] would load: active + /// rules inside their schedule window, plus injected extra rules. + async fn render_pf_rules(&self) -> Result> { let rules = self.db.list_active_rules().await?; let schedules = self.db.list_schedule_specs().await?; let gateways = self.db.list_gateway_routes().await; @@ -202,6 +199,16 @@ impl RuleEngine { pf_rules.extend(extras.iter().cloned()); } } + Ok(pf_rules) + } + + /// Generate pf rules from active rules and load them into the pf anchor. + /// Rules referencing a schedule are only compiled while inside their + /// active window, evaluated against appliance local time (#537). + /// Extra rules (from VPN, etc.) are inserted just before any block rule + /// so they aren't shadowed by a `block quick` default. + pub async fn apply_rules(&self) -> Result<()> { + let pf_rules = self.render_pf_rules().await?; tracing::info!( anchor = %self.anchor, @@ -226,6 +233,36 @@ impl RuleEngine { Ok(()) } + /// Verify the pf anchor holds the ruleset [`Self::apply_rules`] would + /// render right now (#535 post-apply verification). Exact string + /// comparison only works on backends that echo the loaded rules (the + /// mock); real pfctl lists rules in canonical re-rendered form, so + /// there the check degrades to the emptiness invariant (loaded a + /// non-empty ruleset ⇒ anchor is non-empty, and vice versa). Full + /// pfctl-side verification is tracked under the FreeBSD CI epic (#533). + pub async fn verify_applied(&self) -> Result<()> { + let expected = self.render_pf_rules().await?; + let actual = self + .pf + .get_rules(&self.anchor) + .await + .map_err(|e| AifwError::Pf(e.to_string()))?; + let mismatch = if self.pf.echoes_exact_rules() { + actual != expected + } else { + actual.is_empty() != expected.is_empty() + }; + if mismatch { + return Err(AifwError::Pf(format!( + "anchor {} holds {} rules but {} were expected — pf does not match the database", + self.anchor, + actual.len(), + expected.len() + ))); + } + Ok(()) + } + /// Flush all rules from the pf anchor pub async fn flush_rules(&self) -> Result<()> { self.pf diff --git a/aifw-core/src/geoip.rs b/aifw-core/src/geoip.rs index 161b9c8b..d01f4deb 100644 --- a/aifw-core/src/geoip.rs +++ b/aifw-core/src/geoip.rs @@ -99,6 +99,17 @@ impl GeoIpEngine { /// Insert a per-country block/allow rule row. pf tables aren't touched /// until the geo-IP rules are next applied pub async fn add_rule(&self, rule: GeoIpRule) -> Result { + Self::insert_rule_on(&self.pool, &rule).await?; + tracing::info!(id = %rule.id, country = %rule.country, action = %rule.action, "geo-ip rule added"); + Ok(rule) + } + + /// Executor-generic insert. Public so the transactional restore path + /// (#158/#535) can batch geo-IP rows with every other section. + pub async fn insert_rule_on<'e, E>(exec: E, rule: &GeoIpRule) -> Result<()> + where + E: sqlx::Executor<'e, Database = sqlx::Sqlite>, + { sqlx::query( r#" INSERT INTO geoip_rules (id, country, action, label, status, created_at, updated_at) @@ -115,11 +126,9 @@ impl GeoIpEngine { }) .bind(rule.created_at.to_rfc3339()) .bind(rule.updated_at.to_rfc3339()) - .execute(&self.pool) + .execute(exec) .await?; - - tracing::info!(id = %rule.id, country = %rule.country, action = %rule.action, "geo-ip rule added"); - Ok(rule) + Ok(()) } /// All geo-IP rules ordered by country code @@ -306,6 +315,40 @@ impl GeoIpEngine { Ok(()) } + /// Verify the pf anchor holds the geo-IP lines [`Self::apply_rules`] + /// would render right now (#535 post-apply verification). Table + /// *contents* aren't compared — only the table definitions and + /// block/allow rules. Exact comparison on backends that echo loaded + /// rules; emptiness invariant on real pfctl, which re-renders rules and + /// omits table definitions from `-sr` (see `RuleEngine::verify_applied`). + pub async fn verify_applied(&self) -> Result<()> { + let rules = self.list_rules().await?; + let expected: Vec = rules + .iter() + .filter(|r| r.status == GeoIpRuleStatus::Active) + .flat_map(|r| [r.to_pf_table(), r.to_pf_rule()]) + .collect(); + let actual = self + .pf + .get_rules(&self.anchor) + .await + .map_err(|e| AifwError::Pf(e.to_string()))?; + let mismatch = if self.pf.echoes_exact_rules() { + actual != expected + } else { + actual.is_empty() != expected.is_empty() + }; + if mismatch { + return Err(AifwError::Pf(format!( + "anchor {} holds {} geo-ip lines but {} were expected — pf does not match the database", + self.anchor, + actual.len(), + expected.len() + ))); + } + Ok(()) + } + /// Get database statistics pub async fn db_stats(&self) -> (usize, usize) { let index = self.index.load(); diff --git a/aifw-core/src/ha.rs b/aifw-core/src/ha.rs index 97aa1d5f..5378fa22 100644 --- a/aifw-core/src/ha.rs +++ b/aifw-core/src/ha.rs @@ -169,6 +169,17 @@ impl ClusterEngine { /// Store a new CARP virtual IP. Fails validation if VHID is 0 or the password is empty pub async fn add_carp_vip(&self, vip: CarpVip) -> Result { + Self::insert_carp_vip_on(&self.pool, &vip).await?; + tracing::info!(id = %vip.id, vhid = vip.vhid, ip = %vip.virtual_ip, "CARP VIP added"); + Ok(vip) + } + + /// Executor-generic validate + insert. Public for the transactional + /// restore path (#158/#535). + pub async fn insert_carp_vip_on<'e, E>(exec: E, vip: &CarpVip) -> Result<()> + where + E: sqlx::Executor<'e, Database = sqlx::Sqlite>, + { if vip.vhid == 0 { return Err(AifwError::Validation("VHID must be > 0".to_string())); } @@ -192,11 +203,9 @@ impl ClusterEngine { .bind(vip.status.to_string()) .bind(vip.created_at.to_rfc3339()) .bind(vip.updated_at.to_rfc3339()) - .execute(&self.pool) + .execute(exec) .await?; - - tracing::info!(id = %vip.id, vhid = vip.vhid, ip = %vip.virtual_ip, "CARP VIP added"); - Ok(vip) + Ok(()) } /// List all configured CARP VIPs ordered by VHID @@ -251,9 +260,23 @@ impl ClusterEngine { /// Store the pfsync configuration, replacing any existing one (singleton table) pub async fn set_pfsync(&self, config: PfsyncConfig) -> Result { + let mut conn = self.pool.acquire().await?; + Self::set_pfsync_on(&mut conn, &config).await?; + tracing::info!(interface = %config.sync_interface, "pfsync configured"); + Ok(config) + } + + /// Replace the pfsync config (DELETE + INSERT) on a single connection. + /// Public for the transactional restore path (#158/#535); takes `&mut + /// SqliteConnection` rather than a generic executor because it runs two + /// statements. + pub async fn set_pfsync_on( + conn: &mut sqlx::SqliteConnection, + config: &PfsyncConfig, + ) -> Result<()> { // Replace any existing config sqlx::query("DELETE FROM pfsync_config") - .execute(&self.pool) + .execute(&mut *conn) .await?; sqlx::query( @@ -273,11 +296,9 @@ impl ClusterEngine { .bind(config.heartbeat_interval_ms.map(|n| n as i64)) .bind(config.dhcp_link) .bind(config.created_at.to_rfc3339()) - .execute(&self.pool) + .execute(&mut *conn) .await?; - - tracing::info!(interface = %config.sync_interface, "pfsync configured"); - Ok(config) + Ok(()) } /// Fetch the pfsync configuration, or `None` if pfsync has never been configured @@ -296,6 +317,17 @@ impl ClusterEngine { /// Register a peer node in the cluster. Fails validation if the name is empty pub async fn add_node(&self, node: ClusterNode) -> Result { + Self::insert_node_on(&self.pool, &node).await?; + tracing::info!(name = %node.name, role = %node.role, "cluster node added"); + Ok(node) + } + + /// Executor-generic validate + insert. Public for the transactional + /// restore path (#158/#535). + pub async fn insert_node_on<'e, E>(exec: E, node: &ClusterNode) -> Result<()> + where + E: sqlx::Executor<'e, Database = sqlx::Sqlite>, + { if node.name.is_empty() { return Err(AifwError::Validation("node name required".to_string())); } @@ -314,11 +346,9 @@ impl ClusterEngine { .bind(node.last_seen.to_rfc3339()) .bind(node.config_version as i64) .bind(node.created_at.to_rfc3339()) - .execute(&self.pool) + .execute(exec) .await?; - - tracing::info!(name = %node.name, role = %node.role, "cluster node added"); - Ok(node) + Ok(()) } /// List all registered cluster nodes ordered by name diff --git a/aifw-core/src/ipsec.rs b/aifw-core/src/ipsec.rs index 60566646..274f43df 100644 --- a/aifw-core/src/ipsec.rs +++ b/aifw-core/src/ipsec.rs @@ -625,6 +625,17 @@ impl IpsecEngine { /// Validate and persist a new tunnel. pub async fn add_tunnel(&self, tunnel: IpsecTunnel) -> Result { + Self::insert_tunnel_on(&self.pool, &tunnel).await?; + Ok(tunnel) + } + + /// Executor-generic validate + insert with no swanctl side effects. + /// Public for the transactional restore path (#158/#535); callers apply + /// the swanctl config separately. + pub async fn insert_tunnel_on<'e, E>(exec: E, tunnel: &IpsecTunnel) -> Result<()> + where + E: sqlx::Executor<'e, Database = sqlx::Sqlite>, + { tunnel.validate()?; sqlx::query( r#" @@ -664,9 +675,9 @@ impl IpsecEngine { .bind(tunnel.start_action.to_string()) .bind(tunnel.created_at.to_rfc3339()) .bind(tunnel.updated_at.to_rfc3339()) - .execute(&self.pool) + .execute(exec) .await?; - Ok(tunnel) + Ok(()) } /// All tunnels, oldest first. diff --git a/aifw-core/src/nat.rs b/aifw-core/src/nat.rs index b4f7d220..7bacf3b6 100644 --- a/aifw-core/src/nat.rs +++ b/aifw-core/src/nat.rs @@ -11,7 +11,7 @@ use uuid::Uuid; use crate::audit::{AuditAction, AuditLog}; /// NAT engine: persists SNAT/DNAT/masquerade rules in the `nat_rules` -/// SQLite table and loads active ones as pf NAT rules into the `aifw` +/// SQLite table and loads active ones as pf NAT rules into the `aifw-nat` /// anchor (override via [`Self::with_anchor`]). Every mutation commits its /// audit row in the same transaction. pub struct NatEngine { @@ -23,14 +23,19 @@ pub struct NatEngine { impl NatEngine { /// Build a NAT engine over the shared pool and pf backend, targeting - /// the default `aifw` anchor + /// the default `aifw-nat` anchor. + /// + /// The default MUST stay `aifw-nat` (#531 review L1): `apply_rules` + /// replaces every rule class in its anchor, so an engine accidentally + /// pointed at `aifw` would wipe the RuleEngine's filter ruleset on the + /// first NAT apply. pub fn new(pool: SqlitePool, pf: Arc) -> Self { let audit = AuditLog::new(pool.clone()); Self { pool, pf, audit, - anchor: "aifw".to_string(), + anchor: "aifw-nat".to_string(), } } @@ -81,6 +86,7 @@ impl NatEngine { /// neither a destination nor a redirect port. pub async fn add_rule(&self, rule: NatRule) -> Result { validate_nat_rule(&rule)?; + self.parser_gate_with(&rule).await?; let pf_syntax = rule.to_pf_rule(); // PERF-H6 (#350): mutation + audit row commit together — one fsync // instead of two per NAT rule change. @@ -140,6 +146,7 @@ impl NatEngine { /// pf is untouched until [`Self::apply_rules`]. pub async fn update_rule(&self, rule: &NatRule) -> Result<()> { validate_nat_rule(rule)?; + self.parser_gate_with(rule).await?; let mut tx = self.pool.begin().await?; let result = sqlx::query( r#" @@ -222,7 +229,7 @@ impl NatEngine { /// Generate pf NAT rules and load them into the anchor pub async fn apply_rules(&self) -> Result<()> { let rules = self.list_active_rules().await?; - let pf_rules: Vec = rules.iter().flat_map(|r| r.to_pf_rules()).collect(); + let pf_rules = self.render_ruleset(&rules); tracing::info!( anchor = %self.anchor, @@ -251,6 +258,92 @@ impl NatEngine { Ok(()) } + /// Real-parser gate (#531): dry-run the full prospective active ruleset + /// (current active rules with `candidate` inserted/substituted) through + /// `pfctl -n` before persisting, so rules only real pf can judge (af-to + /// family constraints, prefix extraction limits) never land in the DB + /// unloadable. No-op on the mock backend (dev/tests). + async fn parser_gate_with(&self, candidate: &NatRule) -> Result<()> { + let mut prospective: Vec = self + .list_active_rules() + .await? + .into_iter() + .filter(|r| r.id != candidate.id) + .collect(); + if candidate.status == NatStatus::Active { + prospective.push(candidate.clone()); + } + // Only fork pfctl when the ruleset contains cross-family rules — + // the classic nat/rdr/binat renderers are fully covered by + // validate_nat_rule, and gating them too would cost O(N²) pfctl + // execs on a scripted bulk load (#531 review L5). + if !prospective + .iter() + .any(|r| matches!(r.nat_type, NatType::Nat64 | NatType::Nat46)) + { + return Ok(()); + } + let rendered = self.render_ruleset(&prospective); + self.pf + .validate_rules(&self.anchor, &rendered) + .await + .map_err(|e| AifwError::Validation(format!("pf rejected NAT ruleset: {e}"))) + } + + /// Render the full pf ruleset for a set of NAT rules, translation-class + /// lines first (traditional pf.conf section order). Cross-family + /// (af-to) rules render as filter-class `pass` lines and pf evaluates + /// the two classes independently, so cross-class order has no effect — + /// this ordering just keeps the loaded file conventional. + fn render_ruleset(&self, rules: &[NatRule]) -> Vec { + let (translation, filter): (Vec, Vec) = rules + .iter() + .flat_map(|r| r.to_pf_rules()) + .partition(|l| is_translation_class(l)); + translation.into_iter().chain(filter).collect() + } + + /// Verify the pf anchor holds the NAT ruleset [`Self::apply_rules`] + /// would render right now (#535 post-apply verification). Exact + /// per-class comparison on backends that echo loaded rules; per-class + /// emptiness invariant on real pfctl (see `RuleEngine::verify_applied`). + /// Cross-family (af-to) rules are filter-class and surface via + /// `get_rules`, not `get_nat_rules`. + pub async fn verify_applied(&self) -> Result<()> { + let rules = self.list_active_rules().await?; + let (expected_nat, expected_filter): (Vec, Vec) = rules + .iter() + .flat_map(|r| r.to_pf_rules()) + .partition(|l| is_translation_class(l)); + let actual_nat = self + .pf + .get_nat_rules(&self.anchor) + .await + .map_err(|e| AifwError::Pf(e.to_string()))?; + let actual_filter = self + .pf + .get_rules(&self.anchor) + .await + .map_err(|e| AifwError::Pf(e.to_string()))?; + let mismatch = if self.pf.echoes_exact_rules() { + actual_nat != expected_nat || actual_filter != expected_filter + } else { + actual_nat.is_empty() != expected_nat.is_empty() + || actual_filter.is_empty() != expected_filter.is_empty() + }; + if mismatch { + return Err(AifwError::Pf(format!( + "anchor {} holds {} nat-class / {} filter-class rules but {} / {} were expected — pf does not match the database", + self.anchor, + actual_nat.len(), + actual_filter.len(), + expected_nat.len(), + expected_filter.len() + ))); + } + Ok(()) + } + /// Flush all NAT rules from the pf anchor and record an audit entry. /// Fails if the pf backend rejects the flush pub async fn flush_rules(&self) -> Result<()> { @@ -272,7 +365,9 @@ impl NatEngine { Ok(()) } - async fn insert_rule_on<'e, E>(exec: E, rule: &NatRule) -> Result<()> + /// Executor-generic insert. Public so the transactional restore path + /// (#158/#535) can batch NAT rows with every other section. + pub async fn insert_rule_on<'e, E>(exec: E, rule: &NatRule) -> Result<()> where E: sqlx::Executor<'e, Database = sqlx::Sqlite>, { @@ -312,13 +407,34 @@ impl NatEngine { } } -fn validate_nat_rule(rule: &NatRule) -> Result<()> { +/// True for pf translation-class rule text (`nat`/`rdr`/`binat`), false for +/// filter-class lines (the af-to `pass` rules cross-family NAT renders to). +fn is_translation_class(rule: &str) -> bool { + ["nat ", "rdr ", "binat "] + .iter() + .any(|p| rule.starts_with(p)) +} + +/// Validate a NAT rule before persisting: interface required, DNAT/RDR +/// needs a destination or redirect port. Public so the backup restore path +/// can pre-validate a whole config with the same checks `add_rule` applies. +pub fn validate_nat_rule(rule: &NatRule) -> Result<()> { if rule.interface.0.is_empty() { return Err(AifwError::Validation( "NAT rule requires an interface".to_string(), )); } + // pf-injection guards at the engine level (#531 review M2): the API + // route validates these too, but the CLI, TUI, and backup-restore + // paths reach this function without passing through it — and + // cross-family (af-to) rules render the label into pf rule text, so a + // quote+newline label would become an arbitrary extra pf rule line. + crate::validation::validate_interface_name(&rule.interface.0)?; + if let Some(ref label) = rule.label { + crate::validation::validate_label(label)?; + } + // DNAT requires a destination port or redirect port if rule.nat_type == NatType::Dnat && rule.dst_port.is_none() && rule.redirect.port.is_none() { return Err(AifwError::Validation( @@ -331,6 +447,83 @@ fn validate_nat_rule(rule: &NatRule) -> Result<()> { // This is fine — we'll ignore the redirect address and use the interface } + // Cross-family (af-to) rules: pf requires the matched side and the + // translation source to be in opposite, concrete families (#531). + match rule.nat_type { + NatType::Nat64 => validate_af_to(rule, true)?, + NatType::Nat46 => validate_af_to(rule, false)?, + _ => {} + } + + Ok(()) +} + +/// Family/prefix checks for pf `af-to` rules. `inet6_match` is true for +/// NAT64 (rule matches IPv6, translates to IPv4) and false for NAT46. +fn validate_af_to(rule: &NatRule, inet6_match: bool) -> Result<()> { + let (kind, matched, translated) = if inet6_match { + ("nat64", "IPv6", "IPv4") + } else { + ("nat46", "IPv4", "IPv6") + }; + + // pf tables can mix families — af-to needs concrete same-family matches. + if matches!(rule.src_addr, Address::Table(_)) || matches!(rule.dst_addr, Address::Table(_)) { + return Err(AifwError::Validation(format!( + "{kind} rules cannot use pf tables for source/destination — the matched family must be concrete" + ))); + } + + // Source must be `any` or in the matched family. + if rule.src_addr.is_ipv6() == Some(!inet6_match) { + return Err(AifwError::Validation(format!( + "{kind} source address must be {matched} (the rule matches {matched} traffic)" + ))); + } + + if inet6_match { + // NAT64 destination is the translation prefix: an IPv6 network with + // a /96 prefix so the IPv4 destination embeds in the low 32 bits + // (RFC 6052). pf's af-to extraction supports /96 exactly. + match rule.dst_addr { + Address::Network(std::net::IpAddr::V6(_), 96) => {} + _ => { + return Err(AifwError::Validation( + "nat64 destination must be an IPv6 /96 translation prefix (e.g. 64:ff9b::/96)" + .to_string(), + )); + } + } + } else { + // NAT46 destination is the concrete IPv4 host/network being reached. + match rule.dst_addr.is_ipv6() { + Some(false) => {} + _ => { + return Err(AifwError::Validation( + "nat46 destination must be a concrete IPv4 address or network".to_string(), + )); + } + } + } + + // Translation source: a single concrete host in the translated family + // (pf: `af-to inet|inet6 from ` — the new source of the packet). + match (&rule.redirect.address, rule.redirect.address.is_ipv6()) { + (Address::Single(_), Some(v6)) if v6 != inet6_match => {} + _ => { + return Err(AifwError::Validation(format!( + "{kind} translation source (redirect) must be a single {translated} address the firewall owns" + ))); + } + } + + // af-to has no port-rewrite syntax. + if rule.redirect.port.is_some() { + return Err(AifwError::Validation(format!( + "{kind} rules do not support a redirect port — af-to translates addresses, not ports" + ))); + } + Ok(()) } diff --git a/aifw-core/src/shaping.rs b/aifw-core/src/shaping.rs index bb92cfb5..05b71fb2 100644 --- a/aifw-core/src/shaping.rs +++ b/aifw-core/src/shaping.rs @@ -1,6 +1,6 @@ use aifw_common::{ - Address, AifwError, Bandwidth, BandwidthUnit, Interface, PortRange, Protocol, QueueConfig, - QueueStatus, QueueType, RateLimitRule, RateLimitStatus, Result, TrafficClass, + Address, AifwError, Bandwidth, BandwidthUnit, FqCodelConfig, Interface, PortRange, Protocol, + QueueConfig, QueueStatus, QueueType, RateLimitRule, RateLimitStatus, Result, TrafficClass, }; use aifw_pf::PfBackend; use chrono::{DateTime, Utc}; @@ -8,6 +8,11 @@ use sqlx::sqlite::SqlitePool; use std::sync::Arc; use uuid::Uuid; +const DUMMYNET_PIPE_BASE: u32 = 10_000; +const DUMMYNET_PIPE_SPAN: u32 = 10_000; +const DUMMYNET_RULE_OUT_BASE: u32 = 30_000; +const DUMMYNET_RULE_IN_BASE: u32 = 40_000; + /// Traffic-shaping engine: bandwidth queues (`queue_configs` table) and /// connection rate limits (`rate_limit_rules` table). Queue definitions /// load into the base `aifw` anchor; rate-limit tables/rules load into @@ -57,6 +62,28 @@ impl ShapingEngine { ) .execute(&self.pool) .await?; + for (column, definition) in [ + ("fq_codel_target_ms", "INTEGER NOT NULL DEFAULT 5"), + ("fq_codel_interval_ms", "INTEGER NOT NULL DEFAULT 100"), + ("fq_codel_quantum_bytes", "INTEGER NOT NULL DEFAULT 1514"), + ("fq_codel_limit_packets", "INTEGER NOT NULL DEFAULT 10240"), + ("fq_codel_flows", "INTEGER NOT NULL DEFAULT 1024"), + ("fq_codel_ecn", "INTEGER NOT NULL DEFAULT 1"), + ] { + let present = sqlx::query_scalar::<_, i64>( + "SELECT count(*) FROM pragma_table_info('queue_configs') WHERE name = ?1", + ) + .bind(column) + .fetch_one(&self.pool) + .await?; + if present == 0 { + let statement = + format!("ALTER TABLE queue_configs ADD COLUMN {column} {definition}"); + sqlx::query(sqlx::AssertSqlSafe(statement)) + .execute(&self.pool) + .await?; + } + } sqlx::query( r#" @@ -90,7 +117,10 @@ impl ShapingEngine { /// Insert a queue config row. pf isn't touched until /// [`Self::apply_queues`] pub async fn add_queue(&self, config: QueueConfig) -> Result { - self.insert_queue(&config).await?; + if config.queue_type == QueueType::Codel { + config.fq_codel.validate()?; + } + Self::insert_queue_on(&self.pool, &config).await?; tracing::info!(id = %config.id, name = %config.name, "queue added"); Ok(config) } @@ -118,6 +148,26 @@ impl ShapingEngine { Ok(()) } + /// Replace an existing queue definition after validating its scheduler + /// parameters. The live backend is applied separately by the caller. + pub async fn update_queue(&self, config: QueueConfig) -> Result { + if config.queue_type == QueueType::Codel { + config.fq_codel.validate()?; + } + let result = sqlx::query("DELETE FROM queue_configs WHERE id = ?1") + .bind(config.id.to_string()) + .execute(&self.pool) + .await?; + if result.rows_affected() == 0 { + return Err(AifwError::NotFound(format!( + "queue {} not found", + config.id + ))); + } + Self::insert_queue_on(&self.pool, &config).await?; + Ok(config) + } + /// Render active queue configs (one parent queue per interface plus /// each child queue) and load them into pf. Fails if the pf backend /// rejects the queue definitions @@ -129,13 +179,27 @@ impl ShapingEngine { .collect(); let mut pf_lines = Vec::new(); + let mut dummynet = Vec::new(); + let mut used_pipes = std::collections::HashSet::new(); // Group by interface — each needs a parent queue let mut interfaces_seen = std::collections::HashSet::new(); for q in &active { - if interfaces_seen.insert(q.interface.0.clone()) { - pf_lines.push(q.to_pf_parent_queue()); + if q.queue_type == QueueType::Codel { + q.fq_codel.validate()?; + let commands = render_dummynet_commands(q)?; + let pipe = pipe_id(q.id); + if !used_pipes.insert(pipe) { + return Err(AifwError::Validation(format!( + "duplicate dummynet pipe id {pipe}" + ))); + } + dummynet.extend(commands); + } else { + if interfaces_seen.insert(q.interface.0.clone()) { + pf_lines.push(q.to_pf_parent_queue()); + } + pf_lines.push(q.to_pf_queue()); } - pf_lines.push(q.to_pf_queue()); } tracing::info!(count = pf_lines.len(), "applying queue configs to pf"); @@ -143,6 +207,7 @@ impl ShapingEngine { .load_queues(&self.anchor, &pf_lines) .await .map_err(|e| AifwError::Pf(e.to_string()))?; + apply_dummynet(&dummynet).await?; Ok(()) } @@ -153,20 +218,7 @@ impl ShapingEngine { /// `max_connections` or `window_secs` is 0 or the overload table name /// is empty. pf isn't touched until [`Self::apply_rate_limits`] pub async fn add_rate_limit(&self, rule: RateLimitRule) -> Result { - if rule.max_connections == 0 { - return Err(AifwError::Validation( - "max_connections must be > 0".to_string(), - )); - } - if rule.window_secs == 0 { - return Err(AifwError::Validation("window_secs must be > 0".to_string())); - } - if rule.overload_table.is_empty() { - return Err(AifwError::Validation( - "overload_table name required".to_string(), - )); - } - self.insert_rate_limit(&rule).await?; + Self::insert_rate_limit_on(&self.pool, &rule).await?; tracing::info!(id = %rule.id, name = %rule.name, "rate limit rule added"); Ok(rule) } @@ -221,7 +273,12 @@ impl ShapingEngine { // --- DB helpers --- - async fn insert_queue(&self, q: &QueueConfig) -> Result<()> { + /// Executor-generic insert. Public for the transactional restore path + /// (#158/#535). + pub async fn insert_queue_on<'e, E>(exec: E, q: &QueueConfig) -> Result<()> + where + E: sqlx::Executor<'e, Database = sqlx::Sqlite>, + { let bw_unit = match q.bandwidth.unit { BandwidthUnit::Bps => "bps", BandwidthUnit::Kbps => "kbps", @@ -231,8 +288,10 @@ impl ShapingEngine { sqlx::query( r#" INSERT INTO queue_configs (id, interface, queue_type, bandwidth_value, bandwidth_unit, - name, traffic_class, bandwidth_pct, is_default, status, created_at, updated_at) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12) + name, traffic_class, bandwidth_pct, fq_codel_target_ms, fq_codel_interval_ms, + fq_codel_quantum_bytes, fq_codel_limit_packets, fq_codel_flows, fq_codel_ecn, + is_default, status, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18) "#, ) .bind(q.id.to_string()) @@ -243,6 +302,12 @@ impl ShapingEngine { .bind(&q.name) .bind(q.traffic_class.to_string()) .bind(q.bandwidth_pct.map(|p| p as i64)) + .bind(q.fq_codel.target_ms as i64) + .bind(q.fq_codel.interval_ms as i64) + .bind(q.fq_codel.quantum_bytes as i64) + .bind(q.fq_codel.limit_packets as i64) + .bind(q.fq_codel.flows as i64) + .bind(q.fq_codel.ecn) .bind(q.default) .bind(match q.status { QueueStatus::Active => "active", @@ -250,12 +315,30 @@ impl ShapingEngine { }) .bind(q.created_at.to_rfc3339()) .bind(q.updated_at.to_rfc3339()) - .execute(&self.pool) + .execute(exec) .await?; Ok(()) } - async fn insert_rate_limit(&self, r: &RateLimitRule) -> Result<()> { + /// Executor-generic validate + insert. Public for the transactional + /// restore path (#158/#535). + pub async fn insert_rate_limit_on<'e, E>(exec: E, r: &RateLimitRule) -> Result<()> + where + E: sqlx::Executor<'e, Database = sqlx::Sqlite>, + { + if r.max_connections == 0 { + return Err(AifwError::Validation( + "max_connections must be > 0".to_string(), + )); + } + if r.window_secs == 0 { + return Err(AifwError::Validation("window_secs must be > 0".to_string())); + } + if r.overload_table.is_empty() { + return Err(AifwError::Validation( + "overload_table name required".to_string(), + )); + } sqlx::query( r#" INSERT INTO rate_limit_rules (id, name, interface, protocol, src_addr, dst_addr, @@ -282,7 +365,7 @@ impl ShapingEngine { }) .bind(r.created_at.to_rfc3339()) .bind(r.updated_at.to_rfc3339()) - .execute(&self.pool) + .execute(exec) .await?; Ok(()) } @@ -294,8 +377,62 @@ impl ShapingEngine { /// `SELECT *` which triggers a sqlx-sqlite column-count panic and blocks /// column pruning (#348). const QUEUE_COLUMNS: &str = "id, interface, queue_type, bandwidth_value, \ - bandwidth_unit, name, traffic_class, bandwidth_pct, is_default, status, \ - created_at, updated_at"; + bandwidth_unit, name, traffic_class, bandwidth_pct, fq_codel_target_ms, \ + fq_codel_interval_ms, fq_codel_quantum_bytes, fq_codel_limit_packets, \ + fq_codel_flows, fq_codel_ecn, is_default, status, created_at, updated_at"; + +fn pipe_id(id: Uuid) -> u32 { + DUMMYNET_PIPE_BASE + (id.as_u128() as u32 % DUMMYNET_PIPE_SPAN) +} + +fn render_dummynet_commands(q: &QueueConfig) -> Result> { + let fq = q.fq_codel; + fq.validate()?; + let id = pipe_id(q.id); + let out_rule = DUMMYNET_RULE_OUT_BASE + (id - DUMMYNET_PIPE_BASE); + let in_rule = DUMMYNET_RULE_IN_BASE + (id - DUMMYNET_PIPE_BASE); + let bandwidth = q.bandwidth.to_bits_per_sec(); + Ok(vec![ + format!("pipe {id} config bw {bandwidth}bit/s",), + format!( + "sched {id} config pipe {id} type fq_codel target {target}ms interval {interval}ms quantum {quantum} limit {limit} flows {flows} {ecn}", + target = fq.target_ms, + interval = fq.interval_ms, + quantum = fq.quantum_bytes, + limit = fq.limit_packets, + flows = fq.flows, + ecn = if fq.ecn { "ecn" } else { "noecn" }, + ), + format!("queue {id} config sched {id}"), + format!( + "ipfw add {} queue {id} ip from any to any out xmit {}", + out_rule, q.interface + ), + format!( + "ipfw add {} queue {id} ip from any to any in recv {}", + in_rule, q.interface + ), + ]) +} + +async fn apply_dummynet(commands: &[String]) -> Result<()> { + #[cfg(not(target_os = "freebsd"))] + { + let _ = commands; + Ok(()) + } + #[cfg(target_os = "freebsd")] + { + let clear = ["clear".to_string()]; + crate::sudo::dummynet_apply(if commands.is_empty() { + &clear + } else { + commands + }) + .await + .map_err(|e| AifwError::Other(format!("dummynet apply failed: {e}"))) + } +} /// Explicit column list for `RateLimitRow` selects, in schema order. Replaces /// `SELECT *` which triggers a sqlx-sqlite column-count panic and blocks @@ -314,6 +451,12 @@ struct QueueRow { name: String, traffic_class: String, bandwidth_pct: Option, + fq_codel_target_ms: i64, + fq_codel_interval_ms: i64, + fq_codel_quantum_bytes: i64, + fq_codel_limit_packets: i64, + fq_codel_flows: i64, + fq_codel_ecn: bool, is_default: bool, status: String, created_at: String, @@ -351,6 +494,14 @@ impl QueueRow { updated_at: DateTime::parse_from_rfc3339(&self.updated_at) .map_err(|e| AifwError::Database(format!("invalid date: {e}")))? .with_timezone(&Utc), + fq_codel: FqCodelConfig { + target_ms: self.fq_codel_target_ms as u32, + interval_ms: self.fq_codel_interval_ms as u32, + quantum_bytes: self.fq_codel_quantum_bytes as u32, + limit_packets: self.fq_codel_limit_packets as u32, + flows: self.fq_codel_flows as u32, + ecn: self.fq_codel_ecn, + }, }) } } @@ -408,3 +559,44 @@ impl RateLimitRow { }) } } + +#[cfg(test)] +mod dummynet_tests { + use super::*; + + #[test] + fn renderer_uses_documented_scheduler_pipeline_and_reserved_ids() { + let mut queue = QueueConfig::new( + Interface("em0".into()), + QueueType::Codel, + Bandwidth { + value: 100, + unit: BandwidthUnit::Mbps, + }, + "wan".into(), + TrafficClass::Default, + ); + queue.fq_codel.ecn = false; + let rendered = render_dummynet_commands(&queue).unwrap(); + let id = pipe_id(queue.id); + assert!((DUMMYNET_PIPE_BASE..DUMMYNET_PIPE_BASE + DUMMYNET_PIPE_SPAN).contains(&id)); + assert_eq!(rendered[0], format!("pipe {id} config bw 100000000bit/s")); + assert!(rendered[1].contains(&format!("sched {id} config pipe {id} type fq_codel"))); + assert!(rendered[1].ends_with("noecn")); + assert_eq!(rendered[2], format!("queue {id} config sched {id}")); + assert!(rendered[3].contains(" out xmit em0")); + assert!(rendered[4].contains(" in recv em0")); + } + + #[test] + fn validation_uses_freebsd_fq_codel_limits() { + let mut config = FqCodelConfig { + quantum_bytes: 9_001, + ..Default::default() + }; + assert!(config.validate().is_err()); + config = FqCodelConfig::default(); + config.limit_packets = 20_481; + assert!(config.validate().is_err()); + } +} diff --git a/aifw-core/src/sudo.rs b/aifw-core/src/sudo.rs index dfbe36fd..ec335745 100644 --- a/aifw-core/src/sudo.rs +++ b/aifw-core/src/sudo.rs @@ -80,6 +80,19 @@ const HELPER_CP: &str = "/usr/local/libexec/aifw-sudo-cp"; const HELPER_TAR: &str = "/usr/local/libexec/aifw-sudo-tar"; const HELPER_TCPDUMP: &str = "/usr/local/libexec/aifw-sudo-tcpdump"; const HELPER_SWANCTL: &str = "/usr/local/libexec/aifw-sudo-swanctl"; +const HELPER_DUMMYNET: &str = "/usr/local/libexec/aifw-sudo-dummynet"; +/// Apply dummynet/FQ-CoDel commands through the closed helper. +pub async fn dummynet_apply(commands: &[String]) -> std::io::Result<()> { + let payload = commands.join("\n"); + let output = run_with_stdin_pipe(&[HELPER_DUMMYNET], payload.as_bytes()).await?; + if output.status.success() { + Ok(()) + } else { + Err(std::io::Error::other( + String::from_utf8_lossy(&output.stderr).trim().to_string(), + )) + } +} /// Atomically write `contents` to `path`, as root, via the /// `aifw-sudo-write` helper script. @@ -399,3 +412,30 @@ pub async fn tcpdump(args: &[&str]) -> std::io::Result { fallback.extend_from_slice(args); sudo_with_fallback(&narrow, &fallback).await } + +#[cfg(test)] +mod allowlist_tests { + /// Guard (#601): every path the code writes through `aifw-sudo-write` + /// must appear in the helper's allowlist. `pf.conf.aifw` was missing, + /// so pf-tuning's boot apply failed silently on every appliance — + /// the same drift family as the sudoers guard in aifw-setup. + #[test] + fn sudo_write_allowlist_covers_call_sites() { + let helper = include_str!("../../freebsd/overlay/usr/local/libexec/aifw-sudo-write"); + for path in [ + "/etc/pf.conf", + "/usr/local/etc/aifw/pf.conf.aifw", + "/usr/local/etc/trafficcop/config.yaml", + "/usr/local/etc/aifw/daemon.key", + "/usr/local/etc/swanctl/conf.d/aifw-*.conf", + "/usr/local/etc/swanctl/private/aifw-*.pem", + "/usr/local/etc/swanctl/x509/aifw-*.pem", + "/usr/local/etc/swanctl/x509ca/aifw-*.pem", + ] { + assert!( + helper.contains(path), + "aifw-sudo-write allowlist is missing {path} — runtime writes to it are refused" + ); + } + } +} diff --git a/aifw-core/src/system_apply/freebsd_impl.rs b/aifw-core/src/system_apply/freebsd_impl.rs index b3de8a9e..112acfb0 100644 --- a/aifw-core/src/system_apply/freebsd_impl.rs +++ b/aifw-core/src/system_apply/freebsd_impl.rs @@ -5,6 +5,8 @@ use super::freebsd_helpers::{rewrite_hosts_loopback, rewrite_resolv_conf_search} use super::{ApplyReport, BannerInput, ConsoleInput, GeneralInput, SshInput, SystemInfo}; use crate::system_apply_helpers::replace_managed_block; +/// Apply general system settings (hostname, domain, DNS search) via +/// sysrc for persistence plus the live equivalents. pub async fn apply_general(i: &GeneralInput) -> ApplyReport { let mut warnings: Vec = Vec::new(); @@ -58,6 +60,7 @@ pub async fn apply_general(i: &GeneralInput) -> ApplyReport { } } +/// Install the login banner (/etc/issue) and MOTD content. pub async fn apply_banner(i: &BannerInput) -> ApplyReport { let mut warnings: Vec = Vec::new(); @@ -84,12 +87,16 @@ pub async fn apply_banner(i: &BannerInput) -> ApplyReport { } } +/// Whether the operator has hand-edited the MOTD (marker file set) — +/// when true, apply paths must not overwrite it. pub async fn motd_user_edited_marker_set() -> bool { tokio::fs::try_exists("/var/db/aifw/motd.user-edited") .await .unwrap_or(false) } +/// Apply SSH daemon settings (enable flag via sysrc + sshd_config +/// managed block) and restart sshd when changed. pub async fn apply_ssh(i: &SshInput) -> ApplyReport { let mut warnings: Vec = Vec::new(); @@ -141,6 +148,7 @@ pub async fn apply_ssh(i: &SshInput) -> ApplyReport { r } +/// Apply the console kind (serial/video/dual) to /boot/loader.conf. pub async fn apply_console(i: &ConsoleInput) -> ApplyReport { let console_val = match i.kind { crate::config::ConsoleKind::Serial => "comconsole", @@ -164,6 +172,8 @@ pub async fn apply_console(i: &ConsoleInput) -> ApplyReport { r } +/// Collect live system facts (hostname, OS release, uptime, etc.) for +/// the system-info API. pub async fn collect_info() -> SystemInfo { let hostname = read_sysctl_str("kern.hostname").unwrap_or_default(); let os_release = run_stdout("/usr/bin/uname", &["-sr"]) diff --git a/aifw-core/src/tests.rs b/aifw-core/src/tests.rs index eb96ee77..f4c203fe 100644 --- a/aifw-core/src/tests.rs +++ b/aifw-core/src/tests.rs @@ -544,11 +544,41 @@ mod tests { engine.add_rule(make_test_nat_rule()).await.unwrap(); engine.apply_rules().await.unwrap(); - let nat_rules = mock.get_nat_rules("aifw").await.unwrap(); + let nat_rules = mock.get_nat_rules("aifw-nat").await.unwrap(); assert_eq!(nat_rules.len(), 1); assert!(nat_rules[0].contains("nat on em0")); } + #[tokio::test] + async fn test_nat_apply_mixed_classes_and_flush() { + let db = Database::new_in_memory().await.unwrap(); + let mock = Arc::new(aifw_pf::PfMock::new()); + let pf: Arc = mock.clone(); + let engine = + crate::nat::NatEngine::new(db.pool().clone(), pf).with_anchor("aifw-nat".to_string()); + + engine.add_rule(make_test_nat_rule()).await.unwrap(); + engine.add_rule(make_test_nat64_rule()).await.unwrap(); + engine.apply_rules().await.unwrap(); + + // nat-class rule lands in the nat ruleset; the af-to pass rule is + // filter-class and must land in the filter ruleset (#531). + let nat_rules = mock.get_nat_rules("aifw-nat").await.unwrap(); + assert_eq!(nat_rules.len(), 1); + assert!(nat_rules[0].starts_with("nat on em0")); + let filter_rules = mock.get_rules("aifw-nat").await.unwrap(); + assert_eq!(filter_rules.len(), 1); + assert!(filter_rules[0].contains("af-to inet from 203.0.113.1")); + + // per-class post-apply verification passes + engine.verify_applied().await.unwrap(); + + // flush clears both classes + engine.flush_rules().await.unwrap(); + assert!(mock.get_nat_rules("aifw-nat").await.unwrap().is_empty()); + assert!(mock.get_rules("aifw-nat").await.unwrap().is_empty()); + } + #[tokio::test] async fn test_nat_validation_dnat_needs_port() { let engine = create_nat_engine().await; @@ -588,6 +618,127 @@ mod tests { assert!(engine.add_rule(rule).await.is_err()); } + fn make_test_nat64_rule() -> NatRule { + NatRule::new( + NatType::Nat64, + Interface("em0".to_string()), + Protocol::Any, + Address::Any, + Address::Network(std::net::IpAddr::V6("64:ff9b::".parse().unwrap()), 96), + NatRedirect { + address: Address::Single(std::net::IpAddr::V4(std::net::Ipv4Addr::new( + 203, 0, 113, 1, + ))), + port: None, + }, + ) + } + + #[tokio::test] + async fn test_nat64_validation_accepts_well_formed_rule() { + let engine = create_nat_engine().await; + engine.add_rule(make_test_nat64_rule()).await.unwrap(); + + // NAT46 mirror: v4 match, v6 translation source + let nat46 = NatRule::new( + NatType::Nat46, + Interface("em0".to_string()), + Protocol::Tcp, + Address::Any, + Address::Single(std::net::IpAddr::V4(std::net::Ipv4Addr::new(10, 99, 1, 1))), + NatRedirect { + address: Address::Single(std::net::IpAddr::V6("2001:db8:2::1".parse().unwrap())), + port: None, + }, + ); + engine.add_rule(nat46).await.unwrap(); + } + + #[tokio::test] + async fn test_nat64_validation_rejects_bad_families() { + let engine = create_nat_engine().await; + + // dst not a /96 prefix + let mut rule = make_test_nat64_rule(); + rule.dst_addr = Address::Network(std::net::IpAddr::V6("64:ff9b::".parse().unwrap()), 64); + assert!(engine.add_rule(rule).await.is_err()); + + // dst IPv4 + let mut rule = make_test_nat64_rule(); + rule.dst_addr = Address::Network( + std::net::IpAddr::V4(std::net::Ipv4Addr::new(10, 0, 0, 0)), + 8, + ); + assert!(engine.add_rule(rule).await.is_err()); + + // source in the wrong (translated) family + let mut rule = make_test_nat64_rule(); + rule.src_addr = Address::Single(std::net::IpAddr::V4(std::net::Ipv4Addr::new(10, 0, 0, 1))); + assert!(engine.add_rule(rule).await.is_err()); + + // translation source IPv6 (must be IPv4 for nat64) + let mut rule = make_test_nat64_rule(); + rule.redirect.address = + Address::Single(std::net::IpAddr::V6("2001:db8::1".parse().unwrap())); + assert!(engine.add_rule(rule).await.is_err()); + + // translation source as network (must be a single host) + let mut rule = make_test_nat64_rule(); + rule.redirect.address = Address::Network( + std::net::IpAddr::V4(std::net::Ipv4Addr::new(203, 0, 113, 0)), + 24, + ); + assert!(engine.add_rule(rule).await.is_err()); + + // redirect port unsupported by af-to + let mut rule = make_test_nat64_rule(); + rule.redirect.port = Some(PortRange { start: 80, end: 80 }); + assert!(engine.add_rule(rule).await.is_err()); + + // pf tables can't determine family + let mut rule = make_test_nat64_rule(); + rule.src_addr = Address::Table("v6clients".to_string()); + assert!(engine.add_rule(rule).await.is_err()); + } + + #[tokio::test] + async fn test_nat46_validation_rejects_bad_families() { + let engine = create_nat_engine().await; + + let make = || { + NatRule::new( + NatType::Nat46, + Interface("em0".to_string()), + Protocol::Any, + Address::Any, + Address::Single(std::net::IpAddr::V4(std::net::Ipv4Addr::new(10, 99, 1, 1))), + NatRedirect { + address: Address::Single(std::net::IpAddr::V6( + "2001:db8:2::1".parse().unwrap(), + )), + port: None, + }, + ) + }; + + // dst IPv6 (must be the concrete IPv4 target) + let mut rule = make(); + rule.dst_addr = Address::Single(std::net::IpAddr::V6("2001:db8::5".parse().unwrap())); + assert!(engine.add_rule(rule).await.is_err()); + + // dst Any (a concrete IPv4 destination is required) + let mut rule = make(); + rule.dst_addr = Address::Any; + assert!(engine.add_rule(rule).await.is_err()); + + // translation source IPv4 (must be IPv6 for nat46) + let mut rule = make(); + rule.redirect.address = Address::Single(std::net::IpAddr::V4(std::net::Ipv4Addr::new( + 203, 0, 113, 1, + ))); + assert!(engine.add_rule(rule).await.is_err()); + } + // --- Shaping engine tests --- async fn create_shaping_engine() -> crate::shaping::ShapingEngine { @@ -676,9 +827,24 @@ mod tests { engine.apply_queues().await.unwrap(); let pf_queues = mock.get_queues("aifw").await.unwrap(); - assert_eq!(pf_queues.len(), 2); // parent + child - assert!(pf_queues[0].contains("queue on em0")); - assert!(pf_queues[1].contains("queue default_q")); + assert!(pf_queues.is_empty()); // FQ-CoDel is entirely dummynet + } + + #[test] + fn test_fq_codel_uses_dummynet_not_false_pf_queue_options() { + let q = QueueConfig::new( + Interface("em0".to_string()), + QueueType::Codel, + Bandwidth { + value: 100, + unit: BandwidthUnit::Mbps, + }, + "bulk".to_string(), + TrafficClass::Bulk, + ); + assert!(q.to_pf_queue().is_empty()); + assert!(!q.to_pf_queue().contains("flows 1024")); + assert!(q.fq_codel.validate().is_ok()); } #[tokio::test] @@ -827,6 +993,7 @@ mod tests { .unwrap(); tunnel.dns = Some("1.1.1.1".to_string()); tunnel.mtu = Some(1420); + tunnel.address6 = Some(Address::Network("fd00:a1f0::1".parse().unwrap(), 64)); let id = tunnel.id; engine.add_wg_tunnel(tunnel).await.unwrap(); @@ -835,10 +1002,111 @@ mod tests { assert_eq!(fetched.interface.0, "wg1"); assert_eq!(fetched.dns, Some("1.1.1.1".to_string())); assert_eq!(fetched.mtu, Some(1420)); + assert_eq!( + fetched.address6, + Some(Address::Network("fd00:a1f0::1".parse().unwrap(), 64)) + ); assert!(!fetched.private_key.is_empty()); assert!(!fetched.public_key.is_empty()); } + #[tokio::test] + async fn test_next_peer_ip_v4_skips_used() { + let engine = create_vpn_engine().await; + let tunnel = WgTunnel::new( + "wg0".to_string(), + Interface("wg0".to_string()), + 51820, + Address::Network("10.0.0.1".parse().unwrap(), 24), + ) + .unwrap(); + let tid = tunnel.id; + engine.add_wg_tunnel(tunnel).await.unwrap(); + + assert_eq!(engine.next_peer_ip(tid).await.unwrap(), "10.0.0.2/32"); + + let mut peer = WgPeer::new(tid, "p1".to_string(), "pk1".to_string()); + peer.allowed_ips = vec![Address::Network("10.0.0.2".parse().unwrap(), 32)]; + engine.add_wg_peer(peer).await.unwrap(); + + assert_eq!(engine.next_peer_ip(tid).await.unwrap(), "10.0.0.3/32"); + } + + #[tokio::test] + async fn test_next_peer_ip_dual_stack() { + let engine = create_vpn_engine().await; + let mut tunnel = WgTunnel::new( + "wg0".to_string(), + Interface("wg0".to_string()), + 51820, + Address::Network("10.0.0.1".parse().unwrap(), 24), + ) + .unwrap(); + tunnel.address6 = Some(Address::Network("fd00:a1f0::1".parse().unwrap(), 64)); + let tid = tunnel.id; + engine.add_wg_tunnel(tunnel).await.unwrap(); + + assert_eq!( + engine.next_peer_ip(tid).await.unwrap(), + "10.0.0.2/32, fd00:a1f0::2/128" + ); + + let mut peer = WgPeer::new(tid, "p1".to_string(), "pk1".to_string()); + peer.allowed_ips = vec![ + Address::Network("10.0.0.2".parse().unwrap(), 32), + Address::Network("fd00:a1f0::2".parse().unwrap(), 128), + ]; + engine.add_wg_peer(peer).await.unwrap(); + + assert_eq!( + engine.next_peer_ip(tid).await.unwrap(), + "10.0.0.3/32, fd00:a1f0::3/128" + ); + } + + #[tokio::test] + async fn test_next_peer_ip_v6_only_tunnel() { + let engine = create_vpn_engine().await; + let tunnel = WgTunnel::new( + "wg6".to_string(), + Interface("wg0".to_string()), + 51820, + Address::Network("fd00:b::1".parse().unwrap(), 64), + ) + .unwrap(); + let tid = tunnel.id; + engine.add_wg_tunnel(tunnel).await.unwrap(); + + assert_eq!(engine.next_peer_ip(tid).await.unwrap(), "fd00:b::2/128"); + } + + #[tokio::test] + async fn test_wg_tunnel_address6_validation() { + let engine = create_vpn_engine().await; + + // address6 must actually be IPv6 + let mut t = WgTunnel::new( + "bad6".to_string(), + Interface("wg0".to_string()), + 51830, + Address::Network("10.9.0.1".parse().unwrap(), 24), + ) + .unwrap(); + t.address6 = Some(Address::Network("192.168.1.1".parse().unwrap(), 24)); + assert!(engine.add_wg_tunnel(t).await.is_err()); + + // with address6 set, the primary address must be IPv4 + let mut t = WgTunnel::new( + "doublev6".to_string(), + Interface("wg0".to_string()), + 51831, + Address::Network("fd00:1::1".parse().unwrap(), 64), + ) + .unwrap(); + t.address6 = Some(Address::Network("fd00:2::1".parse().unwrap(), 64)); + assert!(engine.add_wg_tunnel(t).await.is_err()); + } + #[tokio::test] async fn test_wg_peer_crud() { let engine = create_vpn_engine().await; @@ -1763,5 +2031,43 @@ mod tests { assert_eq!(c.system.domain, ""); // default assert_eq!(c.system.timezone, "UTC"); // default assert_eq!(c.system.ssh.port, 22); // default + assert!( + c.dns_resolver.is_none(), + "pre-#589 backups must restore with the resolver section absent, not defaulted" + ); + } + + #[test] + fn dns_resolver_section_round_trips() { + let c = crate::FirewallConfig { + dns_resolver: Some(crate::config::DnsResolverSection { + forwarding_servers: vec!["9.9.9.9".into()], + enabled: true, + ..Default::default() + }), + ..Default::default() + }; + c.validate().expect("valid resolver section must pass"); + + let json = c.to_json(); + let back = crate::FirewallConfig::from_json(&json).expect("round trip"); + let r = back.dns_resolver.expect("section must survive round trip"); + assert_eq!(r.forwarding_servers, vec!["9.9.9.9".to_string()]); + assert!(r.enabled); + } + + #[test] + fn dns_resolver_section_validates_forwarder_ips() { + let c = crate::FirewallConfig { + dns_resolver: Some(crate::config::DnsResolverSection { + forwarding_servers: vec!["not-an-ip; rm -rf /".into()], + ..Default::default() + }), + ..Default::default() + }; + assert!( + c.validate().is_err(), + "non-IP forwarding_servers entries must fail validation" + ); } } diff --git a/aifw-core/src/tls.rs b/aifw-core/src/tls.rs index 3e5ea7d8..535b0d72 100644 --- a/aifw-core/src/tls.rs +++ b/aifw-core/src/tls.rs @@ -81,6 +81,17 @@ impl TlsEngine { /// Insert an SNI rule row. Fails validation when the pattern is empty pub async fn add_sni_rule(&self, rule: SniRule) -> Result { + Self::insert_sni_rule_on(&self.pool, &rule).await?; + tracing::info!(id = %rule.id, pattern = %rule.pattern, action = %rule.action, "SNI rule added"); + Ok(rule) + } + + /// Executor-generic validate + insert. Public for the transactional + /// restore path (#158/#535). + pub async fn insert_sni_rule_on<'e, E>(exec: E, rule: &SniRule) -> Result<()> + where + E: sqlx::Executor<'e, Database = sqlx::Sqlite>, + { if rule.pattern.is_empty() { return Err(AifwError::Validation("SNI pattern required".to_string())); } @@ -95,11 +106,9 @@ impl TlsEngine { .bind(match rule.status { SniRuleStatus::Active => "active", SniRuleStatus::Disabled => "disabled" }) .bind(rule.created_at.to_rfc3339()) .bind(rule.updated_at.to_rfc3339()) - .execute(&self.pool) + .execute(exec) .await?; - - tracing::info!(id = %rule.id, pattern = %rule.pattern, action = %rule.action, "SNI rule added"); - Ok(rule) + Ok(()) } /// All SNI rules ordered by pattern @@ -139,15 +148,25 @@ impl TlsEngine { /// Insert or replace a JA3 fingerprint hash in the blocklist pub async fn add_ja3_block(&self, hash: &str, description: &str) -> Result<()> { + Self::insert_ja3_block_on(&self.pool, hash, description).await?; + tracing::info!(hash, "JA3 hash blocked"); + Ok(()) + } + + /// Executor-generic insert. Public for the transactional restore path + /// (#158/#535). + pub async fn insert_ja3_block_on<'e, E>(exec: E, hash: &str, description: &str) -> Result<()> + where + E: sqlx::Executor<'e, Database = sqlx::Sqlite>, + { sqlx::query( "INSERT OR REPLACE INTO ja3_blocklist (hash, description, created_at) VALUES (?1, ?2, ?3)", ) .bind(hash) .bind(description) .bind(Utc::now().to_rfc3339()) - .execute(&self.pool) + .execute(exec) .await?; - tracing::info!(hash, "JA3 hash blocked"); Ok(()) } diff --git a/aifw-core/src/updater.rs b/aifw-core/src/updater.rs index 082c484e..eb76be39 100644 --- a/aifw-core/src/updater.rs +++ b/aifw-core/src/updater.rs @@ -112,6 +112,14 @@ const EMBEDDED_SUDO_HELPERS: &[(&str, &str)] = &[ "aifw-sudo-swanctl", include_str!("../../freebsd/overlay/usr/local/libexec/aifw-sudo-swanctl"), ), + ( + "aifw-sudo-dummynet", + include_str!("../../freebsd/overlay/usr/local/libexec/aifw-sudo-dummynet"), + ), + ( + "aifw-dummynet-control", + include_str!("../../freebsd/overlay/usr/local/libexec/aifw-dummynet-control"), + ), ]; #[derive(Deserialize)] @@ -223,6 +231,17 @@ pub enum UpdaterError { /// Downloaded tarball didn't match its published SHA-256 #[error("Checksum verification failed")] Checksum, + /// The release did not contain a detached signature for its checksum + #[error("Release checksum signature is missing")] + NoSignature, + /// The checksum's publisher signature could not be verified + #[error("Release signature verification failed")] + Signature, + /// minisign could not be located or executed, so the signature could + /// not be checked at all (distinct from a signature that checked and + /// failed — the operator fixes this one with `pkg install minisign`) + #[error("Signature verification unavailable: {0}")] + VerifyUnavailable(String), /// Extracting or installing the update failed #[error("Installation failed: {0}")] Install(String), @@ -251,6 +270,9 @@ pub struct AifwUpdateInfo { pub tarball_url: Option, /// Download URL of the tarball's SHA-256 checksum asset, if present pub checksum_url: Option, + /// Download URL of the checksum's detached minisign signature. + #[serde(default)] + pub checksum_signature_url: Option, /// True when a rollback backup exists on disk pub has_backup: bool, /// Version the on-disk backup was taken from, when known @@ -343,24 +365,7 @@ pub async fn check_for_update(include_prereleases: bool) -> Result Result Result Result Result Result<&'static str, UpdaterError> { + EMBEDDED_PUBKEY + .lines() + .map(str::trim) + .rfind(|l| !l.is_empty() && !l.starts_with("untrusted comment:")) + .filter(|l| l.starts_with("RW")) + .ok_or_else(|| { + UpdaterError::VerifyUnavailable( + "embedded update-signing public key is malformed".into(), + ) + }) +} + +async fn verify_minisign_checksum(checksum: &str, signature: &str) -> Result<(), UpdaterError> { + let pubkey = embedded_pubkey_b64()?; + let args = ["-Vm", checksum, "-x", signature, "-P", pubkey]; + + let mut result = Command::new("minisign").args(args).output().await; + if matches!(&result, Err(e) if e.kind() == std::io::ErrorKind::NotFound) { + // An appliance upgraded from a pre-signing build doesn't have + // minisign yet: the OLD updater that installed this build worked + // from its own embedded package list, which predates the minisign + // entry in the manifest. The pkg sudo grant does exist on such + // appliances, so install it here rather than failing the upgrade. + info!("minisign not found; installing via pkg"); + if let Some(err) = step_failure(&crate::sudo::pkg("install", &["-y", "minisign"]).await) { + return Err(UpdaterError::VerifyUnavailable(format!( + "minisign is not installed and installing it failed: {err}" + ))); + } + result = Command::new("minisign").args(args).output().await; + } + let output = result + .map_err(|e| UpdaterError::VerifyUnavailable(format!("failed to run minisign: {e}")))?; + if output.status.success() { + Ok(()) + } else { + warn!( + stderr = %String::from_utf8_lossy(&output.stderr), + "release checksum signature rejected" + ); + Err(UpdaterError::Signature) + } +} + /// Services that may have had their rc.d script replaced by an update and /// therefore need a restart for the new script to take effect. Order /// matters for aifw_api (last) so HTTP stays up as long as possible. @@ -1323,6 +1426,28 @@ fn extract_hash(checksum_content: &str) -> String { line.split_whitespace().next().unwrap_or("").to_string() } +fn release_asset_urls( + release: &serde_json::Value, +) -> (Option, Option, Option) { + let mut tarball = None; + let mut checksum = None; + let mut signature = None; + if let Some(assets) = release["assets"].as_array() { + for asset in assets { + let name = asset["name"].as_str().unwrap_or(""); + let url = asset["browser_download_url"].as_str().unwrap_or(""); + if name.starts_with("aifw-update-") && name.ends_with(".tar.xz") { + tarball = Some(url.to_string()); + } else if name.starts_with("aifw-update-") && name.ends_with(".tar.xz.sha256.minisig") { + signature = Some(url.to_string()); + } else if name.starts_with("aifw-update-") && name.ends_with(".tar.xz.sha256") { + checksum = Some(url.to_string()); + } + } + } + (tarball, checksum, signature) +} + async fn http_get(url: &str) -> Result { // Try fetch (FreeBSD) first, fall back to curl if let Ok(o) = Command::new("fetch").args(["-qo", "-", url]).output().await @@ -1463,8 +1588,15 @@ mod tests { script.contains("aifw-setup --print-sudoers"), "{name} must regenerate sudoers from aifw-setup" ); + // Absolute path required (#601): a bare `visudo` isn't found + // under the daemon(8) default PATH, silently disabling the + // refresh on every boot. assert!( - script.contains("visudo -cf"), + script.contains("VISUDO=/usr/local/sbin/visudo"), + "{name} must resolve visudo by absolute path (not in daemon PATH)" + ); + assert!( + script.contains("\"$VISUDO\" -cf"), "{name} must validate sudoers with visudo before installing" ); assert!( @@ -1544,6 +1676,33 @@ mod tests { assert_eq!(extract_hash(input), "abc123def456"); } + // The compiled-in signing key is the trust root for every self-update: + // if the committed .pub file is reformatted into something this parser + // rejects, all appliances fail closed on the next release. + #[test] + fn embedded_update_signing_pubkey_parses() { + let key = embedded_pubkey_b64().expect("embedded public key must parse"); + assert!( + key.starts_with("RW"), + "minisign keys are RW-prefixed: {key}" + ); + assert!(!key.contains(char::is_whitespace)); + assert_eq!(key.len(), 56, "Ed25519 minisign pubkey is 56 base64 chars"); + } + + #[test] + fn release_assets_require_distinct_checksum_and_signature_sidecars() { + let release = serde_json::json!({"assets": [ + {"name": "aifw-update-6.0.0-amd64.tar.xz", "browser_download_url": "tar"}, + {"name": "aifw-update-6.0.0-amd64.tar.xz.sha256", "browser_download_url": "sum"}, + {"name": "aifw-update-6.0.0-amd64.tar.xz.sha256.minisig", "browser_download_url": "sig"} + ]}); + assert_eq!( + release_asset_urls(&release), + (Some("tar".into()), Some("sum".into()), Some("sig".into())) + ); + } + // Regression gate for #469: every overlay libexec script must carry the // execute bit in git. Eight aifw-sudo-* helpers were committed mode 644, // build-iso.sh's `cp -a` preserved that onto installed systems, and sudo diff --git a/aifw-core/src/vpn.rs b/aifw-core/src/vpn.rs index 2a884843..80783ca0 100644 --- a/aifw-core/src/vpn.rs +++ b/aifw-core/src/vpn.rs @@ -90,6 +90,12 @@ impl VpnEngine { .execute(&self.pool) .await; + // Add address6 column: the server's IPv6 tunnel address for + // dual-stack tunnels (#471). NULL means no inner IPv6. + let _ = sqlx::query("ALTER TABLE wg_tunnels ADD COLUMN address6 TEXT") + .execute(&self.pool) + .await; + // Shared with aifw-setup / aifw-api (QUAL-C5) — wan_interface() below // reads it to build the WireGuard outbound NAT rule. sqlx::query(aifw_common::schemas::INTERFACE_ROLES_CREATE) @@ -162,11 +168,33 @@ impl VpnEngine { ))); } + Self::insert_wg_tunnel_on(&self.pool, &tunnel).await?; + + tracing::info!(id = %tunnel.id, name = %tunnel.name, "WireGuard tunnel added"); + Ok(tunnel) + } + + /// Executor-generic validate + insert with no duplicate-port lookup + /// (callers must ensure port uniqueness, e.g. the restore path + /// pre-validates the whole config). Public for the transactional restore + /// path (#158/#535). + pub async fn insert_wg_tunnel_on<'e, E>(exec: E, tunnel: &WgTunnel) -> Result<()> + where + E: sqlx::Executor<'e, Database = sqlx::Sqlite>, + { + if tunnel.name.is_empty() { + return Err(AifwError::Validation("tunnel name required".to_string())); + } + if tunnel.listen_port == 0 { + return Err(AifwError::Validation("listen port required".to_string())); + } + tunnel.validate_addresses()?; sqlx::query( r#" INSERT INTO wg_tunnels (id, name, interface, private_key, public_key, listen_port, - address, dns, mtu, listen_interface, split_routes, status, created_at, updated_at) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14) + address, address6, dns, mtu, listen_interface, split_routes, status, + created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15) "#, ) .bind(tunnel.id.to_string()) @@ -176,6 +204,7 @@ impl VpnEngine { .bind(&tunnel.public_key) .bind(tunnel.listen_port as i64) .bind(tunnel.address.to_string()) + .bind(tunnel.address6.as_ref().map(|a| a.to_string())) .bind(tunnel.dns.as_deref()) .bind(tunnel.mtu.map(|m| m as i64)) .bind(tunnel.listen_interface.as_deref()) @@ -183,11 +212,9 @@ impl VpnEngine { .bind(tunnel.status.to_string()) .bind(tunnel.created_at.to_rfc3339()) .bind(tunnel.updated_at.to_rfc3339()) - .execute(&self.pool) + .execute(exec) .await?; - - tracing::info!(id = %tunnel.id, name = %tunnel.name, "WireGuard tunnel added"); - Ok(tunnel) + Ok(()) } /// All WireGuard tunnels, oldest first @@ -239,18 +266,21 @@ impl VpnEngine { ))); } + tunnel.validate_addresses()?; + let result = sqlx::query( r#" UPDATE wg_tunnels - SET name = ?1, listen_port = ?2, address = ?3, dns = ?4, mtu = ?5, - listen_interface = ?6, split_routes = ?7, - private_key = ?8, public_key = ?9, updated_at = ?10 - WHERE id = ?11 + SET name = ?1, listen_port = ?2, address = ?3, address6 = ?4, dns = ?5, + mtu = ?6, listen_interface = ?7, split_routes = ?8, + private_key = ?9, public_key = ?10, updated_at = ?11 + WHERE id = ?12 "#, ) .bind(&tunnel.name) .bind(tunnel.listen_port as i64) .bind(tunnel.address.to_string()) + .bind(tunnel.address6.as_ref().map(|a| a.to_string())) .bind(tunnel.dns.as_deref()) .bind(tunnel.mtu.map(|m| m as i64)) .bind(tunnel.listen_interface.as_deref()) @@ -327,6 +357,25 @@ impl VpnEngine { } } + Self::insert_wg_peer_on(&self.pool, &peer).await?; + + tracing::info!(id = %peer.id, name = %peer.name, "WireGuard peer added"); + Ok(peer) + } + + /// Executor-generic validate + insert with no tunnel-exists or + /// duplicate-IP lookups (the restore path inserts the tunnel in the same + /// transaction, so those pool-side reads would not see it). Public for + /// the transactional restore path (#158/#535). + pub async fn insert_wg_peer_on<'e, E>(exec: E, peer: &WgPeer) -> Result<()> + where + E: sqlx::Executor<'e, Database = sqlx::Sqlite>, + { + if peer.public_key.is_empty() { + return Err(AifwError::Validation( + "peer public key required".to_string(), + )); + } let allowed_ips: Vec = peer.allowed_ips.iter().map(|a| a.to_string()).collect(); sqlx::query( @@ -347,11 +396,9 @@ impl VpnEngine { .bind(peer.persistent_keepalive.map(|k| k as i64)) .bind(peer.created_at.to_rfc3339()) .bind(peer.updated_at.to_rfc3339()) - .execute(&self.pool) + .execute(exec) .await?; - - tracing::info!(id = %peer.id, name = %peer.name, "WireGuard peer added"); - Ok(peer) + Ok(()) } /// Peers of one tunnel, oldest first @@ -442,6 +489,17 @@ impl VpnEngine { /// Insert an IPsec security-association row. Fails validation when the /// name is empty pub async fn add_ipsec_sa(&self, sa: IpsecSa) -> Result { + Self::insert_ipsec_sa_on(&self.pool, &sa).await?; + tracing::info!(id = %sa.id, name = %sa.name, "IPsec SA added"); + Ok(sa) + } + + /// Executor-generic validate + insert. Public for the transactional + /// restore path (#158/#535). + pub async fn insert_ipsec_sa_on<'e, E>(exec: E, sa: &IpsecSa) -> Result<()> + where + E: sqlx::Executor<'e, Database = sqlx::Sqlite>, + { if sa.name.is_empty() { return Err(AifwError::Validation("SA name required".to_string())); } @@ -465,11 +523,9 @@ impl VpnEngine { .bind(sa.status.to_string()) .bind(sa.created_at.to_rfc3339()) .bind(sa.updated_at.to_rfc3339()) - .execute(&self.pool) + .execute(exec) .await?; - - tracing::info!(id = %sa.id, name = %sa.name, "IPsec SA added"); - Ok(sa) + Ok(()) } /// All IPsec SAs, oldest first @@ -529,9 +585,24 @@ impl VpnEngine { ))); } - // Set address and bring up + // Set addresses and bring up. The keyword must match the address + // family — FreeBSD's `ifconfig inet ` fails, which is + // how IPv6 tunnel addresses used to silently not configure (#471). let addr = tunnel.address.to_string(); - let _ = crate::sudo::ifconfig(iface, "inet", &[&addr, "up"]).await; + let v6_primary = matches!( + &tunnel.address, + Address::Single(std::net::IpAddr::V6(_)) | Address::Network(std::net::IpAddr::V6(_), _) + ); + if v6_primary { + Self::ifconfig_logged(iface, "inet6", &[&addr]).await; + Self::ifconfig_logged(iface, "up", &[]).await; + } else { + Self::ifconfig_logged(iface, "inet", &[&addr, "up"]).await; + } + if let Some(ref a6) = tunnel.address6 { + let a6 = a6.to_string(); + Self::ifconfig_logged(iface, "inet6", &[&a6]).await; + } // Set MTU if specified if let Some(mtu) = tunnel.mtu { @@ -591,6 +662,25 @@ impl VpnEngine { Ok(()) } + /// Run an ifconfig action, logging (not failing) on error. Address + /// configuration problems shouldn't abort tunnel startup, but they must + /// be visible — a swallowed error here is how IPv6 addresses silently + /// failed to configure before #471. + async fn ifconfig_logged(iface: &str, action: &str, args: &[&str]) { + match crate::sudo::ifconfig(iface, action, args).await { + Ok(out) if !out.status.success() => { + tracing::warn!( + iface, + action, + stderr = %String::from_utf8_lossy(&out.stderr), + "ifconfig failed" + ); + } + Err(e) => tracing::warn!(iface, action, error = %e, "ifconfig failed"), + _ => {} + } + } + /// Stop a WireGuard tunnel: destroy the interface. pub async fn stop_tunnel(&self, id: Uuid) -> Result<()> { let tunnel = self.get_wg_tunnel(id).await?; @@ -737,40 +827,43 @@ impl VpnEngine { Ok(started) } - /// Compute the next available IP in a tunnel's subnet for auto-assigning to a new peer. + /// Compute the next available IP(s) in a tunnel's subnet for + /// auto-assigning to a new peer. IPv4 tunnels yield `a.b.c.d/32`; + /// dual-stack tunnels (#471) yield `a.b.c.d/32, x::y/128` so the new + /// peer gets an address in every family the tunnel carries. Errors when + /// any carried family has no free addresses left — a dual-stack peer + /// with only half its addresses would silently lose one family. pub async fn next_peer_ip(&self, tunnel_id: Uuid) -> Result { - let tunnel = self.get_wg_tunnel(tunnel_id).await?; - let addr_str = tunnel.address.to_string(); - // Parse "10.10.0.1/24" → base "10.10.0", server_last = 1 - let parts: Vec<&str> = addr_str.split('/').collect(); - let ip_str = parts[0]; - let octets: Vec = ip_str.split('.').filter_map(|o| o.parse().ok()).collect(); - if octets.len() != 4 { - return Err(AifwError::Validation("Invalid tunnel address".to_string())); - } + use std::net::IpAddr; - // Collect existing peer IPs + let tunnel = self.get_wg_tunnel(tunnel_id).await?; let peers = self.list_wg_peers(tunnel_id).await?; - let used: std::collections::HashSet = peers + let used: std::collections::HashSet = peers .iter() - .flat_map(|p| { - p.allowed_ips - .iter() - .map(|a| a.to_string().split('/').next().unwrap_or("").to_string()) + .flat_map(|p| p.allowed_ips.iter()) + .filter_map(|a| match a { + Address::Single(ip) => Some(*ip), + Address::Network(ip, _) => Some(*ip), + _ => None, }) .collect(); - // Find next free IP in the /24 (or smaller) range - let base = [octets[0], octets[1], octets[2]]; - for last in 2u8..=254 { - let candidate = format!("{}.{}.{}.{}", base[0], base[1], base[2], last); - if candidate != ip_str && !used.contains(&candidate) { - return Ok(format!("{candidate}/32")); - } + let mut parts: Vec = Vec::new(); + // A Single (prefix-less) tunnel address falls back to the + // conventional subnet size for its family. + match &tunnel.address { + Address::Single(IpAddr::V4(ip)) => parts.push(next_free_v4(*ip, 24, &used)?), + Address::Network(IpAddr::V4(ip), p) => parts.push(next_free_v4(*ip, *p, &used)?), + Address::Single(IpAddr::V6(ip)) => parts.push(next_free_v6(*ip, 64, &used)?), + Address::Network(IpAddr::V6(ip), p) => parts.push(next_free_v6(*ip, *p, &used)?), + _ => return Err(AifwError::Validation("Invalid tunnel address".to_string())), } - Err(AifwError::Validation( - "No free IPs in tunnel subnet".to_string(), - )) + match &tunnel.address6 { + Some(Address::Single(IpAddr::V6(ip))) => parts.push(next_free_v6(*ip, 64, &used)?), + Some(Address::Network(IpAddr::V6(ip), p)) => parts.push(next_free_v6(*ip, *p, &used)?), + _ => {} + } + Ok(parts.join(", ")) } // ============================================================ @@ -814,7 +907,8 @@ impl VpnEngine { } /// Collect outbound NAT rules for up WireGuard tunnels so clients reach - /// the internet through the WAN (#469). These load into the `aifw-vpn` + /// the internet through the WAN — IPv4 masquerade (#469) plus NAT66 for + /// tunnels carrying inner IPv6 (#471). These load into the `aifw-vpn` /// nat-anchor declared in pf.conf — do NOT mix them into the filter-rule /// extras, pfctl requires translation rules before filter rules. pub async fn collect_vpn_nat_rules(&self) -> Result> { @@ -832,7 +926,7 @@ impl VpnEngine { ); return Ok(Vec::new()); }; - Ok(up.iter().filter_map(|t| t.to_nat_rule(&wan)).collect()) + Ok(up.iter().flat_map(|t| t.to_nat_rules(&wan)).collect()) } /// Resolve the WAN interface from the interface_roles table (seeded by @@ -870,6 +964,73 @@ impl VpnEngine { } } +/// Scanning more candidates than this per family means the subnet is +/// effectively full for auto-assignment purposes (a /64 has 2^64 hosts — +/// exhaustive iteration is not an option). +const MAX_AUTO_IP_SCAN: u32 = 65536; + +/// Next free IPv4 host in `server`'s subnet as a `/32` peer address: +/// skips the network address, the broadcast address, the server itself, +/// and every already-assigned peer IP. +fn next_free_v4( + server: std::net::Ipv4Addr, + prefix: u8, + used: &std::collections::HashSet, +) -> Result { + let p = prefix.min(32); + let server_bits = u32::from(server); + let mask: u32 = if p == 0 { 0 } else { u32::MAX << (32 - p) }; + let net = server_bits & mask; + let bcast = net | !mask; + + let mut candidate = net.saturating_add(1); + let mut tries = 0u32; + while candidate < bcast && tries < MAX_AUTO_IP_SCAN { + if candidate != server_bits { + let ip = std::net::Ipv4Addr::from(candidate); + if !used.contains(&std::net::IpAddr::V4(ip)) { + return Ok(format!("{ip}/32")); + } + } + candidate += 1; + tries += 1; + } + Err(AifwError::Validation( + "No free IPv4 addresses in tunnel subnet".to_string(), + )) +} + +/// Next free IPv6 host in `server`'s subnet as a `/128` peer address: +/// skips the subnet-router anycast address (all-zero host part), the +/// server itself, and every already-assigned peer IP. +fn next_free_v6( + server: std::net::Ipv6Addr, + prefix: u8, + used: &std::collections::HashSet, +) -> Result { + let p = prefix.min(128); + let server_bits = u128::from(server); + let mask: u128 = if p == 0 { 0 } else { u128::MAX << (128 - p) }; + let net = server_bits & mask; + let last = net | !mask; + + let mut candidate = net.saturating_add(1); + let mut tries = 0u32; + while candidate <= last && tries < MAX_AUTO_IP_SCAN { + if candidate != server_bits { + let ip = std::net::Ipv6Addr::from(candidate); + if !used.contains(&std::net::IpAddr::V6(ip)) { + return Ok(format!("{ip}/128")); + } + } + candidate += 1; + tries += 1; + } + Err(AifwError::Validation( + "No free IPv6 addresses in tunnel subnet".to_string(), + )) +} + // ============================================================ // Row types // ============================================================ @@ -881,7 +1042,7 @@ impl VpnEngine { /// CREATE TABLE (base columns then ALTER-added columns). const WG_TUNNEL_COLUMNS: &str = "id, name, interface, private_key, public_key, \ listen_port, address, dns, mtu, status, created_at, updated_at, \ - listen_interface, split_routes"; + listen_interface, split_routes, address6"; #[derive(sqlx::FromRow)] struct WgTunnelRow { @@ -897,6 +1058,8 @@ struct WgTunnelRow { listen_interface: Option, #[sqlx(default)] split_routes: Option, + #[sqlx(default)] + address6: Option, status: String, created_at: String, updated_at: String, @@ -912,6 +1075,7 @@ impl WgTunnelRow { public_key: self.public_key, listen_port: self.listen_port as u16, address: Address::parse(&self.address)?, + address6: self.address6.as_deref().map(Address::parse).transpose()?, dns: self.dns, mtu: self.mtu.map(|m| m as u16), listen_interface: self.listen_interface, diff --git a/aifw-daemon/src/main.rs b/aifw-daemon/src/main.rs index 07f5bfbe..17ef763d 100644 --- a/aifw-daemon/src/main.rs +++ b/aifw-daemon/src/main.rs @@ -77,7 +77,10 @@ async fn main() -> anyhow::Result<()> { let pf: Arc = Arc::from(aifw_pf::create_backend()); let engine = Arc::new(RuleEngine::new(pool.clone(), pf.clone()).with_anchor(args.anchor.clone())); - let nat_engine = NatEngine::new(pool.clone(), pf.clone()); + // Same anchor as the API's engine ("aifw-nat", not the default "aifw"): + // NAT loads replace every rule class in their anchor since #531, so a + // boot-time apply into "aifw" would wipe the filter rules loaded above. + let nat_engine = NatEngine::new(pool.clone(), pf.clone()).with_anchor("aifw-nat".to_string()); let alias_engine = AliasEngine::new(pool.clone(), pf.clone()); // Check pf status @@ -536,21 +539,10 @@ async fn reconcile_pf_main() { } let has_hooks = stdout.contains("aifw-nat") || stdout.contains("anchor \"aifw\""); - if has_hooks { - info!("pf drift check: main ruleset has aifw anchor hooks"); - return; - } - - let auto_heal_disabled = std::env::var("AIFW_NO_PF_AUTO_HEAL") - .map(|v| { - !v.is_empty() - && v != "0" - && !v.eq_ignore_ascii_case("no") - && !v.eq_ignore_ascii_case("false") - }) - .unwrap_or(false); + let auto_heal_disabled = + pf_auto_heal_disabled(std::env::var("AIFW_NO_PF_AUTO_HEAL").ok().as_deref()); - if auto_heal_disabled { + if !has_hooks && auto_heal_disabled { error!( "pf drift check: main ruleset is MISSING aifw anchor hooks — \ auto-heal disabled via AIFW_NO_PF_AUTO_HEAL; run `aifw reconcile` to reload from {PF_CONF}" @@ -558,30 +550,99 @@ async fn reconcile_pf_main() { return; } - error!( - "pf drift check: main ruleset is MISSING aifw anchor hooks — auto-healing by reloading {PF_CONF}" - ); - let reload = tokio::process::Command::new("/usr/local/bin/sudo") - .args(["/sbin/pfctl", "-f", PF_CONF]) + if has_hooks { + info!("pf drift check: main ruleset has aifw anchor hooks"); + } else { + error!( + "pf drift check: main ruleset is MISSING aifw anchor hooks — auto-healing by reloading {PF_CONF}" + ); + let reload = tokio::process::Command::new("/usr/local/bin/sudo") + .args(["/sbin/pfctl", "-f", PF_CONF]) + .output() + .await; + match reload { + Ok(o) if o.status.success() => { + info!("pf drift check: auto-heal succeeded — {PF_CONF} loaded into main ruleset"); + } + Ok(o) => { + error!( + "pf drift check: auto-heal FAILED (status {}): {}", + o.status, + String::from_utf8_lossy(&o.stderr).trim() + ); + return; + } + Err(e) => { + error!("pf drift check: auto-heal could not spawn pfctl: {e}"); + return; + } + } + } + + // A valid ruleset can still be present while packet filtering itself is + // disabled. In that state `pfctl -sn` succeeds and the anchor check above + // looks healthy, but traffic bypasses every rule. Only enable pf after the + // ruleset is known to be valid, and preserve the detect-only opt-out. + let status = tokio::process::Command::new("/usr/local/bin/sudo") + .args(["/sbin/pfctl", "-si"]) .output() .await; - match reload { - Ok(o) if o.status.success() => { - info!("pf drift check: auto-heal succeeded — {PF_CONF} loaded into main ruleset"); + match status { + Ok(o) if o.status.success() && pf_status_is_disabled(&o.stdout) => { + if auto_heal_disabled { + error!( + "pf drift check: packet filter is DISABLED — auto-heal disabled via \ + AIFW_NO_PF_AUTO_HEAL; run `pfctl -e` to enable it" + ); + return; + } + + let enable = tokio::process::Command::new("/usr/local/bin/sudo") + .args(["/sbin/pfctl", "-e"]) + .output() + .await; + match enable { + Ok(o) if o.status.success() => { + info!("pf drift check: packet filter was disabled; enabled it") + } + Ok(o) => error!( + "pf drift check: pfctl -e FAILED (status {}): {}", + o.status, + String::from_utf8_lossy(&o.stderr).trim() + ), + Err(e) => error!("pf drift check: could not spawn pfctl -e: {e}"), + } } Ok(o) => { - error!( - "pf drift check: auto-heal FAILED (status {}): {}", - o.status, - String::from_utf8_lossy(&o.stderr).trim() - ); - } - Err(e) => { - error!("pf drift check: auto-heal could not spawn pfctl: {e}"); + if !o.status.success() { + info!( + "pf drift check: pfctl -si failed (status {}): {}", + o.status, + String::from_utf8_lossy(&o.stderr).trim() + ); + } } + Err(e) => info!("pf drift check: could not inspect pf status: {e}"), } } +fn pf_auto_heal_disabled(value: Option<&str>) -> bool { + value + .map(|v| { + !v.is_empty() + && v != "0" + && !v.eq_ignore_ascii_case("no") + && !v.eq_ignore_ascii_case("false") + }) + .unwrap_or(false) +} + +fn pf_status_is_disabled(stdout: &[u8]) -> bool { + String::from_utf8_lossy(stdout) + .lines() + .any(|line| line.trim_start().starts_with("Status: Disabled")) +} + /// Boot-time drift detection for rc.conf vs DB DNS backend. /// Logs an error on mismatch. Does NOT auto-write rc.conf — too risky to /// force-flip enable flags on boot without operator oversight. @@ -661,3 +722,31 @@ async fn shutdown_signal() { #[cfg(not(unix))] ctrl_c.await; } + +#[cfg(test)] +mod pf_reconcile_tests { + use super::{pf_auto_heal_disabled, pf_status_is_disabled}; + + #[test] + fn auto_heal_opt_out_parses_false_values() { + for value in [None, Some(""), Some("0"), Some("no"), Some("FALSE")] { + assert!( + !pf_auto_heal_disabled(value), + "unexpected opt-out: {value:?}" + ); + } + assert!(pf_auto_heal_disabled(Some("1"))); + assert!(pf_auto_heal_disabled(Some("yes"))); + } + + #[test] + fn disabled_status_is_detected_without_matching_enabled_status() { + assert!(pf_status_is_disabled( + b"Status: Disabled for 0 days 00:01:00\nDebug: Urgent\n" + )); + assert!(pf_status_is_disabled(b" Status: Disabled\n")); + assert!(!pf_status_is_disabled( + b"Status: Enabled for 0 days 00:01:00\n" + )); + } +} diff --git a/aifw-ids-bin/src/main.rs b/aifw-ids-bin/src/main.rs index 90afcc00..c14aaf4c 100644 --- a/aifw-ids-bin/src/main.rs +++ b/aifw-ids-bin/src/main.rs @@ -99,6 +99,12 @@ async fn main() -> anyhow::Result<()> { .map_err(|e| anyhow::anyhow!("init engine: {e}"))?, ); + // Alert retention runs regardless of engine mode (#601): pruning and + // the clock-skew scrub are hygiene for rows already in the DB, and + // gating them behind the mode check left disabled-IDS appliances with + // multi-million-row ids_alerts tables that never shrink. + engine.spawn_retention_worker(); + // Compile and start if mode != Disabled. if let Ok(cfg) = engine.load_config().await && cfg.mode != aifw_common::ids::IdsMode::Disabled diff --git a/aifw-ids/src/capture/mod.rs b/aifw-ids/src/capture/mod.rs index 6eb3ce9b..0c308216 100644 --- a/aifw-ids/src/capture/mod.rs +++ b/aifw-ids/src/capture/mod.rs @@ -8,8 +8,10 @@ pub mod factory; pub mod pcap; pub mod types; +/// BPF-based packet capture (FreeBSD /dev/bpf). #[cfg(target_os = "freebsd")] pub mod bpf; +/// netmap-based high-rate capture (FreeBSD, experimental — #484). #[cfg(target_os = "freebsd")] pub mod netmap; diff --git a/aifw-ids/src/config.rs b/aifw-ids/src/config.rs index 68accbe7..b296f30e 100644 --- a/aifw-ids/src/config.rs +++ b/aifw-ids/src/config.rs @@ -142,7 +142,7 @@ impl RuntimeConfig { cfg.interfaces = serde_json::from_str(&value).unwrap_or_default(); } "alert_retention_days" => { - cfg.alert_retention_days = value.parse().unwrap_or(30); + cfg.alert_retention_days = value.parse().unwrap_or(7); } "eve_log_enabled" => { cfg.eve_log_enabled = value == "true" || value == "1"; @@ -314,12 +314,14 @@ mod tests { let mut cfg = (*config.config()).clone(); cfg.mode = IdsMode::Ids; - cfg.alert_retention_days = 7; + // Non-default value (default is 7) so the round-trip proves the + // saved value is read back, not the default. + cfg.alert_retention_days = 3; config.save_to_db(&pool, &cfg).await.unwrap(); let loaded = config.load_from_db(&pool).await.unwrap(); assert_eq!(loaded.mode, IdsMode::Ids); - assert_eq!(loaded.alert_retention_days, 7); + assert_eq!(loaded.alert_retention_days, 3); } #[tokio::test] diff --git a/aifw-ids/src/lib.rs b/aifw-ids/src/lib.rs index cf17700f..0af113c9 100644 --- a/aifw-ids/src/lib.rs +++ b/aifw-ids/src/lib.rs @@ -472,59 +472,84 @@ impl IdsEngine { info!(interface = %iface_name, "capture worker started"); } - // Retention worker — periodically prunes alerts past - // `alert_retention_days` and scrubs rows with implausible timestamps - // left by past clock-skew events. Without this, `ids_alerts` grows - // unbounded (the test VM hit 2.97M rows / 2.9GB before this landed). + info!("IDS engine started"); + Ok(()) + } + + /// Spawn the alert-retention worker: an initial scrub + purge at boot, + /// then an hourly prune of alerts past `alert_retention_days` plus a + /// scrub of rows with implausible timestamps left by past clock-skew + /// events (year-9920 rows defeat age-based pruning forever). + /// + /// Called by the binary UNCONDITIONALLY — not from `start()` — because + /// data hygiene must not depend on the engine mode (#601): an appliance + /// with IDS disabled previously never pruned, leaving a 2.97M-row + /// `ids_alerts` table that slowed every dashboard query. The worker + /// deliberately ignores the engine `running` flag: it owns no capture + /// resources and dies with the process. + pub fn spawn_retention_worker(&self) { + // Auto-vacuum threshold: once this many rows have been purged since + // the last space reclaim, run VACUUM + WAL truncate. Deleted rows + // only hit SQLite's freelist otherwise, and the file never shrinks + // (#601: 2.9GB file, 34 live rows). + const VACUUM_AFTER_PURGED_ROWS: u64 = 100_000; + let retention_pool = self.pool.clone(); let retention_config = self.config.clone(); - let retention_running = self.running.clone(); tokio::spawn(async move { let output = crate::output::sqlite::SqliteOutput::new(retention_pool); + let mut purged_since_vacuum: u64 = 0; // Initial scrub on startup so a stale appliance cleans itself up // without waiting an hour. match output.purge_invalid_timestamps().await { Ok(0) => {} - Ok(n) => warn!( - rows = n, - "ids retention: scrubbed alerts with implausible timestamps" - ), + Ok(n) => { + purged_since_vacuum += n; + warn!( + rows = n, + "ids retention: scrubbed alerts with implausible timestamps" + ) + } Err(e) => error!("ids retention: invalid-timestamp scrub failed: {e}"), } let initial_days = retention_config.config().alert_retention_days; match output.purge_old(initial_days).await { Ok(0) => {} - Ok(n) => info!( - rows = n, - days = initial_days, - "ids retention: initial purge complete" - ), + Ok(n) => { + purged_since_vacuum += n; + info!( + rows = n, + days = initial_days, + "ids retention: initial purge complete" + ) + } Err(e) => error!("ids retention: initial purge failed: {e}"), } + purged_since_vacuum = + maybe_reclaim(&output, purged_since_vacuum, VACUUM_AFTER_PURGED_ROWS).await; let mut interval = tokio::time::interval(std::time::Duration::from_secs(3600)); interval.tick().await; // burn the first immediate tick - while retention_running.load(Ordering::Relaxed) { + loop { interval.tick().await; - if !retention_running.load(Ordering::Relaxed) { - break; - } let days = retention_config.config().alert_retention_days; match output.purge_old(days).await { Ok(0) => {} - Ok(n) => info!(rows = n, days, "ids retention: purged old alerts"), + Ok(n) => { + purged_since_vacuum += n; + info!(rows = n, days, "ids retention: purged old alerts") + } Err(e) => error!("ids retention: purge failed: {e}"), } - if let Err(e) = output.purge_invalid_timestamps().await { - error!("ids retention: invalid-timestamp scrub failed: {e}"); + match output.purge_invalid_timestamps().await { + Ok(n) => purged_since_vacuum += n, + Err(e) => error!("ids retention: invalid-timestamp scrub failed: {e}"), } + purged_since_vacuum = + maybe_reclaim(&output, purged_since_vacuum, VACUUM_AFTER_PURGED_ROWS).await; } - info!("ids retention worker stopped"); }); - - info!("IDS engine started"); - Ok(()) } /// Stop the IDS engine gracefully. @@ -752,6 +777,29 @@ async fn detect_network_interfaces() -> Vec { /// Capture worker — uses BPF on FreeBSD, pcap mock on Linux. /// Reads raw packets directly from the kernel with zero shell overhead. +/// Run the space reclaim (VACUUM + WAL truncate) once `purged` crosses +/// `threshold`; returns the new purged-since-vacuum counter. On reclaim +/// failure the counter is kept so the next sweep retries. +async fn maybe_reclaim( + output: &crate::output::sqlite::SqliteOutput, + purged: u64, + threshold: u64, +) -> u64 { + if purged < threshold { + return purged; + } + match output.reclaim_space().await { + Ok(()) => { + info!(purged_rows = purged, "ids retention: reclaimed disk space"); + 0 + } + Err(e) => { + warn!("ids retention: space reclaim failed (will retry next sweep): {e}"); + purged + } + } +} + fn capture_interface_worker( iface: &str, detection: &std::sync::Arc, diff --git a/aifw-ids/src/output/sqlite.rs b/aifw-ids/src/output/sqlite.rs index 0ad15472..5858b4cd 100644 --- a/aifw-ids/src/output/sqlite.rs +++ b/aifw-ids/src/output/sqlite.rs @@ -259,6 +259,20 @@ impl SqliteOutput { Ok(result.rows_affected()) } + /// Reclaim disk space after mass deletions. A bare DELETE only frees + /// pages to SQLite's freelist — an appliance in the field sat at a + /// 2.9GB DB file holding 34 alert rows (#601). VACUUM rewrites the + /// file; the TRUNCATE checkpoint shrinks the WAL that the rewrite + /// itself streams through (without it the gigabytes just move into + /// aifw.db-wal). + pub async fn reclaim_space(&self) -> Result<()> { + sqlx::query("VACUUM").execute(&self.pool).await?; + sqlx::query("PRAGMA wal_checkpoint(TRUNCATE)") + .fetch_all(&self.pool) + .await?; + Ok(()) + } + /// Scrub rows whose `timestamp` column is implausible — system-clock skew /// at insert time has left rows dated year 9920 or 2012 on appliances in /// the field. The companion guard at insert time (`sanitize_alert_timestamp`) diff --git a/aifw-pf/src/backend.rs b/aifw-pf/src/backend.rs index 2ebc7745..03e87c8e 100644 --- a/aifw-pf/src/backend.rs +++ b/aifw-pf/src/backend.rs @@ -10,9 +10,32 @@ use std::net::IpAddr; /// chosen at compile time via `#[cfg(target_os)]` in [`crate::create_backend`]. #[async_trait] pub trait PfBackend: Send + Sync { + /// Downcast support so tests can reach implementation-specific helpers + /// (e.g. [`crate::PfMock`] failure injection) through `Arc`. + fn as_any(&self) -> &dyn std::any::Any; + + /// Whether `get_rules`/`get_nat_rules` return the exact strings that were + /// loaded. True for the in-memory mock; false for the pfctl backend, + /// which lists rules in pfctl's canonical re-rendered form (normalized + /// keywords, possible expansion), so string equality against the loaded + /// source is not meaningful there. Verification code uses this to pick + /// between exact comparison and weaker invariants. + fn echoes_exact_rules(&self) -> bool { + false + } + /// Add a pf rule to the specified anchor async fn add_rule(&self, anchor: &str, rule: &str) -> Result<(), crate::PfError>; + /// Dry-run parse a prospective ruleset against the real pf parser + /// (`pfctl -n`) without applying it. Backends without a real parser + /// (mock) accept everything — engines call this as a best-effort gate + /// before persisting rules whose syntax only real pfctl can judge + /// (e.g. af-to cross-family translation, #531). + async fn validate_rules(&self, _anchor: &str, _rules: &[String]) -> Result<(), crate::PfError> { + Ok(()) + } + /// Remove all rules from the specified anchor async fn flush_rules(&self, anchor: &str) -> Result<(), crate::PfError>; diff --git a/aifw-pf/src/ioctl.rs b/aifw-pf/src/ioctl.rs index 24dd6520..40c211f1 100644 --- a/aifw-pf/src/ioctl.rs +++ b/aifw-pf/src/ioctl.rs @@ -96,13 +96,34 @@ async fn pfctl_stdin(args: &[&str], input: &str) -> Result &dyn std::any::Any { + self + } + async fn add_rule(&self, anchor: &str, rule: &str) -> Result<(), PfError> { let output = pfctl_stdin(&["-a", anchor, "-f", "-"], rule).await?; if !output.status.success() { + // Strict: pfctl rejects rules with messages that don't contain + // "syntax error" (e.g. af-to family mismatches report "no + // translation address with matching address family found"), so + // any non-zero exit is a load failure (#531). let stderr = String::from_utf8_lossy(&output.stderr); - if stderr.contains("syntax error") { - return Err(PfError::Rule(stderr.to_string())); - } + return Err(PfError::Rule(format!("rule error: {stderr}"))); + } + Ok(()) + } + + async fn validate_rules(&self, anchor: &str, rules: &[String]) -> Result<(), PfError> { + if rules.is_empty() { + return Ok(()); + } + let ruleset = rules.join("\n"); + // `pfctl -n` parses the full ruleset without committing anything — + // the real-parser gate for rules only pfctl can judge (#531). + let output = pfctl_stdin(&["-a", anchor, "-n", "-f", "-"], &ruleset).await?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(PfError::Rule(format!("pf rejected ruleset: {stderr}"))); } Ok(()) } @@ -427,8 +448,13 @@ impl PfBackend for PfIoctl { let ruleset = rules.join("\n"); tracing::debug!(anchor, rules = %ruleset, "loading pf NAT rules"); - // SEC-H5: pipe via stdin (`pfctl -N -f -`) — no /tmp staging. - let output = pfctl_stdin(&["-a", anchor, "-N", "-f", "-"], &ruleset).await?; + // Plain `-f` (not `-N`): the NAT engine's ruleset can mix nat-class + // rules with filter-class `af-to` pass rules (#531), and `-N` would + // silently drop the latter. A plain load replaces every rule class + // in the anchor atomically (pfctl parses the whole set before + // committing, so a rejected ruleset leaves the old one intact). + // SEC-H5: pipe via stdin — no /tmp staging. + let output = pfctl_stdin(&["-a", anchor, "-f", "-"], &ruleset).await?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); @@ -447,7 +473,17 @@ impl PfBackend for PfIoctl { } async fn flush_nat_rules(&self, anchor: &str) -> Result<(), PfError> { - pfctl(&["-a", anchor, "-Fn"]).await?; + // The NAT anchor holds nat-class rules AND filter-class af-to pass + // rules (#531). Loading an empty ruleset replaces every class in + // one pf transaction — atomic, unlike sequential -Fn + -Fr which + // could leave orphaned af-to rules if the second flush failed + // (verified against real pfctl on FreeBSD 15.0). Uses the same + // sudoers grant as load_nat_rules. + let output = pfctl_stdin(&["-a", anchor, "-f", "-"], "").await?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(PfError::Rule(format!("NAT flush error: {stderr}"))); + } Ok(()) } diff --git a/aifw-pf/src/mock.rs b/aifw-pf/src/mock.rs index 0c7ec7ec..526e90de 100644 --- a/aifw-pf/src/mock.rs +++ b/aifw-pf/src/mock.rs @@ -18,6 +18,7 @@ pub struct PfMock { running: RwLock, iface_fibs: RwLock>, fib_count: RwLock, + armed_failures: RwLock>, } impl PfMock { @@ -32,9 +33,29 @@ impl PfMock { running: RwLock::new(true), iface_fibs: RwLock::new(HashMap::new()), fib_count: RwLock::new(1), + armed_failures: RwLock::new(std::collections::HashSet::new()), } } + /// Arm a persistent injected failure for the named backend operation + /// (e.g. `"load_rules"`); every call to that op fails until + /// [`Self::clear_fail`]. Test helper for #535 failure-injection suites. + pub async fn fail_op(&self, op: &str) { + self.armed_failures.write().await.insert(op.to_string()); + } + + /// Disarm an injected failure set by [`Self::fail_op`]. + pub async fn clear_fail(&self, op: &str) { + self.armed_failures.write().await.remove(op); + } + + async fn check_fail(&self, op: &str) -> Result<(), PfError> { + if self.armed_failures.read().await.contains(op) { + return Err(PfError::Other(format!("injected failure: {op}"))); + } + Ok(()) + } + /// Override the number of available FIBs for testing multi-WAN scenarios. pub async fn set_fib_count(&self, n: u32) { *self.fib_count.write().await = n.max(1); @@ -59,7 +80,16 @@ impl Default for PfMock { #[async_trait] impl PfBackend for PfMock { + fn as_any(&self) -> &dyn std::any::Any { + self + } + + fn echoes_exact_rules(&self) -> bool { + true + } + async fn add_rule(&self, anchor: &str, rule: &str) -> Result<(), PfError> { + self.check_fail("add_rule").await?; tracing::debug!(anchor, rule, "mock: add_rule"); let mut rules = self.rules.write().await; rules @@ -70,6 +100,7 @@ impl PfBackend for PfMock { } async fn flush_rules(&self, anchor: &str) -> Result<(), PfError> { + self.check_fail("flush_rules").await?; tracing::debug!(anchor, "mock: flush_rules"); let mut rules = self.rules.write().await; rules.remove(anchor); @@ -77,6 +108,7 @@ impl PfBackend for PfMock { } async fn load_rules(&self, anchor: &str, new_rules: &[String]) -> Result<(), PfError> { + self.check_fail("load_rules").await?; tracing::debug!(anchor, count = new_rules.len(), "mock: load_rules"); let mut rules = self.rules.write().await; rules.insert(anchor.to_string(), new_rules.to_vec()); @@ -84,6 +116,7 @@ impl PfBackend for PfMock { } async fn get_rules(&self, anchor: &str) -> Result, PfError> { + self.check_fail("get_rules").await?; let rules = self.rules.read().await; Ok(rules.get(anchor).cloned().unwrap_or_default()) } @@ -105,6 +138,7 @@ impl PfBackend for PfMock { } async fn add_table_entry(&self, table: &str, addr: IpAddr) -> Result<(), PfError> { + self.check_fail("add_table_entry").await?; tracing::debug!(%addr, table, "mock: add_table_entry"); let mut tables = self.tables.write().await; let entries = tables.entry(table.to_string()).or_default(); @@ -119,6 +153,7 @@ impl PfBackend for PfMock { table: &str, entries: &[(IpAddr, u8)], ) -> Result<(), PfError> { + self.check_fail("replace_table_entries").await?; tracing::debug!(table, count = entries.len(), "mock: replace_table_entries"); let mut tables = self.tables.write().await; // Mock only tracks bare addresses, not prefixes — match the existing @@ -131,6 +166,7 @@ impl PfBackend for PfMock { } async fn remove_table_entry(&self, table: &str, addr: IpAddr) -> Result<(), PfError> { + self.check_fail("remove_table_entry").await?; tracing::debug!(%addr, table, "mock: remove_table_entry"); let mut tables = self.tables.write().await; if let Some(entries) = tables.get_mut(table) { @@ -140,6 +176,7 @@ impl PfBackend for PfMock { } async fn flush_table(&self, table: &str) -> Result<(), PfError> { + self.check_fail("flush_table").await?; tracing::debug!(table, "mock: flush_table"); let mut tables = self.tables.write().await; tables.remove(table); @@ -165,25 +202,46 @@ impl PfBackend for PfMock { } async fn load_nat_rules(&self, anchor: &str, rules: &[String]) -> Result<(), PfError> { + self.check_fail("load_nat_rules").await?; tracing::debug!(anchor, count = rules.len(), "mock: load_nat_rules"); - let mut nat_rules = self.nat_rules.write().await; - nat_rules.insert(anchor.to_string(), rules.to_vec()); + // Mirror real pf semantics (#531): a plain `-f` load replaces every + // rule class in the anchor. nat-class lines land in the nat ruleset + // (`-sn`), filter-class lines (af-to pass rules) in the filter + // ruleset (`-sr`). + let (nat_class, filter_class): (Vec, Vec) = + rules.iter().cloned().partition(|r| { + ["nat ", "rdr ", "binat ", "nat-anchor", "rdr-anchor"] + .iter() + .any(|p| r.starts_with(p)) + }); + self.nat_rules + .write() + .await + .insert(anchor.to_string(), nat_class); + self.rules + .write() + .await + .insert(anchor.to_string(), filter_class); Ok(()) } async fn get_nat_rules(&self, anchor: &str) -> Result, PfError> { + self.check_fail("get_nat_rules").await?; let nat_rules = self.nat_rules.read().await; Ok(nat_rules.get(anchor).cloned().unwrap_or_default()) } async fn flush_nat_rules(&self, anchor: &str) -> Result<(), PfError> { + self.check_fail("flush_nat_rules").await?; tracing::debug!(anchor, "mock: flush_nat_rules"); - let mut nat_rules = self.nat_rules.write().await; - nat_rules.remove(anchor); + // Both classes, matching PfIoctl (-Fn + -Fr) — see load_nat_rules. + self.nat_rules.write().await.remove(anchor); + self.rules.write().await.remove(anchor); Ok(()) } async fn load_queues(&self, anchor: &str, queue_defs: &[String]) -> Result<(), PfError> { + self.check_fail("load_queues").await?; tracing::debug!(anchor, count = queue_defs.len(), "mock: load_queues"); let mut queues = self.queues.write().await; queues.insert(anchor.to_string(), queue_defs.to_vec()); @@ -196,6 +254,7 @@ impl PfBackend for PfMock { } async fn flush_queues(&self, anchor: &str) -> Result<(), PfError> { + self.check_fail("flush_queues").await?; tracing::debug!(anchor, "mock: flush_queues"); let mut queues = self.queues.write().await; queues.remove(anchor); diff --git a/aifw-setup/src/apply.rs b/aifw-setup/src/apply.rs index 7e1db714..9e7427f8 100644 --- a/aifw-setup/src/apply.rs +++ b/aifw-setup/src/apply.rs @@ -273,7 +273,6 @@ pub async fn apply(config: &SetupConfig, tuning_items: &[TuningItem]) -> Result< // 8. Configure network interfaces in rc.conf #[cfg(target_os = "freebsd")] { - use std::process::Command; console::info("Configuring network interfaces..."); // WAN interface @@ -319,7 +318,6 @@ pub async fn apply(config: &SetupConfig, tuning_items: &[TuningItem]) -> Result< // 9. Start services #[cfg(target_os = "freebsd")] { - use std::process::Command; console::info("Starting services..."); // Anchor population is handled at runtime by aifw-daemon reading from @@ -443,9 +441,10 @@ pub async fn apply(config: &SetupConfig, tuning_items: &[TuningItem]) -> Result< fn create_service_user() -> Result<(), String> { #[cfg(target_os = "freebsd")] { - use std::process::Command; // Check if user already exists - let status = Command::new("pw").args(["usershow", "aifw"]).output(); + let status = std::process::Command::new("pw") + .args(["usershow", "aifw"]) + .output(); if let Ok(out) = status { if out.status.success() { return Ok(()); // user exists @@ -454,7 +453,7 @@ fn create_service_user() -> Result<(), String> { // Create group run_best_effort("pw", &["groupadd", "aifw", "-g", "470"]); // Create user: no login shell, no home, system account - let out = Command::new("pw") + let out = std::process::Command::new("pw") .args([ "useradd", "aifw", @@ -486,8 +485,6 @@ fn create_service_user() -> Result<(), String> { fn configure_devfs() -> Result<(), String> { #[cfg(target_os = "freebsd")] { - use std::process::Command; - // Write rules directly to /etc/devfs.rules (the canonical location) let devfs_rules_path = "/etc/devfs.rules"; let existing = std::fs::read_to_string(devfs_rules_path).unwrap_or_default(); @@ -655,7 +652,6 @@ fn create_dirs(config: &SetupConfig) -> Result<(), String> { // Set ownership: config dir readable by aifw, db/log owned by aifw #[cfg(target_os = "freebsd")] { - use std::process::Command; // Config dir: root owns, aifw group can read run_best_effort("chown", &["root:aifw", &config.config_dir]); run_best_effort("chmod", &["750", &config.config_dir]); @@ -1818,6 +1814,16 @@ pub fn generate_pf_conf(config: &SetupConfig) -> String { lines.push(String::new()); } + // ICMPv6 neighbor discovery — unlike ARP (layer 2, unfiltered), ND is + // IPv6 traffic pf filters, and the default block policy would eat + // neighbor solicitations, breaking ALL IPv6 (incl. NAT64, #531). + lines.push("# ICMPv6 neighbor discovery — required for IPv6 to function".to_string()); + lines.push( + "pass quick inet6 proto icmp6 icmp6-type { routersol, routeradv, neighbrsol, neighbradv }" + .to_string(), + ); + lines.push(String::new()); + lines.push("# AiFw filter anchors".to_string()); // Multi-WAN anchors MUST be evaluated first so policy-routing decisions // (route-to / rtable) are set before general filtering. @@ -2491,6 +2497,7 @@ pub fn sudoers_content() -> &'static str { # world-writable /tmp file (SEC-H5); the old `-f /tmp/aifw_pf_*.conf` grants # were removed with that fix. aifw ALL=(root) NOPASSWD: /sbin/pfctl -a aifw* -f - +aifw ALL=(root) NOPASSWD: /sbin/pfctl -a aifw* -n -f - aifw ALL=(root) NOPASSWD: /sbin/pfctl -a aifw* -N -f - aifw ALL=(root) NOPASSWD: /sbin/pfctl -a aifw* -f /usr/local/etc/aifw/anchors/aifw* aifw ALL=(root) NOPASSWD: /sbin/pfctl -a aifw* -sr @@ -2511,6 +2518,7 @@ aifw ALL=(root) NOPASSWD: /sbin/pfctl -sr # gets a password-required error, bails, and never heals -> LAN outage. Do # NOT drop this (it was missing in v5.96.16 and caused a LAN-down regression). aifw ALL=(root) NOPASSWD: /sbin/pfctl -sn +aifw ALL=(root) NOPASSWD: /sbin/pfctl -e aifw ALL=(root) NOPASSWD: /sbin/pfctl -ss aifw ALL=(root) NOPASSWD: /sbin/pfctl -ss -v aifw ALL=(root) NOPASSWD: /sbin/pfctl -ss -vv @@ -2560,6 +2568,7 @@ aifw ALL=(root) NOPASSWD: /usr/local/libexec/aifw-sudo-cp * aifw ALL=(root) NOPASSWD: /usr/local/libexec/aifw-sudo-tar * aifw ALL=(root) NOPASSWD: /usr/local/libexec/aifw-sudo-tcpdump * aifw ALL=(root) NOPASSWD: /usr/local/libexec/aifw-sudo-swanctl * +aifw ALL=(root) NOPASSWD: /usr/local/libexec/aifw-sudo-dummynet # --- Broad compat grants --- # Kept alongside the narrow helpers above for upgrade compat: in-place @@ -2615,6 +2624,27 @@ pub mod tests_support { } } +#[cfg(test)] +mod pf_conf_tests { + /// Guard (#531): the generated pf.conf must pass ICMPv6 neighbor + /// discovery before the anchors/default policy. ND is filterable IPv6 + /// traffic (unlike ARP) — without this rule the default block policy + /// silently kills all IPv6, including NAT64. + #[test] + fn icmp6_neighbor_discovery_passes_before_anchors() { + let conf = super::tests_support::test_pf_conf(); + let nd = conf + .find("proto icmp6 icmp6-type { routersol, routeradv, neighbrsol, neighbradv }") + .expect("ICMPv6 ND pass rule missing from generated pf.conf"); + // Leading newline — a bare substring search would match the earlier + // `nat-anchor "aifw"` line instead of the filter anchor. + let anchors = conf + .find("\nanchor \"aifw\"") + .expect("filter anchors missing"); + assert!(nd < anchors, "ND pass must precede the filter anchors"); + } +} + #[cfg(test)] mod sudoers_tests { use super::sudoers_content; @@ -2673,9 +2703,14 @@ mod sudoers_tests { ("add_rule (stdin)", "/sbin/pfctl -a aifw* -f -"), ("load_queues (stdin)", "/sbin/pfctl -a aifw* -f -"), ("load_nat_rules (stdin)", "/sbin/pfctl -a aifw* -N -f -"), + ( + "validate_rules (stdin dry-run)", + "/sbin/pfctl -a aifw* -n -f -", + ), ("get_rules", "/sbin/pfctl -a aifw* -sr"), ("get_nat_rules", "/sbin/pfctl -a aifw* -sn"), ("daemon pf drift auto-heal (global -sn)", "/sbin/pfctl -sn"), + ("daemon pf re-enable after boot", "/sbin/pfctl -e"), ("get_queues", "/sbin/pfctl -a aifw* -sq"), ("flush_rules", "/sbin/pfctl -a aifw* -Fr"), ("flush_nat_rules", "/sbin/pfctl -a aifw* -Fn"), @@ -2787,6 +2822,7 @@ mod sudoers_tests { "/usr/local/libexec/aifw-sudo-tar", "/usr/local/libexec/aifw-sudo-tcpdump", "/usr/local/libexec/aifw-sudo-swanctl", + "/usr/local/libexec/aifw-sudo-dummynet", ] { assert!( content.contains(helper), diff --git a/aifw-setup/src/config.rs b/aifw-setup/src/config.rs index bb0b8adb..656f9081 100644 --- a/aifw-setup/src/config.rs +++ b/aifw-setup/src/config.rs @@ -108,10 +108,8 @@ impl SetupConfig { if self.wan_interface.trim().is_empty() { return Err("wan_interface must not be empty".to_string()); } - if matches!(self.wan_mode, WanMode::Static) - && (self.wan_ip.is_none() || self.wan_gateway.is_none()) - { - return Err("static wan_mode requires wan_ip and wan_gateway".to_string()); + if matches!(self.wan_mode, WanMode::Static) && self.wan_ip.is_none() { + return Err("static wan_mode requires wan_ip".to_string()); } if self.lan_ip.is_some() && self.lan_interface.is_none() { return Err("lan_ip requires lan_interface".to_string()); diff --git a/aifw-setup/src/tests.rs b/aifw-setup/src/tests.rs index 25bc965d..cd157f91 100644 --- a/aifw-setup/src/tests.rs +++ b/aifw-setup/src/tests.rs @@ -527,7 +527,13 @@ mod let_underscore_tests { }; let mut c = mk(); c.wan_mode = crate::config::WanMode::Static; - assert!(c.validate_seed().is_err(), "static without ip/gateway"); + assert!(c.validate_seed().is_err(), "static without ip"); + + c.wan_ip = Some("198.18.0.1/24".to_string()); + assert!( + c.validate_seed().is_ok(), + "static WAN without a default gateway is valid" + ); let mut c = mk(); c.lan_ip = Some("192.168.1.1/24".to_string()); diff --git a/aifw-tui/src/app.rs b/aifw-tui/src/app.rs index 97310f15..28addad3 100644 --- a/aifw-tui/src/app.rs +++ b/aifw-tui/src/app.rs @@ -89,7 +89,9 @@ impl App { let pf: Arc = Arc::from(aifw_pf::create_backend()); let rule_engine = Arc::new(RuleEngine::new(pool.clone(), pf.clone())); - let nat_engine = Arc::new(NatEngine::new(pool.clone(), pf.clone())); + // "aifw-nat" — must match the API/daemon anchor (#531). + let nat_engine = + Arc::new(NatEngine::new(pool.clone(), pf.clone()).with_anchor("aifw-nat".to_string())); nat_engine.migrate().await?; let shaping_engine = Arc::new(ShapingEngine::new(pool.clone(), pf.clone())); shaping_engine.migrate().await?; diff --git a/aifw-ui/package-lock.json b/aifw-ui/package-lock.json index 7ee787a4..f9777089 100644 --- a/aifw-ui/package-lock.json +++ b/aifw-ui/package-lock.json @@ -1,12 +1,12 @@ { "name": "aifw-ui", - "version": "5.109.1", + "version": "5.111.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "aifw-ui", - "version": "5.109.1", + "version": "5.111.0", "dependencies": { "@tanstack/react-virtual": "^3.14.6", "lucide-react": "^1.20.0", @@ -533,9 +533,9 @@ } }, "node_modules/@img/sharp-darwin-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", - "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.3.tgz", + "integrity": "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==", "cpu": [ "arm64" ], @@ -545,19 +545,19 @@ "darwin" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.2.4" + "@img/sharp-libvips-darwin-arm64": "1.3.2" } }, "node_modules/@img/sharp-darwin-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", - "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.3.tgz", + "integrity": "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==", "cpu": [ "x64" ], @@ -567,19 +567,38 @@ "darwin" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.2.4" + "@img/sharp-libvips-darwin-x64": "1.3.2" + } + }, + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.3.tgz", + "integrity": "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==", + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "dependencies": { + "@img/sharp-wasm32": "0.35.3" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", - "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.2.tgz", + "integrity": "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==", "cpu": [ "arm64" ], @@ -593,9 +612,9 @@ } }, "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", - "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.2.tgz", + "integrity": "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==", "cpu": [ "x64" ], @@ -609,9 +628,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", - "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.2.tgz", + "integrity": "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==", "cpu": [ "arm" ], @@ -628,9 +647,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", - "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.2.tgz", + "integrity": "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==", "cpu": [ "arm64" ], @@ -647,9 +666,9 @@ } }, "node_modules/@img/sharp-libvips-linux-ppc64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", - "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.2.tgz", + "integrity": "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==", "cpu": [ "ppc64" ], @@ -666,9 +685,9 @@ } }, "node_modules/@img/sharp-libvips-linux-riscv64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", - "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.2.tgz", + "integrity": "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==", "cpu": [ "riscv64" ], @@ -685,9 +704,9 @@ } }, "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", - "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.2.tgz", + "integrity": "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==", "cpu": [ "s390x" ], @@ -704,9 +723,9 @@ } }, "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", - "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.2.tgz", + "integrity": "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==", "cpu": [ "x64" ], @@ -723,9 +742,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", - "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.2.tgz", + "integrity": "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==", "cpu": [ "arm64" ], @@ -742,9 +761,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", - "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.2.tgz", + "integrity": "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==", "cpu": [ "x64" ], @@ -761,9 +780,9 @@ } }, "node_modules/@img/sharp-linux-arm": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", - "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.3.tgz", + "integrity": "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==", "cpu": [ "arm" ], @@ -776,19 +795,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.2.4" + "@img/sharp-libvips-linux-arm": "1.3.2" } }, "node_modules/@img/sharp-linux-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", - "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.3.tgz", + "integrity": "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==", "cpu": [ "arm64" ], @@ -801,19 +820,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.2.4" + "@img/sharp-libvips-linux-arm64": "1.3.2" } }, "node_modules/@img/sharp-linux-ppc64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", - "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.3.tgz", + "integrity": "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==", "cpu": [ "ppc64" ], @@ -826,19 +845,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-ppc64": "1.2.4" + "@img/sharp-libvips-linux-ppc64": "1.3.2" } }, "node_modules/@img/sharp-linux-riscv64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", - "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.3.tgz", + "integrity": "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==", "cpu": [ "riscv64" ], @@ -851,19 +870,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-riscv64": "1.2.4" + "@img/sharp-libvips-linux-riscv64": "1.3.2" } }, "node_modules/@img/sharp-linux-s390x": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", - "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.3.tgz", + "integrity": "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==", "cpu": [ "s390x" ], @@ -876,19 +895,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.2.4" + "@img/sharp-libvips-linux-s390x": "1.3.2" } }, "node_modules/@img/sharp-linux-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", - "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.3.tgz", + "integrity": "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==", "cpu": [ "x64" ], @@ -901,19 +920,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.2.4" + "@img/sharp-libvips-linux-x64": "1.3.2" } }, "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", - "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.3.tgz", + "integrity": "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==", "cpu": [ "arm64" ], @@ -926,19 +945,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + "@img/sharp-libvips-linuxmusl-arm64": "1.3.2" } }, "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", - "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.3.tgz", + "integrity": "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==", "cpu": [ "x64" ], @@ -951,38 +970,54 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + "@img/sharp-libvips-linuxmusl-x64": "1.3.2" } }, "node_modules/@img/sharp-wasm32": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", - "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.3.tgz", + "integrity": "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==", + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.11.1" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.3.tgz", + "integrity": "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==", "cpu": [ "wasm32" ], - "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "license": "Apache-2.0", "optional": true, "dependencies": { - "@emnapi/runtime": "^1.7.0" + "@img/sharp-wasm32": "0.35.3" }, "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-win32-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", - "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.3.tgz", + "integrity": "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==", "cpu": [ "arm64" ], @@ -992,16 +1027,16 @@ "win32" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-win32-ia32": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", - "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.3.tgz", + "integrity": "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==", "cpu": [ "ia32" ], @@ -1011,16 +1046,16 @@ "win32" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": "^20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-win32-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", - "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.3.tgz", + "integrity": "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==", "cpu": [ "x64" ], @@ -1030,7 +1065,7 @@ "win32" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" @@ -1560,6 +1595,72 @@ "node": ">=14.0.0" } }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.11.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.11.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { + "version": "2.8.1", + "dev": true, + "inBundle": true, + "license": "0BSD", + "optional": true + }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { "version": "4.3.3", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", @@ -5934,48 +6035,53 @@ } }, "node_modules/sharp": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", - "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", - "hasInstallScript": true, + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz", + "integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==", "license": "Apache-2.0", "optional": true, "dependencies": { - "@img/colour": "^1.0.0", + "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", - "semver": "^7.7.3" + "semver": "^7.8.5" }, "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.34.5", - "@img/sharp-darwin-x64": "0.34.5", - "@img/sharp-libvips-darwin-arm64": "1.2.4", - "@img/sharp-libvips-darwin-x64": "1.2.4", - "@img/sharp-libvips-linux-arm": "1.2.4", - "@img/sharp-libvips-linux-arm64": "1.2.4", - "@img/sharp-libvips-linux-ppc64": "1.2.4", - "@img/sharp-libvips-linux-riscv64": "1.2.4", - "@img/sharp-libvips-linux-s390x": "1.2.4", - "@img/sharp-libvips-linux-x64": "1.2.4", - "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", - "@img/sharp-libvips-linuxmusl-x64": "1.2.4", - "@img/sharp-linux-arm": "0.34.5", - "@img/sharp-linux-arm64": "0.34.5", - "@img/sharp-linux-ppc64": "0.34.5", - "@img/sharp-linux-riscv64": "0.34.5", - "@img/sharp-linux-s390x": "0.34.5", - "@img/sharp-linux-x64": "0.34.5", - "@img/sharp-linuxmusl-arm64": "0.34.5", - "@img/sharp-linuxmusl-x64": "0.34.5", - "@img/sharp-wasm32": "0.34.5", - "@img/sharp-win32-arm64": "0.34.5", - "@img/sharp-win32-ia32": "0.34.5", - "@img/sharp-win32-x64": "0.34.5" + "@img/sharp-darwin-arm64": "0.35.3", + "@img/sharp-darwin-x64": "0.35.3", + "@img/sharp-freebsd-wasm32": "0.35.3", + "@img/sharp-libvips-darwin-arm64": "1.3.2", + "@img/sharp-libvips-darwin-x64": "1.3.2", + "@img/sharp-libvips-linux-arm": "1.3.2", + "@img/sharp-libvips-linux-arm64": "1.3.2", + "@img/sharp-libvips-linux-ppc64": "1.3.2", + "@img/sharp-libvips-linux-riscv64": "1.3.2", + "@img/sharp-libvips-linux-s390x": "1.3.2", + "@img/sharp-libvips-linux-x64": "1.3.2", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.2", + "@img/sharp-libvips-linuxmusl-x64": "1.3.2", + "@img/sharp-linux-arm": "0.35.3", + "@img/sharp-linux-arm64": "0.35.3", + "@img/sharp-linux-ppc64": "0.35.3", + "@img/sharp-linux-riscv64": "0.35.3", + "@img/sharp-linux-s390x": "0.35.3", + "@img/sharp-linux-x64": "0.35.3", + "@img/sharp-linuxmusl-arm64": "0.35.3", + "@img/sharp-linuxmusl-x64": "0.35.3", + "@img/sharp-webcontainers-wasm32": "0.35.3", + "@img/sharp-win32-arm64": "0.35.3", + "@img/sharp-win32-ia32": "0.35.3", + "@img/sharp-win32-x64": "0.35.3" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, "node_modules/sharp/node_modules/semver": { diff --git a/aifw-ui/package.json b/aifw-ui/package.json index 46305081..eac267ff 100644 --- a/aifw-ui/package.json +++ b/aifw-ui/package.json @@ -1,7 +1,7 @@ { "name": "aifw-ui", - "version": "5.109.4", + "version": "5.111.0", "private": true, "scripts": { "dev": "next dev", @@ -28,6 +28,7 @@ "typescript": "^6.0.3" }, "overrides": { - "postcss": "^8.5.15" + "postcss": "^8.5.15", + "sharp": "^0.35.3" } } diff --git a/aifw-ui/src/app/dns/components/GeneralTab.tsx b/aifw-ui/src/app/dns/components/GeneralTab.tsx index 633060bb..f0481763 100644 --- a/aifw-ui/src/app/dns/components/GeneralTab.tsx +++ b/aifw-ui/src/app/dns/components/GeneralTab.tsx @@ -140,6 +140,25 @@ export function GeneralTab({ config, setConfig, interfaces, isAllInterfaces, tog onToggle={() => setConfig((p) => ({ ...p, dns64: !p.dns64 }))} label="DNS64" /> + {config.dns64 && ( +
+ + setConfig((p) => ({ ...p, dns64_prefix: e.target.value }))} + placeholder="64:ff9b::/96" + className="w-full max-w-xs bg-gray-900 border border-gray-700 rounded-md px-3 py-1.5 text-sm text-white placeholder:text-gray-500 focus:outline-none focus:border-blue-500 transition-colors" + /> +

+ AAAA records are synthesized in this prefix for IPv4-only names. Must match your NAT64 + rule prefix so v6-only clients can reach the translated addresses. +

+ {!/^[0-9a-fA-F:]+\/96$/.test(config.dns64_prefix.trim()) && ( +

Must be an IPv6 prefix with /96 (e.g. 64:ff9b::/96)

+ )} +
+ )} {/* Register DHCP leases */} (null); + const [purgeConfirm, setPurgeConfirm] = useState(false); + const [purging, setPurging] = useState(false); const clearFeedback = useCallback(() => { setTimeout(() => setFeedback(null), 4000); @@ -136,6 +138,27 @@ export default function IdsAlertsPage() { } } + async function handlePurgeAll() { + setPurging(true); + setFeedback(null); + try { + // Deletes every stored alert and reclaims the disk space (VACUUM) — + // millions of stale alerts have bloated appliances to multi-GB DBs. + const res = await api.delete<{ message?: string }>("/api/v1/ids/alerts"); + setFeedback({ type: "success", message: res.message || "All alerts purged" }); + clearFeedback(); + setPage(0); + await fetchAlerts(); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : "Failed to purge alerts"; + setFeedback({ type: "error", message: msg }); + clearFeedback(); + } finally { + setPurging(false); + setPurgeConfirm(false); + } + } + function handleFilterApply() { setPage(0); } @@ -199,9 +222,41 @@ export default function IdsAlertsPage() { > Filter + + {/* Clear-all confirmation */} + {purgeConfirm && ( +
setPurgeConfirm(false)}> +
e.stopPropagation()}> +

Clear all IDS alerts?

+

+ This permanently deletes all {total.toLocaleString()} stored alerts and reclaims the + disk space. It cannot be undone. Retention normally prunes alerts automatically + (IDS → Settings → Alert Retention) — use this for one-off cleanup. +

+
+ + +
+
+
+ )} + {/* Alerts Table */}
diff --git a/aifw-ui/src/app/ids/settings/page.tsx b/aifw-ui/src/app/ids/settings/page.tsx index 724bf6e4..46aa25ca 100644 --- a/aifw-ui/src/app/ids/settings/page.tsx +++ b/aifw-ui/src/app/ids/settings/page.tsx @@ -50,7 +50,7 @@ export default function IdsSettingsPage() { home_net: ["10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"], external_net: ["!$HOME_NET"], interfaces: [], - alert_retention_days: 30, + alert_retention_days: 7, eve_log_enabled: false, eve_log_path: "/var/log/aifw/eve.json", syslog_target: "", @@ -83,7 +83,7 @@ export default function IdsSettingsPage() { home_net: d.home_net || ["10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"], external_net: d.external_net || ["!$HOME_NET"], interfaces: d.interfaces || [], - alert_retention_days: d.alert_retention_days ?? 30, + alert_retention_days: d.alert_retention_days ?? 7, eve_log_enabled: d.eve_log_enabled ?? false, eve_log_path: d.eve_log_path || "/var/log/aifw/eve.json", syslog_target: d.syslog_target || "", @@ -335,21 +335,27 @@ export default function IdsSettingsPage() { {/* Alert Retention */}
- setConfig({ ...config, - alert_retention_days: parseInt(e.target.value) || 30, + alert_retention_days: parseInt(e.target.value) || 7, }) } - min={1} - max={365} className="w-full bg-[var(--bg-primary)] border border-[var(--border)] rounded-md px-3 py-2 text-sm text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] transition-colors" - /> + > + + + + + + +

+ Older alerts are pruned hourly; disk space is reclaimed automatically after large purges. +

{/* Syslog Target */} diff --git a/aifw-ui/src/app/login/page.tsx b/aifw-ui/src/app/login/page.tsx index 2c884602..b0336e0e 100644 --- a/aifw-ui/src/app/login/page.tsx +++ b/aifw-ui/src/app/login/page.tsx @@ -2,7 +2,7 @@ import { useState } from "react"; import Image from "next/image"; -import { api } from "@/lib/api"; +import { api, setAuthed } from "@/lib/api"; export default function LoginPage() { const [username, setUsername] = useState(""); @@ -21,14 +21,15 @@ export default function LoginPage() { try { // noAuthRedirect: a 401 here means bad credentials, not an expired // session — redirecting to /login would just loop. + // On success the server installs the session as HttpOnly cookies + // (SEC-M7 #304); the page only records the logged-in marker. const data = await api.post<{ tokens?: { access_token?: string }; totp_required?: boolean }>( "/api/v1/auth/login", { username, password }, { noAuthRedirect: true }, ); - const token = data.tokens?.access_token; - if (token) { - localStorage.setItem("aifw_token", token); + if (data.tokens?.access_token) { + setAuthed(true); window.location.href = "/"; } else if (data.totp_required) { setTotpRequired(true); @@ -50,9 +51,8 @@ export default function LoginPage() { { username, password, totp_code: totpCode }, { noAuthRedirect: true }, ); - const token = data.access_token; - if (token) { - localStorage.setItem("aifw_token", token); + if (data.access_token) { + setAuthed(true); window.location.href = "/"; } } catch { diff --git a/aifw-ui/src/app/nat/components/CrossFamilyHint.tsx b/aifw-ui/src/app/nat/components/CrossFamilyHint.tsx new file mode 100644 index 00000000..5d6cbff1 --- /dev/null +++ b/aifw-ui/src/app/nat/components/CrossFamilyHint.tsx @@ -0,0 +1,68 @@ +// Live preview panel for cross-family (af-to) NAT rules (#531): makes the +// RFC 6052 embedded-address behavior visible while the form is filled in. +"use client"; + +import { embedRfc6052, WELL_KNOWN_PREFIX } from "./crossFamily"; + +export default function CrossFamilyHint({ + natType, + dstAddr, + redirectAddr, +}: { + natType: string; + dstAddr: string; + redirectAddr: string; +}) { + if (natType === "nat64") { + const prefix = dstAddr || WELL_KNOWN_PREFIX; + const example = embedRfc6052(prefix, "10.0.0.1"); + return ( +
+

NAT64 — IPv6-only clients reaching IPv4 hosts

+

+ IPv6 clients reach any IPv4 host through the prefix{" "} + {prefix}: the IPv4 address is embedded in + the last 32 bits (RFC 6052). +

+ {example && ( +

+ Example: 10.0.0.1 is reached at{" "} + {example} +

+ )} + {redirectAddr && ( +

+ Translated traffic leaves sourced from{" "} + {redirectAddr}. Point clients' DNS64 + at the same prefix so hostnames resolve automatically. +

+ )} +

Requires FreeBSD 15+ (pf af-to). Applied on inbound traffic of the selected interface.

+
+ ); + } + + if (natType === "nat46") { + const embedded = + dstAddr && redirectAddr ? embedRfc6052(redirectAddr, dstAddr) : null; + return ( +
+

NAT46 — IPv4-only clients reaching an IPv6 service

+

+ The translated destination is the IPv4 destination embedded in the /96 subnet of the IPv6 + translation source (RFC 6052). +

+ {embedded && ( +

+ The IPv6 server must answer on{" "} + {embedded} (embedding of{" "} + {dstAddr}). +

+ )} +

Requires FreeBSD 15+ (pf af-to). Applied on inbound traffic of the selected interface.

+
+ ); + } + + return null; +} diff --git a/aifw-ui/src/app/nat/components/crossFamily.ts b/aifw-ui/src/app/nat/components/crossFamily.ts new file mode 100644 index 00000000..04bd71ce --- /dev/null +++ b/aifw-ui/src/app/nat/components/crossFamily.ts @@ -0,0 +1,183 @@ +// Helpers for cross-family (NAT64/NAT46, pf af-to) rule UX (#531). +// Pure functions only — no React here. + +export const WELL_KNOWN_PREFIX = "64:ff9b::/96"; + +export function isCrossFamily(natType: string): boolean { + return natType === "nat64" || natType === "nat46"; +} + +/** + * Expand an IPv6 address string to its 8 groups, or null if malformed. + * Strict: hex groups only (1-4 digits) — `parseInt` alone would accept + * trailing garbage like `64:ff9bzz::` or dotted-quad tails, and a wrong + * parse here mis-renders the RFC 6052 preview users configure against. + */ +const HEX_GROUP = /^[0-9a-fA-F]{1,4}$/; + +function expandV6(addr: string): number[] | null { + const parts = addr.split("::"); + if (parts.length > 2) return null; + const head = parts[0] ? parts[0].split(":") : []; + const tail = parts.length === 2 && parts[1] ? parts[1].split(":") : []; + const missing = 8 - head.length - tail.length; + if (parts.length === 2 && missing < 0) return null; + if (parts.length === 1 && head.length !== 8) return null; + const groups = [...head, ...Array(Math.max(missing, 0)).fill("0"), ...tail]; + if (groups.length !== 8) return null; + if (!groups.every((g) => g === "0" || HEX_GROUP.test(g))) return null; + return groups.map((g) => parseInt(g, 16)); +} + +export function isV6Host(addr: string): boolean { + return !addr.includes("/") && addr.includes(":") && expandV6(addr) !== null; +} + +export function isV6Prefix96(addr: string): boolean { + const [ip, len] = addr.split("/"); + return len === "96" && expandV6(ip) !== null; +} + +export function isV6AddrOrNet(addr: string): boolean { + if (addr === "" || addr === "any") return true; + const [ip, len] = addr.split("/"); + if (len !== undefined && (!/^\d{1,3}$/.test(len) || parseInt(len, 10) > 128)) return false; + return expandV6(ip) !== null; +} + +function parseV4(addr: string): number[] | null { + const octets = addr.split("."); + if (octets.length !== 4) return null; + const nums = octets.map((o) => (/^\d{1,3}$/.test(o) ? parseInt(o, 10) : NaN)); + return nums.every((n) => n >= 0 && n <= 255) ? nums : null; +} + +export function isV4Host(addr: string): boolean { + return !addr.includes("/") && parseV4(addr) !== null; +} + +export function isV4AddrOrNet(addr: string): boolean { + if (addr === "" || addr === "any") return true; + const [ip, len] = addr.split("/"); + if (len !== undefined && (!/^\d{1,2}$/.test(len) || parseInt(len, 10) > 32)) return false; + return parseV4(ip) !== null; +} + +/** + * RFC 6052 §2.2: embed an IPv4 address in the low 32 bits of an IPv6 /96 + * prefix — the address an IPv6-only client uses to reach an IPv4 host + * through NAT64. Returns null if either input is malformed. + */ +export function embedRfc6052(prefix: string, v4: string): string | null { + const groups = expandV6(prefix.split("/")[0]); + const octets = parseV4(v4); + if (!groups || !octets) return null; + const out = [...groups.slice(0, 6), (octets[0] << 8) | octets[1], (octets[2] << 8) | octets[3]]; + // Compress the longest zero run (prefer the RFC 5952 canonical-ish form). + let bestStart = -1; + let bestLen = 0; + for (let i = 0; i < 8; i++) { + if (out[i] !== 0) continue; + let j = i; + while (j < 8 && out[j] === 0) j++; + if (j - i > bestLen) { + bestLen = j - i; + bestStart = i; + } + i = j; + } + const hex = out.map((n) => n.toString(16)); + if (bestLen >= 2) { + const head = hex.slice(0, bestStart).join(":"); + const tail = hex.slice(bestStart + bestLen).join(":"); + return `${head}::${tail}`; + } + return hex.join(":"); +} + +export interface FieldMeta { + srcLabel: string; + srcPlaceholder: string; + dstLabel: string; + dstPlaceholder: string; + redirectLabel: string; + redirectPlaceholder: string; + redirectHelp: string; + showRedirectPort: boolean; +} + +const GENERIC_META: FieldMeta = { + srcLabel: "Source Address", + srcPlaceholder: "any", + dstLabel: "Destination Address", + dstPlaceholder: "any", + redirectLabel: "Redirect Address", + redirectPlaceholder: "e.g. 10.0.0.5", + redirectHelp: "", + showRedirectPort: true, +}; + +/** Contextual form labels/placeholders per NAT type. */ +export function fieldMeta(natType: string): FieldMeta { + switch (natType) { + case "nat64": + return { + srcLabel: "IPv6 Source Network", + srcPlaceholder: "any (e.g. 2001:db8:1::/64)", + dstLabel: "NAT64 Prefix (/96)", + dstPlaceholder: WELL_KNOWN_PREFIX, + redirectLabel: "IPv4 Translation Source", + redirectPlaceholder: "e.g. 203.0.113.1", + redirectHelp: + "IPv4 address the firewall sources translated traffic from — usually the WAN address.", + showRedirectPort: false, + }; + case "nat46": + return { + srcLabel: "IPv4 Source Network", + srcPlaceholder: "any (e.g. 10.0.0.0/24)", + dstLabel: "IPv4 Destination", + dstPlaceholder: "e.g. 10.99.1.1", + redirectLabel: "IPv6 Translation Source", + redirectPlaceholder: "e.g. 2001:db8:2::1", + redirectHelp: + "IPv6 address the firewall sources translated traffic from. The translated destination is the IPv4 destination embedded in this address's /96 subnet (RFC 6052).", + showRedirectPort: false, + }; + default: + return GENERIC_META; + } +} + +export interface CrossFamilyErrors { + src?: string; + dst?: string; + redirect?: string; +} + +/** Client-side pre-submit checks mirroring the API's family validation. */ +export function validateCrossFamily( + natType: string, + src: string, + dst: string, + redirect: string, +): CrossFamilyErrors { + const errs: CrossFamilyErrors = {}; + if (natType === "nat64") { + if (!isV6AddrOrNet(src)) errs.src = "Must be an IPv6 address/network (the rule matches IPv6 traffic)"; + if (dst && !isV6Prefix96(dst)) errs.dst = `Must be an IPv6 /96 prefix, e.g. ${WELL_KNOWN_PREFIX}`; + if (redirect && !isV4Host(redirect)) errs.redirect = "Must be a single IPv4 address"; + } else if (natType === "nat46") { + if (!isV4AddrOrNet(src)) errs.src = "Must be an IPv4 address/network (the rule matches IPv4 traffic)"; + if (!dst || !isV4AddrOrNet(dst) || dst === "any") errs.dst = "A concrete IPv4 destination is required"; + if (redirect && !isV6Host(redirect)) errs.redirect = "Must be a single IPv6 address"; + } + return errs; +} + +/** "IPv6 → IPv4" style direction summary for the rules table. */ +export function directionSummary(natType: string): string | null { + if (natType === "nat64") return "IPv6 → IPv4"; + if (natType === "nat46") return "IPv4 → IPv6"; + return null; +} diff --git a/aifw-ui/src/app/nat/flows/page.tsx b/aifw-ui/src/app/nat/flows/page.tsx index 4b67892f..fcaf4c6b 100644 --- a/aifw-ui/src/app/nat/flows/page.tsx +++ b/aifw-ui/src/app/nat/flows/page.tsx @@ -83,7 +83,7 @@ function ifaceForIp(table: Route[], ip: string): string | null { } type WgTunnelLite = { id: string; interface: string; address: string | null; split_routes: string | null }; -type WgPeerLite = { allowed_ips: string }; +type WgPeerLite = { allowed_ips: string[] }; /* ────────────────────────── Pipe geometry helpers ────────────────────────── * @@ -239,7 +239,7 @@ export default function NatFlowsPage() { try { const pRes = await fetchApi<{ data: WgPeerLite[] }>(`/api/v1/vpn/wg/${t.id}/peers`); for (const p of pRes.data || []) - for (const a of (p.allowed_ips || "").split(/[,\s]+/)) if (a) add(a); + for (const a of p.allowed_ips || []) if (a) add(a); } catch { /* peers are optional — address/split_routes already cover the interface */ } } setWgRoutes(routes); diff --git a/aifw-ui/src/app/nat/page.tsx b/aifw-ui/src/app/nat/page.tsx index 22079a83..9a85dd11 100644 --- a/aifw-ui/src/app/nat/page.tsx +++ b/aifw-ui/src/app/nat/page.tsx @@ -2,6 +2,14 @@ import { useState, useEffect, useCallback } from "react"; import { api, NatRule, CreateNatRequest, UpdateNatRequest } from "@/lib/api"; +import CrossFamilyHint from "./components/CrossFamilyHint"; +import { + WELL_KNOWN_PREFIX, + directionSummary, + fieldMeta, + isCrossFamily, + validateCrossFamily, +} from "./components/crossFamily"; const defaultForm = { nat_type: "dnat", @@ -30,6 +38,8 @@ function natTypeBadge(natType: string) { snat: "bg-green-500/20 text-green-400 border-green-500/30", masquerade: "bg-yellow-500/20 text-yellow-400 border-yellow-500/30", binat: "bg-purple-500/20 text-purple-400 border-purple-500/30", + nat64: "bg-cyan-500/20 text-cyan-400 border-cyan-500/30", + nat46: "bg-orange-500/20 text-orange-400 border-orange-500/30", }; const cls = colors[natType.toLowerCase()] || "bg-gray-500/20 text-gray-400 border-gray-500/30"; return ( @@ -179,6 +189,36 @@ export default function NatPage() { setForm((f) => ({ ...f, [field]: value })); }; + // Type switches prefill sensible cross-family defaults: nat64 practically + // always uses the well-known prefix, and af-to has no port rewriting. + const handleTypeChange = (value: string) => { + setForm((f) => { + const next = { ...f, nat_type: value }; + if (value === "nat64" && (!f.dst_addr || f.dst_addr === "any")) { + next.dst_addr = WELL_KNOWN_PREFIX; + next.protocol = "any"; + } + if (f.nat_type === "nat64" && value !== "nat64" && f.dst_addr === WELL_KNOWN_PREFIX) { + next.dst_addr = ""; + } + if (isCrossFamily(value)) { + next.redirect_port_start = ""; + next.redirect_port_end = ""; + } + return next; + }); + }; + + const meta = fieldMeta(form.nat_type); + const fieldErrors = validateCrossFamily( + form.nat_type, + form.src_addr.trim() || "any", + form.dst_addr.trim(), + form.redirect_addr.trim(), + ); + const hasFieldErrors = Boolean(fieldErrors.src || fieldErrors.dst || fieldErrors.redirect); + const fieldErrCls = "mt-1 text-[11px] text-red-400"; + const inputCls = "w-full bg-gray-900 border border-gray-700 rounded-md px-3 py-1.5 text-sm text-white placeholder:text-gray-500 focus:outline-none focus:border-blue-500 transition-colors"; const selectCls = @@ -237,11 +277,13 @@ export default function NatPage() { {/* Type */}
- handleTypeChange(e.target.value)} className={selectCls}> + +
{/* Interface */} @@ -261,6 +303,7 @@ export default function NatPage() {
@@ -276,14 +319,15 @@ export default function NatPage() { )} {/* Source Address */}
- + updateField("src_addr", e.target.value)} - placeholder="any" + placeholder={meta.srcPlaceholder} className={inputCls} /> + {fieldErrors.src &&

{fieldErrors.src}

}
{/* Source Port */}
@@ -298,14 +342,15 @@ export default function NatPage() {
{/* Destination Address */}
- + updateField("dst_addr", e.target.value)} - placeholder="any" + placeholder={meta.dstPlaceholder} className={inputCls} /> + {fieldErrors.dst &&

{fieldErrors.dst}

}
{/* Destination Port */}
@@ -319,27 +364,33 @@ export default function NatPage() { />
{/* Redirect Address */} -
- +
+ updateField("redirect_addr", e.target.value)} - placeholder="e.g. 10.0.0.5" - className={inputCls} - /> -
- {/* Redirect Port */} -
- - updateField("redirect_port_start", e.target.value)} - placeholder="e.g. 8080" + placeholder={meta.redirectPlaceholder} className={inputCls} /> + {fieldErrors.redirect &&

{fieldErrors.redirect}

} + {!fieldErrors.redirect && meta.redirectHelp && ( +

{meta.redirectHelp}

+ )}
+ {/* Redirect Port — af-to translates addresses, not ports */} + {meta.showRedirectPort && ( +
+ + updateField("redirect_port_start", e.target.value)} + placeholder="e.g. 8080" + className={inputCls} + /> +
+ )} {/* Label */}
@@ -352,10 +403,15 @@ export default function NatPage() { />
+
+ {info?.checksum_signature_url && ( + + Verify signed checksum + + )}

{configTab === "full" - ? "Everything goes through the VPN: the device reaches the internet via the firewall\u2019s WAN address. Use this on untrusted networks (public Wi-Fi) or to apply the firewall\u2019s filtering everywhere. IPv6 traffic stays on the device\u2019s normal connection \u2014 the tunnel carries IPv4 only for now." + ? "Everything goes through the VPN: the device reaches the internet via the firewall\u2019s WAN address. Use this on untrusted networks (public Wi-Fi) or to apply the firewall\u2019s filtering everywhere. IPv6 goes through the VPN too when the tunnel has an IPv6 address; otherwise it stays on the device\u2019s normal connection." : "Only the VPN subnet (plus any split-tunnel routes configured on the tunnel) goes through the VPN \u2014 use this to reach home/office devices while everything else uses the device\u2019s normal connection. Faster, but internet traffic is not protected by the VPN."}

diff --git a/aifw-ui/src/app/vpn/components/WgPeerForm.tsx b/aifw-ui/src/app/vpn/components/WgPeerForm.tsx
index 4a7f0427..e1f6ad70 100644
--- a/aifw-ui/src/app/vpn/components/WgPeerForm.tsx
+++ b/aifw-ui/src/app/vpn/components/WgPeerForm.tsx
@@ -43,9 +43,12 @@ export function WgPeerForm({
             
               This device's address inside the
               tunnel, e.g. 10.10.0.2/32.
+              On a dual-stack tunnel give both,
+              comma-separated:{" "}
+              10.10.0.2/32, fd00:a1f0::2/128.
               Every peer needs its own — Auto{" "}
-              picks the next free IP in the tunnel
-              subnet.
+              picks the next free IP in every subnet
+              the tunnel carries.
             
           
           
diff --git a/aifw-ui/src/app/vpn/components/WgPeerTable.tsx b/aifw-ui/src/app/vpn/components/WgPeerTable.tsx index 2187feb7..174b9635 100644 --- a/aifw-ui/src/app/vpn/components/WgPeerTable.tsx +++ b/aifw-ui/src/app/vpn/components/WgPeerTable.tsx @@ -54,7 +54,7 @@ export function WgPeerTable({ tunnelId, peers, vpnStatus, onShowConfig, onDelete {peer.public_key.slice(0, 16)}... - {peer.allowed_ips} + {peer.allowed_ips.join(", ")} {peer.endpoint || "-"} diff --git a/aifw-ui/src/app/vpn/components/WgTunnelForm.tsx b/aifw-ui/src/app/vpn/components/WgTunnelForm.tsx index fa6b3ca1..217ca4fe 100644 --- a/aifw-ui/src/app/vpn/components/WgTunnelForm.tsx +++ b/aifw-ui/src/app/vpn/components/WgTunnelForm.tsx @@ -62,13 +62,13 @@ export function WgTunnelForm({
+
+ + setWgForm((f) => ({ ...f, address6: e.target.value }))} + placeholder="fd00:a1f0::1/64" + className={inputCls} + /> +
+ +
-
-
diff --git a/aifw-ui/src/app/vpn/components/WgTunnelRow.tsx b/aifw-ui/src/app/vpn/components/WgTunnelRow.tsx index 0358ba7f..98e0bb09 100644 --- a/aifw-ui/src/app/vpn/components/WgTunnelRow.tsx +++ b/aifw-ui/src/app/vpn/components/WgTunnelRow.tsx @@ -105,7 +105,10 @@ export function WgTunnelRow({
Address:{" "} - {tunnel.address} + + {tunnel.address} + {tunnel.address6 ? `, ${tunnel.address6}` : ""} +
Public Key:{" "} diff --git a/aifw-ui/src/app/vpn/page.tsx b/aifw-ui/src/app/vpn/page.tsx index d4209194..c939216f 100644 --- a/aifw-ui/src/app/vpn/page.tsx +++ b/aifw-ui/src/app/vpn/page.tsx @@ -41,9 +41,12 @@ export default function VpnPage() { another router, forward the listen port (UDP) to it there.

- Peers can connect to the firewall over IPv4 or IPv6, but - traffic inside the tunnel is IPv4-only for now — client - configs are generated accordingly. + Peers can connect to the firewall over IPv4 or IPv6. Give + a tunnel an IPv6 address too (e.g. fd00:a1f0::1/64) + and it carries both families inside: peers get dual-stack + addresses and full-tunnel configs route IPv6 through the VPN. + Tunnels without one stay IPv4-only and client configs are + generated accordingly.

diff --git a/aifw-ui/src/components/AppShell.tsx b/aifw-ui/src/components/AppShell.tsx index e53e8477..aca69b41 100644 --- a/aifw-ui/src/components/AppShell.tsx +++ b/aifw-ui/src/components/AppShell.tsx @@ -114,7 +114,10 @@ export default function AppShell({ children }: { children: React.ReactNode }) {
{/* Main content */} -
= 1024 ? sidebarWidth : 0 }}> +
{/* Mobile header bar */}

Full NAT suite

-

SNAT, DNAT/port forwarding, masquerade, and 1:1 binat. NAT64/NAT46 rule types are in development — cross-family translation is being built.

+

SNAT, DNAT/port forwarding, masquerade, 1:1 binat, and real NAT64/NAT46 cross-family translation (pf af-to) with DNS64.

snatdnatnat64nat46
@@ -269,7 +269,7 @@

How it stacks up

Suricata IDS
pkg
Sigma + YARA rules
AI behavioural detection
-
NAT46
in dev
+
NAT46
OAuth / SSO
Commit confirm (auto-rollback)
Modern UI stack
React/Next.js
PHP
PHP
diff --git a/docs/maturity.md b/docs/maturity.md index 46d7ce06..82e40bce 100644 --- a/docs/maturity.md +++ b/docs/maturity.md @@ -32,7 +32,7 @@ AiFw is in **active development (beta)**. This matrix is the honest, per-feature Rule scheduling✓✓⏳⏳Enforced since v5.99.7 (windows compiled into pf, minute-tick reload) Rule policy routing (route-to)✓✓⏳⏳Since v5.100.0 SNAT / DNAT / masquerade / 1:1✓✓⏳⏳Beta -NAT64 / NAT46✓✗✗✗In development — current rules do not perform cross-family translation ([#531](https://github.com/ZerosAndOnesLLC/AiFw/issues/531)) +NAT64 / NAT46✓✓✓✓pf af-to translation (FreeBSD 15+); TCP/UDP/ICMP live-traffic tested in CI ([#531](https://github.com/ZerosAndOnesLLC/AiFw/issues/531)) WireGuard✓✓⏳⏳Beta IPsec✓✓✓✓IKEv2 site-to-site (tunnel mode, PSK/X.509, NAT-T) via strongSwan; verified two-endpoint on FreeBSD incl. rekey, DPD, and reboot recovery ([#530](https://github.com/ZerosAndOnesLLC/AiFw/issues/530)). No IKEv1/AH/transport/mobile-EAP. OAuth / SSO login✓✗✗✗In development — provider config + authorize flow exist; token exchange not implemented ([#170](https://github.com/ZerosAndOnesLLC/AiFw/issues/170)) @@ -43,7 +43,7 @@ AiFw is in **active development (beta)**. This matrix is the honest, per-feature Geo-IP filtering✓✓⏳⏳Beta Multi-WAN (FIB, gateways, policies)✓✓⏳⏳Beta — latency thresholds + dampening enforced since v5.100.0; needs environment-specific validation HA (CARP / pfsync / replication)✓✓✗✗Beta — automated two-node failover validation tracked in [#534](https://github.com/ZerosAndOnesLLC/AiFw/issues/534); quantitative claims are design targets until then -Backup / restore / OPNsense import✓✓⏳⏳Beta — snapshot + strict apply with automatic rollback (since v5.99.7); not a single DB+kernel transaction +Backup / restore / OPNsense import✓✓⏳⏳Beta — pre-validated apply, single DB transaction, post-apply pf verification, snapshot rollback for kernel state (since v5.99.7; hardened for #535); kernel applies are still not transactional DHCP / DNS / NTP (companions)✓✓⏳⏳Beta — builds pinned to reviewed revisions since v5.99.8 Reverse proxy + ACME (TrafficCop)✓✓⏳⏳Beta AI/ML threat detection✓⏳✗✗Experimental, opt-in, disabled by default diff --git a/freebsd/RELEASE-SIGNING.md b/freebsd/RELEASE-SIGNING.md new file mode 100644 index 00000000..e4aa5181 --- /dev/null +++ b/freebsd/RELEASE-SIGNING.md @@ -0,0 +1,74 @@ +# Release Signing + +Every release checksum (`*.sha256`) is signed with +[minisign](https://jedisct1.github.io/minisign/). The in-app updater **fails +closed**: it downloads `aifw-update-*.tar.xz.sha256.minisig`, verifies it +against the public key **compiled into the running `aifw-api` binary**, and +refuses to install when the signature is missing or invalid. Because the key +travels inside the binary, a compromised GitHub release (swapped tarball + +swapped checksum) is not sufficient to push code onto appliances — the +attacker would also need the secret key. + +CI-built releases are additionally attested with GitHub build provenance +(`gh attestation verify --repo ZerosAndOnesLLC/AiFw`) and ship a +CycloneDX SBOM (`aifw-release.cdx.json`) generated from `Cargo.lock` and +`aifw-ui/package-lock.json`. + +## Key locations + +| What | Where | +|------|-------| +| Public key (trust root) | `freebsd/overlay/usr/local/etc/aifw/update-signing.pub` — committed; compiled into the updater via `include_str!`, baked into ISOs, published as a release asset | +| Secret key (local releases) | `~/.minisign/aifw-update.key` on the release machine (override: `AIFW_MINISIGN_SECKEY`) — **never commit** | +| Secret key (CI releases) | GitHub secret `MINISIGN_SECRET_KEY` (file contents); optional `MINISIGN_PASSWORD` if the key is password-protected | + +`freebsd/release.sh` signs at publish time and refuses to publish unsigned +artifacts; CI signs in `build-iso.yml` and re-verifies against the committed +public key before the release job uploads anything. Both paths catch a +secret key that doesn't match the committed public key. + +## Generating a key + +```sh +minisign -G -p ~/.minisign/aifw-update.pub -s ~/.minisign/aifw-update.key +``` + +Use `-W` for a passwordless key (required if CI must sign and no +`MINISIGN_PASSWORD` secret is set). Keep an offline backup of the secret +key; losing it means rotating (below) with no overlap. + +## Rotating the key + +Appliances trust exactly the key embedded in the build they are running, so +rotation is a two-release handover: + +1. Generate the new keypair. Do **not** replace the old secret key yet. +2. Commit the new public key to + `freebsd/overlay/usr/local/etc/aifw/update-signing.pub`. +3. Cut the transition release **signed with the OLD key**. Fleet appliances + verify it with their embedded old key, install it, and are now running a + build that trusts the new key. +4. All releases after the transition release are signed with the NEW key. + Update `~/.minisign/aifw-update.key` and the `MINISIGN_SECRET_KEY` + secret; retire the old key. + +An appliance that skips the transition release (offline during the window) +verifies with the old key and will reject newer releases. Recovery: install +the transition release explicitly (`aifw update install --from ` +or the UI's manual upload), then update normally. + +## Compromised key / compromised release + +1. Immediately delete the `MINISIGN_SECRET_KEY` GitHub secret and destroy + the on-disk secret key. +2. Mark every release signed by the compromised key as a **pre-release** on + GitHub (stable-channel appliances only pull `/releases/latest`, which + skips pre-releases) and delete any release known to carry a tampered + artifact. +3. Rotate per the procedure above. The transition release must be built + from audited source and signed with the compromised key — that is + unavoidable (it is the only key the fleet trusts), so pin its content by + publishing its SHA-256 and attestation through an out-of-band channel + (project site, security advisory) before promoting it to stable. +4. Publish a security advisory listing the compromised key ID, the affected + release window, and the new key. diff --git a/freebsd/build-iso.sh b/freebsd/build-iso.sh index 29e04534..381edc94 100755 --- a/freebsd/build-iso.sh +++ b/freebsd/build-iso.sh @@ -12,8 +12,8 @@ ARCH="${2:-amd64}" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -FREEBSD_VERSION="15.0" -FREEBSD_RELEASE="15.0-RELEASE" +FREEBSD_VERSION="15.1" +FREEBSD_RELEASE="15.1-RELEASE" FREEBSD_MIRROR="https://download.freebsd.org/releases/${ARCH}/${FREEBSD_RELEASE}" WORKDIR="/usr/obj/aifw-iso" @@ -113,7 +113,7 @@ cp /etc/resolv.conf "$STAGEDIR/etc/resolv.conf" # Bootstrap pkg and install required packages chroot "$STAGEDIR" /bin/sh -c ' env ASSUME_ALWAYS_YES=yes pkg bootstrap -f - pkg install -y wireguard-tools sudo unbound curl strongswan + pkg install -y wireguard-tools sudo unbound curl strongswan minisign ' umount "$STAGEDIR/dev" diff --git a/freebsd/build-local.sh b/freebsd/build-local.sh index d8071b37..f277393a 100755 --- a/freebsd/build-local.sh +++ b/freebsd/build-local.sh @@ -134,21 +134,39 @@ build_companion() { exit 1 } fi - echo "--- Checking out $name @ $pin ---" + # Remember the operator's checkout so we can put it back: these are the + # same working repos they develop in, and leaving them detached at the + # pin meant manually switching back to main after every build. + local prev + prev=$(git -C "$dir" symbolic-ref --quiet --short HEAD || git -C "$dir" rev-parse HEAD) + echo "--- Checking out $name @ $pin (will restore '$prev' after build) ---" ( cd "$dir" && \ git fetch --tags origin && \ - git checkout --detach "$pin" ) || { - echo "ERROR: pinned commit $pin not found in $name ($dir)" >&2 + git checkout --quiet --detach "$pin" ) || { + echo "ERROR: pinned commit $pin not found in $name ($dir), or the" >&2 + echo " working tree has uncommitted changes — commit/stash first" >&2 exit 1 } local actual actual=$(git -C "$dir" rev-parse HEAD) [ "$actual" = "$pin" ] || die "$name checked out $actual, expected pinned $pin" echo "--- $name commit: $actual ---" - ( cd "$dir" && cargo build --release ) || { + local rc=0 + ( cd "$dir" && cargo build --release ) || rc=1 + # Restore the original ref even when the build fails. + git -C "$dir" checkout --quiet "$prev" || \ + echo "WARN: could not restore $name to '$prev' — left detached at $pin" >&2 + # Root-run builds litter the operator's repo with root-owned .git + # objects and target/ artifacts, breaking their later git/cargo use + # (seen live: 'insufficient permission for adding an object'). Hand + # the repo back to the invoking user. + if [ -n "${SUDO_USER:-}" ] && [ "$SUDO_USER" != "root" ]; then + chown -R "$SUDO_USER" "$dir" 2>/dev/null || true + fi + if [ "$rc" -ne 0 ]; then echo "ERROR: cargo build of $name failed" >&2 exit 1 - } + fi } # Build companion services (reverse proxy, DHCP, DNS, NTP). @@ -276,10 +294,14 @@ echo "$VERSION" > "$TARBALL_DIR/version" # tarball) visible at build time. { echo "AiFw $(git -C "$PROJECT_ROOT" rev-parse --short HEAD 2>/dev/null || echo unknown)" - [ -d "$TRAFFICCOP_DIR/.git" ] && echo "TrafficCop $(git -C "$TRAFFICCOP_DIR" rev-parse --short HEAD)" - [ -d "$RDHCP_DIR/.git" ] && echo "rDHCP $(git -C "$RDHCP_DIR" rev-parse --short HEAD)" - [ -d "$RDNS_DIR/.git" ] && echo "rDNS $(git -C "$RDNS_DIR" rev-parse --short HEAD)" - [ -d "$RTIME_DIR/.git" ] && echo "rTIME $(git -C "$RTIME_DIR" rev-parse --short HEAD)" + # Companion SHAs come from the manifest pins — build_companion verified + # each checkout matched its pin and then RESTORED the operator's branch, + # so the repos' HEADs no longer reflect what was built. + for n in TrafficCop rDHCP rDNS rTIME; do + p=$(jq -r --arg n "$n" '.external_repos[] | select(.name == $n) | .commit // empty' \ + "$PROJECT_ROOT/freebsd/manifest.json") + [ -n "$p" ] && printf '%-16s %.12s (manifest pin)\n' "$n" "$p" + done if [ -f "$TARBALL_DIR/bin/rdns" ]; then rver=$(grep -ao 'rDNS [0-9][0-9.]*' "$TARBALL_DIR/bin/rdns" | head -1 || true) echo "rdns binary ${rver:-unknown}" @@ -353,6 +375,15 @@ for d in stage dist iso efi-stage; do done echo " Removed staged binaries, UI export, and build intermediates" +# Hand the artifacts to the user who invoked the build: this script runs as +# root, but release.sh runs unprivileged (it needs the operator's gh auth +# and minisign key) and must be able to write .minisig files next to the +# checksums. Without this, every release run died on "Permission denied". +if [ -n "${SUDO_USER:-}" ] && [ "$SUDO_USER" != "root" ]; then + chown -R "$SUDO_USER" "$OUTPUTDIR" + echo " Output ownership -> $SUDO_USER (for unprivileged release.sh)" +fi + # --- Done --- echo "" echo "=== Complete ===" diff --git a/freebsd/build-update.sh b/freebsd/build-update.sh index 68cdcc5e..b18557df 100755 --- a/freebsd/build-update.sh +++ b/freebsd/build-update.sh @@ -128,10 +128,22 @@ BIN_VER="$("$PROJECT_ROOT/target/release/aifw" --version 2>/dev/null | grep -oE [ "$BIN_VER" = "$VERSION" ] || die "version mismatch: building v${VERSION} but target/release/aifw reports v${BIN_VER:-unknown}. Bump Cargo.toml and rebuild (try 'cargo clean -p aifw') before releasing." echo "--- Verified compiled aifw binary is v${VERSION} ---" -# Clone-or-update a companion repo; fail loudly on pull errors. -# (Same function as in build-local.sh — keep them in sync.) +# Clone-or-build a companion repo at its manifest pin, restoring the +# operator's checkout afterwards. (Same function as in build-local.sh — +# keep them in sync.) +# +# This used to `git reset --hard origin/main`, which (a) silently discarded +# any uncommitted work in the operator's companion repos, (b) left them +# moved off their branch, and (c) bypassed the #538 manifest pins — the +# update tarball shipped whatever origin/main happened to be, not the +# reviewed revision. build_companion() { local name="$1" dir="$2" url="$3" + local pin + pin=$(jq -r --arg n "$name" \ + '.external_repos[] | select(.name == $n) | .commit // empty' \ + "$PROJECT_ROOT/freebsd/manifest.json") + [ -n "$pin" ] || die "no pinned commit for $name in manifest.json (#538)" if [ ! -d "$dir" ]; then echo "Cloning $name from $url ..." git clone "$url" "$dir" || { @@ -139,20 +151,35 @@ build_companion() { exit 1 } fi - echo "--- Updating $name ---" + local prev + prev=$(git -C "$dir" symbolic-ref --quiet --short HEAD || git -C "$dir" rev-parse HEAD) + echo "--- Checking out $name @ $pin (will restore '$prev' after build) ---" ( cd "$dir" && \ git fetch --tags origin && \ - git reset --hard origin/main ) || { - echo "ERROR: git update of $name ($dir) failed — refusing to build stale code" >&2 + git checkout --quiet --detach "$pin" ) || { + echo "ERROR: pinned commit $pin not found in $name ($dir), or the" >&2 + echo " working tree has uncommitted changes — commit/stash first" >&2 exit 1 } - local sha - sha=$(git -C "$dir" rev-parse --short HEAD) - echo "--- $name commit: $sha ---" - ( cd "$dir" && cargo build --release ) || { + local actual + actual=$(git -C "$dir" rev-parse HEAD) + [ "$actual" = "$pin" ] || die "$name checked out $actual, expected pinned $pin" + echo "--- $name commit: $actual ---" + local rc=0 + ( cd "$dir" && cargo build --release ) || rc=1 + git -C "$dir" checkout --quiet "$prev" || \ + echo "WARN: could not restore $name to '$prev' — left detached at $pin" >&2 + # Root-run builds litter the operator's repo with root-owned .git + # objects and target/ artifacts, breaking their later git/cargo use + # (seen live: 'insufficient permission for adding an object'). Hand + # the repo back to the invoking user. + if [ -n "${SUDO_USER:-}" ] && [ "$SUDO_USER" != "root" ]; then + chown -R "$SUDO_USER" "$dir" 2>/dev/null || true + fi + if [ "$rc" -ne 0 ]; then echo "ERROR: cargo build of $name failed" >&2 exit 1 - } + fi } echo "=== [4/6] Building companion services ===" @@ -227,10 +254,13 @@ echo "$VERSION" > "$TARBALL_DIR/version" # Write a BUILD_MANIFEST so stale companion repos are visible at build time. { echo "AiFw $(git -C "$PROJECT_ROOT" rev-parse --short HEAD 2>/dev/null || echo unknown)" - [ -d "$TRAFFICCOP_DIR/.git" ] && echo "TrafficCop $(git -C "$TRAFFICCOP_DIR" rev-parse --short HEAD)" - [ -d "$RDHCP_DIR/.git" ] && echo "rDHCP $(git -C "$RDHCP_DIR" rev-parse --short HEAD)" - [ -d "$RDNS_DIR/.git" ] && echo "rDNS $(git -C "$RDNS_DIR" rev-parse --short HEAD)" - [ -d "$RTIME_DIR/.git" ] && echo "rTIME $(git -C "$RTIME_DIR" rev-parse --short HEAD)" + # Companion SHAs from the manifest pins — build_companion verified each + # checkout matched its pin, then restored the operator's branch. + for n in TrafficCop rDHCP rDNS rTIME; do + p=$(jq -r --arg n "$n" '.external_repos[] | select(.name == $n) | .commit // empty' \ + "$PROJECT_ROOT/freebsd/manifest.json") + [ -n "$p" ] && printf '%-16s %.12s (manifest pin)\n' "$n" "$p" + done if [ -f "$TARBALL_DIR/bin/rdns" ]; then rver=$(grep -ao 'rDNS [0-9][0-9.]*' "$TARBALL_DIR/bin/rdns" | head -1 || true) echo "rdns binary ${rver:-unknown}" @@ -264,6 +294,11 @@ if [ -z "${AIFW_STAGE_OUT:-}" ]; then mv "${STAGE_OUT}/aifw-update-${VERSION}-amd64.tar.xz.sha256" "$OUTPUTDIR/" rmdir "$STAGE_OUT" 2>/dev/null || true STAGE_OUT="$OUTPUTDIR" + # release.sh runs unprivileged and signs next to these files — hand + # them to the invoking user when this build ran under sudo. + if [ -n "${SUDO_USER:-}" ] && [ "$SUDO_USER" != "root" ]; then + chown -R "$SUDO_USER" "$OUTPUTDIR" 2>/dev/null || true + fi fi echo "" diff --git a/freebsd/deploy.sh b/freebsd/deploy.sh index 08d7a4cd..503c68f7 100755 --- a/freebsd/deploy.sh +++ b/freebsd/deploy.sh @@ -37,7 +37,7 @@ if ! command -v cargo >/dev/null 2>&1; then fi # --- Ensure dependencies --- -for pkg in sudo unbound curl wireguard-tools strongswan; do +for pkg in sudo unbound curl wireguard-tools strongswan minisign; do if ! pkg info -q "$pkg" 2>/dev/null; then echo "Installing $pkg..." pkg install -y "$pkg" @@ -255,7 +255,7 @@ done # login migrate, shutdown hook, narrow-grant sudo wrappers from #204). # Mode 755 so the daemon supervisor / sudo can exec them. mkdir -p /usr/local/libexec -for script in aifw-restart.sh aifw-watchdog.sh aifw-motd-cleanup.sh aifw-login-migrate.sh aifw-shutdown-hook.sh aifw-sudo-write aifw-sudo-wg aifw-sudo-freebsd-update aifw-sudo-pkg aifw-sudo-service aifw-sudo-chown aifw-sudo-ifconfig aifw-sudo-install aifw-sudo-sysrc aifw-sudo-dhclient aifw-sudo-route aifw-sudo-pkill aifw-sudo-rm aifw-sudo-mkdir aifw-sudo-cp aifw-sudo-tar aifw-sudo-tcpdump; do +for script in aifw-restart.sh aifw-watchdog.sh aifw-motd-cleanup.sh aifw-login-migrate.sh aifw-shutdown-hook.sh aifw-sudo-write aifw-sudo-wg aifw-sudo-freebsd-update aifw-sudo-pkg aifw-sudo-service aifw-sudo-chown aifw-sudo-ifconfig aifw-sudo-install aifw-sudo-sysrc aifw-sudo-dhclient aifw-sudo-route aifw-sudo-pkill aifw-sudo-rm aifw-sudo-mkdir aifw-sudo-cp aifw-sudo-tar aifw-sudo-tcpdump aifw-sudo-dummynet aifw-dummynet-control; do src="$REPO_DIR/freebsd/overlay/usr/local/libexec/$script" if [ -f "$src" ]; then install -m 755 "$src" "/usr/local/libexec/$script" diff --git a/freebsd/manifest.json b/freebsd/manifest.json index 1e77fe3d..d2301a0e 100644 --- a/freebsd/manifest.json +++ b/freebsd/manifest.json @@ -6,7 +6,7 @@ "local": ["aifw", "aifw-daemon", "aifw-api", "aifw-ids", "aifw-tui", "aifw-setup"] }, - "packages": ["curl", "strongswan", "sudo", "unbound", "wireguard-tools"], + "packages": ["curl", "minisign", "strongswan", "sudo", "unbound", "wireguard-tools"], "_packages_comment": "OS packages required at runtime. build-iso.sh installs them into the image; deploy.sh ensures them on the dev VM; the in-app updater installs any that are missing during upgrade. A workspace test (aifw-core packaging_tests) asserts the build scripts stay in sync with this list.", "external_repos": [ @@ -26,7 +26,7 @@ { "name": "rDNS", "repo": "ZerosAndOnesLLC/rDNS", - "commit": "7bd12e293536ec3adae09e340b572b87e0fe942b", + "commit": "04d5975f2386f883db9b357424ca550a1bf72bce", "binaries": ["rdns", "rdns-control"] }, { diff --git a/freebsd/overlay/usr/local/etc/aifw/update-signing.pub b/freebsd/overlay/usr/local/etc/aifw/update-signing.pub new file mode 100644 index 00000000..f4d0a3fb --- /dev/null +++ b/freebsd/overlay/usr/local/etc/aifw/update-signing.pub @@ -0,0 +1,2 @@ +untrusted comment: minisign public key F1B14C3B88B534C1 +RWTBNLWIO0yx8R+mBWcURAIcRwDUCQDWK4ArTEKcL+V9knPAtsukmvua diff --git a/freebsd/overlay/usr/local/libexec/aifw-dummynet-control b/freebsd/overlay/usr/local/libexec/aifw-dummynet-control new file mode 100755 index 00000000..e5081c96 --- /dev/null +++ b/freebsd/overlay/usr/local/libexec/aifw-dummynet-control @@ -0,0 +1,87 @@ +#!/bin/sh +# Ownership-safe dummynet/IPFW controller for AiFw's reserved ranges. +set -eu +set -f + +PIPE_MIN=10000 +PIPE_MAX=19999 +RULE_MIN=30000 +RULE_MAX=49999 + +die() { echo "dummynet-control: $*" >&2; exit 1; } +valid_id() { case "$1" in ''|*[!0-9]*) return 1;; esac; [ "$1" -ge "$2" ] && [ "$1" -le "$3" ]; } + +snapshot() { + [ "$#" -eq 1 ] || die "snapshot requires a path" + case "$1" in /var/run/aifw-dummynet.snapshot) ;; *) die "invalid snapshot path";; esac + { echo '# aifw snapshot'; ipfw -a list; dnctl list; } > "$1" +} + +apply() { + [ "$#" -eq 1 ] || die "apply requires a command file" + [ -f "$1" ] || die "command file missing" + desired="$1" + # A single apply owns the reserved ranges. Remove only those objects before + # installing the desired set; administrator-owned IDs are never touched. + ipfw -q delete $(ipfw -a list | awk '$1 >= 30000 && $1 <= 49999 { print $1 }') 2>/dev/null || true + dnctl -q queue delete $(dnctl queue list | awk '$1 ~ /^[0-9]+/ { id=$1; sub(/:.*/, "", id); if (id >= 10000 && id <= 19999) print id }') 2>/dev/null || true + dnctl -q sched delete $(dnctl sched list | awk '$1 ~ /^[0-9]+/ { id=$1; sub(/:.*/, "", id); if (id >= 10000 && id <= 19999) print id }') 2>/dev/null || true + dnctl -q pipe delete $(dnctl pipe list | awk '$1 ~ /^[0-9]+/ { id=$1; sub(/:.*/, "", id); if (id >= 10000 && id <= 19999) print id }') 2>/dev/null || true + while IFS= read -r line || [ -n "$line" ]; do + [ -n "$line" ] || continue + [ "$line" = clear ] && continue + set -- $line + case "$1" in + pipe) + [ "$#" -ge 4 ] || die "invalid pipe command" + valid_id "$2" "$PIPE_MIN" "$PIPE_MAX" || die "pipe outside AiFw range" + [ "$3" = config ] || die "invalid pipe operation" + dnctl "$@" + ;; + sched|queue) + [ "$#" -ge 4 ] || die "invalid scheduler command" + valid_id "$2" "$PIPE_MIN" "$PIPE_MAX" || die "object outside AiFw range" + [ "$3" = config ] || die "invalid scheduler operation" + dnctl "$@" + ;; + ipfw) + [ "$#" -ge 4 ] || die "invalid ipfw command" + [ "$2" = add ] || die "only ipfw add is permitted" + valid_id "$3" "$RULE_MIN" "$RULE_MAX" || die "rule outside AiFw range" + shift + ipfw "$@" + ;; + *) die "unsupported command: $1";; + esac + done < "$desired" +} + +verify() { + expected=${1:-} + [ -f "$expected" ] || die "verify requires desired command file" + [ "$(sed '/^clear$/d;/^$/d' "$expected" | wc -l | tr -d ' ')" -eq 0 ] && return 0 + live=$(dnctl list; ipfw -a list) + for id in $(awk '$1 == "pipe" || $1 == "sched" || $1 == "queue" {print $2} $1 == "ipfw" && $2 == "add" {print $3}' "$expected" | sort -u); do + echo "$live" | grep -Eq "(^|[[:space:]])${id}([:[:space:]]|$)" || die "managed id $id missing after apply" + done +} + +restore() { + [ "$#" -eq 1 ] || die "restore requires a path" + [ -f "$1" ] || die "snapshot missing" + # Restore is intentionally conservative: remove only AiFw-owned objects. + ipfw -q delete $(ipfw -a list | awk '$1 >= 30000 && $1 <= 49999 { print $1 }') 2>/dev/null || true + dnctl -q queue delete $(dnctl queue list | awk '$1 ~ /^[0-9]+/ { id=$1; sub(/:.*/, "", id); if (id >= 10000 && id <= 19999) print id }') 2>/dev/null || true + dnctl -q sched delete $(dnctl sched list | awk '$1 ~ /^[0-9]+/ { id=$1; sub(/:.*/, "", id); if (id >= 10000 && id <= 19999) print id }') 2>/dev/null || true + dnctl -q pipe delete $(dnctl pipe list | awk '$1 ~ /^[0-9]+/ { id=$1; sub(/:.*/, "", id); if (id >= 10000 && id <= 19999) print id }') 2>/dev/null || true + [ -s "$1" ] && apply "$1" +} + +[ "$#" -ge 1 ] || die "usage: snapshot|apply|verify|restore" +case "$1" in +snapshot) shift; snapshot "$@";; +apply) shift; apply "$@";; +verify) shift; verify "$@";; +restore) shift; restore "$@";; +*) die "unknown operation";; +esac diff --git a/freebsd/overlay/usr/local/libexec/aifw-restart.sh b/freebsd/overlay/usr/local/libexec/aifw-restart.sh index 0084e931..8981138b 100755 --- a/freebsd/overlay/usr/local/libexec/aifw-restart.sh +++ b/freebsd/overlay/usr/local/libexec/aifw-restart.sh @@ -40,11 +40,19 @@ log "starting (pid $$)" # service bounce so the restarted services come up with correct grants. refresh_sudoers() { [ -x /usr/local/sbin/aifw-setup ] || return 0 + # Absolute path (#601): visudo lives in /usr/local/sbin, which daemon(8) + # contexts don't have in PATH — a bare name fails command-not-found and + # reads as a validation failure. + VISUDO=/usr/local/sbin/visudo + [ -x "$VISUDO" ] || VISUDO=$(command -v visudo) || { + log "WARN visudo not found; sudoers refresh skipped" + return 0 + } _tmp=$(mktemp /tmp/aifw-sudoers.XXXXXX) || return 0 if /usr/local/sbin/aifw-setup --print-sudoers > "$_tmp" 2>/dev/null && [ -s "$_tmp" ]; then # Validate before installing — a malformed sudoers file would void # every NOPASSWD grant the appliance depends on. - if visudo -cf "$_tmp" >/dev/null 2>&1; then + if "$VISUDO" -cf "$_tmp" >/dev/null 2>&1; then if ! cmp -s "$_tmp" /usr/local/etc/sudoers.d/aifw 2>/dev/null; then if install -m 440 -o root -g wheel "$_tmp" /usr/local/etc/sudoers.d/aifw; then log "refreshed /usr/local/etc/sudoers.d/aifw from aifw-setup" diff --git a/freebsd/overlay/usr/local/libexec/aifw-sudo-dummynet b/freebsd/overlay/usr/local/libexec/aifw-sudo-dummynet new file mode 100755 index 00000000..b5f6ec2d --- /dev/null +++ b/freebsd/overlay/usr/local/libexec/aifw-sudo-dummynet @@ -0,0 +1,21 @@ +#!/bin/sh +# Apply only AiFw-owned dummynet pipes/IPFW rules. Commands are parsed as +# argv by the controller, never evaluated by a shell, and the previous state +# is restored if any command or live verification fails. +set -eu +[ $# -eq 0 ] || { echo "usage: $0" >&2; exit 2; } +CONTROL=/usr/local/libexec/aifw-dummynet-control +[ -x "$CONTROL" ] || { echo "dummynet controller is not installed" >&2; exit 1; } +TMP=$(mktemp /tmp/aifw-dummynet.XXXXXX) +LAST=/var/run/aifw-dummynet.last-good +SNAP=$(mktemp /tmp/aifw-dummynet-snapshot.XXXXXX) +trap 'rm -f "$TMP" "$SNAP"' EXIT INT TERM HUP +cat >"$TMP" +[ -s "$TMP" ] || { echo "empty dummynet command set" >&2; exit 1; } +if [ -f "$LAST" ]; then cp "$LAST" "$SNAP"; fi +if ! "$CONTROL" apply "$TMP" || ! "$CONTROL" verify "$TMP"; then + "$CONTROL" restore "$SNAP" >/dev/null 2>&1 || true + echo "dummynet apply/verify failed; prior state restored" >&2 + exit 1 +fi +cp "$TMP" "$LAST" diff --git a/freebsd/overlay/usr/local/libexec/aifw-sudo-write b/freebsd/overlay/usr/local/libexec/aifw-sudo-write index a00f85e7..14c09476 100755 --- a/freebsd/overlay/usr/local/libexec/aifw-sudo-write +++ b/freebsd/overlay/usr/local/libexec/aifw-sudo-write @@ -38,6 +38,10 @@ esac case "$DEST" in /etc/pf.conf) FMODE=644; FOWNER=root:wheel ;; + /usr/local/etc/aifw/pf.conf.aifw) + # pf_tuning + the API's anchor self-heal commit their patched + # pf.conf here; missing entry broke every boot-time tuning apply (#601). + FMODE=644; FOWNER=root:wheel ;; /usr/local/etc/trafficcop/config.yaml) FMODE=644; FOWNER=root:wheel ;; /usr/local/etc/aifw/daemon.key) diff --git a/freebsd/overlay/usr/local/libexec/aifw-watchdog.sh b/freebsd/overlay/usr/local/libexec/aifw-watchdog.sh index fea371c3..c5e87758 100755 --- a/freebsd/overlay/usr/local/libexec/aifw-watchdog.sh +++ b/freebsd/overlay/usr/local/libexec/aifw-watchdog.sh @@ -54,9 +54,18 @@ log "starting (pid $$, interval ${INTERVAL}s)" # validated; a no-op once the file already matches. refresh_sudoers() { [ -x /usr/local/sbin/aifw-setup ] || return 0 + # Absolute path (#601): visudo lives in /usr/local/sbin, which is NOT in + # the daemon(8) default PATH the watchdog runs under — the bare name + # made this check fail command-not-found on every boot, so the refresh + # never ran and the failure was misreported as a validation error. + VISUDO=/usr/local/sbin/visudo + [ -x "$VISUDO" ] || VISUDO=$(command -v visudo) || { + log "WARN visudo not found; sudoers refresh skipped" + return 0 + } _tmp=$(mktemp /tmp/aifw-sudoers.XXXXXX) || return 0 if /usr/local/sbin/aifw-setup --print-sudoers > "$_tmp" 2>/dev/null && [ -s "$_tmp" ]; then - if visudo -cf "$_tmp" >/dev/null 2>&1; then + if "$VISUDO" -cf "$_tmp" >/dev/null 2>&1; then if ! cmp -s "$_tmp" /usr/local/etc/sudoers.d/aifw 2>/dev/null; then if install -m 440 -o root -g wheel "$_tmp" /usr/local/etc/sudoers.d/aifw; then log "refreshed /usr/local/etc/sudoers.d/aifw from aifw-setup" diff --git a/freebsd/release.sh b/freebsd/release.sh index 6e162ffe..86cceed9 100755 --- a/freebsd/release.sh +++ b/freebsd/release.sh @@ -123,6 +123,50 @@ if [ -z "$ISO_UPLOAD" ] && [ -z "$IMG_UPLOAD" ] && [ -z "$UPDATE_TARBALL" ]; the die "No artifacts to release. Run build-local.sh first." fi +# --- Guard: signing writes .minisig files next to the checksums, so the +# output dir must be writable by this (unprivileged) user. Builds run as +# root and used to leave root-owned artifacts, killing every release run +# with "Permission denied" mid-signing. The build scripts now chown their +# output, but self-heal here too for artifacts from older builds — sudo -n +# only, so this never hangs on a password prompt. +UNWRITABLE=0 +[ -w "$OUTPUTDIR" ] || UNWRITABLE=1 +for f in "$OUTPUTDIR"/*; do + [ -e "$f" ] || continue + [ -w "$f" ] || { UNWRITABLE=1; break; } +done +if [ "$UNWRITABLE" = 1 ]; then + echo "Output dir has files this user can't write (root-owned build artifacts); fixing ownership..." + sudo -n chown -R "$(id -un)" "$OUTPUTDIR" 2>/dev/null \ + || die "Cannot write to ${OUTPUTDIR} — run: sudo chown -R $(id -un) ${OUTPUTDIR}" +fi + +# --- Sign checksums (publisher authenticity) --- +# The in-app updater fails closed: a release without a valid .minisig for +# its update tarball checksum cannot be installed by any appliance. Signing +# happens here — the single local publish gate — so build-local.sh / +# build-update.sh test iterations don't need the key. +# Key management: freebsd/RELEASE-SIGNING.md. +SIGNKEY="${AIFW_MINISIGN_SECKEY:-$HOME/.minisign/aifw-update.key}" +PUBKEY="$PROJECT_ROOT/freebsd/overlay/usr/local/etc/aifw/update-signing.pub" +command -v minisign >/dev/null 2>&1 || die "minisign is required to sign release checksums: pkg install -y minisign" +[ -f "$PUBKEY" ] || die "Missing committed public key: $PUBKEY" +SIG_ASSETS="" +for sha in "$ISO_SHA_UPLOAD" "$IMG_SHA_UPLOAD" "$UPDATE_SHA"; do + [ -n "$sha" ] || continue + sig="${sha}.minisig" + if [ ! -f "$sig" ] || [ "$sha" -nt "$sig" ]; then + [ -f "$SIGNKEY" ] || die "No signing key at $SIGNKEY (override with AIFW_MINISIGN_SECKEY). Unsigned releases cannot be installed — see freebsd/RELEASE-SIGNING.md." + minisign -S -s "$SIGNKEY" -m "$sha" -x "$sig" || die "Signing failed for $sha" + fi + # Verify against the COMMITTED public key — the one compiled into the + # appliance updater. Catches signing with a stale/rotated secret key + # before the release is published. + minisign -Vm "$sha" -x "$sig" -p "$PUBKEY" >/dev/null || die "Signature for $sha does not verify against $PUBKEY — signed with the wrong key?" + SIG_ASSETS="$SIG_ASSETS $sig" +done +echo "Signed and verified $(echo "$SIG_ASSETS" | wc -w | tr -d ' ') checksum file(s)" + echo "Artifacts:" [ -n "$ISO_UPLOAD" ] && ls -lh "$ISO_UPLOAD" [ -n "$IMG_UPLOAD" ] && ls -lh "$IMG_UPLOAD" @@ -186,7 +230,7 @@ fi BODY="## AiFw v${VERSION} -AI-Powered Firewall for FreeBSD 15.0 +AI-Powered Firewall for FreeBSD 15.1 ${TARBALL_ONLY_NOTE} ### Downloads @@ -205,6 +249,13 @@ ${DECOMPRESS_NOTE} ### Verify Downloads +Each \`.sha256\` file is signed with the AiFw release key (\`update-signing.pub\`): + +\`\`\`bash +minisign -Vm .sha256 -x .sha256.minisig -p update-signing.pub +sha256sum -c .sha256 +\`\`\` + \`\`\` ${SHASUMS}\`\`\`" @@ -215,6 +266,7 @@ ASSETS="" [ -n "$ISO_UPLOAD" ] && ASSETS="$ASSETS $ISO_UPLOAD $ISO_SHA_UPLOAD" [ -n "$IMG_UPLOAD" ] && ASSETS="$ASSETS $IMG_UPLOAD $IMG_SHA_UPLOAD" [ -n "$UPDATE_TARBALL" ] && ASSETS="$ASSETS $UPDATE_TARBALL $UPDATE_SHA" +ASSETS="$ASSETS $SIG_ASSETS $PUBKEY" # Publish as a PRE-RELEASE by default so it can be tested before the # community auto-pulls it: the in-app updater's stable channel uses GitHub @@ -238,8 +290,18 @@ fi # Create release (or update if exists) if gh release view "$TAG" >/dev/null 2>&1; then echo "Release ${TAG} exists, uploading assets..." + # Pre-release gate FIRST, upload after: field appliances poll + # /releases/latest, so fresh assets must never sit on a stable release + # even for the duration of the upload. Strict (no || true) — if the + # flag can't be applied, abort before any asset lands. The stable flip + # for AIFW_RELEASE_FINAL stays AFTER the upload for the same reason, + # in the publish edit below. + if [ -z "${AIFW_RELEASE_FINAL:-}" ]; then + gh release edit "$TAG" --prerelease=true + fi gh release upload "$TAG" $ASSETS --clobber - # Ensure it's not stuck as a draft, and apply the chosen pre-release state. + # Publish last: un-draft, retitle, and (for FINAL) flip to stable only + # once the asset set is complete. gh release edit "$TAG" --draft=false $EDIT_PRE --title "AiFw v${VERSION}" --notes "$BODY" 2>/dev/null || true else gh release create "$TAG" \ @@ -251,7 +313,7 @@ fi # --- Cleanup temp files --- if [ -n "$XZ_IMG" ]; then - rm -f "$XZ_IMG" "$IMG_SHA_UPLOAD" + rm -f "$XZ_IMG" "$IMG_SHA_UPLOAD" "${IMG_SHA_UPLOAD}.minisig" echo "Cleaned up temp files in /tmp" fi diff --git a/freebsd/tests/README.md b/freebsd/tests/README.md index 421453b5..4f4598d1 100644 --- a/freebsd/tests/README.md +++ b/freebsd/tests/README.md @@ -30,6 +30,7 @@ by hand on a test VM and under `vmactions/freebsd-vm` in CI | `t04-nat` | Outbound SNAT (translation visible in the pf state table) and rdr port-forward into the server jail, with return traffic | | `t05-restore-roundtrip` | #535: save → mutate → restore brings both DB and the live anchor back | | `t06-wireguard` | Tunnel creation on the real kernel + #541 pubkey-derives-from-privkey via `wg pubkey` (skips without wireguard-kmod) | +| `t08-control-plane` | Unauthenticated access rejection, invalid-login rejection, live PF/rule counters, identity, and representative JSON response shapes | ## Running on a test VM @@ -62,8 +63,10 @@ Expect ~20–30 minutes; the FreeBSD VM boot + build dominates. Runs on a **Linux** host with qemu (KVM when available): boots the built USB IMG unmodified with a seed ISO attached as a CD, waits for `aifw_firstboot` to complete unattended setup, then asserts the seeded -admin can log in through the LAN side and that the WAN side stays -default-denied. +admin can log in through the LAN side, invalid credentials are rejected, +the static UI is served, live PF/rule counters are reported, and the WAN +side stays default-denied. It also creates a rule, reboots the appliance, +and verifies PF plus the persisted live rule recover after boot. ```sh xz -dk aifw--amd64.img.xz diff --git a/freebsd/tests/lib.sh b/freebsd/tests/lib.sh index a40a1c72..ce9df52c 100644 --- a/freebsd/tests/lib.sh +++ b/freebsd/tests/lib.sh @@ -115,6 +115,16 @@ CLIENT_IP="10.99.1.2" SERVER_NET_HOST="10.99.2.1" SERVER_IP="10.99.2.2" +# IPv6 addressing for the cross-family (NAT64/NAT46, t09) tests. The client +# jail is dual-stack; the server jail is v4-only plus an embedded-address +# alias added by t09 for the NAT46 leg. +CLIENT6_NET_HOST="2001:db8:1::1" +CLIENT6_IP="2001:db8:1::2" +SERVER6_NET_HOST="2001:db8:2::1" +# Well-known NAT64 prefix + RFC 6052 embedding of SERVER_IP (10.99.2.2). +NAT64_PREFIX="64:ff9b::/96" +SERVER_IP_EMBEDDED="64:ff9b::a63:202" + jx_client() { jexec "$CLIENT_JAIL" "$@"; } jx_server() { jexec "$SERVER_JAIL" "$@"; } @@ -129,6 +139,16 @@ server_listen_once() { # $1 = port, $2 = marker file jx_server sh -c "rm -f '$2'; (nc -l '$1' >/dev/null 2>&1 && touch '$2') &" } +# One-shot UDP listener for packet-path tests. UDP has no connection refusal, +# so callers must wait for the marker rather than rely on nc's exit status. +server_listen_udp_once() { # $1 = port, $2 = marker file + jx_server sh -c "rm -f '$2'; (nc -u -l '$1' 2>/dev/null | (IFS= read -r line && touch '$2')) &" +} + +client_send_udp() { # $1 = host, $2 = port + printf 'aifw-functional-udp\n' | jx_client nc -u -w 2 "$1" "$2" >/dev/null 2>&1 +} + wait_for_file() { # $1 = jail (client|server), $2 = file, $3 = seconds _i=0 while [ "$_i" -lt "$3" ]; do diff --git a/freebsd/tests/run-all.sh b/freebsd/tests/run-all.sh index 7fc42494..5612567c 100644 --- a/freebsd/tests/run-all.sh +++ b/freebsd/tests/run-all.sh @@ -156,6 +156,20 @@ jx_server ifconfig "${EPAIR_S}b" inet "$SERVER_IP/24" up jx_server ifconfig lo0 127.0.0.1/8 up jx_server route -q add default "$SERVER_NET_HOST" >/dev/null +# IPv6 for the cross-family tests (t09): client jail is dual-stack, host +# routes v6, server leg keeps a host-side v6 for NAT46 sourcing. dad_count=0 +# avoids multi-second DAD holds on the freshly created epairs; -ifdisabled +# clears the default IFDISABLED flag jail-cloned interfaces inherit. +sysctl net.inet6.ip6.forwarding=1 >/dev/null +sysctl net.inet6.ip6.dad_count=0 >/dev/null 2>&1 || true +jx_client sysctl net.inet6.ip6.dad_count=0 >/dev/null 2>&1 || true +jx_server sysctl net.inet6.ip6.dad_count=0 >/dev/null 2>&1 || true +ifconfig "${EPAIR_C}a" inet6 -ifdisabled "$CLIENT6_NET_HOST/64" up +ifconfig "${EPAIR_S}a" inet6 -ifdisabled "$SERVER6_NET_HOST/64" up +jx_client ifconfig "${EPAIR_C}b" inet6 -ifdisabled "$CLIENT6_IP/64" up +jx_client route -q add -inet6 default "$CLIENT6_NET_HOST" >/dev/null +jx_server ifconfig "${EPAIR_S}b" inet6 -ifdisabled auto_linklocal up + # ---------------------------------------------------------------- pf.conf # Mirrors the anchor layout aifw-setup generates for the appliance @@ -176,6 +190,10 @@ rdr-anchor "aifw-nat" nat-anchor "aifw-vpn" pass quick on $MGMT_IF keep state +# ICMPv6 neighbor discovery — unlike ARP, ND is filterable IPv6 traffic and +# the trailing block would eat neighbor solicitations, killing all IPv6 on +# the test path (t09 NAT64, #531). Mirrors the appliance pf.conf. +pass quick inet6 proto icmp6 icmp6-type { routersol, routeradv, neighbrsol, neighbradv } anchor "aifw-pbr" anchor "aifw-mwan-leak" anchor "aifw-mwan-reply" @@ -231,7 +249,7 @@ log "API up, authenticated" # ---------------------------------------------------------------- run tests -TESTS="${TESTS:-t01-pfctl-acceptance t02-pass-block t03-schedule-gating t04-nat t05-restore-roundtrip t06-wireguard t07-ipsec}" +TESTS="${TESTS:-t01-pfctl-acceptance t02-pass-block t03-schedule-gating t04-nat t05-restore-roundtrip t06-wireguard t07-ipsec t08-control-plane t09-nat64 t10-dns64}" TOTAL=0 FAILED_TESTS="" diff --git a/freebsd/tests/smoke-boot.sh b/freebsd/tests/smoke-boot.sh index a09db6b2..5ded80e1 100644 --- a/freebsd/tests/smoke-boot.sh +++ b/freebsd/tests/smoke-boot.sh @@ -27,7 +27,7 @@ while [ $# -gt 0 ]; do done [ -n "$IMG" ] && [ -f "$IMG" ] || { echo "usage: smoke-boot.sh --img PATH" >&2; exit 2; } -for c in qemu-system-x86_64 curl jq; do +for c in qemu-system-x86_64 curl jq ssh ssh-keygen; do command -v "$c" >/dev/null 2>&1 || { echo "missing: $c" >&2; exit 2; } done MKISO="" @@ -44,9 +44,14 @@ ADMIN_USER="admin" ADMIN_PASS="SmokeTest123" LAN_PORT=18443 # host port forwarded to the appliance LAN address WAN_PORT=18081 # host port forwarded to the appliance WAN address (must stay blocked) +SSH_PORT=18422 # root SSH on the LAN side, used for reboot persistence # ---------------------------------------------------------------- seed ISO +ssh-keygen -q -t ed25519 -N '' -f "$WORK/id_ed25519" \ + || { echo "ephemeral SSH key generation failed" >&2; exit 2; } +SSH_PUB=$(cat "$WORK/id_ed25519.pub") + cat > "$WORK/aifw-seed.json" < "$WORK/aifw-seed.json" </dev/null 2>&1 || { log "FAIL: /api/v1/status did not return JSON: $status" exit 1 } +printf '%s' "$status" | jq -e '.pf_running == true and (.aifw_rules | type == "number") and (.nat_rules | type == "number")' >/dev/null 2>&1 || { + log "FAIL: status response did not report live PF/rule counters: $status" + exit 1 +} log "status endpoint OK" +# Create a distinctive rule, reboot the real appliance, and prove both the DB +# record and live PF anchor recover. This catches boot ordering and disabled-PF +# regressions that an API-only restart cannot expose. +rule=$(curl -k -s -m 15 -X POST \ + "$SCHEME://127.0.0.1:$LAN_PORT/api/v1/rules" \ + -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ + -d '{"action":"block","direction":"in","protocol":"tcp","src_addr":"203.0.113.77","dst_addr":"192.0.2.77","dst_port_start":65530,"dst_port_end":65530,"label":"smoke-reboot-persist"}') +RULE_ID=$(printf '%s' "$rule" | jq -r '.data.id // empty') +[ -n "$RULE_ID" ] || { + log "FAIL: could not create reboot-persistence rule: $rule" + exit 1 +} +curl -k -s -m 30 -X POST "$SCHEME://127.0.0.1:$LAN_PORT/api/v1/reload" \ + -H "Authorization: Bearer $TOKEN" >/dev/null || { + log "FAIL: reload before reboot failed" + exit 1 +} + +SSH="ssh -i $WORK/id_ed25519 -p $SSH_PORT -o BatchMode=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=5" +ssh_elapsed=0 +while [ "$ssh_elapsed" -lt 60 ]; do + # shellcheck disable=SC2086 + $SSH root@127.0.0.1 true >/dev/null 2>&1 && break + sleep 2 + ssh_elapsed=$((ssh_elapsed + 2)) +done +[ "$ssh_elapsed" -lt 60 ] || { log "FAIL: appliance SSH never became reachable"; exit 1; } + +log "requesting appliance reboot" +# A successful reboot normally drops the SSH transport before it can return. +# shellcheck disable=SC2086 +$SSH root@127.0.0.1 /sbin/shutdown -r now >/dev/null 2>&1 || true +sleep 10 + +TOKEN="" +elapsed=0 +while [ "$elapsed" -lt "$TIMEOUT" ]; do + resp=$(curl -k -s -m 5 -X POST "$SCHEME://127.0.0.1:$LAN_PORT/api/v1/auth/login" \ + -H 'Content-Type: application/json' -d "$login_body" 2>/dev/null) || true + TOKEN=$(printf '%s' "$resp" | jq -r '.tokens.access_token // empty' 2>/dev/null) + [ -n "$TOKEN" ] && break + sleep 5 + elapsed=$((elapsed + 5)) +done +[ -n "$TOKEN" ] || { log "FAIL: API did not recover after reboot"; exit 1; } + +post_status=$(curl -k -s -m 10 "$SCHEME://127.0.0.1:$LAN_PORT/api/v1/status" \ + -H "Authorization: Bearer $TOKEN") +printf '%s' "$post_status" | jq -e '.pf_running == true' >/dev/null 2>&1 || { + log "FAIL: post-reboot status reports PF is not running: $post_status" + exit 1 +} +post_rules=$(curl -k -s -m 10 "$SCHEME://127.0.0.1:$LAN_PORT/api/v1/rules" \ + -H "Authorization: Bearer $TOKEN") +printf '%s' "$post_rules" | jq -e --arg id "$RULE_ID" '.data[] | select(.id == $id and .label == "smoke-reboot-persist")' >/dev/null 2>&1 || { + log "FAIL: persisted rule missing from API after reboot: $post_rules" + exit 1 +} +# shellcheck disable=SC2086 +$SSH root@127.0.0.1 "pfctl -a aifw -sr" 2>/dev/null | grep -q 'smoke-reboot-persist' || { + log "FAIL: persisted rule missing from live PF anchor after reboot" + exit 1 +} +log "reboot recovery OK; PF enabled and persisted rule live" + # Default-deny on WAN: the management API port via the WAN interface must # NOT answer — the seed configures a LAN, so generate_pf_conf scopes the # SSH/API pass rules to it (#560). A reachable WAN-side API means that diff --git a/freebsd/tests/t02-pass-block.sh b/freebsd/tests/t02-pass-block.sh index ecb8e32b..01af589c 100644 --- a/freebsd/tests/t02-pass-block.sh +++ b/freebsd/tests/t02-pass-block.sh @@ -41,5 +41,34 @@ save_pf_artifacts t02 api DELETE "/api/v1/rules/$PASS_ID" >/dev/null || fail "delete pass" api_reload || fail "cleanup reload" +# 5. UDP follows the same default-deny/pass/remove contract. UDP has no +# handshake, so the server marker is the authoritative evidence of delivery. +UDP_PORT=8085 +UDP_MARKER=/tmp/aifwfx-t02-udp-marker +server_listen_udp_once "$UDP_PORT" "$UDP_MARKER" +client_send_udp "$SERVER_IP" "$UDP_PORT" +wait_for_file server "$UDP_MARKER" 3 && fail "default-deny: UDP reached server with no pass rule" + +UDP_ID=$(add_rule '{"action":"pass","direction":"in","protocol":"udp","dst_addr":"'"$SERVER_IP"'","dst_port_start":'"$UDP_PORT"',"dst_port_end":'"$UDP_PORT"',"label":"t02-udp-pass"}') || fail "create UDP pass" +api_reload || fail "reload UDP pass" +server_listen_udp_once "$UDP_PORT" "$UDP_MARKER" +client_send_udp "$SERVER_IP" "$UDP_PORT" +wait_for_file server "$UDP_MARKER" 3 || fail "UDP pass: server never saw the datagram" + +api DELETE "/api/v1/rules/$UDP_ID" >/dev/null || fail "delete UDP pass" +api_reload || fail "reload UDP removal" +server_listen_udp_once "$UDP_PORT" "$UDP_MARKER" +client_send_udp "$SERVER_IP" "$UDP_PORT" +wait_for_file server "$UDP_MARKER" 3 && fail "after removing UDP pass, datagram was delivered" + +# 6. ICMP is explicitly exercised instead of being inferred from TCP. +jx_client ping -c 1 -t 2 "$SERVER_IP" >/dev/null 2>&1 && fail "default-deny: ICMP reached server with no pass rule" +ICMP_ID=$(add_rule '{"action":"pass","direction":"in","protocol":"icmp","dst_addr":"'"$SERVER_IP"'","label":"t02-icmp-pass"}') || fail "create ICMP pass" +api_reload || fail "reload ICMP pass" +jx_client ping -c 1 -t 2 "$SERVER_IP" >/dev/null 2>&1 || fail "ICMP pass: echo request failed" +api DELETE "/api/v1/rules/$ICMP_ID" >/dev/null || fail "delete ICMP pass" +api_reload || fail "reload ICMP removal" +jx_client ping -c 1 -t 2 "$SERVER_IP" >/dev/null 2>&1 && fail "after removing ICMP pass, echo succeeded" + [ "$FAILURES" = 0 ] || exit 1 exit 0 diff --git a/freebsd/tests/t03-schedule-gating.sh b/freebsd/tests/t03-schedule-gating.sh index b2fc552f..7de39858 100644 --- a/freebsd/tests/t03-schedule-gating.sh +++ b/freebsd/tests/t03-schedule-gating.sh @@ -11,6 +11,7 @@ ALWAYS=$(api POST /api/v1/schedules '{"name":"t03-always","time_ranges":"00:00-0 # Closed window: a one-minute range starting two hours from now (local time, # same evaluation the engine uses), so it is deterministically inactive. H=$(date +%H) +H=${H#0} # strip leading zero — POSIX $(( )) reads "08"/"09" as bad octal CLOSED_H=$(( (H + 2) % 24 )) CLOSED_RANGE=$(printf '%02d:00-%02d:01' "$CLOSED_H" "$CLOSED_H") CLOSED=$(api POST /api/v1/schedules "{\"name\":\"t03-closed\",\"time_ranges\":\"$CLOSED_RANGE\",\"days_of_week\":\"mon,tue,wed,thu,fri,sat,sun\"}" | jq -r '.data.id') || fail "create closed schedule" diff --git a/freebsd/tests/t08-control-plane.sh b/freebsd/tests/t08-control-plane.sh new file mode 100644 index 00000000..35b2075d --- /dev/null +++ b/freebsd/tests/t08-control-plane.sh @@ -0,0 +1,36 @@ +# shellcheck shell=sh +# T08 — authenticated control-plane contract. The Linux/PfMock suite covers +# handler logic; this checks the shipped API, auth boundary, and representative +# response shapes on the same FreeBSD process used by the packet tests. + +BASE="$API_BASE" + +code=$(curl -sS -m 15 -o /dev/null -w '%{http_code}' "$BASE/api/v1/status") +[ "$code" = 401 ] || fail "protected /status returned $code without auth" + +code=$(curl -sS -m 15 -o /dev/null -w '%{http_code}' -X POST \ + "$BASE/api/v1/auth/login" -H 'Content-Type: application/json' \ + -d '{"username":"functest","password":"definitely-wrong"}') +[ "$code" = 401 ] || fail "invalid login returned $code" + +for endpoint in /api/v1/status /api/v1/about /api/v1/auth/me \ + /api/v1/rules /api/v1/nat /api/v1/interfaces/detailed /api/v1/auth/users; do + body=$(api GET "$endpoint") || { fail "GET $endpoint failed"; continue; } + printf '%s' "$body" | jq -e 'type == "object"' >/dev/null 2>&1 \ + || fail "$endpoint did not return a JSON object" +done + +status=$(api GET /api/v1/status) || fail "status request failed" +printf '%s' "$status" | jq -e '.pf_running == true and (.aifw_rules | type == "number") and (.nat_rules | type == "number")' >/dev/null 2>&1 \ + || fail "status response is missing live pf/rule counters: $status" + +me=$(api GET /api/v1/auth/me) || fail "auth/me request failed" +printf '%s' "$me" | jq -e '.username == "functest" and (.permissions | type == "array")' >/dev/null 2>&1 \ + || fail "auth/me response does not identify the authenticated user: $me" + +about=$(api GET /api/v1/about) || fail "about request failed" +printf '%s' "$about" | jq -e '(.version | type == "string") and (.memory | type == "object")' >/dev/null 2>&1 \ + || fail "about response is missing version/memory: $about" + +[ "$FAILURES" = 0 ] || exit 1 +exit 0 diff --git a/freebsd/tests/t08-fq-codel.sh b/freebsd/tests/t08-fq-codel.sh new file mode 100755 index 00000000..9d169a6d --- /dev/null +++ b/freebsd/tests/t08-fq-codel.sh @@ -0,0 +1,54 @@ +#!/bin/sh +# T08: real FreeBSD FQ-CoDel lifecycle and ownership qualification. +set -eu + +CONTROL=/usr/local/libexec/aifw-dummynet-control +WORK=$(mktemp -d /tmp/aifw-fq-codel.XXXXXX) +UNMANAGED_PIPE=9999 +MANAGED_PIPE=10001 +trap 'ipfw -q delete 30001 40001 29999 2>/dev/null || true; dnctl -q queue delete 10001 9999 2>/dev/null || true; dnctl -q sched delete 10001 9999 2>/dev/null || true; dnctl -q pipe delete 10001 9999 2>/dev/null || true; rm -rf "$WORK"' EXIT INT TERM HUP + +[ "$(id -u)" -eq 0 ] || { echo "T08 requires root" >&2; exit 77; } +kldload dummynet 2>/dev/null || true +sysctl net.inet.ip.fw.enable=1 >/dev/null + +cat > "$WORK/desired" </dev/null + +# A rejected command must not be able to escape the reserved ranges. +cat > "$WORK/hostile" <&2 + exit 1 +fi +dnctl pipe list | grep -q "^${UNMANAGED_PIPE}" + +# Empty desired state removes AiFw objects without touching admin objects. +printf 'clear\n' > "$WORK/clear" +"$CONTROL" apply "$WORK/clear" +"$CONTROL" verify "$WORK/clear" +! ipfw list 30001 >/dev/null 2>&1 +! dnctl pipe list | grep -q "^${MANAGED_PIPE}" +dnctl pipe list | grep -q "^${UNMANAGED_PIPE}" +ipfw list 29999 >/dev/null + +echo "T08 PASS: FQ-CoDel live lifecycle, IPv4/IPv6 classifiers, and ownership boundaries" diff --git a/freebsd/tests/t09-nat64.sh b/freebsd/tests/t09-nat64.sh new file mode 100644 index 00000000..fa74ef44 --- /dev/null +++ b/freebsd/tests/t09-nat64.sh @@ -0,0 +1,95 @@ +# shellcheck shell=sh +# T09 — cross-family translation (pf af-to, #531). +# +# NAT64: the v6-only client reaches the v4-only server through the +# well-known prefix (TCP, UDP, and ICMPv6→ICMP echo), with return traffic +# riding the pf state. NAT46: the v4 client reaches a v6 service bound to +# the RFC 6052 embedded address. Plus a negative case: the API must reject +# a wrong-family rule with 400. + +# --- NAT64: v6 client -> v4 server --------------------------------- +# Translation source is the host's server-leg v4 address, so the server +# jail routes replies straight back to the firewall. +api POST /api/v1/nat '{"nat_type":"nat64","interface":"'"${EPAIR_C}a"'","protocol":"any","src_addr":"2001:db8:1::/64","dst_addr":"'"$NAT64_PREFIX"'","redirect_addr":"'"$SERVER_NET_HOST"'","label":"t09-nat64"}' >/dev/null || fail "create nat64" +api_reload || fail "reload nat64" + +# The af-to rule is filter-class: it must appear in the anchor's -sr output. +pfctl -a aifw-nat -sr > "$RESULTS_DIR/t09-afto-rules.txt" 2>&1 +grep -q 'af-to' "$RESULTS_DIR/t09-afto-rules.txt" || fail "af-to rule not in aifw-nat filter ruleset" + +# Route the NAT64 prefix from the client jail via the firewall. +jx_client route -q add -inet6 "$NAT64_PREFIX" "$CLIENT6_NET_HOST" >/dev/null 2>&1 + +# TCP through the translator. +PORT=8091 +MARKER=/tmp/aifwfx-t09-tcp-marker +server_listen_once $PORT $MARKER +if client_can_reach "$SERVER_IP_EMBEDDED" $PORT; then + wait_for_file server $MARKER 5 || fail "nat64 tcp: connected but server never saw it" +else + fail "nat64 tcp: client cannot reach $SERVER_IP_EMBEDDED:$PORT" +fi + +# The state table must show the cross-family translation: the server-side +# state talks v4 to the server, sourced from the translation address. +/sbin/pfctl -ss > "$RESULTS_DIR/t09-states.txt" 2>&1 +grep "$SERVER_IP:$PORT" "$RESULTS_DIR/t09-states.txt" | grep -q "$SERVER_NET_HOST" \ + || fail "nat64 tcp: no v4-side state sourced from $SERVER_NET_HOST" + +# UDP through the translator. +UDP_PORT=8092 +UDP_MARKER=/tmp/aifwfx-t09-udp-marker +server_listen_udp_once $UDP_PORT $UDP_MARKER +client_send_udp "$SERVER_IP_EMBEDDED" $UDP_PORT +wait_for_file server $UDP_MARKER 5 || fail "nat64 udp: server never saw the datagram" + +# ICMPv6 echo -> ICMP echo (RFC 7915 translation) and the reply back. +if jx_client ping -6 -c 2 -t 5 "$SERVER_IP_EMBEDDED" > "$RESULTS_DIR/t09-ping6.txt" 2>&1; then + : +else + fail "nat64 icmp: ICMPv6 echo through the translator got no reply" +fi + +# --- NAT46: v4 client -> v6 service -------------------------------- +# The client reaches proxy v4 192.0.2.80; the translated destination is its +# RFC 6052 embedding in the /96 subnet of the translation source +# (SERVER6_NET_HOST) = 2001:db8:2::c000:250, which the server jail holds. +NAT46_V4="192.0.2.80" +NAT46_EMBEDDED="2001:db8:2::c000:250" +jx_server ifconfig "${EPAIR_S}b" inet6 -ifdisabled "$NAT46_EMBEDDED/64" alias +jx_client route -q add "$NAT46_V4" "$CLIENT_NET_HOST" >/dev/null 2>&1 + +api POST /api/v1/nat '{"nat_type":"nat46","interface":"'"${EPAIR_C}a"'","protocol":"tcp","src_addr":"10.99.1.0/24","dst_addr":"'"$NAT46_V4"'","redirect_addr":"'"$SERVER6_NET_HOST"'","label":"t09-nat46"}' >/dev/null || fail "create nat46" +api_reload || fail "reload nat46" + +PORT46=8093 +MARKER46=/tmp/aifwfx-t09-nat46-marker +# Explicit -6: plain `nc -l` binds the IPv4 wildcard only, so the translated +# IPv6 SYN would be RST'd and the test would fail with no listener at fault. +jx_server sh -c "rm -f '$MARKER46'; (nc -6 -l '$PORT46' >/dev/null 2>&1 && touch '$MARKER46') &" +if client_can_reach "$NAT46_V4" $PORT46; then + wait_for_file server $MARKER46 5 || fail "nat46 tcp: connected but server never saw it" +else + fail "nat46 tcp: v4 client cannot reach $NAT46_V4:$PORT46" +fi + +# --- negative: wrong-family rules are rejected with a message ------ +CODE=$(api_status POST /api/v1/nat '{"nat_type":"nat64","interface":"'"${EPAIR_C}a"'","protocol":"any","dst_addr":"'"$NAT64_PREFIX"'","redirect_addr":"2001:db8::1"}') +[ "$CODE" = "400" ] || fail "nat64 with IPv6 redirect must 400 (got $CODE)" + +CODE=$(api_status POST /api/v1/nat '{"nat_type":"nat64","interface":"'"${EPAIR_C}a"'","protocol":"any","dst_addr":"64:ff9b::/64","redirect_addr":"'"$SERVER_NET_HOST"'"}') +[ "$CODE" = "400" ] || fail "nat64 with /64 prefix must 400 (got $CODE)" + +save_pf_artifacts t09 + +# --- cleanup ------------------------------------------------------- +for id in $(api GET /api/v1/nat | jq -r '.data[] | select(.label != null and (.label | startswith("t09-"))) | .id'); do + api DELETE "/api/v1/nat/$id" >/dev/null || fail "delete nat $id" +done +api_reload || fail "cleanup reload" +jx_client route -q delete -inet6 "$NAT64_PREFIX" >/dev/null 2>&1 +jx_client route -q delete "$NAT46_V4" >/dev/null 2>&1 +jx_server ifconfig "${EPAIR_S}b" inet6 "$NAT46_EMBEDDED" -alias 2>/dev/null + +[ "$FAILURES" = 0 ] || exit 1 +exit 0 diff --git a/freebsd/tests/t10-dns64.sh b/freebsd/tests/t10-dns64.sh new file mode 100644 index 00000000..a1af4c91 --- /dev/null +++ b/freebsd/tests/t10-dns64.sh @@ -0,0 +1,130 @@ +# shellcheck shell=sh +# T10 — DNS64 synthesis (RFC 6147, #531): a hermetic two-instance rDNS +# setup. An authoritative upstream serves an A-only zone; the front +# resolver forwards to it with dns64 enabled. A AAAA query for the A-only +# name must return the RFC 6052 embedding in 64:ff9b::/96; names with no +# records must stay NXDOMAIN (never synthesized). +# +# SKIPs (exit 0 with a note) when no rdns binary is available or the +# installed one predates DNS64 — the harness must stay green on hosts +# where only the AiFw binaries are under test. Override the binary with +# RDNS_BIN=/path/to/rdns. + +RDNS_BIN="${RDNS_BIN:-/usr/local/sbin/rdns}" +if [ ! -x "$RDNS_BIN" ]; then + note "SKIP: no rdns binary at $RDNS_BIN (set RDNS_BIN to test DNS64)" + exit 0 +fi + +# Capability probe on the BINARY, not the runtime log: a build that knows +# DNS64 embeds the enable-log string. This keeps "binary predates DNS64" +# (skip) distinct from "DNS64 configured but failed to enable" (fail) — +# otherwise a regression in the enable path would go green-by-skip. +if ! strings "$RDNS_BIN" 2>/dev/null | grep -q "DNS64 synthesis enabled"; then + note "SKIP: rdns at $RDNS_BIN has no DNS64 support (pre-#531 build)" + exit 0 +fi + +T10_DIR="$WORK_DIR/t10-dns64" +mkdir -p "$T10_DIR/zones" + +cat > "$T10_DIR/zones/t10test.internal.zone" < "$T10_DIR/upstream.toml" < "$T10_DIR/front.toml" < "$RESULTS_DIR/t10-upstream.log" 2>&1 & +T10_UP_PID=$! +RDNS_LOCK_PATH="$T10_DIR/front.lock" \ + "$RDNS_BIN" --config "$T10_DIR/front.toml" > "$RESULTS_DIR/t10-front.log" 2>&1 & +T10_FRONT_PID=$! +t10_cleanup() { kill "$T10_UP_PID" "$T10_FRONT_PID" 2>/dev/null; } +sleep 2 + +# The binary supports DNS64 (probed above), so a missing enable line here +# is a real failure, not a skip. +if ! grep -q "DNS64 synthesis enabled" "$RESULTS_DIR/t10-front.log"; then + fail "dns64: binary supports DNS64 but it did not enable (see t10-front.log)" + t10_cleanup + exit 1 +fi + +# A-only name through DNS64: expect the RFC 6052 embedding of 10.99.2.2. +drill -p 15354 @127.0.0.1 AAAA v4only.t10test.internal > "$RESULTS_DIR/t10-aaaa.txt" 2>&1 +grep -q "64:ff9b::a63:202" "$RESULTS_DIR/t10-aaaa.txt" \ + || fail "dns64: AAAA for A-only name not synthesized (see t10-aaaa.txt)" + +# CNAME to an A-only target must ALSO synthesize (RFC 6147 §5.1.6) — the +# answer section is non-empty (the chain), which the original gate got +# wrong; this is the CDN-hosted-site shape (#531 review H1). +drill -p 15354 @127.0.0.1 AAAA alias.t10test.internal > "$RESULTS_DIR/t10-cname.txt" 2>&1 +grep -q "64:ff9b::a63:202" "$RESULTS_DIR/t10-cname.txt" \ + || fail "dns64: CNAME'd A-only name not synthesized (see t10-cname.txt)" + +# The A record itself must still resolve normally. +drill -p 15354 @127.0.0.1 A v4only.t10test.internal > "$RESULTS_DIR/t10-a.txt" 2>&1 +grep -q "10.99.2.2" "$RESULTS_DIR/t10-a.txt" || fail "dns64: A lookup broken" + +# NXDOMAIN is never synthesized. +drill -p 15354 @127.0.0.1 AAAA missing.t10test.internal > "$RESULTS_DIR/t10-nx.txt" 2>&1 +grep -q "NXDOMAIN" "$RESULTS_DIR/t10-nx.txt" || fail "dns64: NXDOMAIN must not be synthesized" +grep -q "64:ff9b" "$RESULTS_DIR/t10-nx.txt" && fail "dns64: synthesized an answer for NXDOMAIN" + +# Cached second query keeps the synthesized answer (positive AAAA cache). +drill -p 15354 @127.0.0.1 AAAA v4only.t10test.internal > "$RESULTS_DIR/t10-aaaa2.txt" 2>&1 +grep -q "64:ff9b::a63:202" "$RESULTS_DIR/t10-aaaa2.txt" \ + || fail "dns64: synthesized answer lost on cache hit" + +t10_cleanup + +[ "$FAILURES" = 0 ] || exit 1 +exit 0