diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 359716f9..ee3df224 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -87,12 +87,26 @@ jobs: run: | sudo apt-get update sudo apt-get install -y --no-install-recommends jq - python3 -m pip install --upgrade platformio + python3 -m pip install platformio==6.1.18 + + - name: Provision verified public trust anchors + working-directory: iobox-firmware + env: + BF_IOBOX_TLS_CA_PEM: ${{ vars.BF_IOBOX_TLS_CA_PEM }} + BF_IOBOX_OTA_PUBLIC_KEY_PEM: ${{ vars.BF_IOBOX_OTA_PUBLIC_KEY_PEM }} + run: python3 scripts/provision_trust.py - name: Build ioBOX firmware working-directory: iobox-firmware run: pio run -e ${{ matrix.env }} + - name: Test firmware trust contracts + if: matrix.env == 'iobox_wifi' + working-directory: iobox-firmware + run: | + python3 -m unittest discover -s tests -v + node tests/test_ota_signature.mjs + - name: Package firmware artifact working-directory: iobox-firmware run: | @@ -220,7 +234,7 @@ jobs: BF_BUILD_VERSION: ${{ inputs.version }} BF_AXIOM_KEY: ${{ secrets.BF_AXIOM_KEY }} BF_AXIOM_DATASET: ${{ secrets.BF_AXIOM_DATASET }} - run: cargo build --release --target ${{ matrix.rust_target }} + run: cargo build --release --locked --target ${{ matrix.rust_target }} - name: Strip + rename working-directory: client @@ -335,7 +349,7 @@ jobs: "LIB=C:\Program Files\gstreamer\1.0\msvc_x86_64\lib;$env:LIB" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append - name: Build and test Windows client working-directory: client - run: cargo test --release + run: cargo test --release --locked - name: Build MSI working-directory: client env: diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml new file mode 100644 index 00000000..44f2f014 --- /dev/null +++ b/.github/workflows/validate.yml @@ -0,0 +1,133 @@ +name: validate + +on: + pull_request: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: validate-${{ github.ref }} + cancel-in-progress: true + +jobs: + server: + runs-on: ubuntu-24.04 + services: + postgres: + image: postgres:18-alpine + env: + POSTGRES_USER: betterframe + POSTGRES_PASSWORD: integration-only + POSTGRES_DB: betterframe_test + ports: ["5432:5432"] + options: >- + --health-cmd "pg_isready -U betterframe -d betterframe_test" + --health-interval 5s --health-timeout 5s --health-retries 10 + env: + BF_TEST_PG_URL: postgres://betterframe:integration-only@127.0.0.1:5432/betterframe_test + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version: 24 + cache: npm + - run: npm ci + - run: npm test + - run: npm run build + - name: Node-RED routing and deployment syntax + env: + BF_NODERED_MANAGER_SECRET: 0123456789abcdef0123456789abcdef + BF_NODERED_MANAGER_SELF_TEST: "1" + run: | + node deploy/nodered-manager/manager.mjs + find deploy scripts -name '*.sh' -print0 | xargs -0 -n1 bash -n + + client: + runs-on: ubuntu-24.04 + env: + CARGO_BUILD_JOBS: "2" + CARGO_PROFILE_DEV_DEBUG: "0" + CARGO_PROFILE_TEST_DEBUG: "0" + steps: + - uses: actions/checkout@v6 + - uses: dtolnay/rust-toolchain@stable + - name: Install native client dependencies + run: | + sudo apt-get update + sudo apt-get install -y libgtk-4-dev libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev libwebkitgtk-6.0-dev libssl-dev + - run: cargo test --manifest-path client/Cargo.toml --workspace --locked + + windows-client: + runs-on: windows-latest + env: + GSTREAMER_VERSION: "1.26.9" + GSTREAMER_RELEASE: deps-1 + GSTREAMER_RUNTIME_SHA256: ef56a1e9077c6cadcf8384369d1e435a0f3bc0d5a26594773256cffe79f40ecb + GSTREAMER_DEVEL_SHA256: 79c7ca2a5013181d94a2c823ef1c8a575f3e036e813633dc3e5ac3d393581a15 + GSTREAMER_MSM_SHA256: d3f16159d60ac5c7df6be73afdc6715b08e6de77906364ddb8560f706a09452d + steps: + - uses: actions/checkout@v6 + - uses: dtolnay/rust-toolchain@stable + - name: Install GStreamer SDK + shell: powershell + env: + GH_TOKEN: ${{ github.token }} + run: | + $runtime = Join-Path $env:RUNNER_TEMP "gstreamer-1.0-msvc-x86_64-$env:GSTREAMER_VERSION.msi" + $devel = Join-Path $env:RUNNER_TEMP "gstreamer-1.0-devel-msvc-x86_64-$env:GSTREAMER_VERSION.msi" + gh release download $env:GSTREAMER_RELEASE --repo $env:GITHUB_REPOSITORY --dir $env:RUNNER_TEMP ` + --pattern (Split-Path $runtime -Leaf) --pattern (Split-Path $devel -Leaf) + @( + @{ Path = $runtime; Hash = $env:GSTREAMER_RUNTIME_SHA256 }, + @{ Path = $devel; Hash = $env:GSTREAMER_DEVEL_SHA256 } + ) | ForEach-Object { + $actualHash = (Get-FileHash $_.Path -Algorithm SHA256).Hash.ToLowerInvariant() + if ($actualHash -ne $_.Hash) { throw "GStreamer checksum mismatch for $($_.Path): $actualHash" } + $arguments = "/i `"$($_.Path)`" ADDLOCAL=ALL /qn /norestart" + $installer = Start-Process msiexec.exe -ArgumentList $arguments -Wait -PassThru + if ($installer.ExitCode -notin 0, 3010) { throw "GStreamer install failed with exit code $($installer.ExitCode)" } + } + $pkgConfigPath = "C:/Program Files/gstreamer/1.0/msvc_x86_64/lib/pkgconfig" + if (-not (Test-Path "$pkgConfigPath/glib-2.0.pc")) { throw "GStreamer development files were not installed" } + C:\msys64\usr\bin\bash.exe -lc "pacman -Sy --noconfirm --needed mingw-w64-x86_64-pkgconf" + $pkgConfig = "C:\msys64\mingw64\bin\pkg-config.exe" + $env:PKG_CONFIG_PATH = $pkgConfigPath + & $pkgConfig --modversion glib-2.0 + if ($LASTEXITCODE -ne 0) { throw "pkg-config could not find GStreamer GLib" } + "C:\Program Files\gstreamer\1.0\msvc_x86_64\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append + "PKG_CONFIG=$pkgConfig" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + "PKG_CONFIG_PATH=$pkgConfigPath" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + "LIB=C:\Program Files\gstreamer\1.0\msvc_x86_64\lib;$env:LIB" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + - name: Build and test Windows client + working-directory: client + run: cargo test --workspace --locked + + iobox: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version: 24 + - uses: actions/setup-python@v6 + with: + python-version: '3.12' + - name: Install pinned firmware toolchain manager + run: python3 -m pip install platformio==6.1.18 + - name: Generate disposable public test trust anchors + working-directory: iobox-firmware + run: | + openssl req -x509 -newkey rsa:2048 -nodes -days 1 -subj '/CN=ioBOX CI test CA' -keyout "$RUNNER_TEMP/iobox-ca.key" -out "$RUNNER_TEMP/iobox-ca.pem" + openssl genpkey -algorithm ED25519 -out "$RUNNER_TEMP/iobox-sign.key" + openssl pkey -in "$RUNNER_TEMP/iobox-sign.key" -pubout -out "$RUNNER_TEMP/iobox-sign.pub.pem" + python3 scripts/provision_trust.py --ca "$RUNNER_TEMP/iobox-ca.pem" --signing-public-key "$RUNNER_TEMP/iobox-sign.pub.pem" + - name: Build Wi-Fi and W5500 variants with verification enabled + working-directory: iobox-firmware + run: pio run -e iobox_wifi -e iobox_eth + - name: Verify provisioning and server-to-firmware signature contract + working-directory: iobox-firmware + run: | + python3 -m unittest discover -s tests -v + node tests/test_ota_signature.mjs diff --git a/client/core/src/protocol.rs b/client/core/src/protocol.rs index ecdf8e16..5e59c688 100644 --- a/client/core/src/protocol.rs +++ b/client/core/src/protocol.rs @@ -1,5 +1,6 @@ -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use serde_json::Value; +use std::time::Duration; use url::Url; pub const LOCAL_SERVER_URL: &str = "http://localhost"; @@ -13,15 +14,103 @@ pub fn server_origin(url: &Url) -> String { url.origin().ascii_serialization() } -#[derive(Debug, Deserialize)] +#[derive(Clone, Deserialize, Serialize)] pub struct PairInitiateResponse { pub code: String, pub expires_at: String, + pub expires_in_seconds: Option, + pub poll_after_ms: Option, + pub polling_secret: Option, } -#[derive(Debug, Deserialize)] +impl PairInitiateResponse { + /// Old servers only provide wall-clock expiry. Bound those sessions too, + /// without trusting a kiosk clock that may not yet have synchronized. + pub fn lifetime(&self) -> Duration { + Duration::from_secs(self.expires_in_seconds.unwrap_or(900).clamp(1, 1800)) + } + + pub fn poll_delay(&self) -> Duration { + poll_delay(self.poll_after_ms) + } +} + +pub fn claim_body(code: &str, polling_secret: Option<&str>) -> Value { + let mut body = serde_json::json!({ "code": code }); + if let Some(secret) = polling_secret { + body["polling_secret"] = Value::String(secret.to_string()); + } + body +} + +pub fn poll_delay(milliseconds: Option) -> Duration { + Duration::from_millis(milliseconds.unwrap_or(2000).clamp(1000, 60000)) +} + +#[derive(Clone, Deserialize, Serialize)] +pub struct DeviceIdentity { + pub version: u32, + pub server_url: String, + pub kiosk_id: String, + pub kiosk_name: String, + pub kiosk_key: String, + pub cluster_key: Option, + pub encrypt_key: Option, + pub pairing_code: String, + pub polling_secret: Option, +} + +impl DeviceIdentity { + pub fn from_claim( + server: &str, + session: &PairInitiateResponse, + claim: PairClaimResponse, + ) -> Result { + let identity = Self { + version: 1, + server_url: server.to_string(), + kiosk_id: match claim.kiosk_id { + Some(Value::String(id)) => id, + Some(Value::Number(id)) => id.to_string(), + _ => return Err("claim is missing kiosk ID".into()), + }, + kiosk_name: claim.kiosk_name.unwrap_or_else(|| "kiosk".into()), + kiosk_key: claim.kiosk_key.ok_or("claim is missing kiosk key")?, + cluster_key: claim.cluster_key, + encrypt_key: claim.encrypt_key, + pairing_code: session.code.clone(), + polling_secret: session.polling_secret.clone(), + }; + identity.validate()?; + Ok(identity) + } + + pub fn validate(&self) -> Result<(), String> { + if self.version != 1 || self.kiosk_id.trim().is_empty() || self.kiosk_key.trim().is_empty() + { + return Err("invalid saved device identity".into()); + } + let origin = Url::parse(&self.server_url).map_err(|_| "invalid identity server URL")?; + if !matches!(origin.scheme(), "http" | "https") || origin.host_str().is_none() { + return Err("invalid identity server URL".into()); + } + if !self + .encrypt_key + .as_deref() + .or(self.cluster_key.as_deref()) + .is_some_and(|key| !key.trim().is_empty()) + { + return Err("device identity is missing encryption material".into()); + } + Ok(()) + } +} + +#[derive(Deserialize)] pub struct PairClaimResponse { pub status: String, + pub expires_in_seconds: Option, + pub poll_after_ms: Option, pub kiosk_id: Option, pub kiosk_name: Option, pub kiosk_key: Option, @@ -61,6 +150,35 @@ pub fn websocket_url(server_url: &str, token: &str) -> Result { mod tests { use super::*; + #[test] + fn pairing_supports_legacy_and_bounds_untrusted_timing() { + let legacy: PairInitiateResponse = + serde_json::from_str(r#"{"code":"ABC123","expires_at":"invalid"}"#).unwrap(); + assert_eq!(legacy.lifetime(), Duration::from_secs(900)); + assert_eq!(legacy.poll_delay(), Duration::from_secs(2)); + let modern: PairInitiateResponse = serde_json::from_str(r#"{"code":"ABC123","expires_at":"invalid","expires_in_seconds":18446744073709551615,"poll_after_ms":0}"#).unwrap(); + assert_eq!(modern.lifetime(), Duration::from_secs(1800)); + assert_eq!(modern.poll_delay(), Duration::from_secs(1)); + } + + #[test] + fn incomplete_claim_cannot_be_saved_as_paired() { + let session: PairInitiateResponse = + serde_json::from_str(r#"{"code":"ABC123","expires_at":"invalid"}"#).unwrap(); + let parse = |json| serde_json::from_str::(json).unwrap(); + assert!( + DeviceIdentity::from_claim( + "https://example.com", + &session, + parse(r#"{"status":"claimed","kiosk_key":"key","kiosk_id":"42"}"#) + ) + .is_err() + ); + let identity = DeviceIdentity::from_claim("https://example.com", &session, parse(r#"{"status":"claimed","kiosk_key":"key","kiosk_id":42,"encrypt_key":"encryption-key"}"#)).unwrap(); + assert_eq!(identity.kiosk_id, "42"); + assert!(identity.validate().is_ok()); + } + #[test] fn websocket_url_preserves_proxy_or_maps_direct_api_port() { assert_eq!( diff --git a/client/core/src/state.rs b/client/core/src/state.rs index d77431fb..ffc77aa3 100644 --- a/client/core/src/state.rs +++ b/client/core/src/state.rs @@ -20,6 +20,8 @@ pub struct ClientState { #[serde(default)] pub pairing_expires_at: Option, #[serde(default)] + pub pairing_secret: Option, + #[serde(default)] pub active_layouts: HashMap, } diff --git a/client/src/platform/linux/at_rest.rs b/client/src/platform/linux/at_rest.rs index f6a2096f..48e9a49b 100644 --- a/client/src/platform/linux/at_rest.rs +++ b/client/src/platform/linux/at_rest.rs @@ -33,25 +33,29 @@ const TPM_MAGIC: &[u8; 4] = b"BFE2"; const HKDF_SALT: &[u8] = b"betterframe-at-rest-v1"; const HKDF_INFO: &[u8] = b"file-encryption"; -fn active_key() -> &'static ([u8; 4], [u8; 32]) { +fn active_key() -> Result<&'static ([u8; 4], [u8; 32]), String> { static ACTIVE: OnceLock<([u8; 4], [u8; 32])> = OnceLock::new(); - ACTIVE.get_or_init(|| { - let sealed = std::path::Path::new("/var/lib/betterframe/at-rest.cred"); - if sealed.is_file() { - let output = Command::new("systemd-creds") - .args(["--name=betterframe-at-rest", "decrypt"]) - .arg(sealed) - .arg("-") - .output() - .expect("TPM-sealed at-rest key could not be decrypted"); - if !output.status.success() || output.stdout.is_empty() { - panic!("TPM-sealed at-rest key could not be decrypted"); - } - (*TPM_MAGIC, derive_key(&output.stdout)) - } else { - (*LEGACY_MAGIC, *legacy_key()) + if let Some(key) = ACTIVE.get() { + return Ok(key); + } + let sealed = std::path::Path::new("/var/lib/betterframe/at-rest.cred"); + let key = if sealed.is_file() { + let output = Command::new("systemd-creds") + .args(["--name=betterframe-at-rest", "decrypt"]) + .arg(sealed) + .arg("-") + .output() + .map_err(|error| format!("TPM credential unavailable: {error}"))?; + if !output.status.success() || output.stdout.is_empty() { + return Err("TPM-sealed at-rest key could not be decrypted".into()); } - }) + (*TPM_MAGIC, derive_key(&output.stdout)) + } else { + (*LEGACY_MAGIC, *legacy_key()) + }; + // Cache only success; a temporary TPM/storage error remains retryable. + let _ = ACTIVE.set(key); + ACTIVE.get().ok_or_else(|| "at-rest key unavailable".into()) } fn legacy_key() -> &'static [u8; 32] { @@ -99,20 +103,20 @@ fn derive_key(material: &[u8]) -> [u8; 32] { /// Encrypt plaintext for on-disk storage. Each call uses a fresh random /// nonce (AES-GCM is unsafe to reuse a nonce under the same key). -pub fn encrypt_for_disk(plaintext: &[u8]) -> Vec { - let (magic, key_bytes) = active_key(); +pub fn encrypt_for_disk(plaintext: &[u8]) -> Result, String> { + let (magic, key_bytes) = active_key()?; let cipher = Aes256Gcm::new(Key::::from_slice(key_bytes)); let mut nonce_bytes = [0u8; 12]; rand::thread_rng().fill_bytes(&mut nonce_bytes); let nonce = Nonce::from_slice(&nonce_bytes); let ciphertext = cipher .encrypt(nonce, plaintext) - .expect("AES-GCM encrypt: only fails on >2^36 byte input"); + .map_err(|_| "Unable to encrypt device state")?; let mut out = Vec::with_capacity(magic.len() + nonce_bytes.len() + ciphertext.len()); out.extend_from_slice(magic); out.extend_from_slice(&nonce_bytes); out.extend_from_slice(&ciphertext); - out + Ok(out) } /// Decrypt an on-disk blob. Returns Err for both "not our format" and @@ -124,7 +128,7 @@ pub fn decrypt_from_disk(blob: &[u8]) -> Result, String> { } let key_bytes = match &blob[..LEGACY_MAGIC.len()] { magic if magic == LEGACY_MAGIC => legacy_key(), - magic if magic == TPM_MAGIC && active_key().0 == *TPM_MAGIC => &active_key().1, + magic if magic == TPM_MAGIC && active_key()?.0 == *TPM_MAGIC => &active_key()?.1, magic if magic == TPM_MAGIC => return Err("TPM credential missing".to_string()), _ => return Err("missing BetterFrame encryption magic".to_string()), }; @@ -145,7 +149,8 @@ pub fn read_maybe_encrypted(path: &std::path::Path) -> Option> { let bytes = fs::read(path).ok()?; match decrypt_from_disk(&bytes) { Ok(pt) => { - if bytes.starts_with(LEGACY_MAGIC) && active_key().0 == *TPM_MAGIC { + if bytes.starts_with(LEGACY_MAGIC) && active_key().is_ok_and(|key| key.0 == *TPM_MAGIC) + { let _ = write_encrypted(path, &pt); } Some(pt) @@ -165,23 +170,63 @@ pub fn read_text_maybe_encrypted(path: &std::path::Path) -> Option { /// Write plaintext encrypted-on-disk. Atomic via tempfile + rename so a /// crash mid-write can't leave a half-encrypted file. pub fn write_encrypted(path: &std::path::Path, plaintext: &[u8]) -> std::io::Result<()> { - let blob = encrypt_for_disk(plaintext); - let tmp = path.with_extension("tmp"); - fs::write(&tmp, &blob)?; - fs::rename(&tmp, path)?; - Ok(()) + let blob = encrypt_for_disk(plaintext).map_err(std::io::Error::other)?; + use std::io::Write; + use std::os::unix::fs::OpenOptionsExt; + let tmp = path.with_extension(format!( + "{}.{}.tmp", + std::process::id(), + rand::random::() + )); + let result = (|| { + let mut file = fs::OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(&tmp)?; + file.write_all(&blob)?; + file.sync_all()?; + fs::rename(&tmp, path)?; + if let Some(parent) = path.parent() { + fs::File::open(parent)?.sync_all()?; + } + Ok(()) + })(); + if result.is_err() { + let _ = fs::remove_file(&tmp); + } + result } #[cfg(test)] mod tests { use super::*; + #[test] + fn encrypted_identity_replaces_atomically_and_rejects_corruption() { + let dir = std::env::temp_dir().join(format!("bf-identity-{}", rand::random::())); + fs::create_dir(&dir).unwrap(); + let path = dir.join("identity.json"); + write_encrypted(&path, b"original identity").unwrap(); + write_encrypted(&path, b"complete replacement identity").unwrap(); + assert_eq!( + read_maybe_encrypted(&path).unwrap(), + b"complete replacement identity" + ); + assert_eq!(fs::read_dir(&dir).unwrap().count(), 1); + let mut bytes = fs::read(&path).unwrap(); + *bytes.last_mut().unwrap() ^= 1; + fs::write(&path, bytes).unwrap(); + assert!(read_maybe_encrypted(&path).is_none()); + fs::remove_dir_all(dir).unwrap(); + } + #[test] fn round_trip_short() { let pt = b"hello world"; - let ct = encrypt_for_disk(pt); + let ct = encrypt_for_disk(pt).unwrap(); assert_ne!(&ct[..LEGACY_MAGIC.len() + 12], pt); - assert_eq!(&ct[..LEGACY_MAGIC.len()], &active_key().0); + assert_eq!(&ct[..LEGACY_MAGIC.len()], &active_key().unwrap().0); let back = decrypt_from_disk(&ct).expect("decrypt"); assert_eq!(back, pt); } @@ -193,7 +238,7 @@ mod tests { "cameras": [{"id": 1, "rtsp": "rtsp://u:p@host/path"}], })) .unwrap(); - let ct = encrypt_for_disk(&pt); + let ct = encrypt_for_disk(&pt).unwrap(); let back = decrypt_from_disk(&ct).expect("decrypt"); assert_eq!(back, pt); } diff --git a/client/src/platform/linux/firmware.rs b/client/src/platform/linux/firmware.rs index f23901e5..c4b2ff66 100644 --- a/client/src/platform/linux/firmware.rs +++ b/client/src/platform/linux/firmware.rs @@ -106,9 +106,14 @@ pub fn check_public(server: &str, current_version: &str) -> Option { let client = reqwest::blocking::Client::new(); let resp = match client.get(&url).timeout(Duration::from_secs(10)).send() { Ok(r) => r, - Err(err) => { warn!("preboot firmware check: {err}"); return None; } + Err(err) => { + warn!("preboot firmware check: {err}"); + return None; + } }; - if !resp.status().is_success() { return None; } + if !resp.status().is_success() { + return None; + } resp.json::() .ok() .and_then(|check| newer_update(check, current_version)) @@ -118,10 +123,14 @@ pub fn check_public(server: &str, current_version: &str) -> Option { /// On success exits so systemd restarts with new binary. pub fn apply_public(server: &str, info: &UpdateInfo) -> Result<(), String> { ensure_upgrade(info, crate::server::kiosk_app_version())?; - info!("preboot firmware: applying {} ({} bytes)", info.version, info.size_bytes); + info!( + "preboot firmware: applying {} ({} bytes)", + info.version, info.size_bytes + ); let download_url = format!("{server}{}", info.download_url); let client = reqwest::blocking::Client::new(); - let resp = client.get(&download_url) + let resp = client + .get(&download_url) .timeout(Duration::from_secs(300)) .send() .map_err(|e| format!("download failed: {e}"))?; @@ -133,7 +142,10 @@ pub fn apply_public(server: &str, info: &UpdateInfo) -> Result<(), String> { hasher.update(&bytes); let got_sha = hex_lower(&hasher.finalize()); if got_sha != info.sha256 { - return Err(format!("sha256 mismatch: expected {}, got {}", info.sha256, got_sha)); + return Err(format!( + "sha256 mismatch: expected {}, got {}", + info.sha256, got_sha + )); } verify_signature(&info.sha256, &info.signature) .map_err(|e| format!("signature verify: {e}"))?; @@ -144,7 +156,10 @@ pub fn apply_public(server: &str, info: &UpdateInfo) -> Result<(), String> { { use std::os::unix::fs::OpenOptionsExt; let mut f = fs::OpenOptions::new() - .create(true).write(true).truncate(true).mode(0o755) + .create(true) + .write(true) + .truncate(true) + .mode(0o755) .open(&new_path) .map_err(|e| format!("open {}: {e}", new_path.display()))?; use std::io::Write; @@ -156,7 +171,9 @@ pub fn apply_public(server: &str, info: &UpdateInfo) -> Result<(), String> { } fs::rename(&new_path, &bin).map_err(|e| format!("rename: {e}"))?; info!("preboot firmware: updated to {}, rebooting", info.version); - let _ = std::process::Command::new("systemctl").arg("reboot").status(); + let _ = std::process::Command::new("systemctl") + .arg("reboot") + .status(); std::thread::sleep(Duration::from_secs(30)); std::process::exit(0); } @@ -186,9 +203,6 @@ pub fn check(server: &str, key: &str, current_version: &str) -> Option Result<(), String> { ensure_upgrade(info, crate::server::kiosk_app_version())?; - info!("firmware: applying {} ({} bytes)", info.version, info.size_bytes); + info!( + "firmware: applying {} ({} bytes)", + info.version, info.size_bytes + ); on_progress("Downloading", 0); // 1. Download @@ -225,11 +242,6 @@ pub fn apply( .timeout(Duration::from_secs(300)) .send() .map_err(|e| format!("download request: {e}"))?; - if resp.status().as_u16() == 401 { - crate::server::reset_pairing_and_restart( - "server rejected kiosk key during firmware download", - ); - } if !resp.status().is_success() { return Err(format!("download HTTP {}", resp.status())); @@ -252,7 +264,10 @@ pub fn apply( let digest = hasher.finalize(); let got_sha = hex_lower(&digest); if got_sha != info.sha256 { - return Err(format!("sha256 mismatch: expected {}, got {}", info.sha256, got_sha)); + return Err(format!( + "sha256 mismatch: expected {}, got {}", + info.sha256, got_sha + )); } // 3. Ed25519 signature verify (sig is over the hex-encoded sha256 string) @@ -277,7 +292,8 @@ pub fn apply( .mode_for_unix(0o755) .open(&new_path) .map_err(|e| format!("open {}: {e}", new_path.display()))?; - f.write_all(&bytes).map_err(|e| format!("write {}: {e}", new_path.display()))?; + f.write_all(&bytes) + .map_err(|e| format!("write {}: {e}", new_path.display()))?; f.sync_all().ok(); } if cancel_requested() { @@ -325,7 +341,10 @@ pub fn apply( on_progress("Rebooting", 100); info!("firmware: swap complete → rebooting to pick up new binary"); - match std::process::Command::new("systemctl").arg("reboot").status() { + match std::process::Command::new("systemctl") + .arg("reboot") + .status() + { Ok(_) => { std::thread::sleep(Duration::from_secs(30)); std::process::exit(0); @@ -480,7 +499,9 @@ impl OpenOptionsModeExt for fs::OpenOptions { #[cfg(not(unix))] impl OpenOptionsModeExt for fs::OpenOptions { - fn mode_for_unix(&mut self, _mode: u32) -> &mut Self { self } + fn mode_for_unix(&mut self, _mode: u32) -> &mut Self { + self + } } #[cfg(test)] diff --git a/client/src/platform/linux/os_update.rs b/client/src/platform/linux/os_update.rs index edc86f15..d5bbc342 100644 --- a/client/src/platform/linux/os_update.rs +++ b/client/src/platform/linux/os_update.rs @@ -147,11 +147,6 @@ fn check_at(server: &str, key: Option<&str>, path: &str) -> Option { return None; } }; - if key.is_some() && resp.status().as_u16() == 401 { - crate::server::reset_pairing_and_restart( - "server rejected kiosk key during os update check", - ); - } if !resp.status().is_success() { warn!("os-update check: HTTP {}", resp.status()); @@ -245,11 +240,6 @@ fn apply_inner( }; let status = resp.status().as_u16(); - if key.is_some() && status == 401 { - crate::server::reset_pairing_and_restart( - "server rejected kiosk key during os update download", - ); - } if status != 200 && status != 206 { return Err(format!("download HTTP {status}")); diff --git a/client/src/platform/linux/pipeline.rs b/client/src/platform/linux/pipeline.rs index 078f34b8..20521390 100644 --- a/client/src/platform/linux/pipeline.rs +++ b/client/src/platform/linux/pipeline.rs @@ -76,6 +76,7 @@ pub fn create_camera_pipeline( Arc, Arc, Arc, + Box, )> { let pipeline_name = format!( "cam-{name}-{}", @@ -189,6 +190,11 @@ pub fn create_camera_pipeline( let Some(sink_pad) = sink.static_pad("sink") else { return; }; + // The previous software conversion chain may still own the sink + // after NULL -> PLAYING. Detach it before switching to zero-copy. + if let Some(peer) = sink_pad.peer() { + let _ = peer.unlink(&sink_pad); + } if pad.link(&sink_pad).is_ok() { info!("[{decode_name}] decoder linked directly to GTK sink (zero-copy)"); return; @@ -198,16 +204,35 @@ pub fn create_camera_pipeline( let Some(pipeline) = pipeline_weak.upgrade() else { return; }; - let Ok(convert) = gst::ElementFactory::make("videoconvert").build() else { - error!("[{decode_name}] videoconvert is unavailable"); - return; + let convert = if let Some(convert) = pipeline.by_name("software-convert") { + convert + } else { + let Ok(convert) = gst::ElementFactory::make("videoconvert") + .name("software-convert") + .build() + else { + error!("[{decode_name}] videoconvert is unavailable"); + return; + }; + if pipeline.add(&convert).is_err() { + error!("[{decode_name}] could not add software converter"); + return; + } + convert }; let Some(convert_sink) = convert.static_pad("sink") else { return; }; - if pipeline.add(&convert).is_err() - || convert.link(&sink).is_err() - || pad.link(&convert_sink).is_err() + if let Some(peer) = convert_sink.peer() { + if peer != *pad { + let _ = peer.unlink(&convert_sink); + } + } + let Some(convert_src) = convert.static_pad("src") else { + return; + }; + if (!convert_src.is_linked() && convert.link(&sink).is_err()) + || (!convert_sink.is_linked() && pad.link(&convert_sink).is_err()) || convert.sync_state_with_parent().is_err() { error!("[{decode_name}] software conversion link failed"); @@ -251,7 +276,6 @@ pub fn create_camera_pipeline( gst::glib::ControlFlow::Continue }) .ok()?; - std::mem::forget(guard); let last_buffer = Arc::new(AtomicU64::new(epoch_millis())); if let Some(pad) = sink.static_pad("sink") { @@ -267,7 +291,7 @@ pub fn create_camera_pipeline( } info!("[{pipeline_name}] pipeline created for {rtsp_uri}"); - Some((pipeline, sink, last_buffer, status, stats)) + Some((pipeline, sink, last_buffer, status, stats, Box::new(guard))) } pub fn play(pipeline: &Pipeline) { diff --git a/client/src/platform/linux/server.rs b/client/src/platform/linux/server.rs index e33159fb..4b05e534 100644 --- a/client/src/platform/linux/server.rs +++ b/client/src/platform/linux/server.rs @@ -1,13 +1,13 @@ use std::fs; use std::path::PathBuf; use std::process::Command; -use std::time::Duration; +use std::time::{Duration, Instant}; +use crate::core::protocol::{DeviceIdentity, PairClaimResponse, PairInitiateResponse}; use serde::Deserialize; use serde_json::Value; use std::sync::Mutex; use std::sync::atomic::{AtomicBool, Ordering}; -use time::{OffsetDateTime, format_description::well_known::Rfc3339}; use tracing::info; use crate::bundle::KioskBundle; @@ -146,6 +146,20 @@ pub fn startup_network_summary() -> (String, String) { format_startup_network_summary(&read_network_interfaces()) } +#[cfg(test)] +fn state_dir() -> PathBuf { + static DIRECTORY: std::sync::OnceLock = std::sync::OnceLock::new(); + DIRECTORY + .get_or_init(|| { + let path = std::env::temp_dir() + .join(format!("betterframe-client-test-{}", rand::random::())); + fs::create_dir_all(&path).unwrap(); + path + }) + .clone() +} + +#[cfg(not(test))] fn state_dir() -> PathBuf { let persistent = PathBuf::from("/var/lib/betterframe/kiosk"); if fs::create_dir_all(&persistent).is_ok() { @@ -174,6 +188,8 @@ fn migrate_legacy_state(persistent: &PathBuf) { } for name in [ + "identity.json", + "pairing.json", "kiosk.key", "server.url", "bundle.json", @@ -270,13 +286,20 @@ pub fn load_cached_bundle() -> Option { } pub fn load_kiosk_id() -> Option { - load_cached_bundle().map(|b| b.kiosk_id) + load_identity() + .ok() + .map(|identity| identity.kiosk_id) + .or_else(|| load_cached_bundle().map(|b| b.kiosk_id)) } /// Discover the BetterFrame server. -pub fn discover_server(override_url: Option<&str>) -> String { +pub fn discover_server(override_url: Option<&str>) -> Result { if let Some(url) = override_url { - return url.to_string(); + return Ok(url.to_string()); + } + + if identity_file().exists() { + return Ok(load_identity()?.server_url); } // A paired kiosk must boot from cache without waiting for its server. @@ -285,11 +308,11 @@ pub fn discover_server(override_url: Option<&str>) -> String { let saved = saved.trim().to_string(); if !saved.is_empty() { if is_paired() { - return saved; + return Ok(saved); } if let Some(resolved) = healthy_server_origin(&saved) { fs::write(server_file(), &resolved).ok(); - return resolved; + return Ok(resolved); } } } @@ -302,11 +325,11 @@ pub fn discover_server(override_url: Option<&str>) -> String { info!("trying {url}..."); if let Some(resolved) = healthy_server_origin(url) { fs::write(server_file(), &resolved).ok(); - return resolved; + return Ok(resolved); } } - panic!("Could not find BetterFrame server"); + Err("Could not find BetterFrame server".into()) } fn healthy_server_origin(url: &str) -> Option { @@ -323,7 +346,7 @@ fn healthy_server_origin(url: &str) -> Option { /// Check if already paired (key file exists). pub fn is_paired() -> bool { - key_file().exists() + identity_file().exists() || key_file().exists() } /// Confirm with the server that our key is truly rejected before wiping. @@ -343,6 +366,8 @@ fn confirm_deletion(server: &str, key: &str) -> bool { fn remove_pairing_state_files(dir: &PathBuf) { for name in [ + "identity.json", + "pairing.json", "kiosk.key", "server.url", "bundle.json", @@ -373,133 +398,236 @@ fn wipe_and_restart() -> ! { reset_pairing_and_restart("server confirmed kiosk key is invalid") } -/// Load cluster key (if stored from pairing). Used for ONVIF password decrypt. +fn identity_file() -> PathBuf { + state_dir().join("identity.json") +} + +fn load_identity() -> Result { + let bytes = crate::at_rest::read_maybe_encrypted(&identity_file()) + .ok_or("Unable to read saved device identity")?; + let identity: DeviceIdentity = + serde_json::from_slice(&bytes).map_err(|_| "Invalid saved device identity")?; + identity.validate()?; + Ok(identity) +} + pub fn load_cluster_key() -> Option { + if identity_file().exists() { + return load_identity().ok()?.cluster_key; + } crate::at_rest::read_text_maybe_encrypted(&cluster_key_file()) } -/// Read stored kiosk key. Detects legacy plaintext (kiosks upgraded from -/// a pre-at_rest build) and re-stores it ciphertext in place so subsequent -/// SD-card extractions don't see the bearer token. -pub fn load_key() -> String { - let path = key_file(); - let raw = fs::read(&path).expect("failed to read kiosk key"); - let was_encrypted = crate::at_rest::decrypt_from_disk(&raw).is_ok(); - let key = crate::at_rest::read_text_maybe_encrypted(&path).expect("failed to decode kiosk key"); - if !was_encrypted { - // Best-effort migrate. If write fails (e.g. RO mount during a - // recovery boot) we still hand back the key so the kiosk works. - let _ = crate::at_rest::write_encrypted(&path, key.as_bytes()); +/// Keep legacy readers for existing deployments; a damaged new identity must +/// never silently fall back to an older bearer token or start a new enrollment. +pub fn load_key() -> Result { + if identity_file().exists() { + return Ok(load_identity()?.kiosk_key); } - key + crate::at_rest::read_text_maybe_encrypted(&key_file()) + .filter(|key| !key.is_empty()) + .ok_or_else(|| "Unable to read saved kiosk key".into()) } -/// Initiate pairing — returns (code, expires_at). -pub fn initiate_pairing(server: &str) -> (String, String) { +fn pairing_client() -> Result { + reqwest::blocking::Client::builder() + .connect_timeout(Duration::from_secs(5)) + .timeout(Duration::from_secs(15)) + .build() + .map_err(|error| error.to_string()) +} + +/// Persist the polling secret before displaying the code so a reboot can +/// retrieve an already confirmed claim instead of creating an orphan kiosk. +pub fn initiate_pairing(server: &str) -> Result { + let pending_path = state_dir().join("pairing.json"); + if pending_path.exists() { + let bytes = crate::at_rest::read_maybe_encrypted(&pending_path) + .ok_or("Unable to read saved pairing session; restore storage or reset locally")?; + let (origin, session) = serde_json::from_slice::<(String, PairInitiateResponse)>(&bytes) + .map_err(|_| "Invalid saved pairing session; restore storage or reset locally")?; + if origin != server || session.code.trim().is_empty() { + return Err("Saved pairing session does not match this server; reset locally to change enrollment".into()); + } + return Ok(session); + } let hostname = hostname::get() .map(|h| h.to_string_lossy().to_string()) .unwrap_or_else(|_| "kiosk".into()); - let hw_model = fs::read_to_string("/proc/device-tree/model") .unwrap_or_else(|_| "unknown".into()) .replace('\0', ""); - - let client = reqwest::blocking::Client::new(); - let resp: crate::core::protocol::PairInitiateResponse = client + let resp: PairInitiateResponse = pairing_client()? .post(format!("{server}/api/pair/initiate")) .json(&serde_json::json!({ "proposed_name": hostname, "hardware_model": hw_model, "firmware_target": crate::firmware::FIRMWARE_TARGET, - "capabilities": ["rtsp", "gstreamer", "gtk4"] + "capabilities": ["rtsp", "gstreamer", "gtk4"], + "managed_image": std::path::Path::new("/etc/betterframe/managed-image").is_file(), + "secure_claim": true })) .send() - .expect("pairing initiate failed") + .and_then(|response| response.error_for_status()) + .map_err(|error| format!("Pairing connection failed: {error}"))? .json() - .expect("bad initiate response"); - - (resp.code, resp.expires_at) + .map_err(|error| format!("Invalid pairing response: {error}"))?; + if resp.code.trim().is_empty() { + return Err("Server returned an empty pairing code".into()); + } + let bytes = serde_json::to_vec(&(server, &resp)).map_err(|error| error.to_string())?; + crate::at_rest::write_encrypted(&pending_path, &bytes) + .map_err(|error| format!("Unable to save pairing session: {error}"))?; + Ok(resp) } fn encrypt_key_file() -> PathBuf { state_dir().join("encrypt.key") } -/// Load the per-kiosk encryption key. Preferred over cluster_key for -/// decrypting camera passwords in the bundle. pub fn load_encrypt_key() -> Option { + if identity_file().exists() { + return load_identity().ok()?.encrypt_key; + } crate::at_rest::read_text_maybe_encrypted(&encrypt_key_file()) } -/// Poll for pairing claim. Returns (name, key) when admin confirms. -pub fn poll_claim(server: &str, code: &str) -> (String, String) { - loop { - if let Some(claim) = poll_claim_once(server, code) { - return claim; +/// Retry delivery acknowledgments on startup and bundle retrieval. Unsupported +/// legacy servers simply retain their normal delivery window. +pub fn acknowledge_identity(server: &str) { + let Ok(mut identity) = load_identity() else { + return; + }; + if identity.server_url != server || identity.pairing_code.is_empty() { + return; + } + let Ok(client) = pairing_client() else { + return; + }; + if let Ok(response) = client + .post(format!("{server}/api/pair/ack")) + .bearer_auth(&identity.kiosk_key) + .json(&crate::core::protocol::claim_body( + &identity.pairing_code, + identity.polling_secret.as_deref(), + )) + .send() + { + if response.status().is_success() { + identity.pairing_code.clear(); + identity.polling_secret = None; + if let Ok(bytes) = serde_json::to_vec(&identity) { + let _ = crate::at_rest::write_encrypted(&identity_file(), &bytes); + } } - std::thread::sleep(Duration::from_secs(2)); } } -/// Poll for pairing claim until the server-provided expiry passes. -/// Returns None when the kiosk should request and show a fresh code. pub fn poll_claim_until_expiry( server: &str, - code: &str, - expires_at: &str, + session: &PairInitiateResponse, + status: impl Fn(&str), ) -> Option<(String, String)> { - let expires_at = OffsetDateTime::parse(expires_at, &Rfc3339).ok(); + let mut deadline = Instant::now() + session.lifetime(); + let mut delay = session.poll_delay(); loop { - if let Some(claim) = poll_claim_once(server, code) { - return Some(claim); - } - if expires_at - .map(|expires_at| OffsetDateTime::now_utc() >= expires_at) - .unwrap_or(false) - { - tracing::info!("pairing code {code} expired, requesting a fresh code"); - return None; - } - std::thread::sleep(Duration::from_secs(2)); - } -} - -fn poll_claim_once(server: &str, code: &str) -> Option<(String, String)> { - let client = reqwest::blocking::Client::new(); - let resp = client - .post(format!("{server}/api/pair/claim")) - .json(&serde_json::json!({ "code": code })) - .send() - .expect("claim request failed"); - - if resp.status().as_u16() == 200 { - let claim: crate::core::protocol::PairClaimResponse = - resp.json().expect("bad claim response"); - if claim.status == "claimed" { - let key = claim.kiosk_key.expect("missing kiosk_key"); - let name = claim.kiosk_name.unwrap_or_else(|| "kiosk".into()); - if let Some(ref id) = claim.kiosk_id { - let id_str = match id { - serde_json::Value::String(s) => s.clone(), - serde_json::Value::Number(n) => n.to_string(), - other => other.to_string(), - }; - crate::axiom::set_kiosk_id(id_str); + let response = pairing_client().and_then(|client| { + client + .post(format!("{server}/api/pair/claim")) + .json(&crate::core::protocol::claim_body( + &session.code, + session.polling_secret.as_deref(), + )) + .send() + .map_err(|error| error.to_string()) + }); + let claim = match response { + Ok(response) if response.status().is_success() || response.status().as_u16() == 503 => { + response + .json::() + .map_err(|error| format!("Invalid pairing response: {error}")) } - crate::at_rest::write_encrypted(&key_file(), key.as_bytes()) - .expect("failed to save kiosk key"); - // Store cluster key for backward compat ONVIF password decryption. - if let Some(ref ck) = claim.cluster_key { - let _ = crate::at_rest::write_encrypted(&cluster_key_file(), ck.as_bytes()); + Ok(response) => { + if let Some(seconds) = response + .headers() + .get("retry-after") + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.parse::().ok()) + { + delay = Duration::from_secs(seconds.clamp(1, 60)); + } + Err(format!("Pairing server returned {}", response.status())) } - // Store per-kiosk encryption key (preferred over cluster_key). - if let Some(ref ek) = claim.encrypt_key { - let _ = crate::at_rest::write_encrypted(&encrypt_key_file(), ek.as_bytes()); + Err(error) => Err(format!("Pairing connection failed: {error}")), + }; + match claim { + Ok(claim) if claim.status == "expired" => break, + Ok(claim) if claim.status == "revoked" || claim.status == "acknowledged" => { + if claim.status == "acknowledged" { + if let Ok(identity) = load_identity() { + return Some((identity.kiosk_name, identity.kiosk_key)); + } + status( + "Pairing acknowledged but saved identity is missing — contact administrator", + ); + } else { + status("Pairing revoked — contact administrator or reset the device locally"); + } + deadline = Instant::now() + Duration::from_secs(900); + delay = Duration::from_secs(60); + } + Ok(claim) if claim.status == "claimed" => { + match DeviceIdentity::from_claim(server, session, claim).and_then(|identity| { + let bytes = serde_json::to_vec(&identity).map_err(|error| error.to_string())?; + crate::at_rest::write_encrypted(&identity_file(), &bytes) + .map_err(|error| format!("Unable to save device identity: {error}"))?; + Ok(identity) + }) { + Ok(identity) => { + crate::axiom::set_kiosk_id(identity.kiosk_id); + let _ = fs::remove_file(state_dir().join("pairing.json")); + acknowledge_identity(server); + crate::remote_debug::reset_all_lockouts(); + return Some((identity.kiosk_name, identity.kiosk_key)); + } + Err(error) => { + tracing::warn!("{error}"); + status("Pairing confirmed — unable to save identity; retrying"); + // Never abandon a confirmed claim because storage is temporarily unavailable. + deadline = Instant::now() + Duration::from_secs(900); + } + } } - crate::remote_debug::reset_all_lockouts(); - return Some((name, key)); + Ok(claim) => { + if let Some(remaining) = claim.expires_in_seconds { + deadline = Instant::now() + Duration::from_secs(remaining.clamp(1, 1800)); + } + delay = crate::core::protocol::poll_delay(claim.poll_after_ms); + if claim.status == "failed" { + status("Pairing server configuration error — retrying"); + } else { + status("Enter this code in BetterFrame admin to pair"); + } + } + Err(error) => { + tracing::warn!("{error}"); + status("Pairing connection interrupted — retrying"); + delay = (delay * 2).min(Duration::from_secs(60)); + } + } + // A secure session may have been confirmed during an outage. Only + // the server can expire it; retain its polling secret across reboots. + if session.polling_secret.is_none() && Instant::now() >= deadline { + break; } + std::thread::sleep(if session.polling_secret.is_some() { + delay + } else { + delay.min(deadline.saturating_duration_since(Instant::now())) + }); } + tracing::info!("pairing session expired; requesting a fresh code"); + let _ = fs::remove_file(state_dir().join("pairing.json")); None } @@ -510,6 +638,7 @@ fn poll_claim_once(server: &str, code: &str) -> Option<(String, String)> { static BUNDLE_ETAG: std::sync::Mutex> = std::sync::Mutex::new(None); pub fn fetch_bundle(server: &str, key: &str) -> Option { + acknowledge_identity(server); let client = reqwest::blocking::Client::new(); let mut req = client .get(format!("{server}/api/kiosk/bundle")) @@ -532,7 +661,8 @@ pub fn fetch_bundle(server: &str, key: &str) -> Option { } if resp.status().as_u16() == 401 { - reset_pairing_and_restart("server rejected kiosk key during bundle fetch"); + tracing::warn!("server rejected kiosk key during bundle fetch; retaining identity"); + return None; } if !resp.status().is_success() { @@ -540,10 +670,13 @@ pub fn fetch_bundle(server: &str, key: &str) -> Option { return None; } - // Cache the ETag for next request. - if let Some(etag) = resp.headers().get("etag").and_then(|v| v.to_str().ok()) { - *BUNDLE_ETAG.lock().unwrap() = Some(etag.to_string()); - } + // Commit the validator only after decoding a usable bundle. Otherwise + // one malformed body can trap subsequent requests on 304 with no cache. + let etag = resp + .headers() + .get("etag") + .and_then(|v| v.to_str().ok()) + .map(str::to_string); let text = match resp.text() { Ok(t) => t, @@ -567,6 +700,7 @@ pub fn fetch_bundle(server: &str, key: &str) -> Option { match serde_json::from_str::(&text) { Ok(b) => { save_bundle(&b); + *BUNDLE_ETAG.lock().unwrap() = etag; Some(b) } Err(e) => { @@ -748,7 +882,8 @@ pub fn heartbeat( .send() .and_then(|r| { if r.status().as_u16() == 401 { - reset_pairing_and_restart("server rejected kiosk key during heartbeat"); + tracing::warn!("server rejected kiosk key during heartbeat; retaining identity"); + return Ok(false); } if !r.status().is_success() { @@ -936,24 +1071,55 @@ fn apply_timezone(timezone: &str) -> Result<(), String> { return Ok(()); } } - let helper = std::path::Path::new("/usr/local/sbin/betterframe-apply-managed-config.sh"); - let out = if helper.is_file() { - let helper_path = helper.to_string_lossy().to_string(); - Command::new("sudo") - .args(["-n", helper_path.as_str(), "timezone", timezone]) - .output() - } else { - Command::new("timedatectl") - .args(["set-timezone", timezone]) - .output() - } - .map_err(|e| format!("set timezone: {e}"))?; - if out.status.success() { - Ok(()) - } else { - let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string(); - Err(format!("timedatectl set-timezone failed: {stderr}")) - } + const LEGACY_HELPER: &str = "/usr/local/sbin/betterframe-apply-managed-config.sh"; + set_timezone_with_fallback( + timezone, + std::path::Path::new(LEGACY_HELPER) + .is_file() + .then_some(LEGACY_HELPER), + |program, args| { + let out = Command::new(program) + .args(args) + .output() + .map_err(|error| format!("{program}: {error}"))?; + if out.status.success() { + Ok(()) + } else { + Err(format!( + "{program}: {}: {}", + out.status, + String::from_utf8_lossy(&out.stderr).trim() + )) + } + }, + ) +} + +fn set_timezone_with_fallback( + timezone: &str, + legacy_helper: Option<&str>, + mut run: impl FnMut(&str, &[&str]) -> Result<(), String>, +) -> Result<(), String> { + let direct_error = match run( + "timedatectl", + &["--no-ask-password", "set-timezone", timezone], + ) { + Ok(()) => return Ok(()), + Err(error) => error, + }; + // App OTA cannot install the new polkit rule. Preserve the provisioned + // sudoers/helper path on older installations where escalation is allowed. + // sudo -n fails promptly under NoNewPrivileges; never weaken the service. + let fallback_error = match legacy_helper { + Some(helper) => match run("sudo", &["-n", helper, "timezone", timezone]) { + Ok(()) => return Ok(()), + Err(error) => error, + }, + None => "legacy managed-config helper is unavailable".to_string(), + }; + Err(format!( + "Unable to set timezone ({direct_error}; {fallback_error}). Check the managed timezone policy; hardened legacy images require an OS policy update." + )) } fn validate_timezone(timezone: &str) -> Result<(), String> { @@ -976,7 +1142,168 @@ fn validate_timezone(timezone: &str) -> Result<(), String> { #[cfg(test)] mod tests { - use super::format_startup_network_summary; + use super::*; + + #[test] + fn timezone_policy_success_does_not_invoke_legacy_helper() { + let mut calls = Vec::new(); + set_timezone_with_fallback("Europe/London", Some("/legacy/helper"), |program, args| { + calls.push(( + program.to_string(), + args.iter().map(|arg| arg.to_string()).collect::>(), + )); + Ok(()) + }) + .unwrap(); + assert_eq!( + calls, + vec![( + "timedatectl".into(), + vec![ + "--no-ask-password".into(), + "set-timezone".into(), + "Europe/London".into() + ] + )] + ); + } + + #[test] + fn timezone_uses_noninteractive_helper_when_direct_authorization_fails() { + let mut calls = Vec::new(); + set_timezone_with_fallback("Europe/London", Some("/legacy/helper"), |program, args| { + calls.push(( + program.to_string(), + args.iter().map(|arg| arg.to_string()).collect::>(), + )); + if program == "timedatectl" { + Err("access denied".into()) + } else { + Ok(()) + } + }) + .unwrap(); + assert_eq!(calls.len(), 2); + assert_eq!( + calls[1], + ( + "sudo".into(), + vec![ + "-n".into(), + "/legacy/helper".into(), + "timezone".into(), + "Europe/London".into() + ] + ) + ); + } + + #[test] + fn timezone_reports_missing_helper_without_attempting_sudo() { + let mut calls = 0; + let error = set_timezone_with_fallback("Etc/UTC", None, |program, _| { + calls += 1; + assert_eq!(program, "timedatectl"); + Err("policy denied".into()) + }) + .unwrap_err(); + assert_eq!(calls, 1); + assert!(error.contains("policy denied")); + assert!(error.contains("helper is unavailable")); + assert!(error.contains("OS policy update")); + } + + #[test] + fn timezone_does_not_report_success_when_hardening_blocks_legacy_helper() { + let error = set_timezone_with_fallback("Etc/UTC", Some("/legacy/helper"), |program, _| { + Err(if program == "sudo" { + "NoNewPrivileges prevents sudo" + } else { + "policy denied" + } + .into()) + }) + .unwrap_err(); + assert!(error.contains("policy denied")); + assert!(error.contains("NoNewPrivileges prevents sudo")); + assert!(error.contains("OS policy update")); + } + + #[test] + fn pairing_recovers_bad_response_and_preserves_identity_after_bundle_rejection() { + use std::io::{Read, Write}; + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let server = format!("http://{}", listener.local_addr().unwrap()); + let peer = std::thread::spawn(move || { + for (status, body) in [ + (500, "{}"), + ( + 200, + r#"{"code":"ABCDEFGH","expires_at":"invalid-clock","expires_in_seconds":1,"polling_secret":"test-session-secret"}"#, + ), + (200, "invalid JSON"), + ( + 200, + r#"{"status":"claimed","kiosk_id":"42","kiosk_name":"Recovered","kiosk_key":"bearer","encrypt_key":"encrypt"}"#, + ), + (200, r#"{"status":"acknowledged"}"#), + (401, "{}"), + ] { + let (mut stream, _) = listener.accept().unwrap(); + stream + .set_read_timeout(Some(Duration::from_secs(5))) + .unwrap(); + let mut request = Vec::new(); + loop { + let mut chunk = [0; 4096]; + let count = stream.read(&mut chunk).unwrap(); + assert_ne!(count, 0); + request.extend_from_slice(&chunk[..count]); + if let Some(end) = request.windows(4).position(|part| part == b"\r\n\r\n") { + let headers = String::from_utf8_lossy(&request[..end]).to_ascii_lowercase(); + let length = headers + .lines() + .find_map(|line| { + line.strip_prefix("content-length:") + .and_then(|value| value.trim().parse::().ok()) + }) + .unwrap_or(0); + if request.len() >= end + 4 + length { + break; + } + } + } + let response = format!( + "HTTP/1.1 {status} Test\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + stream.write_all(response.as_bytes()).unwrap(); + } + }); + assert!(initiate_pairing(&server).is_err()); + let session = initiate_pairing(&server).unwrap(); + let resumed = initiate_pairing(&server).unwrap(); + assert_eq!(resumed.code, session.code); + let mut_statuses = std::sync::Mutex::new(Vec::new()); + let (name, key) = poll_claim_until_expiry(&server, &session, |status| { + mut_statuses.lock().unwrap().push(status.to_string()) + }) + .unwrap(); + assert_eq!(name, "Recovered"); + assert!(!mut_statuses.lock().unwrap().is_empty()); + assert_eq!(load_key().unwrap(), key); + assert_eq!(load_encrypt_key().as_deref(), Some("encrypt")); + assert!(fetch_bundle(&server, &key).is_none()); + assert_eq!(load_key().unwrap(), key); + peer.join().unwrap(); + crate::at_rest::write_encrypted( + &state_dir().join("pairing.json"), + b"broken pending session", + ) + .unwrap(); + assert!(initiate_pairing(&server).is_err()); + fs::remove_dir_all(state_dir()).unwrap(); + } #[test] fn startup_network_summary_keeps_mac_while_waiting_for_ip() { diff --git a/client/src/platform/linux/ui.rs b/client/src/platform/linux/ui.rs index c3d9914c..5066b79e 100644 --- a/client/src/platform/linux/ui.rs +++ b/client/src/platform/linux/ui.rs @@ -97,6 +97,7 @@ const STALL_THRESHOLD_MS: u64 = 15_000; const HEAL_THRESHOLD_MS: u64 = 45_000; struct PipelineEntry { + _bus_watch: Box, pipeline: gstreamer::Pipeline, paintable: gtk::gdk::Paintable, state: WarmthState, @@ -205,20 +206,29 @@ fn activate(app: &Application) { let server_url = std::env::var("BETTERFRAME_SERVER") .ok() .or_else(|| std::env::args().nth(1)); - std::thread::spawn(move || { + let worker = std::thread::spawn(move || { let _ = tx.send(WorkerMsg::StartupStatus( "Finding BetterFrame server".into(), )); - let server = server::discover_server(server_url.as_deref()); + let server = loop { + match server::discover_server(server_url.as_deref()) { + Ok(server) => break server, + Err(error) => { + warn!("startup: {error}"); + let _ = tx.send(WorkerMsg::StartupStatus( + "Server or saved identity unavailable — retrying".into(), + )); + std::thread::sleep(Duration::from_secs(10)); + } + } + }; info!("server: {server}"); // Bootstrap updates run before pairing so an older image can repair // its client before talking to a newer server. if !server::is_paired() { if server::ota_enabled("BF_ENABLE_APP_OTA") { - let _ = tx.send(WorkerMsg::StartupStatus( - "Checking for app updates".into(), - )); + let _ = tx.send(WorkerMsg::StartupStatus("Checking for app updates".into())); let current = crate::server::kiosk_app_version(); if let Some(update) = crate::firmware::check_public(&server, current) { info!("preboot update available: {} → {}", current, update.version); @@ -228,9 +238,7 @@ fn activate(app: &Application) { } } if server::ota_enabled("BF_ENABLE_OS_OTA") { - let _ = tx.send(WorkerMsg::StartupStatus( - "Checking for OS updates".into(), - )); + let _ = tx.send(WorkerMsg::StartupStatus("Checking for OS updates".into())); if let Some(update) = os_update::check_public(&server) { let version = update.version.clone(); let tx_progress = tx.clone(); @@ -249,17 +257,40 @@ fn activate(app: &Application) { let key = if server::is_paired() { info!("already paired"); - let _ = tx.send(WorkerMsg::StartupStatus( - "Loading device identity".into(), - )); - server::load_key() + let _ = tx.send(WorkerMsg::StartupStatus("Loading device identity".into())); + loop { + match server::load_key() { + Ok(key) => break key, + Err(error) => { + warn!("identity: {error}"); + let _ = tx.send(WorkerMsg::StartupStatus( + "Unable to read saved identity — retrying".into(), + )); + std::thread::sleep(Duration::from_secs(10)); + } + } + } } else { loop { - let (code, expires) = server::initiate_pairing(&server); - info!("pairing code: {code} (expires {expires})"); - let _ = tx.send(WorkerMsg::ShowPairingCode(code.clone())); - - if let Some((name, key)) = server::poll_claim_until_expiry(&server, &code, &expires) + let session = match server::initiate_pairing(&server) { + Ok(session) => session, + Err(error) => { + warn!("{error}"); + let _ = tx.send(WorkerMsg::StartupStatus( + "Pairing unavailable — checking connection and storage".into(), + )); + std::thread::sleep(Duration::from_secs(10)); + continue; + } + }; + let _ = tx.send(WorkerMsg::ShowPairingCode(session.code.clone())); + if let Some((name, key)) = + server::poll_claim_until_expiry(&server, &session, |status| { + let _ = tx.send(WorkerMsg::PairingStatus( + session.code.clone(), + status.to_string(), + )); + }) { info!("paired as: {name}"); let _ = tx.send(WorkerMsg::ShowPairingProgress); @@ -270,9 +301,7 @@ fn activate(app: &Application) { // Render cached content before any network request so a paired kiosk // starts immediately while its server is unavailable or rebooting. - let _ = tx.send(WorkerMsg::StartupStatus( - "Loading cached content".into(), - )); + let _ = tx.send(WorkerMsg::StartupStatus("Loading cached content".into())); let cached = server::load_cached_bundle(); if let Some(bundle) = &cached { info!("boot: rendering cached bundle"); @@ -308,11 +337,9 @@ fn activate(app: &Application) { warn!("offline mode: keeping cached bundle"); } else { warn!("no bundle available (server unreachable, no cache)"); - if server::is_paired() { - server::reset_pairing_and_restart( - "paired kiosk has no live bundle and no cached bundle", - ); - } + let _ = tx.send(WorkerMsg::StartupStatus( + "Paired — waiting for configuration".into(), + )); } } } @@ -346,15 +373,12 @@ fn activate(app: &Application) { }); // Background retry thread: if we couldn't fetch a live bundle on boot, - // retry with exponential backoff. After 30 minutes of failures, reboot - // the host to recover from potential stuck state. + // retry with capped exponential backoff while keeping the saved identity. let retry_tx = tx.clone(); let retry_server = server.clone(); let retry_key = key.clone(); std::thread::spawn(move || { let mut backoff_secs: u64 = 10; - let start = std::time::Instant::now(); - let max_wait = Duration::from_secs(30 * 60); loop { std::thread::sleep(Duration::from_secs(backoff_secs)); if let Some(b) = server::fetch_bundle(&retry_server, &retry_key) { @@ -367,14 +391,6 @@ fn activate(app: &Application) { )); return; } - if start.elapsed() > max_wait { - warn!("offline-retry: 30 minutes without bundle, rebooting"); - let _ = std::process::Command::new("systemctl") - .arg("reboot") - .status(); - std::thread::sleep(Duration::from_secs(30)); - std::process::exit(1); - } backoff_secs = (backoff_secs * 2).min(300); } }); @@ -527,6 +543,17 @@ fn activate(app: &Application) { } }); + // Supervise the startup/heartbeat worker even while other threads retain + // channel senders. A panic must not leave GTK showing a frozen pairing code. + std::thread::spawn(move || { + let result = worker.join(); + tracing::error!( + "kiosk worker stopped (panicked={}); restarting service", + result.is_err() + ); + std::process::exit(1); + }); + // Poll channel from UI thread via timeout let app_clone = app.clone(); let pairing_window_clone = pairing_window.clone(); @@ -536,7 +563,14 @@ fn activate(app: &Application) { WorkerMsg::StartupStatus(action) => { show_startup_status(&pairing_window_clone, &action) } - WorkerMsg::ShowPairingCode(code) => show_pairing_code(&pairing_window_clone, &code), + WorkerMsg::ShowPairingCode(code) => show_pairing_code( + &pairing_window_clone, + &code, + "Enter this code in BetterFrame admin to pair", + ), + WorkerMsg::PairingStatus(code, status) => { + show_pairing_code(&pairing_window_clone, &code, &status) + } WorkerMsg::ShowPairingProgress => show_pairing_progress(&pairing_window_clone), WorkerMsg::RenderBundle(bundle, server, key) => { render_bundle(&app_clone, &pairing_window_clone, bundle, &server, &key); @@ -575,6 +609,7 @@ fn activate(app: &Application) { pub enum WorkerMsg { StartupStatus(String), ShowPairingCode(String), + PairingStatus(String, String), ShowPairingProgress, RenderBundle(KioskBundle, String, String), SwitchLayout { @@ -1234,7 +1269,7 @@ fn parse_drm_mode(mode: &str) -> Option<(u32, u32)> { (dimensions.0 > 0 && dimensions.1 > 0).then_some(dimensions) } -fn show_pairing_code(window: &ApplicationWindow, code: &str) { +fn show_pairing_code(window: &ApplicationWindow, code: &str, status: &str) { let vbox = GtkBox::new(Orientation::Vertical, 20); vbox.set_valign(gtk::Align::Center); vbox.set_halign(gtk::Align::Center); @@ -1249,7 +1284,7 @@ fn show_pairing_code(window: &ApplicationWindow, code: &str) { ); code_label.add_css_class("code"); - let hint = Label::new(Some("Enter this code in BetterFrame admin to pair")); + let hint = Label::new(Some(status)); add_css(&hint, ".hint { font-size: 14px; color: #666; }"); hint.add_css_class("hint"); @@ -1966,9 +2001,11 @@ fn operator_focus(request: OperatorFocusRequest) -> Result Result("paintable"); pipeline::play(&pipe); let status_clone = status.clone(); @@ -2826,6 +2865,7 @@ fn ensure_warm( w.borrow_mut().insert( key, PipelineEntry { + _bus_watch: bus_watch, pipeline: pipe, paintable: paintable.clone(), state: WarmthState::Warm, @@ -3052,14 +3092,23 @@ mod display_tests { {"view_id":"empty","entity_id":null,"row":0,"col":2,"row_span":1,"col_span":1,"content_type":"none","camera_id":null,"stream_selector":null,"web_url":null,"html_content":null,"cooling_timeout_seconds":null} ] })).unwrap(); - let overrides = HashMap::from([("used".to_string(), FocusOverride { - camera_id: "camera".to_string(), - stream: "main".to_string(), - generation: 1, - })]); - - assert_eq!(operator_target_cell(&layout, None, &overrides).as_deref(), Some("empty")); - assert_eq!(operator_target_cell(&layout, Some("web"), &overrides).as_deref(), Some("web")); + let overrides = HashMap::from([( + "used".to_string(), + FocusOverride { + camera_id: "camera".to_string(), + stream: "main".to_string(), + generation: 1, + }, + )]); + + assert_eq!( + operator_target_cell(&layout, None, &overrides).as_deref(), + Some("empty") + ); + assert_eq!( + operator_target_cell(&layout, Some("web"), &overrides).as_deref(), + Some("web") + ); } } diff --git a/client/src/platform/linux/ws_client.rs b/client/src/platform/linux/ws_client.rs index 2a3ded56..cf6fdbc4 100644 --- a/client/src/platform/linux/ws_client.rs +++ b/client/src/platform/linux/ws_client.rs @@ -78,7 +78,17 @@ pub fn run(server_url: &str, kiosk_key: &str, tx: Sender) { rt.block_on(async { let mut backoff = 1u64; loop { - match connect_async(&ws_url).await { + let connection = tokio::time::timeout(Duration::from_secs(15), connect_async(&ws_url)).await; + let connection = match connection { + Ok(connection) => connection, + Err(_) => { + warn!("ws: connection timed out"); + tokio::time::sleep(Duration::from_secs(backoff)).await; + backoff = (backoff * 2).min(60); + continue; + } + }; + match connection { Ok((ws_stream, _resp)) => { info!("ws: connected"); backoff = 1; @@ -96,9 +106,15 @@ pub fn run(server_url: &str, kiosk_key: &str, tx: Sender) { Arc::new(Mutex::new(None)); let pending_code: Arc>> = Arc::new(Mutex::new(None)); + let mut last_received = tokio::time::Instant::now(); loop { tokio::select! { + _ = tokio::time::sleep_until(last_received + Duration::from_secs(90)) => { + warn!("ws: coordinator read deadline expired"); + break; + } ws_msg = reader.next() => { + last_received = tokio::time::Instant::now(); let Some(ws_msg) = ws_msg else { break }; match ws_msg { Ok(Message::Text(text)) => { @@ -112,6 +128,9 @@ pub fn run(server_url: &str, kiosk_key: &str, tx: Sender) { &pending_code, ).await; } + Ok(Message::Ping(payload)) => { + if !matches!(tokio::time::timeout(Duration::from_secs(10), writer.send(Message::Pong(payload))).await, Ok(Ok(()))) { break; } + } Ok(Message::Close(_)) => { info!("ws: server closed connection"); break; @@ -124,7 +143,7 @@ pub fn run(server_url: &str, kiosk_key: &str, tx: Sender) { } } Some(out_msg) = outbound_rx.recv() => { - if writer.send(Message::Text(out_msg)).await.is_err() { + if !matches!(tokio::time::timeout(Duration::from_secs(10), writer.send(Message::Text(out_msg))).await, Ok(Ok(()))) { break; } } @@ -157,7 +176,11 @@ type WsWriter = futures_util::stream::SplitSink< >; async fn ws_send(writer: &mut WsWriter, msg: serde_json::Value) { - let _ = writer.send(Message::Text(msg.to_string())).await; + let _ = tokio::time::timeout( + Duration::from_secs(10), + writer.send(Message::Text(msg.to_string())), + ) + .await; } async fn handle_message( @@ -174,13 +197,21 @@ async fn handle_message( return; } if text.contains("\"type\":\"ping\"") { - let _ = writer - .send(Message::Text(r#"{"type":"pong"}"#.to_string())) - .await; + let _ = tokio::time::timeout( + Duration::from_secs(10), + writer.send(Message::Text(r#"{"type":"pong"}"#.to_string())), + ) + .await; } else if text.contains("\"type\":\"operator-enrollment-create\"") { let msg = serde_json::from_str::(text).unwrap_or_default(); - let request_id = msg.get("request_id").and_then(|value| value.as_str()).unwrap_or(""); - let name = msg.get("name").and_then(|value| value.as_str()).unwrap_or("Operator station"); + let request_id = msg + .get("request_id") + .and_then(|value| value.as_str()) + .unwrap_or(""); + let name = msg + .get("name") + .and_then(|value| value.as_str()) + .unwrap_or("Operator station"); let response = match crate::operator_console::shared_auth().create_enrollment(name) { Ok(enrollment) => serde_json::json!({ "type": "operator-enrollment-response", @@ -199,23 +230,37 @@ async fn handle_message( ws_send(writer, response).await; } else if text.contains("\"type\":\"operator-stations-list\"") { let msg = serde_json::from_str::(text).unwrap_or_default(); - ws_send(writer, serde_json::json!({ - "type": "operator-stations-response", - "request_id": msg.get("request_id").and_then(|value| value.as_str()).unwrap_or(""), - "ok": true, - "stations": crate::operator_console::shared_auth().list(), - })).await; + ws_send( + writer, + serde_json::json!({ + "type": "operator-stations-response", + "request_id": msg.get("request_id").and_then(|value| value.as_str()).unwrap_or(""), + "ok": true, + "stations": crate::operator_console::shared_auth().list(), + }), + ) + .await; } else if text.contains("\"type\":\"operator-station-revoke\"") { let msg = serde_json::from_str::(text).unwrap_or_default(); - let request_id = msg.get("request_id").and_then(|value| value.as_str()).unwrap_or(""); - let id = msg.get("station_id").and_then(|value| value.as_str()).unwrap_or(""); + let request_id = msg + .get("request_id") + .and_then(|value| value.as_str()) + .unwrap_or(""); + let id = msg + .get("station_id") + .and_then(|value| value.as_str()) + .unwrap_or(""); let result = crate::operator_console::shared_auth().revoke(id); - ws_send(writer, serde_json::json!({ - "type": "operator-station-revoke-response", - "request_id": request_id, - "ok": result.is_ok(), - "error": result.err(), - })).await; + ws_send( + writer, + serde_json::json!({ + "type": "operator-station-revoke-response", + "request_id": request_id, + "ok": result.is_ok(), + "error": result.err(), + }), + ) + .await; } else if text.contains("\"type\":\"onvif-action-request\"") { let Ok(msg) = serde_json::from_str::(text) else { warn!("ws: onvif action request was not valid JSON"); diff --git a/client/src/platform/windows/mod.rs b/client/src/platform/windows/mod.rs index 740b58ac..c7e11838 100644 --- a/client/src/platform/windows/mod.rs +++ b/client/src/platform/windows/mod.rs @@ -278,7 +278,15 @@ fn run_agent_cli(args: &[String]) -> Result<(), String> { .map_err(|e| format!("tokio runtime: {e}"))?; let override_url = arg_value(args, "--server"); rt.block_on(async { - let server = discover_server(override_url.as_deref(), &load_state()).await?; + let server = loop { + match discover_server(override_url.as_deref(), &load_state()).await { + Ok(server) => break server, + Err(error) => { + warn!("server discovery: {error}; retrying"); + tokio::time::sleep(Duration::from_secs(10)).await; + } + } + }; run_agent(server).await }) } @@ -313,9 +321,12 @@ async fn run_agent(server_url: String) -> Result<(), String> { ensure_secure_state_dir()?; ensure_default_policy()?; - let mut state = load_state(); + let mut state = load_agent_state()?; state.server_url = server_url; save_state(&state)?; + if state.kiosk_key.is_some() { + acknowledge_pairing(&reqwest::Client::new(), &mut state).await; + } if state.kiosk_key.is_none() { state = pair(&state.server_url).await?; @@ -357,18 +368,9 @@ async fn run_agent(server_url: String) -> Result<(), String> { *state.lock().unwrap() = next; } Err(HeartbeatError::Unauthorized) => { - warn!("kiosk was removed from the server; restarting pairing"); - let reset = unpaired_state(&snapshot.server_url); - let _ = remove_cached_bundle(); - let _ = save_state(&reset); - *state.lock().unwrap() = reset; - match pair(&snapshot.server_url).await { - Ok(next) => { - let _ = save_state(&next); - *state.lock().unwrap() = next; - } - Err(err) => warn!("pairing failed: {err}"), - } + warn!( + "server rejected kiosk key; retaining saved identity and cached content" + ); } Err(HeartbeatError::Other(err)) => warn!("heartbeat failed: {err}"), } @@ -441,67 +443,174 @@ async fn run_agent(server_url: String) -> Result<(), String> { } async fn pair(server_url: &str) -> Result { - let client = reqwest::Client::new(); + let client = reqwest::Client::builder() + .connect_timeout(Duration::from_secs(5)) + .timeout(Duration::from_secs(15)) + .build() + .map_err(|error| error.to_string())?; loop { - let init: PairInitiateResponse = client - .post(format!("{server_url}/api/pair/initiate")) - .json(&serde_json::json!({ - "proposed_name": hostname::get().ok().and_then(|h| h.into_string().ok()).unwrap_or_else(|| "Windows Kiosk".to_string()), - "hardware_model": "Windows Desktop", - "firmware_target": "windows-x64", - "capabilities": ["windows", "desktop_app", "rtsp", "d3d11", "mouse", "keyboard", "app_restart", "display_select"], - "managed_image": false - })) - .send() - .await - .map_err(|e| format!("pair initiate: {e}"))? - .json() - .await - .map_err(|e| format!("pair initiate response: {e}"))?; - let expires_at = time::OffsetDateTime::parse( - &init.expires_at, - &time::format_description::well_known::Rfc3339, - ) - .map_err(|error| format!("pair initiate expiry: {error}"))?; - + let saved = load_agent_state()?; + let resume = if saved.server_url == server_url && saved.kiosk_key.is_none() { + saved.pairing_code.map(|code| PairInitiateResponse { + code, + expires_at: saved.pairing_expires_at.unwrap_or_default(), + polling_secret: saved.pairing_secret, + expires_in_seconds: None, + poll_after_ms: None, + }) + } else { + None + }; + let init = if let Some(session) = resume { + session + } else { + let response = client.post(format!("{server_url}/api/pair/initiate")) + .json(&serde_json::json!({ + "proposed_name": hostname::get().ok().and_then(|h| h.into_string().ok()).unwrap_or_else(|| "Windows Kiosk".to_string()), + "hardware_model": "Windows Desktop", "firmware_target": "windows-x64", + "capabilities": ["windows", "desktop_app", "rtsp", "d3d11", "mouse", "keyboard", "app_restart", "display_select"], + "managed_image": false, "secure_claim": true + })).send().await.and_then(|response| response.error_for_status()); + let init = match response { + Ok(response) => response.json::().await, + Err(error) => Err(error), + }; + match init { + Ok(init) if !init.code.trim().is_empty() => init, + _ => { + warn!("pairing initiation unavailable; retrying"); + tokio::time::sleep(Duration::from_secs(10)).await; + continue; + } + } + }; println!("BetterFrame Windows pairing code: {}", init.code); - println!("Enter it in admin before it expires at {}", init.expires_at); let mut pending = unpaired_state(server_url); pending.pairing_code = Some(init.code.clone()); pending.pairing_expires_at = Some(init.expires_at.clone()); + pending.pairing_secret = init.polling_secret.clone(); save_state(&pending)?; - - while time::OffsetDateTime::now_utc() < expires_at { - let resp = client + let mut deadline = Instant::now() + init.lifetime(); + let mut delay = init.poll_delay(); + loop { + let response = client .post(format!("{server_url}/api/pair/claim")) - .json(&serde_json::json!({ "code": &init.code })) + .json(&crate::core::protocol::claim_body( + &init.code, + init.polling_secret.as_deref(), + )) .send() - .await - .map_err(|e| format!("pair claim: {e}"))?; - if resp.status().as_u16() == 200 { - let claim: PairClaimResponse = resp - .json() - .await - .map_err(|e| format!("pair claim response: {e}"))?; - if claim.status == "claimed" { - return Ok(ClientState { - server_url: server_url.to_string(), - kiosk_key: claim.kiosk_key, - encrypt_key: claim.encrypt_key, - kiosk_id: claim.kiosk_id.map(flexible_id), - kiosk_name: claim.kiosk_name, - bundle_version: None, - managed_config_applied_version: 0, - managed_config_error: None, - pairing_code: None, - pairing_expires_at: None, - active_layouts: HashMap::new(), - }); + .await; + let claim = match response { + Ok(response) + if response.status().is_success() || response.status().as_u16() == 503 => + { + response.json::().await.ok() + } + Ok(response) => { + delay = response + .headers() + .get("retry-after") + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.parse::().ok()) + .map(|seconds| Duration::from_secs(seconds.clamp(1, 60))) + .unwrap_or_else(|| (delay * 2).min(Duration::from_secs(60))); + None + } + Err(_) => { + delay = (delay * 2).min(Duration::from_secs(60)); + None + } + }; + if let Some(claim) = claim { + if claim.status == "expired" { + break; + } + if claim.status == "revoked" || claim.status == "acknowledged" { + warn!( + "pairing is {}; contact administrator or reset locally", + claim.status + ); + deadline = Instant::now() + Duration::from_secs(900); + delay = Duration::from_secs(60); + } else if claim.status == "claimed" { + match crate::core::protocol::DeviceIdentity::from_claim( + server_url, &init, claim, + ) { + Ok(identity) => { + let mut state = ClientState { + server_url: identity.server_url, + kiosk_key: Some(identity.kiosk_key), + encrypt_key: identity.encrypt_key.or(identity.cluster_key), + kiosk_id: Some(identity.kiosk_id), + kiosk_name: Some(identity.kiosk_name), + pairing_code: Some(init.code.clone()), + pairing_secret: init.polling_secret.clone(), + ..ClientState::default() + }; + match save_state(&state) { + Ok(()) => { + acknowledge_pairing(&client, &mut state).await; + return Ok(state); + } + Err(error) => { + warn!("pairing confirmed but identity storage failed: {error}") + } + } + deadline = Instant::now() + Duration::from_secs(900); + } + Err(error) => warn!("invalid pairing claim: {error}"), + } + } else { + if let Some(seconds) = claim.expires_in_seconds { + deadline = Instant::now() + Duration::from_secs(seconds.clamp(1, 1800)); + } + delay = crate::core::protocol::poll_delay(claim.poll_after_ms); + if claim.status == "failed" { + warn!("pairing server configuration error; retrying"); + } } + } else { + warn!("pairing connection unavailable; retrying"); + } + if init.polling_secret.is_none() && Instant::now() >= deadline { + break; + } + tokio::time::sleep(if init.polling_secret.is_some() { + delay + } else { + delay.min(deadline.saturating_duration_since(Instant::now())) + }) + .await; + } + save_state(&unpaired_state(server_url))?; + info!("pairing session expired; requesting a new code"); + } +} + +async fn acknowledge_pairing(client: &reqwest::Client, state: &mut ClientState) { + let (Some(code), Some(key)) = (&state.pairing_code, &state.kiosk_key) else { + return; + }; + if let Ok(response) = client + .post(format!("{}/api/pair/ack", state.server_url)) + .bearer_auth(key) + .timeout(Duration::from_secs(15)) + .json(&crate::core::protocol::claim_body( + code, + state.pairing_secret.as_deref(), + )) + .send() + .await + { + if response.status().is_success() { + state.pairing_code = None; + state.pairing_expires_at = None; + state.pairing_secret = None; + if let Err(error) = save_state(state) { + warn!("save pairing acknowledgment: {error}"); } - tokio::time::sleep(Duration::from_secs(2)).await; } - info!("pairing code expired; requesting a new code"); } } @@ -512,18 +621,35 @@ async fn websocket_loop( ) -> Result<(), String> { let ws_url = crate::core::protocol::websocket_url(server_url, key) .map_err(|error| format!("invalid server URL: {error}"))?; - let (ws, _) = connect_async(&ws_url) + let (ws, _) = tokio::time::timeout(Duration::from_secs(15), connect_async(&ws_url)) .await + .map_err(|_| "coordinator connection timed out")? .map_err(|e| format!("connect coordinator websocket: {e}"))?; info!("connected to coordinator"); let (mut writer, mut reader) = ws.split(); - while let Some(msg) = reader.next().await { + while let Some(msg) = tokio::time::timeout(Duration::from_secs(90), reader.next()) + .await + .map_err(|_| "coordinator read deadline expired")? + { let msg = msg.map_err(|e| format!("ws read: {e}"))?; - let Message::Text(text) = msg else { continue }; + let text = match msg { + Message::Text(text) => text, + Message::Ping(payload) => { + tokio::time::timeout(Duration::from_secs(10), writer.send(Message::Pong(payload))) + .await + .map_err(|_| "coordinator write timed out")? + .map_err(|error| error.to_string())?; + continue; + } + Message::Close(_) => break, + _ => continue, + }; if text.contains("\"type\":\"ping\"") { - let _ = writer - .send(Message::Text(r#"{"type":"pong"}"#.to_string())) - .await; + let _ = tokio::time::timeout( + Duration::from_secs(10), + writer.send(Message::Text(r#"{"type":"pong"}"#.to_string())), + ) + .await; continue; } if let Ok(Some(command)) = crate::core::commands::decode(&text) { @@ -635,6 +761,7 @@ async fn fetch_bundle(server_url: &str, key: &str) -> Result Result<(), String> { Ok(()) } +pub(super) fn load_agent_state() -> Result { + ensure_secure_state_dir()?; + if state_path().exists() { + load_state_file(&state_path()) + } else { + Ok(ClientState::default()) + } +} + pub(super) fn load_state() -> ClientState { ensure_secure_state_dir() .and_then(|()| load_state_file(&state_path())) @@ -245,8 +254,24 @@ pub(super) fn read_protected(path: &std::path::Path) -> Result, String> pub(super) fn write_protected(path: &std::path::Path, plaintext: &[u8]) -> Result<(), String> { ensure_secure_state_dir()?; let bytes = protect_machine(plaintext)?; - let temporary = path.with_extension("tmp"); - fs::write(&temporary, bytes).map_err(|error| format!("write protected state: {error}"))?; + use std::io::Write; + static NEXT_WRITE: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + let temporary = path.with_extension(format!( + "{}.{}.tmp", + std::process::id(), + NEXT_WRITE.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + )); + let mut file = fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&temporary) + .map_err(|error| format!("create protected state: {error}"))?; + let written = file.write_all(&bytes).and_then(|()| file.sync_all()); + drop(file); + if let Err(error) = written { + let _ = fs::remove_file(&temporary); + return Err(format!("write protected state: {error}")); + } let from = wide_path(temporary.as_os_str()); let to = wide_path(path.as_os_str()); if unsafe { diff --git a/compose.yaml b/compose.yaml index 73e976b1..276c42c6 100644 --- a/compose.yaml +++ b/compose.yaml @@ -62,7 +62,7 @@ services: POSTGRES_PASSWORD: ${BF_PG_PASSWORD:?set BF_PG_PASSWORD} POSTGRES_DB: ${BF_PG_DB:-betterframe} volumes: - - pgdata:/var/lib/postgresql/data + - pgdata:/var/lib/postgresql healthcheck: test: ["CMD-SHELL", "pg_isready -U ${BF_PG_USER:-betterframe}"] interval: 10s diff --git a/deploy/README.md b/deploy/README.md index 5d5c2532..ced12a2a 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -33,8 +33,9 @@ BF_MQTT_URL= # optional MQTT telemetry export In Coolify: create a Docker compose stack pointing at the repo's `docker-compose.coolify.yml` (repo root), inject the env vars, set a domain on the -`angie` service. Backups via the admin UI (`/admin/backup`) — Coolify's S3 -hook can pull these on a schedule. +`angie` service. Use `deploy/scripts/backup-stack.sh` for coordinated encrypted PostgreSQL, +server-key, and Node-RED backups. See [backup recovery](../docs/backup-recovery.md); +legacy browser SQLite archives cannot restore this stack. ### bf-client (kiosk Pi) diff --git a/deploy/nodered-manager/manager.mjs b/deploy/nodered-manager/manager.mjs index fe2cdba9..b3bc4b25 100644 --- a/deploy/nodered-manager/manager.mjs +++ b/deploy/nodered-manager/manager.mjs @@ -76,11 +76,16 @@ function validTenantId(value) { function publicRuntimePath(path) { let decoded; try { decoded = decodeURIComponent(path); } catch { return null; } - return /^\/nrdp(?:\/|$)/i.test(decoded) || /^\/_betterframe(?:\/|$)/i.test(decoded) ? null : path; + // Reject ambiguous encodings and normalize before checking reserved paths. + if (/[\\%\x00-\x1f]/.test(decoded) || decoded.startsWith("//")) return null; + const canonical = new URL(decoded, "http://localhost").pathname.replace(/\/+/g, "/"); + return /^\/(?:nrdp|_betterframe)(?:\/|$)/i.test(canonical) + || /^\/api\/internal(?:\/|$)/i.test(canonical) ? null : canonical; } function runtimeEnvironment(tenant) { - const env = { ...process.env, HOME: tenant.userDir, USER: `bf-nodered-${tenant.uid}`, PORT: String(tenant.port) }; + const env = { ...process.env, HOME: tenant.userDir, USER: `bf-nodered-${tenant.uid}`, PORT: String(tenant.port), + BF_NODERED_INTERNAL_TOKEN: tenant.adminToken }; delete env.BF_NODERED_MANAGER_SECRET; delete env.BF_NODERED_MANAGER_SECRET_FILE; return env; @@ -265,12 +270,29 @@ function tenantForRequest(req) { return Object.values(state.tenants).find((item) => item.slug === "default"); } +function runtimeHeaders(req, tenant) { + const headers = { ...req.headers, host: `127.0.0.1:${tenant.port}` }; + delete headers["x-betterframe-tenant"]; + delete headers["x-betterframe-runtime-token"]; + const path = decodeURIComponent(new URL(req.url || "/", "http://localhost").pathname); + // Public HTTP nodes can echo request headers. Never give them the credential + // that also authorizes the editor and internal event dispatcher. + if (/^\/(?:nrdp(?:\/|$)|api\/internal(?:\/|$))/i.test(path)) { + headers["x-betterframe-runtime-token"] = tenant.adminToken; + } + if (authorized(req)) delete headers.authorization; + return headers; +} + function proxy(req, res, tenant) { + const path = new URL(req.url || "/", "http://localhost").pathname; + if (/^\/api\/internal(?:\/|$)/i.test(decodeURIComponent(path)) && !authorized(req)) { + res.writeHead(403); res.end("forbidden"); return; + } if (!tenant?.active || !runtimes.get(tenant.tenant_id)?.child) { res.writeHead(503); res.end("tenant runtime unavailable"); return; } - const headers = { ...req.headers, host: `127.0.0.1:${tenant.port}`, "x-betterframe-runtime-token": tenant.adminToken }; - delete headers["x-betterframe-tenant"]; + const headers = runtimeHeaders(req, tenant); const upstream = httpRequest({ hostname: "127.0.0.1", port: tenant.port, method: req.method, path: req.url, headers }, (response) => { res.writeHead(response.statusCode || 502, response.headers); response.pipe(res); @@ -295,12 +317,22 @@ if (process.env.BF_NODERED_MANAGER_SELF_TEST === "1") { if (validTenantId("../../escape")) throw new Error("path traversal accepted"); if (publicRuntimePath("/nrdp/flows") !== null) throw new Error("public admin path accepted"); if (publicRuntimePath("/%6erdp/flows") !== null) throw new Error("encoded public admin path accepted"); + for (const path of ["/api/internal/onvif.motion", "/api%2finternal/onvif.motion", "/x/../api/internal/onvif.motion", "/%2561pi/internal/onvif.motion"]) { + if (publicRuntimePath(path) !== null) throw new Error("public internal event path accepted"); + } if (publicRuntimePath("/camera/event") !== "/camera/event") throw new Error("public node path rejected"); const testId = "2f1c0b2d-9ad7-4e74-8c2c-4bdcb9f365b0"; state.tenants[testId] = { tenant_id: testId, slug: "test" }; if (tenantForRequest({ url: "/", headers: { "x-betterframe-tenant": testId } })?.slug !== "test") throw new Error("tenant UUID route failed"); if (tenantForRequest({ url: "/", headers: { "x-betterframe-tenant": "test" } })?.tenant_id !== testId) throw new Error("tenant slug route failed"); if (runtimeEnvironment({ userDir: "/tmp/test", uid: 1, port: 1 }).BF_NODERED_MANAGER_SECRET) throw new Error("manager secret leaked to tenant runtime"); + const headerTenant = { port: 19000, adminToken: "test-admin-token" }; + const publicHeaders = runtimeHeaders({ url: "/echo", headers: { + "x-betterframe-runtime-token": "caller-forged", authorization: `Bearer ${MANAGER_TOKEN}`, + } }, headerTenant); + if (publicHeaders["x-betterframe-runtime-token"] || publicHeaders.authorization) throw new Error("credential exposed to public flow"); + const eventHeaders = runtimeHeaders({ url: "/api/internal/onvif.motion", headers: {} }, headerTenant); + if (eventHeaders["x-betterframe-runtime-token"] !== headerTenant.adminToken) throw new Error("internal route lacks runtime credential"); console.log("Node-RED manager self-test passed"); process.exit(0); } @@ -349,10 +381,15 @@ const server = createServer(async (req, res) => { }); server.on("upgrade", (req, socket, head) => { - const tenant = tenantForRequest(req); + let tenant; + try { + tenant = tenantForRequest(req); + if (/^\/api\/internal(?:\/|$)/i.test(decodeURIComponent(new URL(req.url || "/", "http://localhost").pathname))) { + socket.destroy(); return; + } + } catch { socket.destroy(); return; } if (!tenant?.active) { socket.destroy(); return; } - const headers = { ...req.headers, host: `127.0.0.1:${tenant.port}`, "x-betterframe-runtime-token": tenant.adminToken }; - delete headers["x-betterframe-tenant"]; + const headers = runtimeHeaders(req, tenant); const upstream = httpRequest({ hostname: "127.0.0.1", port: tenant.port, method: "GET", path: req.url, headers }); upstream.on("upgrade", (response, upstreamSocket, upstreamHead) => { socket.write(`HTTP/1.1 101 Switching Protocols\r\n${Object.entries(response.headers).map(([key, value]) => `${key}: ${value}`).join("\r\n")}\r\n\r\n`); diff --git a/deploy/pi-gen/stage-betterframe-client/01-install-kiosk/01-run-chroot.sh b/deploy/pi-gen/stage-betterframe-client/01-install-kiosk/01-run-chroot.sh index 8772bec6..76de277e 100755 --- a/deploy/pi-gen/stage-betterframe-client/01-install-kiosk/01-run-chroot.sh +++ b/deploy/pi-gen/stage-betterframe-client/01-install-kiosk/01-run-chroot.sh @@ -22,7 +22,7 @@ for grp in video render input audio systemd-journal; do done # --- Deps for first-boot partition expansion --- -apt-get -y install cloud-guest-utils e2fsprogs 2>/dev/null || true +apt-get -y install polkitd cloud-guest-utils e2fsprogs # --- Tailscale VPN --- curl -fsSL https://tailscale.com/install.sh | sh @@ -68,11 +68,19 @@ install -m 755 /tmp/bf-files/betterframe-apply-managed-config.sh \ /usr/local/sbin/betterframe-apply-managed-config.sh install -m 755 /tmp/bf-files/randomize-image-users.sh \ /usr/local/sbin/randomize-image-users.sh -install -d -m 755 /etc/sudoers.d -cat > /etc/sudoers.d/betterframe-managed-config <<'SUDOERS' -bfkiosk ALL=(root) NOPASSWD: /usr/local/sbin/betterframe-apply-managed-config.sh * -SUDOERS -chmod 440 /etc/sudoers.d/betterframe-managed-config + +# Only timezone changes are authorized; the kiosk retains NoNewPrivileges. +install -d -m 755 /etc/polkit-1/rules.d /etc/betterframe +cat > /etc/polkit-1/rules.d/49-betterframe-timezone.rules <<'POLKIT' +polkit.addRule(function(action, subject) { + if (subject.user === "bfkiosk" && action.id === "org.freedesktop.timedate1.set-timezone") { + return polkit.Result.YES; + } +}); +POLKIT +chmod 644 /etc/polkit-1/rules.d/49-betterframe-timezone.rules +install -m 644 /dev/null /etc/betterframe/managed-image + install -d -m 755 /etc/tmpfiles.d install -m 644 /tmp/bf-files/betterframe-kiosk.conf /etc/tmpfiles.d/betterframe-kiosk.conf install -d -m 755 /etc/udev/rules.d diff --git a/deploy/scripts/backup-stack.sh b/deploy/scripts/backup-stack.sh new file mode 100755 index 00000000..0c120a54 --- /dev/null +++ b/deploy/scripts/backup-stack.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +# Run from the Compose project directory. Requires age, tar, and Docker Compose. +# A passphrase is entered directly into age; it is never placed in arguments. +set -euo pipefail +umask 077 + +if [ "$#" -ne 1 ]; then echo "usage: $0 /absolute/path/backup.tar.age" >&2; exit 2; fi +output="$1" +case "$output" in /*) ;; *) echo "output must be an absolute path" >&2; exit 2;; esac +if [ -e "$output" ] || [ -e "${output}.partial" ]; then echo "output already exists" >&2; exit 2; fi +for tool in docker age tar; do command -v "$tool" >/dev/null || { echo "missing $tool" >&2; exit 2; }; done +docker compose version >/dev/null +staging="$(mktemp -d)" +restart_services=() +cleanup() { + result=$? + trap - EXIT + if [ "${#restart_services[@]}" -gt 0 ]; then + docker compose start "${restart_services[@]}" || result=1 + fi + rm -rf -- "$staging" + rm -f -- "${output}.partial" + exit "$result" +} +trap cleanup EXIT +trap 'exit 130' INT TERM + +server_id="$(docker compose ps --all --quiet server)" +nodered_id="$(docker compose ps --all --quiet nodered)" +for container_id in "$server_id" "$nodered_id"; do + if [ -z "$container_id" ] || [[ "$container_id" == *$'\n'* ]]; then + echo "exactly one server and one nodered container are required" >&2; exit 2 + fi +done +for service in server nodered; do + container_id="$(docker compose ps --all --quiet "$service")" + if [ "$(docker inspect --format '{{.State.Running}}' "$container_id")" = true ]; then + restart_services+=("$service") + fi +done + +echo "Stopping server and Node-RED while capturing a consistent stack backup." >&2 +docker compose stop server nodered +docker compose exec -T postgres sh -c 'exec pg_dump --format=custom --username="$POSTGRES_USER" --dbname="${POSTGRES_DB:-$POSTGRES_USER}"' > "$staging/postgres.dump" +test -s "$staging/postgres.dump" +mkdir "$staging/server-data" "$staging/nodered-data" +docker cp -a "$server_id:/var/lib/betterframe/." "$staging/server-data/" +docker cp -a "$nodered_id:/data/." "$staging/nodered-data/" +test -s "$staging/server-data/secret.key" +printf 'BetterFrame stack backup v1\nCreated UTC: %s\nDatabase format: pg_dump custom\n' "$(date -u +%FT%TZ)" > "$staging/MANIFEST.txt" + +# Restart promptly; encryption can take longer and may wait for passphrase input. +if [ "${#restart_services[@]}" -gt 0 ]; then + docker compose start "${restart_services[@]}" + restart_services=() +fi +tar --numeric-owner -C "$staging" -cf - MANIFEST.txt postgres.dump server-data nodered-data | age --passphrase -o "${output}.partial" +mv -- "${output}.partial" "$output" +echo "Encrypted backup written to $output. Test recovery in a fresh deployment." >&2 diff --git a/deploy/scripts/setup-pi-kiosk.sh b/deploy/scripts/setup-pi-kiosk.sh index 45d696ab..d4e13efd 100755 --- a/deploy/scripts/setup-pi-kiosk.sh +++ b/deploy/scripts/setup-pi-kiosk.sh @@ -240,7 +240,23 @@ if [ "${INSTALL_KIOSK}" = "1" ]; then rm -rf "${MEDIAMTX_TMP}" echo " installed → ${BIN_DST}" + # Allow managed timezone changes through polkit. + apt-get install -y polkitd + + # The kiosk retains NoNewPrivileges. + install -d -m 755 /etc/polkit-1/rules.d /etc/betterframe + cat > /etc/polkit-1/rules.d/49-betterframe-timezone.rules <<'POLKIT' +polkit.addRule(function(action, subject) { + if (subject.user === "bfkiosk" && action.id === "org.freedesktop.timedate1.set-timezone") { + return polkit.Result.YES; + } +}); +POLKIT + chmod 644 /etc/polkit-1/rules.d/49-betterframe-timezone.rules + install -m 644 /dev/null /etc/betterframe/managed-image + # -------------------------------------------------------------------------- + # 8. bfkiosk user + PAM + systemd unit # -------------------------------------------------------------------------- # Debian's seatd uses -g video (no separate 'seat' group) — only join groups diff --git a/deploy/x86-image/build-image.sh b/deploy/x86-image/build-image.sh index 87a24ae2..b52fad89 100755 --- a/deploy/x86-image/build-image.sh +++ b/deploy/x86-image/build-image.sh @@ -154,7 +154,7 @@ apt-get -y install --no-install-recommends \ gstreamer1.0-plugins-base gstreamer1.0-plugins-good gstreamer1.0-plugins-bad \ gstreamer1.0-libav gstreamer1.0-tools v4l-utils wlr-randr \ gstreamer1.0-vaapi va-driver-all mesa-va-drivers vainfo intel-gpu-tools \ - rauc dosfstools nftables cloud-guest-utils e2fsprogs openssl udev tpm2-tools qemu-guest-agent + polkitd rauc dosfstools nftables cloud-guest-utils e2fsprogs openssl udev tpm2-tools qemu-guest-agent locale-gen en_US.UTF-8 || true update-locale LANG=en_US.UTF-8 || true @@ -225,11 +225,19 @@ install -m 644 /tmp/bf-files/de.pengutronix.rauc.service /usr/share/dbus-1/syste install -m 644 /tmp/bf-files/de.pengutronix.rauc.conf /usr/share/dbus-1/system.d/de.pengutronix.rauc.conf install -m 644 /tmp/bf-files/nftables.conf /etc/nftables.conf -install -d -m 755 /etc/sudoers.d -cat > /etc/sudoers.d/betterframe-managed-config <<'SUDOERS' -bfkiosk ALL=(root) NOPASSWD: /usr/local/sbin/betterframe-apply-managed-config.sh * -SUDOERS -chmod 440 /etc/sudoers.d/betterframe-managed-config + +# Only timezone changes are authorized; the kiosk retains NoNewPrivileges. +install -d -m 755 /etc/polkit-1/rules.d /etc/betterframe +cat > /etc/polkit-1/rules.d/49-betterframe-timezone.rules <<'POLKIT' +polkit.addRule(function(action, subject) { + if (subject.user === "bfkiosk" && action.id === "org.freedesktop.timedate1.set-timezone") { + return polkit.Result.YES; + } +}); +POLKIT +chmod 644 /etc/polkit-1/rules.d/49-betterframe-timezone.rules +install -m 644 /dev/null /etc/betterframe/managed-image + cat > /etc/default/betterframe-kiosk <<'EOF' BF_ENABLE_APP_OTA=0 diff --git a/docs/backup-recovery.md b/docs/backup-recovery.md new file mode 100644 index 00000000..68f0d530 --- /dev/null +++ b/docs/backup-recovery.md @@ -0,0 +1,74 @@ +# PostgreSQL backup and recovery + +The retired browser `.bfbak` workflow copied a SQLite database file. It does not +back up PostgreSQL. Its download/restore routes now return 409 before reading +uploads or changing keys. Keep old archives for historical recovery, but never +restore their keys over a current deployment. + +Install `age` on the deployment host. Run as root (to preserve container file +ownership) from the directory and Compose project +used for the deployment (set `COMPOSE_FILE=docker-compose.coolify.yml` when +appropriate): + +```sh +bash deploy/scripts/backup-stack.sh /secure/backups/betterframe.tar.age +``` + +The command briefly stops server and Node-RED, obtains a PostgreSQL custom-format +dump, copies the server data/key volume and all Node-RED manager/tenant data, +restarts previously running services, and encrypts the archive with age. The +passphrase is entered into age's terminal prompt. Store it separately. Free +space must accommodate both the unencrypted staging copy and encrypted output; +the private staging directory is removed on exit. For multiple server replicas, +stop every writer and use an equivalent coordinated snapshot procedure. + +Deployment configuration and externally supplied secrets (including private +signing keys provided through environment/Vault) must be retained separately in +the deployment secret manager. Do not export them into logs or Git. This backup +requires the normal file-backed `secret.key`; deployments using an external +system credential need a coordinated backup of that same credential. + +## Restore into a fresh deployment + +1. Create a new isolated Compose project with empty volumes and matching + PostgreSQL major version. Do not attach production volumes. Retain the + original deployment until validation succeeds. +2. Decrypt to a private staging file first, so authentication finishes before + archive extraction. Extract only into a newly created empty directory: + + ```sh + umask 077 + mkdir recovery + age --decrypt -o recovery/stack.tar /secure/backups/betterframe.tar.age + tar -tf recovery/stack.tar + mkdir recovery/files + sudo tar --numeric-owner --same-owner -xpf recovery/stack.tar -C recovery/files + ``` + +3. Start only PostgreSQL in the new project. Restore the dump with + `pg_restore --exit-on-error --single-transaction --no-owner` against the + empty database, using its configured database user. For the supplied Compose: + + ```sh + docker compose up -d postgres + docker compose exec -T postgres sh -c 'exec pg_restore --exit-on-error --single-transaction --no-owner --username="$POSTGRES_USER" --dbname="${POSTGRES_DB:-$POSTGRES_USER}"' < recovery/files/postgres.dump + ``` + +4. Create the server and Node-RED containers without starting them. Copy + `server-data/.` to the server's `/var/lib/betterframe/`, and `nodered-data/.` + to Node-RED's `/data/` on those new containers using `docker cp -a`. Preserve original numeric + ownership and permissions, particularly each Node-RED tenant UID/GID and + the manager's root-only state file. Restore matching deployment secrets. +5. Start the new services. Verify tenant/user counts, camera and layout data, + key decryption, Node-RED flows, and a test device's existing credentials. + Confirm that a fresh backup can be restored again. Only then switch traffic. +6. Remove decrypted staging files after the validation and retention decisions. + +## PostgreSQL 18 volume correction + +Both Compose files now mount `pgdata` at `/var/lib/postgresql`, matching the +official image's `/var/lib/postgresql/18/docker` data directory. Existing users +of `compose.yaml` must back up and inspect the actual running database's mounts +before recreation: the old named `/var/lib/postgresql/data` mount may not hold +the live cluster. Do not assume an empty named volume means there is no data. +Restore into a fresh correctly mounted project using the procedure above. diff --git a/docs/pairing-recovery.md b/docs/pairing-recovery.md new file mode 100644 index 00000000..1ed35026 --- /dev/null +++ b/docs/pairing-recovery.md @@ -0,0 +1,72 @@ +# Pairing recovery rollout + +Deploy the server and Node-RED manager/node package together, followed by kiosk +clients and ioBOX firmware. Existing kiosk keys remain usable. Kiosks opt into +secret-protected claims; older clients retain their original claim protocol. +Take a coordinated [PostgreSQL backup](backup-recovery.md) before deployment. + +## What changes + +- Kiosk confirmation locks the pairing session and commits the device, display, + encryption keys, labels and claim together. Repeating the same confirmation + returns the same device. A failed replacement preserves the previous keys. +- Confirmed kiosk credentials remain retrievable for 15 minutes after + confirmation, even if the original code deadline passes. New clients persist + the session secret before displaying the code and acknowledge only after + saving the complete encrypted identity. Acknowledgment removes the delivered + credentials from the server's session record. +- Network and response errors show retry status. A missing initial bundle or + an HTTP 401 preserves the saved identity. Explicit device reset/unpair remains + the way to discard credentials; a revoked device cannot fetch new content. +- Server migrations serialize across processes. Tenant creation commits its + schema and registration together, including names containing hyphens. Startup + repairs legacy registrations whose tenant schema was never created. +- Node-RED internal events require manager and tenant runtime credentials; + public webhook paths cannot access them. One route dispatches to every matching + trigger node. Standalone Node-RED deployments must configure the equivalent + trusted forwarding/token arrangement before upgrading these nodes. +- Replaced WebSocket connections cannot delete the current connection or finish + its requests. Missing heartbeats force reconnects. Requests rejected while a + kiosk is offline are not queued for unexpected later execution. + +## Device prerequisites + +New managed images and the source installer install +`/etc/betterframe/managed-image` and a narrow polkit rule allowing `bfkiosk` to +set the timezone via timedated. Existing images need those deployment changes +as well as the new binary to report managed-image support. The service retains +`NoNewPrivileges`. The client first uses timedated through polkit, then tries +the existing `sudo -n` managed-config helper if direct authorization fails. +This preserves older installations whose sudo policy permits that helper; +it cannot bypass `NoNewPrivileges` on hardened images. App OTA does not install +OS policy: update the OS image or install the new polkit rule and restart the +kiosk service before retrying failed managed configuration. If both paths fail, +the client reports the failure instead of acknowledging the configuration. + +For ioBOX, configure verified public `BF_IOBOX_TLS_CA_PEM` and +`BF_IOBOX_OTA_PUBLIC_KEY_PEM` repository variables before the release build. +The second must match the server's ioBOX firmware signing key. Release builds +fail when the anchors are missing. See the [firmware guide](../iobox-firmware/README.md) +for provisioning, trust rotation and legacy enrollment recovery. Test firmware +from PR validation contains disposable test trust and must not be deployed. + +New ioBOX enrollment binds a persisted random secret to the serial and permits +retrieval of the same credentials until authenticated acknowledgment. This +protects retries; it does not establish factory provenance on first enrollment. +Already-stranded legacy ioBOX credentials cannot safely be recovered by serial +alone: use the documented operator reset/re-enrollment procedure. + +## Qualification before fleet rollout + +PR validation builds/tests the server against PostgreSQL 18, the native Linux +and Windows clients, and both ioBOX firmware variants. The regression suite +injects malformed responses, failed database writes, concurrent confirmations, +replaced connections and signature tampering. + +Qualify a small hardware group before expanding rollout: disconnect networking +at each pairing stage; reboot after confirmation and credential persistence; +test read-only/full device storage; restore connectivity after initial bundle +failure; exercise W5500 HTTPS and OTA; cycle camera streams and layouts; and +verify timezone application on an image with the polkit rule. Validate a complete +backup restoration in an isolated deployment. Automated checks cannot establish +power-loss behavior, TLS memory usage or media stability on physical devices. diff --git a/iobox-firmware/.gitignore b/iobox-firmware/.gitignore new file mode 100644 index 00000000..f63a46a7 --- /dev/null +++ b/iobox-firmware/.gitignore @@ -0,0 +1,3 @@ +.pio/ +include/trust_config_local.h +__pycache__/ diff --git a/iobox-firmware/README.md b/iobox-firmware/README.md index 41f1e65a..5b8e97e2 100644 --- a/iobox-firmware/README.md +++ b/iobox-firmware/README.md @@ -39,16 +39,80 @@ Set deployment values with PlatformIO build flags or a private local override: - Ethernet SPI pins for the chosen board. - IO pins for PIR/buttons/RS485. -The Wi-Fi variant supports HTTPS using the ESP32 TLS stack. The W5500 Ethernet -variant uses plain HTTP because the standard Arduino W5500 client does not -provide TLS; deploy it against an internal HTTP service URL or terminate TLS -upstream on the same trusted network. +Both variants verify the server certificate and hostname against an embedded +root CA. W5500 uses ESP_SSLClient over EthernetClient. Server pairing and API +traffic require HTTPS. Enrollment bodies and bearer-authenticated requests are +explicitly scoped to the parsed configured HTTPS origin, including when the +stored URL has trailing slashes. Hostname prefixes, different ports and scheme +downgrades cannot bypass this check. The existing local kiosk LAN protocol remains +HTTP through explicitly local calls, which never attach the server bearer key. + +### Required trust provisioning and upgrade order + +1. Deploy the matching server pairing changes before upgrading devices. +2. Obtain the deployment CA certificate and the server's existing firmware signing + **public** PEM through an authenticated operator channel. Verify their + fingerprints independently. Never copy the signing private key into firmware. +3. Generate the ignored local header and build both variants: + + ```sh + python3 scripts/provision_trust.py --ca /secure/public/root-ca.pem \ + --signing-public-key /secure/public/firmware-signing.pub.pem + pio run -e iobox_wifi -e iobox_eth + ``` + + Release CI requires repository variables `BF_IOBOX_TLS_CA_PEM` and + `BF_IOBOX_OTA_PUBLIC_KEY_PEM` with those same public values. Missing values + fail release builds. Unprovisioned developer builds compile, but visibly + refuse server TLS and OTA; they must not be flashed to a production fleet. +4. Ensure the configured HTTPS name resolves on both networks, matches the server + certificate, and NTP/DNS are reachable. `BF_NTP_SERVER` defaults to + `pool.ntp.org`; provision your own time server in the local header when needed. + Certificate verification waits for NTP time, retries on failure, and never + disables validity checks. NTP is unauthenticated: networks requiring protected + time must supply a trusted time source/network and hardware qualification. +5. For old HTTP deployments, provision an HTTPS URL and CA before flashing. + Existing credentials and network configuration are retained. A changed server + origin or corrupt identity stops enrollment with a serial-console diagnostic; + an administrator must explicitly recover/reset it. Existing serial-only + claims whose key was already lost require administrator unpair/re-enrollment; + the device cannot securely recover a secret it never possessed. + +Before mass rollout, test the provisioned images on real Wi-Fi/W5500 hardware: +valid/untrusted/expired/wrong-host certificates, missing NTP, lost claim and ACK +responses, power loss during NVS write, revoked keys, interrupted flash, invalid +and valid firmware signatures. Keep USB recovery available. Firmware does not +replace hardware secure boot, encrypted NVS, or device-level rollback controls. + +### Pairing durability and OTA format + +A 256-bit device secret is saved in NVS **before** announcing. Its hash binds +server-side claim retries; the secret is never logged. This binds the first +accepted announcement, rather than proving factory provenance. Enroll on a +controlled network; deployments needing protection against initial serial +impersonation must pre-register per-device manufacturing credentials. The returned identity is +stored as one versioned NVS value and read back before use/ACK. A reboot or lost +claim response reuses the same secret. ACK retries survive reboot and the server +removes the encrypted retry envelope after ACK. Existing two-key identities are +migrated without deleting the legacy copy. Storage or authentication failures +preserve the identity and report an actionable serial-console state. + +OTA signatures use the existing server format: base64url Ed25519 signature over +the 64-byte lowercase hexadecimal SHA-256 digest of the exact firmware binary. +The embedded Ed25519 public key is independent of the downloaded metadata. The +firmware verifies both digest and signature **before** `Update.end()` marks the +partition bootable; failures abort the inactive-partition write. Downloads must +stay on the configured HTTPS origin, so bearer credentials cannot leak through a +server-supplied third-party URL. Rotating the signing key requires an image signed +by the currently trusted key that carries the new trust material, or USB service. +The existing server import signs firmware; release downloads alone are not a +substitute for signed metadata from that import. ## Implemented Contract - AP provisioning portal at `http://192.168.4.1/`. - Serial/model hint announce to `/api/iobox/announce`. -- Pair claim to `/api/iobox/pair/claim` when the device is registered but unpaired. +- Pair claim to `/api/iobox/pair/claim` and durable-storage acknowledgment to `/api/iobox/pair/ack`. - Heartbeat to `/api/iobox/heartbeat`. - Config pull from `/api/iobox/config`. - Event post to `/api/iobox/event`. @@ -58,3 +122,20 @@ upstream on the same trusted network. - RS485 UART line input can emit generic `rs485` events when `BF_RS485_RX_PIN` and `BF_RS485_TX_PIN` are configured. USB HID host, binary Pelco protocol decoding, and richer IO expanders should be added inside the hardware polling section without changing the server API contract. + +## Local regression checks + +After PlatformIO has installed the pinned libraries, run: + +```sh +python3 -m unittest discover -s tests -v +node tests/test_ota_signature.mjs +``` + +The native signature test compiles the same verification helper and Crypto library +used by firmware, verifies a Node-generated server-format signature, and rejects +modified firmware digests, signatures, and public keys. The provisioning tests +reject invalid certificates, private key input, and non-Ed25519 signing keys. +The native HTTP policy tests exercise the same origin/scope helper as firmware, +including insecure configurations with trailing slashes, HTTPS origins, malformed +URLs, server/enrollment credential isolation, and local kiosk HTTP compatibility. diff --git a/iobox-firmware/include/http_policy.h b/iobox-firmware/include/http_policy.h new file mode 100644 index 00000000..86e84a94 --- /dev/null +++ b/iobox-firmware/include/http_policy.h @@ -0,0 +1,73 @@ +#pragma once +#include +#include + +namespace bf { + +// Enrollment bodies carry a provisioning secret even though they have no bearer +// header. Only explicitly local kiosk calls may use the legacy LAN HTTP protocol. +enum class HttpTarget { Server, Enrollment, LocalKiosk }; + +struct HttpUrl { + std::string host; + std::string path; + uint16_t port = 0; + bool https = false; +}; + +inline bool parseHttpUrl(const std::string &url, HttpUrl &out) { + size_t authorityStart; + if (url.compare(0, 8, "https://") == 0) { + authorityStart = 8; + out.https = true; + } else if (url.compare(0, 7, "http://") == 0) { + authorityStart = 7; + out.https = false; + } else { + return false; + } + // Reject inputs that HTTP clients can interpret differently, including URL + // userinfo, fragments, backslashes, whitespace and control characters. + for (unsigned char ch : url) { + if (ch <= 0x20 || ch == 0x7f || ch == '\\' || ch == '#' || ch == '@') return false; + } + const size_t pathStart = url.find_first_of("/?", authorityStart); + const std::string authority = url.substr(authorityStart, pathStart - authorityStart); + const size_t colon = authority.find(':'); + out.host = authority.substr(0, colon); + if (out.host.empty()) return false; + // The W5500 implementation supports DNS names and IPv4, not IPv6 literals. + for (char &ch : out.host) { + if (ch >= 'A' && ch <= 'Z') ch += 'a' - 'A'; + if (!((ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9') || ch == '-' || ch == '.')) return false; + } + out.port = out.https ? 443 : 80; + if (colon != std::string::npos) { + const std::string port = authority.substr(colon + 1); + if (port.empty() || port.size() > 5) return false; + uint32_t value = 0; + for (char ch : port) { + if (ch < '0' || ch > '9') return false; + value = value * 10 + (ch - '0'); + } + if (value == 0 || value > 65535) return false; + out.port = static_cast(value); + } + out.path = pathStart == std::string::npos ? "/" : url.substr(pathStart); + if (out.path.front() == '?') out.path.insert(0, "/"); + return true; +} + +inline bool allowHttpRequest(const std::string &configuredServer, const std::string &requestUrl, + HttpTarget target = HttpTarget::Server) { + HttpUrl request; + if (!parseHttpUrl(requestUrl, request)) return false; + if (target == HttpTarget::LocalKiosk) { + return request.path.compare(0, 7, "/local/") == 0; + } + HttpUrl server; + return parseHttpUrl(configuredServer, server) && server.https && request.https && + server.host == request.host && server.port == request.port; +} + +} // namespace bf diff --git a/iobox-firmware/include/ota_signature.h b/iobox-firmware/include/ota_signature.h new file mode 100644 index 00000000..d9f42303 --- /dev/null +++ b/iobox-firmware/include/ota_signature.h @@ -0,0 +1,15 @@ +#pragma once +#include + +// Wire contract shared with server/src/shared/firmware.ts. The signature covers +// the lowercase HEX digest, not the binary digest or the firmware bytes directly. +inline bool verifyOtaDigest(const uint8_t digest[32], const uint8_t signature[64], + const uint8_t publicKey[32]) { + static const char hex[] = "0123456789abcdef"; + char message[64]; + for (size_t i = 0; i < 32; ++i) { + message[2 * i] = hex[digest[i] >> 4]; + message[2 * i + 1] = hex[digest[i] & 15]; + } + return Ed25519::verify(signature, publicKey, message, sizeof(message)); +} diff --git a/iobox-firmware/include/trust_config.h b/iobox-firmware/include/trust_config.h new file mode 100644 index 00000000..ad74d685 --- /dev/null +++ b/iobox-firmware/include/trust_config.h @@ -0,0 +1,15 @@ +#pragma once +// Override with include/trust_config_local.h during controlled provisioning. +// Only PUBLIC trust material belongs here, never a firmware signing private key. +#if __has_include("trust_config_local.h") +#include "trust_config_local.h" +#endif +#ifndef BF_TLS_CA_PEM +#define BF_TLS_CA_PEM "" +#endif +#ifndef BF_OTA_PUBLIC_KEY_HEX +#define BF_OTA_PUBLIC_KEY_HEX "" +#endif +#ifndef BF_NTP_SERVER +#define BF_NTP_SERVER "pool.ntp.org" +#endif diff --git a/iobox-firmware/platformio.ini b/iobox-firmware/platformio.ini index c3dee593..eb722f1b 100644 --- a/iobox-firmware/platformio.ini +++ b/iobox-firmware/platformio.ini @@ -2,14 +2,16 @@ default_envs = iobox_wifi [env] -platform = espressif32 +platform = espressif32@6.12.0 framework = arduino board = esp32-s3-devkitc-1 monitor_speed = 115200 upload_speed = 921600 lib_deps = - bblanchon/ArduinoJson@^7.4.2 - arduino-libraries/Ethernet@^2.0.2 + bblanchon/ArduinoJson@7.4.2 + arduino-libraries/Ethernet@2.0.2 + mobizt/ESP_SSLClient@2.2.3 + rweather/Crypto@0.4.0 build_flags = -D BF_IOBOX_FW_VERSION=\"0.1.0\" -D BF_DEFAULT_SERVER_URL=\"https://betterframe.local\" diff --git a/iobox-firmware/scripts/provision_trust.py b/iobox-firmware/scripts/provision_trust.py new file mode 100644 index 00000000..7b86bd24 --- /dev/null +++ b/iobox-firmware/scripts/provision_trust.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python3 +"""Generate a firmware header from operator-verified PUBLIC trust material.""" +import argparse +import json +import os +from pathlib import Path +import subprocess + + +def trust_header(ca_pem: str, signing_pem: str) -> str: + if 'PRIVATE KEY' in ca_pem or 'PRIVATE KEY' in signing_pem: + raise ValueError('Only public certificates and signing keys are accepted') + subprocess.run(['openssl', 'x509', '-noout', '-checkend', '0'], + input=ca_pem.encode(), check=True, capture_output=True) + der = subprocess.run(['openssl', 'pkey', '-pubin', '-outform', 'DER'], + input=signing_pem.encode(), check=True, capture_output=True).stdout + # RFC 8410 Ed25519 SubjectPublicKeyInfo: algorithm OID 1.3.101.112, 32-byte key. + prefix = bytes.fromhex('302a300506032b6570032100') + if len(der) != 44 or not der.startswith(prefix): + raise ValueError('Firmware signing public key must be Ed25519') + return ('#pragma once\n// Generated public trust material; verify its provenance before building.\n' + f'#define BF_TLS_CA_PEM {json.dumps(ca_pem)}\n' + f'#define BF_OTA_PUBLIC_KEY_HEX "{der[12:].hex()}"\n') + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--ca', type=Path, help='Verified PEM root certificate') + parser.add_argument('--signing-public-key', type=Path, help='Verified firmware signing PUBLIC PEM') + parser.add_argument('--output', type=Path, default=Path('include/trust_config_local.h')) + args = parser.parse_args() + ca = args.ca.read_text() if args.ca else os.environ.get('BF_IOBOX_TLS_CA_PEM', '') + signing = args.signing_public_key.read_text() if args.signing_public_key else os.environ.get('BF_IOBOX_OTA_PUBLIC_KEY_PEM', '') + if not ca or not signing: + parser.error('Both CA and signing public key are required; supply files or BF_IOBOX_*_PEM environment variables') + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(trust_header(ca, signing)) + print(f'Public trust header generated: {args.output}') + + +if __name__ == '__main__': + main() diff --git a/iobox-firmware/src/main.cpp b/iobox-firmware/src/main.cpp index 3694f480..39261ad6 100644 --- a/iobox-firmware/src/main.cpp +++ b/iobox-firmware/src/main.cpp @@ -6,10 +6,20 @@ #include #include #include +#include +#include +#include +#include +#include "ota_signature.h" +#include +#include "trust_config.h" +#include "http_policy.h" #include #if BF_ETHERNET_VARIANT #include +#include +#include #include #endif @@ -52,6 +62,10 @@ String serialNumber; String serverUrl; String ioboxKey; String ioboxId; +String provisioningSecret; +bool claimAckPending = false; +bool identityBlocked = false; +String diagnostic; String assignedDisplayId; String assignedKioskLocalKey; String assignedKioskIp; @@ -74,6 +88,7 @@ String rs485Line; #if BF_ETHERNET_VARIANT EthernetClient ethClient; +ESP_SSLClient ethSecureClient; #endif WiFiClient wifiClient; WiFiClientSecure wifiSecureClient; @@ -89,8 +104,37 @@ String prefString(const char *key, const char *fallback = "") { return prefs.getString(key, fallback); } -void saveString(const char *key, const String &value) { - prefs.putString(key, value); +bool saveString(const char *key, const String &value) { + return prefs.putString(key, value) == value.length() && prefs.getString(key) == value; +} + +void report(const String &message) { + if (diagnostic != message) { + diagnostic = message; + Serial.println("[ioBOX] " + message); + } +} + +bool saveIdentity(const String &id, const String &key, bool ackPending) { + if (id.isEmpty() || key.isEmpty()) return false; + JsonDocument identity; + identity["version"] = 1; + identity["id"] = id; + identity["key"] = key; + identity["server"] = serverUrl; + identity["ack_pending"] = ackPending; + String encoded; + serializeJson(identity, encoded); + // NVS commits a single value atomically. Verify before ACK or using it. + if (!saveString("identity", encoded)) { + report("Pairing storage failed; retaining server claim for retry"); + return false; + } + ioboxId = id; + ioboxKey = key; + claimAckPending = ackPending; + paired = true; + return true; } String macSerial() { @@ -135,22 +179,20 @@ String joinUrl(const String &base, const char *path) { } bool parseUrl(const String &url, ParsedUrl &out) { - out.https = url.startsWith("https://"); - int schemeEnd = url.indexOf("://"); - if (schemeEnd < 0) return false; - int hostStart = schemeEnd + 3; - int pathStart = url.indexOf('/', hostStart); - String hostPort = pathStart >= 0 ? url.substring(hostStart, pathStart) : url.substring(hostStart); - out.path = pathStart >= 0 ? url.substring(pathStart) : "/"; - int colon = hostPort.lastIndexOf(':'); - out.port = out.https ? 443 : 80; - if (colon > 0) { - out.host = hostPort.substring(0, colon); - out.port = static_cast(hostPort.substring(colon + 1).toInt()); - } else { - out.host = hostPort; - } - return out.host.length() > 0; + bf::HttpUrl parsed; + if (!bf::parseHttpUrl(std::string(url.c_str(), url.length()), parsed)) return false; + out.host = parsed.host.c_str(); + out.path = parsed.path.c_str(); + out.port = parsed.port; + out.https = parsed.https; + return true; +} + +bool allowRequest(const String &url, bf::HttpTarget target = bf::HttpTarget::Server) { + // Preserve lengths so embedded NUL bytes cannot make validation inspect a + // different authority than Arduino HTTPClient later parses from String. + return bf::allowHttpRequest(std::string(serverUrl.c_str(), serverUrl.length()), + std::string(url.c_str(), url.length()), target); } String sha256Hex(const uint8_t digest[32]) { @@ -164,7 +206,86 @@ String sha256Hex(const uint8_t digest[32]) { return out; } -bool streamUpdateWithSha(Client &client, int contentLength, const String &expectedSha, String &error) { +bool tlsReady() { + if (strlen(BF_TLS_CA_PEM) == 0) { + report("TLS trust root missing; provision trust_config_local.h before deployment"); + return false; + } + if (time(nullptr) < 1704067200) { + report("Waiting for network time before certificate verification"); + return false; + } + return true; +} + +bool syncNetworkTime() { + if (time(nullptr) >= 1704067200) return true; + WiFiUDP wifiUdp; +#if BF_ETHERNET_VARIANT + EthernetUDP ethernetUdp; + UDP &udp = mode == NetMode::Ethernet ? static_cast(ethernetUdp) : static_cast(wifiUdp); +#else + UDP &udp = wifiUdp; +#endif + if (!udp.begin(49152 + (esp_random() % 16000))) return false; + uint8_t packet[48] = {}; + packet[0] = 0x23; // NTP v4 client + esp_fill_random(packet + 40, 8); + uint8_t nonce[8]; + memcpy(nonce, packet + 40, 8); + if (!udp.beginPacket(BF_NTP_SERVER, 123)) { udp.stop(); return false; } + udp.write(packet, sizeof(packet)); + if (!udp.endPacket()) { udp.stop(); return false; } + uint32_t start = millis(); + while (millis() - start < 3000) { + if (udp.parsePacket() >= 48 && udp.remotePort() == 123 && udp.read(packet, 48) == 48 && + (packet[0] & 7) == 4 && (packet[0] >> 6) != 3 && packet[1] > 0 && packet[1] <= 15 && + memcmp(packet + 24, nonce, 8) == 0) { + uint32_t seconds = (uint32_t(packet[40]) << 24) | (uint32_t(packet[41]) << 16) | + (uint32_t(packet[42]) << 8) | packet[43]; + if (seconds >= 3913056000UL) { + timeval value = {static_cast(seconds - 2208988800UL), 0}; + settimeofday(&value, nullptr); + udp.stop(); + return true; + } + } + delay(10); + } + udp.stop(); + report("Network time unavailable; TLS will retry (check NTP/DNS)"); + return false; +} + +bool verifyFirmwareSignature(const uint8_t digest[32], const String &signature, String &error) { + const String publicHex = BF_OTA_PUBLIC_KEY_HEX; + if (publicHex.length() != 64) { error = "OTA signing public key not provisioned"; return false; } + uint8_t publicKey[32]; + for (size_t i = 0; i < 32; ++i) { + char pair[3] = {publicHex[i * 2], publicHex[i * 2 + 1], 0}; + char *end = nullptr; + publicKey[i] = static_cast(strtoul(pair, &end, 16)); + if (end != pair + 2) { error = "invalid OTA signing public key"; return false; } + } + String base64 = signature; + base64.replace('-', '+'); + base64.replace('_', '/'); + while (base64.length() % 4) base64 += '='; + uint8_t decoded[64]; + size_t decodedLength = 0; + if (signature.length() != 86 || + mbedtls_base64_decode(decoded, sizeof(decoded), &decodedLength, + reinterpret_cast(base64.c_str()), base64.length()) != 0 || decodedLength != 64) { + error = "missing or invalid firmware signature"; + return false; + } + // Match server/shared/firmware.ts: Ed25519 over lowercase SHA256 hex text. + bool valid = verifyOtaDigest(digest, decoded, publicKey); + if (!valid) error = "firmware signature verification failed"; + return valid; +} + +bool streamUpdateWithSha(Client &client, int contentLength, const String &expectedSha, const String &signature, String &error) { if (expectedSha.length() != 64) { error = "missing sha256"; return false; @@ -184,7 +305,14 @@ bool streamUpdateWithSha(Client &client, int contentLength, const String &expect uint8_t buffer[1024]; size_t total = 0; uint32_t lastDataMs = millis(); + const uint32_t downloadStartMs = millis(); while ((knownSize && total < static_cast(contentLength)) || (!knownSize && (client.connected() || client.available()))) { + if (millis() - downloadStartMs > 180000) { + error = "download deadline exceeded"; + mbedtls_sha256_free(&sha); + Update.abort(); + return false; + } int available = client.available(); if (available <= 0) { if (millis() - lastDataMs > 15000) break; @@ -219,7 +347,11 @@ bool streamUpdateWithSha(Client &client, int contentLength, const String &expect Update.abort(); return false; } - if (!Update.end()) { + if (!verifyFirmwareSignature(digest, signature, error)) { + Update.abort(); + return false; + } + if (!Update.end(!knownSize)) { error = Update.errorString(); return false; } @@ -229,53 +361,73 @@ bool streamUpdateWithSha(Client &client, int contentLength, const String &expect #if BF_ETHERNET_VARIANT bool ethernetHttpBody(const char *method, const String &url, const String &payload, String &body, bool auth = true) { ParsedUrl parsed; - if (!parseUrl(url, parsed) || parsed.https) return false; - if (!ethClient.connect(parsed.host.c_str(), parsed.port)) return false; - - ethClient.print(method); - ethClient.print(" "); - ethClient.print(parsed.path); - ethClient.println(" HTTP/1.1"); - ethClient.print("Host: "); - ethClient.println(parsed.host); - ethClient.println("Connection: close"); - ethClient.println("Accept: application/json"); + if (!parseUrl(url, parsed)) return false; + if (parsed.https && !tlsReady()) return false; + Client &client = parsed.https ? static_cast(ethSecureClient) : static_cast(ethClient); + client.setTimeout(8000); + if (!client.connect(parsed.host.c_str(), parsed.port)) { + report("Ethernet connection/TLS verification failed"); + return false; + } + + client.print(method); + client.print(" "); + client.print(parsed.path); + client.println(" HTTP/1.0"); + client.print("Host: "); + client.println(parsed.host); + client.println("Connection: close"); + client.println("Accept: application/json"); if (auth && ioboxKey.length() > 0) { - ethClient.print("Authorization: Bearer "); - ethClient.println(ioboxKey); + client.print("Authorization: Bearer "); + client.println(ioboxKey); } if (strcmp(method, "POST") == 0) { - ethClient.println("Content-Type: application/json"); - ethClient.print("Content-Length: "); - ethClient.println(payload.length()); + client.println("Content-Type: application/json"); + client.print("Content-Length: "); + client.println(payload.length()); } - ethClient.println(); - if (payload.length() > 0) ethClient.print(payload); + client.println(); + if (payload.length() > 0) client.print(payload); uint32_t start = millis(); - while (!ethClient.available() && ethClient.connected() && millis() - start < 8000) delay(5); - String status = ethClient.readStringUntil('\n'); + while (!client.available() && client.connected() && millis() - start < 8000) delay(5); + String status = client.readStringUntil('\n'); if (!status.startsWith("HTTP/1.1 2") && !status.startsWith("HTTP/1.0 2")) { - ethClient.stop(); + report("Server HTTP error: " + status.substring(0, 12)); + client.stop(); return false; } - while (ethClient.connected()) { - String line = ethClient.readStringUntil('\n'); + start = millis(); + while (client.connected()) { + if (millis() - start > 10000) { client.stop(); report("Response headers timed out"); return false; } + String line = client.readStringUntil('\n'); if (line == "\r" || line.length() == 0) break; } body = ""; start = millis(); - while (ethClient.connected() || ethClient.available()) { - while (ethClient.available()) body += static_cast(ethClient.read()); - if (millis() - start > 10000) break; + while (client.connected() || client.available()) { + while (client.available()) { + if (body.length() >= 16384) { client.stop(); report("Response too large"); return false; } + body += static_cast(client.read()); + } + if (millis() - start > 10000) { client.stop(); report("Response timed out"); return false; } delay(1); } - ethClient.stop(); + client.stop(); return true; } #endif -bool httpJson(const char *method, const String &url, const JsonDocument *body, JsonDocument &out, bool auth = true) { +bool httpJson(const char *method, const String &url, const JsonDocument *body, JsonDocument &out, + bf::HttpTarget target = bf::HttpTarget::Server) { + // Scope is explicit: enrollment JSON is secret-bearing even without a bearer + // header. Enforce parsed HTTPS origins before serialization or either transport. + if (!allowRequest(url, target)) { + report("Request blocked: server credentials require the configured HTTPS origin (identity retained)"); + return false; + } + const bool auth = target == bf::HttpTarget::Server; String payload; if (body) serializeJson(*body, payload); @@ -288,8 +440,10 @@ bool httpJson(const char *method, const String &url, const JsonDocument *body, J } #endif + if (url.startsWith("https://") && !tlsReady()) return false; HTTPClient http; http.setTimeout(8000); + http.setConnectTimeout(8000); bool began = url.startsWith("https://") ? http.begin(wifiSecureClient, url) : http.begin(wifiClient, url); if (!began) return false; http.addHeader("Content-Type", "application/json"); @@ -306,6 +460,7 @@ bool httpJson(const char *method, const String &url, const JsonDocument *body, J } if (code < 200 || code >= 300) { + report("Server request failed (HTTP " + String(code) + "); retrying with saved identity"); http.end(); return false; } @@ -447,43 +602,59 @@ void maintainSelectedNetwork() { #endif } +void acknowledgeClaim() { + if (!claimAckPending || ioboxKey.isEmpty()) return; + JsonDocument body, response; + body["serial"] = serialNumber; + body["provisioning_secret"] = provisioningSecret; + if (httpJson("POST", joinUrl(serverUrl, "/api/iobox/pair/ack"), &body, response)) { + saveIdentity(ioboxId, ioboxKey, false); + } +} + void announceOrClaim() { - StaticJsonDocument<512> body; + if (identityBlocked) return; + if (!ioboxKey.isEmpty()) { paired = true; acknowledgeClaim(); return; } + if (provisioningSecret.isEmpty()) { + uint8_t random[32]; + // Ethernet disables Wi-Fi; explicitly enable the hardware entropy source. + if (mode == NetMode::Ethernet) bootloader_random_enable(); + esp_fill_random(random, sizeof(random)); + if (mode == NetMode::Ethernet) bootloader_random_disable(); + String nextSecret = sha256Hex(random); + if (!saveString("claim_secret", nextSecret)) { + report("Cannot persist pairing secret; check NVS storage"); + return; + } + provisioningSecret = nextSecret; + } + JsonDocument body, response; body["serial"] = serialNumber; + body["provisioning_secret"] = provisioningSecret; body["model_hint"] = modelId; body["firmware_version"] = BF_IOBOX_FW_VERSION; body["firmware_arch"] = "esp32s3"; body["network_mode"] = modeName(mode); - - StaticJsonDocument<1024> response; - if (!httpJson("POST", joinUrl(serverUrl, "/api/iobox/announce"), &body, response, false)) return; - + if (!httpJson("POST", joinUrl(serverUrl, "/api/iobox/announce"), &body, response, bf::HttpTarget::Enrollment)) return; const char *status = response["status"] | ""; - if (strcmp(status, "unknown_serial") == 0) return; + if (strcmp(status, "unknown_serial") == 0) { report("Serial not registered; waiting for administrator"); return; } if (response["model_id"].is()) modelId = String(response["model_id"].as()); - - if (ioboxKey.length() > 0) { - paired = true; - return; - } - - StaticJsonDocument<512> claim; + JsonDocument claim, claimResponse; claim["serial"] = serialNumber; + claim["provisioning_secret"] = provisioningSecret; claim["firmware_version"] = BF_IOBOX_FW_VERSION; claim["network_mode"] = modeName(mode); - StaticJsonDocument<1024> claimResponse; - if (httpJson("POST", joinUrl(serverUrl, "/api/iobox/pair/claim"), &claim, claimResponse, false)) { - ioboxId = String(claimResponse["iobox_id"] | ""); - ioboxKey = String(claimResponse["iobox_key"] | ""); - if (ioboxKey.length() > 0) { - saveString("iobox_id", ioboxId); - saveString("iobox_key", ioboxKey); - paired = true; - } + if (!httpJson("POST", joinUrl(serverUrl, "/api/iobox/pair/claim"), &claim, claimResponse, bf::HttpTarget::Enrollment)) return; + String id = String(claimResponse["iobox_id"] | ""); + String key = String(claimResponse["iobox_key"] | ""); + if (saveIdentity(id, key, true)) { + report("Paired; identity stored, waiting for configuration"); + acknowledgeClaim(); } } void heartbeat() { + acknowledgeClaim(); StaticJsonDocument<512> body; body["firmware_version"] = BF_IOBOX_FW_VERSION; body["network_mode"] = modeName(mode); @@ -522,14 +693,14 @@ bool checkLocalKiosk() { if (assignedKioskIp.length() == 0 || assignedKioskLocalKey.length() == 0) return false; StaticJsonDocument<256> response; String url = "http://" + assignedKioskIp + ":" + String(assignedKioskPort) + "/local/iobox/check?key=" + assignedKioskLocalKey; - return httpJson("GET", url, nullptr, response, false); + return httpJson("GET", url, nullptr, response, bf::HttpTarget::LocalKiosk); } bool postEventToLocalKiosk(JsonDocument &event) { if (!localKioskReachable) return false; String url = "http://" + assignedKioskIp + ":" + String(assignedKioskPort) + "/local/iobox/event?key=" + assignedKioskLocalKey; StaticJsonDocument<256> response; - return httpJson("POST", url, &event, response, false); + return httpJson("POST", url, &event, response, bf::HttpTarget::LocalKiosk); } void postEventToServer(JsonDocument &event, const char *route) { @@ -568,7 +739,7 @@ bool runLocalMapping(JsonObject mapping) { if (assignedKioskIp.length() == 0 || assignedKioskLocalKey.length() == 0 || strlen(layoutId) == 0) return false; String url = "http://" + assignedKioskIp + ":" + String(assignedKioskPort) + "/local/layout/" + String(layoutId) + "?key=" + assignedKioskLocalKey; StaticJsonDocument<256> response; - return httpJson("GET", url, nullptr, response, false); + return httpJson("GET", url, nullptr, response, bf::HttpTarget::LocalKiosk); } return false; } @@ -664,48 +835,61 @@ void otaCheck() { String downloadUrl = String(response["download_url"] | ""); String version = String(response["version"] | ""); String expectedSha = String(response["sha256"] | ""); + String signature = String(response["signature"] | ""); + if (signature.isEmpty() || strlen(BF_OTA_PUBLIC_KEY_HEX) == 0) { + report("OTA deferred: firmware signature or embedded signing key missing"); + return; + } String absolute = downloadUrl.startsWith("http") ? downloadUrl : joinUrl(serverUrl, downloadUrl.c_str()); - if (absolute.length() == 0) return; + if (!allowRequest(absolute) || !tlsReady()) { + report("OTA rejected: download must use the configured HTTPS origin"); + return; + } bool ok = false; String otaError; #if BF_ETHERNET_VARIANT if (mode == NetMode::Ethernet) { ParsedUrl parsed; - if (parseUrl(absolute, parsed) && !parsed.https && ethClient.connect(parsed.host.c_str(), parsed.port)) { - ethClient.print("GET "); - ethClient.print(parsed.path); - ethClient.println(" HTTP/1.1"); - ethClient.print("Host: "); - ethClient.println(parsed.host); - ethClient.println("Connection: close"); + if (parseUrl(absolute, parsed) && ethSecureClient.connect(parsed.host.c_str(), parsed.port)) { + ethSecureClient.print("GET "); + ethSecureClient.print(parsed.path); + ethSecureClient.println(" HTTP/1.0"); + ethSecureClient.print("Host: "); + ethSecureClient.println(parsed.host); + ethSecureClient.println("Connection: close"); if (ioboxKey.length() > 0) { - ethClient.print("Authorization: Bearer "); - ethClient.println(ioboxKey); + ethSecureClient.print("Authorization: Bearer "); + ethSecureClient.println(ioboxKey); } - ethClient.println(); + ethSecureClient.println(""); uint32_t start = millis(); - while (!ethClient.available() && ethClient.connected() && millis() - start < 8000) delay(5); - String status = ethClient.readStringUntil('\n'); + while (!ethSecureClient.available() && ethSecureClient.connected() && millis() - start < 8000) delay(5); + String status = ethSecureClient.readStringUntil('\n'); int contentLength = UPDATE_SIZE_UNKNOWN; bool statusOk = status.startsWith("HTTP/1.1 2") || status.startsWith("HTTP/1.0 2"); - while (ethClient.connected()) { - String line = ethClient.readStringUntil('\n'); - if (line.startsWith("Content-Length:")) contentLength = line.substring(15).toInt(); + start = millis(); + while (ethSecureClient.connected()) { + if (millis() - start > 10000) { statusOk = false; break; } + String line = ethSecureClient.readStringUntil('\n'); + String headerName = line.substring(0, line.indexOf(':')); + if (headerName.equalsIgnoreCase("Content-Length")) contentLength = line.substring(15).toInt(); if (line == "\r" || line.length() == 0) break; } if (statusOk) { - ok = streamUpdateWithSha(ethClient, contentLength, expectedSha, otaError); + ok = streamUpdateWithSha(ethSecureClient, contentLength, expectedSha, signature, otaError); } else { otaError = "download http error"; } - ethClient.stop(); + ethSecureClient.stop(); } } else #endif { HTTPClient http; + http.setTimeout(15000); + http.setConnectTimeout(8000); bool began = absolute.startsWith("https://") ? http.begin(wifiSecureClient, absolute) : http.begin(wifiClient, absolute); if (!began) return; if (ioboxKey.length() > 0) http.addHeader("Authorization", "Bearer " + ioboxKey); @@ -717,7 +901,7 @@ void otaCheck() { int len = http.getSize(); WiFiClient *stream = http.getStreamPtr(); - ok = streamUpdateWithSha(*stream, len, expectedSha, otaError); + ok = streamUpdateWithSha(*stream, len, expectedSha, signature, otaError); http.end(); } @@ -753,19 +937,50 @@ void setup() { #endif prefs.begin("bf-iobox", false); - wifiSecureClient.setInsecure(); + wifiSecureClient.setCACert(BF_TLS_CA_PEM); + wifiSecureClient.setHandshakeTimeout(15); +#if BF_ETHERNET_VARIANT + ethSecureClient.setClient(ðClient); + ethSecureClient.setCACert(BF_TLS_CA_PEM); + ethSecureClient.setTimeout(8); // ESP_SSLClient takes seconds, unlike Stream. + ethSecureClient.setHandshakeTimeout(15); +#endif serialNumber = prefString("serial"); if (serialNumber.length() == 0) { serialNumber = macSerial(); saveString("serial", serialNumber); } serverUrl = prefString("server", BF_DEFAULT_SERVER_URL); - ioboxId = prefString("iobox_id"); - ioboxKey = prefString("iobox_key"); + provisioningSecret = prefString("claim_secret"); + String storedIdentity = prefString("identity"); + if (!storedIdentity.isEmpty()) { + JsonDocument identity; + if (deserializeJson(identity, storedIdentity) == DeserializationError::Ok && identity["version"] == 1 && + identity["server"].as() == serverUrl && !identity["id"].as().isEmpty() && + !identity["key"].as().isEmpty()) { + ioboxId = identity["id"].as(); + ioboxKey = identity["key"].as(); + claimAckPending = identity["ack_pending"] | false; + paired = true; + } else { + report("Saved identity invalid or server changed; local service required (identity retained)"); + identityBlocked = true; + } + } else { + String legacyId = prefString("iobox_id"); + String legacyKey = prefString("iobox_key"); + if (!legacyId.isEmpty() && !legacyKey.isEmpty()) { + if (!saveIdentity(legacyId, legacyKey, false)) identityBlocked = true; + } else if (!legacyId.isEmpty() || !legacyKey.isEmpty()) { + identityBlocked = true; + report("Incomplete legacy identity; administrator must recover enrollment (identity retained)"); + } + } modelId = prefString("model_id", BF_MODEL_HINT); chooseNetworkAtBoot(); if (networkUp) { + syncNetworkTime(); announceOrClaim(); if (paired) { heartbeat(); @@ -777,9 +992,14 @@ void setup() { void loop() { maintainSelectedNetwork(); - setLed(networkUp); + static uint32_t lastTimeAttempt = 0; + if (networkUp && time(nullptr) < 1704067200 && millis() - lastTimeAttempt > 30000) { + lastTimeAttempt = millis(); + syncNetworkTime(); + } + setLed(networkUp && paired ? true : (millis() / 500) % 2); if (!networkUp || !paired) { - delay(1000); + delay(3000); if (networkUp && !paired) announceOrClaim(); return; } diff --git a/iobox-firmware/tests/http_policy_test.cpp b/iobox-firmware/tests/http_policy_test.cpp new file mode 100644 index 00000000..7b523a1d --- /dev/null +++ b/iobox-firmware/tests/http_policy_test.cpp @@ -0,0 +1,56 @@ +#include "http_policy.h" +#include +#include + +static void expect(bool actual, bool expected, const char *name) { + if (actual != expected) { + std::fprintf(stderr, "HTTP policy failed: %s\n", name); + std::exit(1); + } +} + +int main() { + using bf::HttpTarget; + // A trailing slash on the stored origin must never disable the guard. Both + // bearer-authenticated traffic and enrollment bodies contain credentials. + for (auto target : {HttpTarget::Server, HttpTarget::Enrollment}) { + for (const char *configured : {"http://host", "http://host/", "http://host///", "http://host/base/"}) { + expect(bf::allowHttpRequest(configured, "http://host/api/iobox/pair/claim", target), false, + "HTTP configured origin (with/without trailing slashes)"); + expect(bf::allowHttpRequest(configured, "https://host/api/iobox/pair/claim", target), false, + "an insecure configuration is not silently upgraded"); + } + for (const char *configured : {"https://host", "https://host/", "https://host///", "https://host/base/"}) { + expect(bf::allowHttpRequest(configured, "https://host/api/iobox/pair/claim", target), true, + "HTTPS origin supports trailing slash and base path"); + expect(bf::allowHttpRequest(configured, "http://host/api/iobox/pair/claim", target), false, + "scheme downgrade rejected"); + expect(bf::allowHttpRequest(configured, "https://host.attacker/api/iobox/pair/claim", target), false, + "hostname prefix is not origin equality"); + expect(bf::allowHttpRequest(configured, "https://host:444/api/iobox/pair/claim", target), false, + "different port rejected"); + } + const std::string nulAuthority = std::string("https://host") + '\0' + "@attacker/api"; + expect(bf::allowHttpRequest("https://host/", nulAuthority, target), false, + "embedded NUL request authority rejected"); + expect(bf::allowHttpRequest(nulAuthority, "https://host/api", target), false, + "embedded NUL configured authority rejected"); + expect(bf::allowHttpRequest("https://HOST/", "https://host:443/api", target), true, + "canonical DNS case and effective HTTPS port"); + expect(bf::allowHttpRequest("https://host:8443/", "https://host:8443/api", target), true, + "explicit configured TLS port"); + for (const char *request : {"https://host@attacker/api", "https://host\\@attacker/api", + "https://host:65536/api", "https://host:443x/api", "https://host:/api", + "https://host/api\r\nAuthorization: secret", "https://host/%20#fragment", "//host/api"}) { + expect(bf::allowHttpRequest("https://host/", request, target), false, + "ambiguous/malformed URL rejected"); + } + } + expect(bf::allowHttpRequest("https://host/", "http://192.0.2.1:18090/local/iobox/check?key=local", + HttpTarget::LocalKiosk), true, "explicit local kiosk LAN compatibility"); + expect(bf::allowHttpRequest("http://host/", "http://host/api/iobox/pair/claim", HttpTarget::LocalKiosk), + false, "server enrollment cannot masquerade as local kiosk path"); + expect(bf::allowHttpRequest("https://host/", "http://192.0.2.1:18090/local/iobox/check?key=local"), + false, "default credential-bearing target cannot use LAN exception"); + std::puts("HTTP request policy: enrollment and bearer credentials stay on the configured HTTPS origin"); +} diff --git a/iobox-firmware/tests/ota_signature_test.cpp b/iobox-firmware/tests/ota_signature_test.cpp new file mode 100644 index 00000000..01482a5b --- /dev/null +++ b/iobox-firmware/tests/ota_signature_test.cpp @@ -0,0 +1,29 @@ +#include "ota_signature.h" +#include +#include +#include + +static void decode(const char *hex, uint8_t *out, size_t size) { + for (size_t i = 0; i < size; ++i) { + char value[] = {hex[i * 2], hex[i * 2 + 1], 0}; + out[i] = static_cast(strtoul(value, nullptr, 16)); + } +} +int main(int argc, char **argv) { + if (argc != 4) return 2; + uint8_t digest[32], signature[64], publicKey[32]; + decode(argv[1], digest, 32); + decode(argv[2], signature, 64); + decode(argv[3], publicKey, 32); + if (!verifyOtaDigest(digest, signature, publicKey)) return 3; + digest[0] ^= 1; + if (verifyOtaDigest(digest, signature, publicKey)) return 4; + digest[0] ^= 1; + signature[0] ^= 1; + if (verifyOtaDigest(digest, signature, publicKey)) return 5; + signature[0] ^= 1; + publicKey[0] ^= 1; + if (verifyOtaDigest(digest, signature, publicKey)) return 6; + puts("OTA signature: server-format valid signature accepted; modified digest/signature/key rejected"); + return 0; +} diff --git a/iobox-firmware/tests/test_http_policy.py b/iobox-firmware/tests/test_http_policy.py new file mode 100644 index 00000000..cd6104f3 --- /dev/null +++ b/iobox-firmware/tests/test_http_policy.py @@ -0,0 +1,19 @@ +from pathlib import Path +import subprocess +import tempfile +import unittest + + +class HttpPolicyTests(unittest.TestCase): + def test_firmware_request_origin_and_scope_contract(self): + root = Path(__file__).parents[1] + with tempfile.TemporaryDirectory(prefix='bf-http-policy-') as temp: + exe = Path(temp) / 'http-policy' + subprocess.run(['g++', '-std=c++11', '-Wall', '-Wextra', '-Werror', + '-I' + str(root / 'include'), + str(root / 'tests/http_policy_test.cpp'), '-o', str(exe)], check=True) + subprocess.run([str(exe)], check=True) + + +if __name__ == '__main__': + unittest.main() diff --git a/iobox-firmware/tests/test_ota_signature.mjs b/iobox-firmware/tests/test_ota_signature.mjs new file mode 100644 index 00000000..b9db90af --- /dev/null +++ b/iobox-firmware/tests/test_ota_signature.mjs @@ -0,0 +1,25 @@ +import { createHash, generateKeyPairSync, sign } from 'node:crypto'; +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = fileURLToPath(new URL('..', import.meta.url)); +const lib = resolve(root, '.pio/libdeps/iobox_wifi/Crypto'); +const temp = mkdtempSync(join(tmpdir(), 'bf-ota-signature-')); +try { + const exe = join(temp, 'test-ota'); + execFileSync('g++', ['-std=c++11', '-O2', '-ffunction-sections', '-fdata-sections', + '-Wl,--gc-sections', `-I${lib}`, `-I${join(root, 'include')}`, + join(root, 'tests/ota_signature_test.cpp'), + ...['Ed25519', 'Curve25519', 'SHA512', 'Hash', 'Crypto', 'BigNumberUtil'].map(name => join(lib, `${name}.cpp`)), + '-o', exe], { stdio: 'inherit' }); + const keys = generateKeyPairSync('ed25519'); + const digest = createHash('sha256').update('ioBOX test firmware bytes').digest('hex'); + const signature = sign(null, Buffer.from(digest, 'utf8'), keys.privateKey); + const publicKey = keys.publicKey.export({ format: 'der', type: 'spki' }).subarray(-32); + execFileSync(exe, [digest, signature.toString('hex'), publicKey.toString('hex')], { stdio: 'inherit' }); +} finally { + rmSync(temp, { recursive: true, force: true }); +} diff --git a/iobox-firmware/tests/test_provision_trust.py b/iobox-firmware/tests/test_provision_trust.py new file mode 100644 index 00000000..861cbd7d --- /dev/null +++ b/iobox-firmware/tests/test_provision_trust.py @@ -0,0 +1,51 @@ +import importlib.util +from pathlib import Path +import subprocess +import tempfile +import unittest + +spec = importlib.util.spec_from_file_location('provision', Path(__file__).parents[1] / 'scripts/provision_trust.py') +provision = importlib.util.module_from_spec(spec) +spec.loader.exec_module(provision) + + +class TrustProvisioningTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.temp = tempfile.TemporaryDirectory() + root = Path(cls.temp.name) + subprocess.run(['openssl', 'req', '-x509', '-newkey', 'rsa:2048', '-nodes', + '-keyout', str(root / 'ca.key'), '-out', str(root / 'ca.pem'), + '-subj', '/CN=ioBOX test CA', '-days', '1'], check=True, capture_output=True) + subprocess.run(['openssl', 'genpkey', '-algorithm', 'ED25519', '-out', str(root / 'sign.key')], check=True, capture_output=True) + cls.public = subprocess.run(['openssl', 'pkey', '-in', str(root / 'sign.key'), '-pubout'], check=True, capture_output=True).stdout.decode() + cls.ca = (root / 'ca.pem').read_text() + cls.private = (root / 'sign.key').read_text() + cls.rsa_public = subprocess.run(['openssl', 'pkey', '-in', str(root / 'ca.key'), '-pubout'], check=True, capture_output=True).stdout.decode() + + @classmethod + def tearDownClass(cls): + cls.temp.cleanup() + + def test_preserves_ca_and_extracts_real_ed25519_key(self): + header = provision.trust_header(self.ca, self.public) + der = subprocess.run(['openssl', 'pkey', '-pubin', '-outform', 'DER'], input=self.public.encode(), check=True, capture_output=True).stdout + self.assertIn(der[-32:].hex(), header) + self.assertIn('BEGIN CERTIFICATE', header) + self.assertNotIn('PRIVATE KEY', header) + + def test_rejects_private_key_input(self): + with self.assertRaises(ValueError): + provision.trust_header(self.ca, self.private) + + def test_rejects_other_signing_algorithms(self): + with self.assertRaises(ValueError): + provision.trust_header(self.ca, self.rsa_public) + + def test_rejects_invalid_certificate(self): + with self.assertRaises(subprocess.CalledProcessError): + provision.trust_header('not a certificate', self.public) + + +if __name__ == '__main__': + unittest.main() diff --git a/nodered/src/_event-dispatch.js b/nodered/src/_event-dispatch.js new file mode 100644 index 00000000..976db778 --- /dev/null +++ b/nodered/src/_event-dispatch.js @@ -0,0 +1,38 @@ +const { timingSafeEqual } = require("node:crypto"); +const { readJsonBody } = require("./_http-body.js"); + +const routers = new WeakMap(); + +function authenticated(req) { + const expected = process.env.BF_NODERED_INTERNAL_TOKEN || ""; + const supplied = String(req.headers?.["x-betterframe-runtime-token"] || ""); + const a = Buffer.from(expected), b = Buffer.from(supplied); + return a.length >= 32 && a.length === b.length && timingSafeEqual(a, b); +} + +// One HTTP route fans out to all interested nodes. A node's filter/response +// must not prevent other subscriptions from receiving the same event. +function subscribeEvent(RED, path, handler) { + let routes = routers.get(RED.httpNode); + if (!routes) { routes = new Map(); routers.set(RED.httpNode, routes); } + let subscribers = routes.get(path); + if (!subscribers) { + subscribers = new Set(); + routes.set(path, subscribers); + RED.httpNode.post(path, async (req, res) => { + if (!authenticated(req)) return res.status(403).end(); + try { + req.body = await readJsonBody(req); + const sink = { status() { return this; }, end() { return this; }, json() { return this; } }; + await Promise.all([...subscribers].map(fn => Promise.resolve().then(() => fn(req, sink)))); + return res.status(200).end(); + } catch { + return res.status(400).end(); + } + }); + } + subscribers.add(handler); + return () => subscribers.delete(handler); +} + +module.exports = { subscribeEvent }; diff --git a/nodered/src/_http-body.js b/nodered/src/_http-body.js index 5a90de89..2d5d4c3d 100644 --- a/nodered/src/_http-body.js +++ b/nodered/src/_http-body.js @@ -13,7 +13,12 @@ function readJsonBody(req) { if (req.body && typeof req.body === "object") return resolve(req.body); let data = ""; req.setEncoding("utf8"); - req.on("data", (c) => { data += c; }); + req.on("data", (c) => { + data += c; + if (Buffer.byteLength(data) > 1024 * 1024) { + req.destroy(new Error("event body too large")); + } + }); req.on("end", () => { if (!data) return resolve({}); try { resolve(JSON.parse(data)); } catch { resolve({}); } diff --git a/nodered/src/bf-kiosk-camera-event.js b/nodered/src/bf-kiosk-camera-event.js index cf0b066c..929b034d 100644 --- a/nodered/src/bf-kiosk-camera-event.js +++ b/nodered/src/bf-kiosk-camera-event.js @@ -1,3 +1,4 @@ +const { subscribeEvent } = require("./_event-dispatch.js"); /** * bf-kiosk-camera-event — fires on kiosk-originated camera events forwarded * from the BetterFrame server (ONVIF motion, object detection, line crossing, @@ -62,23 +63,10 @@ module.exports = function (RED) { res.status(200).end(); } - RED.httpNode.post(ROUTE, handler); + const unsubscribe = [subscribeEvent(RED, ROUTE, handler)]; node.on("close", function (done) { - const stack = RED.httpNode && RED.httpNode._router && RED.httpNode._router.stack; - if (stack) { - for (let i = stack.length - 1; i >= 0; i--) { - const layer = stack[i]; - if (!layer || !layer.route || layer.route.path !== ROUTE) continue; - const inner = layer.route.stack; - if (Array.isArray(inner)) { - for (let j = inner.length - 1; j >= 0; j--) { - if (inner[j] && inner[j].handle === handler) inner.splice(j, 1); - } - if (inner.length === 0) stack.splice(i, 1); - } - } - } + for (const off of unsubscribe) off(); done(); }); } diff --git a/nodered/src/bf-trigger-anpr.js b/nodered/src/bf-trigger-anpr.js index 997076f9..2ba4f91c 100644 --- a/nodered/src/bf-trigger-anpr.js +++ b/nodered/src/bf-trigger-anpr.js @@ -1,3 +1,4 @@ +const { subscribeEvent } = require("./_event-dispatch.js"); /** * bf-trigger-anpr — fires on ONVIF license plate recognition events. * @@ -72,27 +73,13 @@ module.exports = function (RED) { res.status(200).end(); } - RED.httpNode.post(ROUTE, handler); + const unsubscribe = [subscribeEvent(RED, ROUTE, handler)]; const GENERIC_ROUTE = "/api/internal/onvif.event"; - RED.httpNode.post(GENERIC_ROUTE, handler); + unsubscribe.push(subscribeEvent(RED, GENERIC_ROUTE, handler)); node.on("close", function (done) { - const stack = RED.httpNode?._router?.stack; - if (stack) { - for (let i = stack.length - 1; i >= 0; i--) { - const layer = stack[i]; - if (!layer?.route) continue; - if (layer.route.path !== ROUTE && layer.route.path !== GENERIC_ROUTE) continue; - const inner = layer.route.stack; - if (Array.isArray(inner)) { - for (let j = inner.length - 1; j >= 0; j--) { - if (inner[j]?.handle === handler) inner.splice(j, 1); - } - if (inner.length === 0) stack.splice(i, 1); - } - } - } + for (const off of unsubscribe) off(); done(); }); } diff --git a/nodered/src/bf-trigger-camera-changed.js b/nodered/src/bf-trigger-camera-changed.js index b1010ab8..9071a177 100644 --- a/nodered/src/bf-trigger-camera-changed.js +++ b/nodered/src/bf-trigger-camera-changed.js @@ -1,3 +1,4 @@ +const { subscribeEvent } = require("./_event-dispatch.js"); /** * bf-trigger-camera-changed — fires when a camera entity is created, updated, * or deleted in admin. @@ -54,23 +55,10 @@ module.exports = function (RED) { res.status(200).end(); } - RED.httpNode.post(ROUTE, handler); + const unsubscribe = [subscribeEvent(RED, ROUTE, handler)]; node.on("close", function (done) { - const stack = RED.httpNode && RED.httpNode._router && RED.httpNode._router.stack; - if (stack) { - for (let i = stack.length - 1; i >= 0; i--) { - const layer = stack[i]; - if (!layer || !layer.route || layer.route.path !== ROUTE) continue; - const inner = layer.route.stack; - if (Array.isArray(inner)) { - for (let j = inner.length - 1; j >= 0; j--) { - if (inner[j] && inner[j].handle === handler) inner.splice(j, 1); - } - if (inner.length === 0) stack.splice(i, 1); - } - } - } + for (const off of unsubscribe) off(); done(); }); } diff --git a/nodered/src/bf-trigger-display-power.js b/nodered/src/bf-trigger-display-power.js index 41962fb0..a43700b1 100644 --- a/nodered/src/bf-trigger-display-power.js +++ b/nodered/src/bf-trigger-display-power.js @@ -1,3 +1,4 @@ +const { subscribeEvent } = require("./_event-dispatch.js"); /** * bf-trigger-display-power — fires when a display's power state changes. * @@ -51,27 +52,10 @@ module.exports = function (RED) { res.status(200).end(); } - RED.httpNode.post(ROUTE, handler); + const unsubscribe = [subscribeEvent(RED, ROUTE, handler)]; node.on("close", function (done) { - // Remove this node's specific route layer from the Express router. - // `app.post(path, handler)` creates a route layer whose inner stack - // holds the actual handler. Match by handler ref so other instances - // of the same node type aren't disturbed. - const stack = RED.httpNode && RED.httpNode._router && RED.httpNode._router.stack; - if (stack) { - for (let i = stack.length - 1; i >= 0; i--) { - const layer = stack[i]; - if (!layer || !layer.route || layer.route.path !== ROUTE) continue; - const inner = layer.route.stack; - if (Array.isArray(inner)) { - for (let j = inner.length - 1; j >= 0; j--) { - if (inner[j] && inner[j].handle === handler) inner.splice(j, 1); - } - if (inner.length === 0) stack.splice(i, 1); - } - } - } + for (const off of unsubscribe) off(); done(); }); } diff --git a/nodered/src/bf-trigger-event.js b/nodered/src/bf-trigger-event.js index 0ca4b531..66b91ad9 100644 --- a/nodered/src/bf-trigger-event.js +++ b/nodered/src/bf-trigger-event.js @@ -1,3 +1,4 @@ +const { subscribeEvent } = require("./_event-dispatch.js"); /** * bf-trigger-event — fires on ANY ONVIF event from any camera. * @@ -63,23 +64,10 @@ module.exports = function (RED) { res.status(200).end(); } - RED.httpNode.post(ROUTE, handler); + const unsubscribe = [subscribeEvent(RED, ROUTE, handler)]; node.on("close", function (done) { - const stack = RED.httpNode?._router?.stack; - if (stack) { - for (let i = stack.length - 1; i >= 0; i--) { - const layer = stack[i]; - if (!layer?.route || layer.route.path !== ROUTE) continue; - const inner = layer.route.stack; - if (Array.isArray(inner)) { - for (let j = inner.length - 1; j >= 0; j--) { - if (inner[j]?.handle === handler) inner.splice(j, 1); - } - if (inner.length === 0) stack.splice(i, 1); - } - } - } + for (const off of unsubscribe) off(); done(); }); } diff --git a/nodered/src/bf-trigger-io-event.js b/nodered/src/bf-trigger-io-event.js index b30eb82f..8a9094ae 100644 --- a/nodered/src/bf-trigger-io-event.js +++ b/nodered/src/bf-trigger-io-event.js @@ -1,3 +1,4 @@ +const { subscribeEvent } = require("./_event-dispatch.js"); /** * bf-trigger-io-event — fires on BetterFrame ioBOX events. * @@ -58,23 +59,10 @@ module.exports = function (RED) { res.status(200).end(); } - RED.httpNode.post(ROUTE, handler); + const unsubscribe = [subscribeEvent(RED, ROUTE, handler)]; node.on("close", function (done) { - const stack = RED.httpNode?._router?.stack; - if (stack) { - for (let i = stack.length - 1; i >= 0; i--) { - const layer = stack[i]; - if (!layer?.route || layer.route.path !== ROUTE) continue; - const inner = layer.route.stack; - if (Array.isArray(inner)) { - for (let j = inner.length - 1; j >= 0; j--) { - if (inner[j]?.handle === handler) inner.splice(j, 1); - } - if (inner.length === 0) stack.splice(i, 1); - } - } - } + for (const off of unsubscribe) off(); done(); }); } diff --git a/nodered/src/bf-trigger-kiosk-changed.js b/nodered/src/bf-trigger-kiosk-changed.js index bec205fd..e496d233 100644 --- a/nodered/src/bf-trigger-kiosk-changed.js +++ b/nodered/src/bf-trigger-kiosk-changed.js @@ -1,3 +1,4 @@ +const { subscribeEvent } = require("./_event-dispatch.js"); /** * bf-trigger-kiosk-changed — fires on kiosk state changes (connect, disconnect, * heartbeat with hardware telemetry). @@ -61,23 +62,10 @@ module.exports = function (RED) { res.status(200).end(); } - RED.httpNode.post(ROUTE, handler); + const unsubscribe = [subscribeEvent(RED, ROUTE, handler)]; node.on("close", function (done) { - const stack = RED.httpNode && RED.httpNode._router && RED.httpNode._router.stack; - if (stack) { - for (let i = stack.length - 1; i >= 0; i--) { - const layer = stack[i]; - if (!layer || !layer.route || layer.route.path !== ROUTE) continue; - const inner = layer.route.stack; - if (Array.isArray(inner)) { - for (let j = inner.length - 1; j >= 0; j--) { - if (inner[j] && inner[j].handle === handler) inner.splice(j, 1); - } - if (inner.length === 0) stack.splice(i, 1); - } - } - } + for (const off of unsubscribe) off(); done(); }); } diff --git a/nodered/src/bf-trigger-layout-changed.js b/nodered/src/bf-trigger-layout-changed.js index d017a668..d6a7856a 100644 --- a/nodered/src/bf-trigger-layout-changed.js +++ b/nodered/src/bf-trigger-layout-changed.js @@ -1,3 +1,4 @@ +const { subscribeEvent } = require("./_event-dispatch.js"); /** * bf-trigger-layout-changed — fires when a display switches to a new layout. * @@ -55,23 +56,10 @@ module.exports = function (RED) { res.status(200).end(); } - RED.httpNode.post(ROUTE, handler); + const unsubscribe = [subscribeEvent(RED, ROUTE, handler)]; node.on("close", function (done) { - const stack = RED.httpNode && RED.httpNode._router && RED.httpNode._router.stack; - if (stack) { - for (let i = stack.length - 1; i >= 0; i--) { - const layer = stack[i]; - if (!layer || !layer.route || layer.route.path !== ROUTE) continue; - const inner = layer.route.stack; - if (Array.isArray(inner)) { - for (let j = inner.length - 1; j >= 0; j--) { - if (inner[j] && inner[j].handle === handler) inner.splice(j, 1); - } - if (inner.length === 0) stack.splice(i, 1); - } - } - } + for (const off of unsubscribe) off(); done(); }); } diff --git a/nodered/src/bf-trigger-motion.js b/nodered/src/bf-trigger-motion.js index d38a5a6d..6a6b728c 100644 --- a/nodered/src/bf-trigger-motion.js +++ b/nodered/src/bf-trigger-motion.js @@ -1,3 +1,4 @@ +const { subscribeEvent } = require("./_event-dispatch.js"); /** * bf-trigger-motion — fires on ONVIF motion detection events. * @@ -71,28 +72,14 @@ module.exports = function (RED) { res.status(200).end(); } - RED.httpNode.post(ROUTE, handler); + const unsubscribe = [subscribeEvent(RED, ROUTE, handler)]; // Also listen on the generic onvif event route as fallback. const GENERIC_ROUTE = "/api/internal/onvif.event"; - RED.httpNode.post(GENERIC_ROUTE, handler); + unsubscribe.push(subscribeEvent(RED, GENERIC_ROUTE, handler)); node.on("close", function (done) { - const stack = RED.httpNode?._router?.stack; - if (stack) { - for (let i = stack.length - 1; i >= 0; i--) { - const layer = stack[i]; - if (!layer?.route) continue; - if (layer.route.path !== ROUTE && layer.route.path !== GENERIC_ROUTE) continue; - const inner = layer.route.stack; - if (Array.isArray(inner)) { - for (let j = inner.length - 1; j >= 0; j--) { - if (inner[j]?.handle === handler) inner.splice(j, 1); - } - if (inner.length === 0) stack.splice(i, 1); - } - } - } + for (const off of unsubscribe) off(); done(); }); } diff --git a/nodered/src/bf-trigger-status.js b/nodered/src/bf-trigger-status.js index f67ec55c..43a57f83 100644 --- a/nodered/src/bf-trigger-status.js +++ b/nodered/src/bf-trigger-status.js @@ -1,3 +1,4 @@ +const { subscribeEvent } = require("./_event-dispatch.js"); /** * bf-trigger-status — fires on kiosk heartbeat telemetry. * @@ -58,23 +59,10 @@ module.exports = function (RED) { res.status(200).end(); } - RED.httpNode.post(ROUTE, handler); + const unsubscribe = [subscribeEvent(RED, ROUTE, handler)]; node.on("close", function (done) { - const stack = RED.httpNode && RED.httpNode._router && RED.httpNode._router.stack; - if (stack) { - for (let i = stack.length - 1; i >= 0; i--) { - const layer = stack[i]; - if (!layer || !layer.route || layer.route.path !== ROUTE) continue; - const inner = layer.route.stack; - if (Array.isArray(inner)) { - for (let j = inner.length - 1; j >= 0; j--) { - if (inner[j] && inner[j].handle === handler) inner.splice(j, 1); - } - if (inner.length === 0) stack.splice(i, 1); - } - } - } + for (const off of unsubscribe) off(); done(); }); } diff --git a/nodered/src/bf-trigger-web-change.js b/nodered/src/bf-trigger-web-change.js index 9ab4f8a7..33a02287 100644 --- a/nodered/src/bf-trigger-web-change.js +++ b/nodered/src/bf-trigger-web-change.js @@ -1,3 +1,4 @@ +const { subscribeEvent } = require("./_event-dispatch.js"); /** * bf-trigger-web-change - fires when a kiosk WebView loads or navigates. * @@ -59,23 +60,10 @@ module.exports = function (RED) { res.status(200).end(); } - RED.httpNode.post(ROUTE, handler); + const unsubscribe = [subscribeEvent(RED, ROUTE, handler)]; node.on("close", function (done) { - const stack = RED.httpNode && RED.httpNode._router && RED.httpNode._router.stack; - if (stack) { - for (let i = stack.length - 1; i >= 0; i--) { - const layer = stack[i]; - if (!layer || !layer.route || layer.route.path !== ROUTE) continue; - const inner = layer.route.stack; - if (Array.isArray(inner)) { - for (let j = inner.length - 1; j >= 0; j--) { - if (inner[j] && inner[j].handle === handler) inner.splice(j, 1); - } - if (inner.length === 0) stack.splice(i, 1); - } - } - } + for (const off of unsubscribe) off(); done(); }); } diff --git a/server/package.json b/server/package.json index b775d008..74b5c754 100644 --- a/server/package.json +++ b/server/package.json @@ -16,7 +16,7 @@ "clean": "bsb-plugin-cli clean", "start": "bsb-plugin-cli start", "dev": "cross-env NODE_OPTIONS=\"--import tsx\" bsb-plugin-cli dev", - "test": "cross-env TSX_TSCONFIG_PATH=tsconfig.test.json node --import tsx --test tests/*.test.ts", + "test": "cross-env TSX_TSCONFIG_PATH=tsconfig.test.json node --import tsx tests/run-tests.ts", "schemas:export": "node --enable-source-maps lib/scripts/export-schemas.js" }, "bsb": {}, diff --git a/server/src/plugins/service-admin-http/routes-admin.ts b/server/src/plugins/service-admin-http/routes-admin.ts index 90ee47f2..fefd5b94 100644 --- a/server/src/plugins/service-admin-http/routes-admin.ts +++ b/server/src/plugins/service-admin-http/routes-admin.ts @@ -58,7 +58,7 @@ import { generateBundle } from "../../shared/bundle.js"; import { captureSnapshot } from "../../shared/snapshot.js"; import { stripSecrets } from "../../shared/strip-secrets.js"; import { audit } from "../../shared/audit.js"; -import { createBackup, restoreBackup } from "../../shared/backup.js"; +import { legacyBackupUnavailable } from "../../shared/backup.js"; import { pickKioskLanIp } from "../../shared/kiosk-lan.js"; import { buildRtspUri, buildRtspUriFromParts, stripRtspCredentials } from "../../shared/rtsp.js"; import { @@ -931,55 +931,11 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { return htmlPage(BackupPage({ user: user.username })); }); - app.post("/admin/backup/download", async (event) => { - const body = await readBody>(event); - const pass = body?.["passphrase"] ?? ""; - let res; - try { - res = createBackup(deps.dataDir, pass); - } catch (err) { - await audit(deps.repo, event as any, "backup.create", { - result: "failed", metadata: { error: (err as Error).message }, - }); - return htmlPage(BackupPage({ user: event.context.user!.username, error: (err as Error).message })); - } - await audit(deps.repo, event as any, "backup.create", { - metadata: { file_count: res.fileCount, size: res.blob.length }, - }); - return new Response(new Uint8Array(res.blob), { - status: 200, - headers: { - "content-type": "application/octet-stream", - "content-disposition": `attachment; filename="${res.filename}"`, - "content-length": String(res.blob.length), - }, - }); - }); - - app.post("/admin/backup/restore", async (event) => { - const form = await event.req.formData(); - const file = form.get("blob"); - const pass = String(form.get("passphrase") ?? ""); - if (!(file instanceof File) || !pass) { - return htmlPage(BackupPage({ user: event.context.user!.username, error: "blob + passphrase required" })); - } - try { - const buf = Buffer.from(await file.arrayBuffer()); - const res = restoreBackup(deps.dataDir, pass, buf); - await audit(deps.repo, event as any, "backup.restore", { - metadata: { file_count: res.fileCount, files: res.files }, - }); - return htmlPage(BackupPage({ - user: event.context.user!.username, - success: `Restored ${String(res.fileCount)} files: ${res.files.join(", ")}. RESTART THE SERVER NOW for changes to take effect.`, - })); - } catch (err) { - await audit(deps.repo, event as any, "backup.restore", { - result: "failed", metadata: { error: (err as Error).message }, - }); - return htmlPage(BackupPage({ user: event.context.user!.username, error: (err as Error).message })); - } - }); + // The retired SQLite archive cannot preserve the PostgreSQL database. + // Reject before reading uploads or writing any key material. + for (const path of ["/admin/backup/download", "/admin/backup/restore"]) { + app.post(path, () => new Response(legacyBackupUnavailable, { status: 409 })); + } // ---- Audit log ------------------------------------------------------------ diff --git a/server/src/plugins/service-admin-http/routes-tenants.ts b/server/src/plugins/service-admin-http/routes-tenants.ts index 5c3851ee..f4464144 100644 --- a/server/src/plugins/service-admin-http/routes-tenants.ts +++ b/server/src/plugins/service-admin-http/routes-tenants.ts @@ -40,7 +40,7 @@ export function registerTenantRoutes(app: H3, deps: AdminDeps): void { const maxCameras = body?.["max_cameras"] ? parseInt(body["max_cameras"], 10) : null; const maxUsers = body?.["max_users"] ? parseInt(body["max_users"], 10) : null; - if (!name || !slug || !/^[a-z0-9][a-z0-9_-]*$/.test(slug)) { + if (!name || !slug || !/^[a-z0-9][a-z0-9_-]{0,127}$/.test(slug)) { const tenants = await deps.repo.listTenants(); return htmlPage(TenantsPage({ user: event.context.user!.username, @@ -62,24 +62,26 @@ export function registerTenantRoutes(app: H3, deps: AdminDeps): void { })); } - // Create tenant record. - await deps.repo.createTenant({ - name, - slug, - max_kiosks: maxKiosks, - max_cameras: maxCameras, - max_users: maxUsers, + // Registration and provisioning succeed or roll back together. + await deps.repo.transact(async () => { + await deps.repo.createTenant({ + name, + slug, + max_kiosks: maxKiosks, + max_cameras: maxCameras, + max_users: maxUsers, + }); + + // Create PG schema and run tenant migrations. + await createTenantSchema( + deps.repo.adapter, + slug, + { + info: (m) => { /* swallow */ }, + warn: (m) => { /* swallow */ }, + }, + ); }); - - // Create PG schema and run tenant migrations. - await createTenantSchema( - deps.repo.adapter, - slug, - { - info: (m) => { /* swallow */ }, - warn: (m) => { /* swallow */ }, - }, - ); deps.scheduleNoderedReconcile(); return new Response(null, { status: 302, headers: { location: "/admin/tenants" } }); diff --git a/server/src/plugins/service-api-http/index.ts b/server/src/plugins/service-api-http/index.ts index 3df6d582..dbbc8438 100644 --- a/server/src/plugins/service-api-http/index.ts +++ b/server/src/plugins/service-api-http/index.ts @@ -20,7 +20,8 @@ import { initDb } from "../../shared/db/init.js"; import type { Repository } from "../../shared/db/repository.js"; import { CLUSTER_SECRET_CONTEXT, initSecrets } from "../../shared/secrets.js"; import { createAuth } from "../../shared/auth.js"; -import { initiatePairing, claimPairing } from "../../shared/pairing.js"; +import { acknowledgeIoBox, bindIoBoxProvisioning, claimIoBox } from "../../shared/iobox-pairing.js"; +import { initiatePairing, claimPairing, acknowledgePairing, PAIR_POLL_AFTER_MS, secretHash } from "../../shared/pairing.js"; import { BundleGenerationError, generateBundle } from "../../shared/bundle.js"; import { initNoderedBridge, type NoderedBridge } from "../../shared/nodered-bridge.js"; import { initFirmware, type FirmwareApi } from "../../shared/firmware.js"; @@ -281,7 +282,7 @@ export class Plugin extends BSBService, typeof Event : ""); registerPairingRoutes(app, repo, auth, secrets, codeTtl, firmware, osUpdates, clientFirmwarePublicKey); registerKioskRoutes(app, repo, auth, secrets, nodered, firmware, osUpdates, mqtt, clientFirmwarePublicKey); - registerIoBoxRoutes(app, repo, auth, nodered, mqtt, firmware); + registerIoBoxRoutes(app, repo, auth, nodered, mqtt, firmware, secrets); this.server = serve(app, { port: this.config.port, @@ -433,6 +434,7 @@ function registerPairingRoutes( // module statically) doesn't see a top-level createRateLimiter call. const pairingGuard = createRateLimiter({ windowMs: 60_000, max: 20 }); const claimGuard = createRateLimiter({ windowMs: 60_000, max: 60 }); + const claimIpGuard = createRateLimiter({ windowMs: 60_000, max: 6_000 }); // Kiosk initiates pairing — no auth required app.post("/api/pair/initiate", async (event) => { const ip = getRequestHeader(event, "x-real-ip") @@ -451,9 +453,10 @@ function registerPairingRoutes( capabilities: body.capabilities, managedImage: body.managed_image, codeTtlSeconds: codeTtl, + secureClaim: body.secure_claim, }); - return { code: result.code, expires_at: result.expiresAt }; + return { code: result.code, expires_at: result.expiresAt, expires_in_seconds: result.expiresInSeconds, poll_after_ms: PAIR_POLL_AFTER_MS, polling_secret: result.pollingSecret }; }); // Kiosk polls for claim result — no auth required @@ -461,28 +464,23 @@ function registerPairingRoutes( const ip = getRequestHeader(event, "x-real-ip") ?? getRequestHeader(event, "x-forwarded-for")?.split(",")[0]?.trim() ?? "anon"; - if (!claimGuard.take(`claim:${ip}`)) { - throw createError({ statusCode: 429, statusMessage: "rate limited" }); - } - const body = validateBody(PairClaimBody, await readBody(event)); const code = body.code.trim().toUpperCase(); - - const reqObs = event.context.obs!; - const result = await claimPairing(repo, code, secrets, reqObs); - if (result.status === "pending") { - return new Response(JSON.stringify({ status: "pending" }), { - status: 202, - headers: { "content-type": "application/json" }, + if (!claimIpGuard.take(`claim:${ip}`) || !claimGuard.take(`session:${secretHash(body.polling_secret ?? code)}`)) { + return new Response(JSON.stringify({ status: "pending", poll_after_ms: 5_000 }), { + status: 429, headers: { "content-type": "application/json", "retry-after": "5" }, + }); + } + const result = await claimPairing(repo, code, secrets, event.context.obs, body.polling_secret); + if (result.status !== "claimed") { + return new Response(JSON.stringify({ status: result.status, poll_after_ms: PAIR_POLL_AFTER_MS, expires_in_seconds: result.expiresInSeconds }), { + status: result.status === "pending" ? 202 : result.status === "failed" ? 503 : 200, + headers: { "content-type": "application/json", "cache-control": "no-store" }, }); } - - reqObs.log.info("pair/claim success for code {code} kiosk {kioskId}", { - code, - kioskId: String(result.kioskId), - }); return { status: "claimed", + expires_in_seconds: result.expiresInSeconds, kiosk_id: result.kioskId, kiosk_name: result.kioskName, kiosk_key: result.kioskKey, @@ -492,6 +490,17 @@ function registerPairingRoutes( }; }); + app.post("/api/pair/ack", async (event) => { + const token = extractBearerToken(event); + const kiosk = token ? await auth.verifyKioskKey(token) : null; + if (!kiosk) throw createError({ statusCode: 401, statusMessage: "Invalid kiosk key" }); + const body = validateBody(PairClaimBody, await readBody(event)); + try { + await acknowledgePairing(repo, body.code.trim().toUpperCase(), kiosk.id, kiosk.schema_name, body.polling_secret); + } catch { throw createError({ statusCode: 409, statusMessage: "Invalid pairing acknowledgement" }); } + return { status: "acknowledged" }; + }); + // Public firmware check — no auth. Used by kiosks on first boot before // pairing to self-update to latest stable binary. Always stable channel. app.get("/api/firmware/public/check", async (event) => { @@ -646,12 +655,17 @@ function registerIoBoxRoutes( nodered: NoderedBridge, mqtt: MqttBridge, firmware: FirmwareApi, + secrets: SecretsApi, ): void { + const enrollGuard = createRateLimiter({ windowMs: 60_000, max: 60 }); app.post("/api/iobox/announce", async (event) => { const body = validateBody(IoBoxAnnounceBody, await readBody(event)); const serial = body.serial.trim(); const registered = await repo.getIoBoxSerial(serial); if (!registered) return { status: "unknown_serial" }; + if (!enrollGuard.take(`announce:${serial}`)) throw createError({ statusCode: 429, statusMessage: "rate limited" }); + try { await bindIoBoxProvisioning(repo, serial, body.provisioning_secret); } + catch { throw createError({ statusCode: 409, statusMessage: "Provisioning conflict; operator reset may be required" }); } await repo.touchIoBoxSerial(serial); const model = await repo.getIoBoxModel(registered.model_id); return { @@ -666,32 +680,18 @@ function registerIoBoxRoutes( app.post("/api/iobox/pair/claim", async (event) => { const body = validateBody(IoBoxPairClaimBody, await readBody(event)); const serial = body.serial.trim(); - const registered = await repo.getIoBoxSerial(serial); - if (!registered) throw createError({ statusCode: 404, statusMessage: "unknown serial" }); - if (registered.paired_iobox_id) throw createError({ statusCode: 409, statusMessage: "serial already paired" }); - const model = await repo.getIoBoxModel(registered.model_id); - if (!model) throw createError({ statusCode: 400, statusMessage: "serial model missing" }); - + if (!enrollGuard.take(`claim:${serial}`)) throw createError({ statusCode: 429, statusMessage: "rate limited" }); const tenant = await resolveTenantForIoBoxClaim(repo, event); - await repo.adapter.setSearchPath(tenant.schema_name); - const plaintext = `bfio-${randomBytes(24).toString("base64url")}`; - const box = await repo.createIoBox({ - serial, - model_id: model.id, - name: body.name?.trim() || `${model.name} ${serial}`, - key_hash: await auth.hashPassword(plaintext), - key_prefix: plaintext.slice(0, 8), - assigned_display_id: body.assigned_display_id ?? null, - }); - await repo.markIoBoxSerialPaired(serial, tenant.id, box.id); - return { - status: "claimed", - tenant_slug: tenant.slug, - iobox_id: box.id, - iobox_key: plaintext, - config_url: "/api/iobox/config", - heartbeat_url: "/api/iobox/heartbeat", - }; + try { return await claimIoBox(repo, auth, secrets, { ...body, serial }, tenant); } + catch { throw createError({ statusCode: 409, statusMessage: "Pairing unavailable; retry or contact operator" }); } + }); + + app.post("/api/iobox/pair/ack", async (event) => { + const verified = await requireIoBox(event, repo, auth); + const body = validateBody(IoBoxPairClaimBody, await readBody(event)); + try { await acknowledgeIoBox(repo, body.serial.trim(), verified.id, verified.tenant_id, body.provisioning_secret); } + catch { throw createError({ statusCode: 409, statusMessage: "Invalid pairing acknowledgement" }); } + return { status: "acknowledged" }; }); app.post("/api/iobox/heartbeat", async (event) => { diff --git a/server/src/plugins/service-coordinator-ws/index.ts b/server/src/plugins/service-coordinator-ws/index.ts index a2c6f0c5..7697432a 100644 --- a/server/src/plugins/service-coordinator-ws/index.ts +++ b/server/src/plugins/service-coordinator-ws/index.ts @@ -21,13 +21,15 @@ import { } from "@bsb/base"; import { createServer, type IncomingMessage, type Server as HttpServer } from "node:http"; import { randomUUID } from "node:crypto"; -import { WebSocketServer, WebSocket } from "ws"; +import { WebSocket, type WebSocketServer } from "ws"; import type { DbConfig } from "../../shared/db/config.js"; import { initDb } from "../../shared/db/init.js"; import { initSecrets } from "../../shared/secrets.js"; import { createAuth } from "../../shared/auth.js"; import { setCoordinator } from "../../shared/coordinator-registry.js"; +import { KioskConnections } from "../../shared/kiosk-connections.js"; +import { createCoordinatorWebSocketServer } from "../../shared/coordinator-websocket.js"; import { initNoderedBridge, type NoderedBridge } from "../../shared/nodered-bridge.js"; // ---- Config ----------------------------------------------------------------- @@ -83,15 +85,10 @@ export const EventSchemas = createEventSchemas({ // ---- Connected kiosks ------------------------------------------------------- -interface ConnectedKiosk { - id: string; - name: string; - ws: WebSocket; -} - -const connectedKiosks = new Map(); +const connectedKiosks = new KioskConnections(); const pendingRequests = new Map void; reject: (err: Error) => void; timer: ReturnType; @@ -148,8 +145,12 @@ function sendToKiosk(kioskId: string, message: object, queueWhenOffline = true): if (q.length > MESSAGE_QUEUE_CAP) q.shift(); // FIFO eviction return false; } - k.ws.send(payload); - return true; + try { + k.ws.send(payload); + return true; + } catch { + return false; + } } function drainOfflineQueue(kioskId: string): void { @@ -166,17 +167,23 @@ function drainOfflineQueue(kioskId: string): void { function requestKiosk(kioskId: string, message: object, timeoutMs = 10000): Promise { const requestId = randomUUID(); return new Promise((resolve, reject) => { + const connection = connectedKiosks.get(kioskId); + if (!connection || connection.ws.readyState !== WebSocket.OPEN) { + reject(new Error("kiosk is not connected")); + return; + } const timer = setTimeout(() => { pendingRequests.delete(requestId); reject(new Error("kiosk request timed out")); }, timeoutMs); pendingRequests.set(requestId, { kioskId, + socket: connection.ws, resolve: (value) => resolve(value as T), reject, timer, }); - const sent = sendToKiosk(kioskId, { ...message, request_id: requestId }); + const sent = sendToKiosk(kioskId, { ...message, request_id: requestId }, false); if (!sent) { clearTimeout(timer); pendingRequests.delete(requestId); @@ -261,7 +268,7 @@ export class Plugin extends BSBService, typeof Event res.end(); }); - const wss = new WebSocketServer({ noServer: true }); + const wss = createCoordinatorWebSocketServer(); httpServer.on("upgrade", async (req: IncomingMessage, socket, head) => { const url = new URL(req.url ?? "/", `http://${req.headers.host}`); @@ -359,8 +366,19 @@ export class Plugin extends BSBService, typeof Event return; } wss.handleUpgrade(req, socket, head, (ws) => { - connectedKiosks.set(kiosk.id, { id: kiosk.id, name: kioskData.name, ws }); + const previous = connectedKiosks.get(kiosk.id); + connectedKiosks.set(kiosk.id, { id: kiosk.id, name: kioskData.name, ws, lastPong: Date.now() }); + if (previous && previous.ws !== ws) { + for (const [requestId, pending] of pendingRequests) { + if (pending.socket !== previous.ws) continue; + pendingRequests.delete(requestId); + clearTimeout(pending.timer); + pending.reject(new Error("kiosk connection replaced")); + } + previous.ws.terminate(); + } obs.log.info("kiosk connected: {name}", { name: kioskData.name }); + ws.on("error", () => ws.terminate()); ws.send(JSON.stringify({ type: "connected", kiosk_id: kiosk.id })); drainOfflineQueue(kiosk.id); nodered.forward( @@ -375,9 +393,10 @@ export class Plugin extends BSBService, typeof Event ); ws.on("message", (data) => { + if (connectedKiosks.get(kiosk.id)?.ws !== ws) return; try { const msg = JSON.parse(data.toString()) as Record; - if (msg["type"] === "pong") return; + if (msg["type"] === "pong") { connectedKiosks.pong(kiosk.id, ws); return; } if ( msg["type"] === "onvif-soap-response" || msg["type"] === "camera-proxy-response" @@ -390,7 +409,7 @@ export class Plugin extends BSBService, typeof Event ) { const requestId = typeof msg["request_id"] === "string" ? msg["request_id"] : ""; const pending = pendingRequests.get(requestId); - if (!pending || pending.kioskId !== kiosk.id) return; + if (!pending || pending.kioskId !== kiosk.id || pending.socket !== ws) return; pendingRequests.delete(requestId); clearTimeout(pending.timer); const error = typeof msg["error"] === "string" ? msg["error"] : ""; @@ -451,9 +470,9 @@ export class Plugin extends BSBService, typeof Event }); ws.on("close", () => { - connectedKiosks.delete(kiosk.id); + if (!connectedKiosks.removeSocket(kiosk.id, ws)) return; for (const [requestId, pending] of pendingRequests) { - if (pending.kioskId !== kiosk.id) continue; + if (pending.socket !== ws) continue; pendingRequests.delete(requestId); clearTimeout(pending.timer); pending.reject(new Error("kiosk disconnected")); @@ -499,6 +518,7 @@ export class Plugin extends BSBService, typeof Event // Ping connected kiosks every 30s this.pingInterval = setInterval(() => { + connectedKiosks.terminateStale(); const payload = JSON.stringify({ type: "ping", t: Date.now() }); for (const k of connectedKiosks.values()) { try { diff --git a/server/src/schemas/wire/pairing.ts b/server/src/schemas/wire/pairing.ts index 6255029d..f557a5c9 100644 --- a/server/src/schemas/wire/pairing.ts +++ b/server/src/schemas/wire/pairing.ts @@ -31,6 +31,7 @@ export const pairInitiateRequest = av.object( // True iff the kiosk runs our pre-built Pi OS image and ships the // betterframe-apply-config helper. Gates the admin Managed Config UI. managed_image: av.optional(av.bool()), + secure_claim: av.optional(av.bool()), }, { unknownKeys: "reject" }, ); @@ -43,20 +44,22 @@ export const pairInitiateResponse = av.object( { code: av.string().pattern("^[A-HJ-NP-Z2-9]{8}$"), // 0/O/1/I excluded expires_at: av.string().format("date-time"), + expires_in_seconds: av.optional(av.int().min(0)), + poll_after_ms: av.optional(av.int().min(0)), + polling_secret: av.optional(av.string().minLength(32)), }, { unknownKeys: "reject" }, ); /** - * Step 3: kiosk polls server. Body carries only the code. Three terminal - * outcomes: - * - 202: still waiting for admin confirmation - * - 200 + body: confirmed; the response carries the kiosk_key + cluster_key - * - 4xx: unknown / expired / already claimed + * Step 3: poll with the code and the negotiated session secret. + * Pending responses use 202; completed/expired/acknowledged responses use 200. + * Temporary delivery failures use 503 and preserve the current session. */ export const pairClaimRequest = av.object( { code: av.string().pattern("^[A-HJ-NP-Z2-9]{8}$"), + polling_secret: av.optional(av.string().minLength(32)), }, { unknownKeys: "reject" }, ); @@ -68,10 +71,13 @@ export const pairClaimRequest = av.object( */ export const pairClaimResponse = av.object( { - kiosk_id: av.int().min(1), - name: av.string().minLength(1).maxLength(128), + status: av.literal("claimed"), + kiosk_id: av.string().minLength(1), + kiosk_name: av.string().minLength(1).maxLength(128), + encrypt_key: av.optional(av.string().minLength(32)), + expires_in_seconds: av.optional(av.int().min(0)), kiosk_key: av.string().minLength(32), - cluster_key: av.string().minLength(32), + cluster_key: av.optional(av.string().minLength(32)), bundle_url: av.string().minLength(1), }, { unknownKeys: "reject" }, diff --git a/server/src/shared/api-schemas.ts b/server/src/shared/api-schemas.ts index 19994b39..17c773aa 100644 --- a/server/src/shared/api-schemas.ts +++ b/server/src/shared/api-schemas.ts @@ -13,6 +13,7 @@ export const PairInitiateBody = av.object( firmware_target: av.string().maxLength(128).default(""), capabilities: av.array(av.string().maxLength(64)).default([]), managed_image: av.bool().default(false), + secure_claim: av.bool().default(false), }, { unknownKeys: "strip" }, ); @@ -20,6 +21,7 @@ export const PairInitiateBody = av.object( export const PairClaimBody = av.object( { code: av.string().minLength(1).maxLength(16), + polling_secret: av.optional(av.string().minLength(32).maxLength(128)), }, { unknownKeys: "strip" }, ); @@ -137,6 +139,7 @@ export const OsStatusBody = av.object( export const IoBoxAnnounceBody = av.object( { serial: av.string().minLength(1).maxLength(128), + provisioning_secret: av.optional(av.string().minLength(32).maxLength(128)), firmware_version: av.string().maxLength(64).default(""), firmware_arch: av.string().maxLength(64).default("esp32s3"), hardware_variant_detected: av.string().maxLength(32).default(""), @@ -148,6 +151,7 @@ export const IoBoxAnnounceBody = av.object( export const IoBoxPairClaimBody = av.object( { serial: av.string().minLength(1).maxLength(128), + provisioning_secret: av.optional(av.string().minLength(32).maxLength(128)), name: av.string().maxLength(128).default(""), assigned_display_id: av.optional(av.nullable(av.string().maxLength(128))).default(null), }, @@ -194,6 +198,7 @@ export const LoginBody = av.object( export const TotpBody = av.object( { code: av.string().minLength(1).maxLength(16), + polling_secret: av.optional(av.string().minLength(32).maxLength(128)), }, { unknownKeys: "strip" }, ); diff --git a/server/src/shared/backup.ts b/server/src/shared/backup.ts index 92506b53..4c8e9f34 100644 --- a/server/src/shared/backup.ts +++ b/server/src/shared/backup.ts @@ -1,128 +1,3 @@ -/** - * Encrypted server backup / restore. - * - * Bundles the SQLite DB + master secret + firmware signing keypair into a - * single `.bfbak` blob encrypted with AES-256-GCM. Key is derived from an - * admin-supplied passphrase via PBKDF2(SHA-256, 200k iters). - * - * format: "bfbak1" || salt[32] || nonce[12] || ciphertext+tag - * - * inner plaintext: JSON - * { version: 1, - * created_at: , - * files: { "betterframe.db": "", "secret.key": "", ... } } - * - * Firmware blobs (firmware/*.bin) are excluded — re-upload via the admin - * Firmware page after restore. They can be GB-sized and would defeat the - * point of a "small portable backup". - */ -import { - createCipheriv, - createDecipheriv, - pbkdf2Sync, - randomBytes, -} from "node:crypto"; -import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs"; -import { join } from "node:path"; - -const MAGIC = Buffer.from("bfbak1", "utf8"); // 6 bytes -const SALT_LEN = 32; -const NONCE_LEN = 12; -const PBKDF2_ITERS = 200_000; -const KEY_LEN = 32; - -const BACKED_UP_FILES = [ - "betterframe.db", - "secret.key", - "firmware-signing.key", - "firmware-signing.pub", -] as const; - -export interface BackupResult { - blob: Buffer; - filename: string; - fileCount: number; -} - -export function createBackup(dataDir: string, passphrase: string): BackupResult { - if (passphrase.length < 8) { - throw new Error("passphrase must be at least 8 characters"); - } - - const files: Record = {}; - for (const name of BACKED_UP_FILES) { - const path = join(dataDir, name); - if (existsSync(path)) { - files[name] = readFileSync(path).toString("base64"); - } - } - - const inner = JSON.stringify({ - version: 1, - created_at: new Date().toISOString(), - files, - }); - - const salt = randomBytes(SALT_LEN); - const nonce = randomBytes(NONCE_LEN); - const key = pbkdf2Sync(passphrase, salt, PBKDF2_ITERS, KEY_LEN, "sha256"); - const cipher = createCipheriv("aes-256-gcm", key, nonce); - const enc = Buffer.concat([cipher.update(Buffer.from(inner, "utf8")), cipher.final()]); - const tag = cipher.getAuthTag(); - - const blob = Buffer.concat([MAGIC, salt, nonce, enc, tag]); - return { - blob, - filename: `betterframe-${new Date().toISOString().replace(/[:.]/g, "-")}.bfbak`, - fileCount: Object.keys(files).length, - }; -} - -export function restoreBackup(dataDir: string, passphrase: string, blob: Buffer): { - fileCount: number; - files: string[]; -} { - if (blob.length < MAGIC.length + SALT_LEN + NONCE_LEN + 16) { - throw new Error("backup file too short / corrupt"); - } - if (!blob.subarray(0, MAGIC.length).equals(MAGIC)) { - throw new Error("not a bfbak file"); - } - let off = MAGIC.length; - const salt = blob.subarray(off, off + SALT_LEN); - off += SALT_LEN; - const nonce = blob.subarray(off, off + NONCE_LEN); - off += NONCE_LEN; - const tag = blob.subarray(blob.length - 16); - const enc = blob.subarray(off, blob.length - 16); - - const key = pbkdf2Sync(passphrase, salt, PBKDF2_ITERS, KEY_LEN, "sha256"); - const decipher = createDecipheriv("aes-256-gcm", key, nonce); - decipher.setAuthTag(tag); - let dec: Buffer; - try { - dec = Buffer.concat([decipher.update(enc), decipher.final()]); - } catch { - throw new Error("wrong passphrase or corrupted backup"); - } - - const inner = JSON.parse(dec.toString("utf8")) as { - version: number; - files: Record; - }; - if (inner.version !== 1) throw new Error(`unsupported backup version ${String(inner.version)}`); - - mkdirSync(dataDir, { recursive: true }); - const restored: string[] = []; - for (const [name, b64] of Object.entries(inner.files)) { - // Refuse path traversal in filenames. - if (name.includes("/") || name.includes("\\") || name.startsWith(".")) continue; - if (!(BACKED_UP_FILES as readonly string[]).includes(name)) continue; - const target = join(dataDir, name); - writeFileSync(target, Buffer.from(b64, "base64"), { - mode: name.endsWith(".key") ? 0o600 : 0o644, - }); - restored.push(name); - } - return { fileCount: restored.length, files: restored }; -} +/** Legacy SQLite archives must never overwrite keys for a live PostgreSQL database. */ +export const legacyBackupUnavailable = + "Legacy SQLite backup/restore is unavailable for PostgreSQL. Use deploy/scripts/backup-stack.sh and docs/backup-recovery.md to preserve the database, server keys, and Node-RED state together."; diff --git a/server/src/shared/coordinator-websocket.ts b/server/src/shared/coordinator-websocket.ts new file mode 100644 index 00000000..12c6377a --- /dev/null +++ b/server/src/shared/coordinator-websocket.ts @@ -0,0 +1,12 @@ +import { WebSocketServer } from "ws"; + +// Camera proxy responses support 10 MiB of binary data. Base64 expands that +// to about 13.34 MiB; allow JSON metadata too while keeping a finite limit. +export const COORDINATOR_MAX_PAYLOAD_BYTES = 16 * 1024 * 1024; + +export function createCoordinatorWebSocketServer(): WebSocketServer { + return new WebSocketServer({ + noServer: true, + maxPayload: COORDINATOR_MAX_PAYLOAD_BYTES, + }); +} diff --git a/server/src/shared/db/db-adapter.ts b/server/src/shared/db/db-adapter.ts index 1886d629..0cc17235 100644 --- a/server/src/shared/db/db-adapter.ts +++ b/server/src/shared/db/db-adapter.ts @@ -22,6 +22,7 @@ export interface DbAdapter { all(sql: string, params?: ReadonlyArray): Promise; exec(sql: string): Promise; transaction(fn: () => Promise): Promise; + afterCommit?(fn: () => void): void; dialect(): "postgres"; setSearchPath(schema: string): Promise; withSearchPath(schema: string, fn: () => T | Promise): Promise; diff --git a/server/src/shared/db/init.ts b/server/src/shared/db/init.ts index 25548431..ac46a139 100644 --- a/server/src/shared/db/init.ts +++ b/server/src/shared/db/init.ts @@ -7,6 +7,8 @@ import { Repository } from "./repository.js"; import type { DbAdapter } from "./db-adapter.js"; import type { DbConfig } from "./config.js"; +import { tenantSchemaName, legacyTenantSchemaName, storedPostgresIdentifier } from "./tenant-schema.js"; +import { quotedSchema } from "./platform-admin.js"; import { mirrorPlatformAdmins } from "./platform-admin.js"; interface DbLog { @@ -32,75 +34,58 @@ export async function initDb( const { PgAdapter } = await import("./pg-adapter.js"); const adapter = new PgAdapter(pgUrl, config.poolMax); - await adapter.exec(`CREATE TABLE IF NOT EXISTS schema_migrations ( - schema_name TEXT NOT NULL, version INTEGER NOT NULL, - applied_at TIMESTAMPTZ NOT NULL DEFAULT now(), - PRIMARY KEY (schema_name, version) - )`); - - const { PUBLIC_MIGRATIONS, TENANT_MIGRATIONS } = await import("./migrations-pg.js"); - const pubVersionRow = await adapter.get<{ version: number }>( - `SELECT COALESCE(MAX(version), 0) AS version FROM schema_migrations WHERE schema_name = 'public_global'`, - ).catch(() => undefined); - const pubCurrentVersion = pubVersionRow?.version ?? 0; - if (pubCurrentVersion < PUBLIC_MIGRATIONS.length) { - log.info(`running PUBLIC migrations from ${pubCurrentVersion} to ${PUBLIC_MIGRATIONS.length}`); - for (let i = pubCurrentVersion; i < PUBLIC_MIGRATIONS.length; i++) { - try { - await adapter.exec(PUBLIC_MIGRATIONS[i]!); - } catch (err) { - log.warn(`PUBLIC migration ${i} failed: ${(err as Error).message}`); - log.warn(`SQL: ${PUBLIC_MIGRATIONS[i]!.slice(0, 200)}`); - throw err; - } - await adapter.run( - `INSERT INTO schema_migrations (schema_name, version) VALUES ('public_global', ?)`, - [i + 1], + try { + await adapter.transaction(async () => { + // One transaction-scoped lock is shared by startup and tenant creation. + await adapter.get("SELECT pg_advisory_xact_lock(734192081)"); + await adapter.exec(`CREATE TABLE IF NOT EXISTS public.schema_migrations ( + schema_name TEXT NOT NULL, version INTEGER NOT NULL, + applied_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (schema_name, version) + )`); + const { PUBLIC_MIGRATIONS, TENANT_MIGRATIONS } = await import("./migrations-pg.js"); + await applyMigrations(adapter, "public_global", PUBLIC_MIGRATIONS, log); + await applyMigrations(adapter, "public", TENANT_MIGRATIONS, log); + await adapter.run(`INSERT INTO public.tenants (name, slug, schema_name, is_active) + VALUES ('Default', 'default', 'public', true) ON CONFLICT (slug) DO NOTHING`); + const tenants = await adapter.all<{ slug: string; schema_name: string }>( + "SELECT slug, schema_name FROM public.tenants WHERE slug <> 'default' ORDER BY created_at", ); - } - } else { - log.info(`PUBLIC schema up to date (version ${pubCurrentVersion})`); - } - - const versionRow = await adapter.get<{ version: number }>( - `SELECT COALESCE(MAX(version), 0) AS version FROM schema_migrations WHERE schema_name = 'public'`, - ).catch(() => undefined); - const currentVersion = versionRow?.version ?? 0; - if (currentVersion < TENANT_MIGRATIONS.length) { - log.info(`running PG tenant migrations from ${currentVersion} to ${TENANT_MIGRATIONS.length}`); - for (let i = currentVersion; i < TENANT_MIGRATIONS.length; i++) { - try { - await adapter.exec(TENANT_MIGRATIONS[i]!); - } catch (err) { - log.warn(`PG migration ${i} failed: ${(err as Error).message}`); - log.warn(`SQL: ${TENANT_MIGRATIONS[i]!.slice(0, 200)}`); - throw err; + // Old registration names were unbounded, but PostgreSQL identifiers are + // not. Refuse ambiguous ownership before renaming any tenant schema. + const schemaOwners = new Map(); + for (const tenant of tenants) { + const storedName = storedPostgresIdentifier(tenant.schema_name); + const previousOwner = schemaOwners.get(storedName); + if (previousOwner) throw new Error(`ambiguous legacy tenant schema ${storedName}: registered by ${previousOwner} and ${tenant.slug}`); + schemaOwners.set(storedName, tenant.slug); } - await adapter.run( - `INSERT INTO schema_migrations (schema_name, version) VALUES ('public', ?)`, - [i + 1], - ); - } - } else { - log.info(`PG schema up to date (version ${currentVersion})`); - } - - const defaultTenant = await adapter.get( - `SELECT id FROM public.tenants WHERE slug = 'default'`, - ); - if (!defaultTenant) { - log.info("creating default tenant"); - await adapter.run( - `INSERT INTO public.tenants (name, slug, schema_name, is_active) - VALUES ('Default', 'default', 'public', true)`, - ); - } - - const tenants = await adapter.all<{ slug: string }>( - `SELECT slug FROM public.tenants WHERE slug <> 'default' ORDER BY created_at`, - ); - for (const tenant of tenants) { - await createTenantSchema(adapter, tenant.slug, log); + for (const tenant of tenants) { + let schemaName = tenant.schema_name; + // Repair registrations made by older builds with invalid/overlong names. + if (!/^[a-z_][a-z0-9_]{0,62}$/.test(schemaName)) { + const repaired = legacyTenantSchemaName(tenant.slug); + const storedName = storedPostgresIdentifier(schemaName); + // Cast to text: comparing pg_namespace.name to a name-typed parameter + // can silently truncate the lookup argument too. + const exists = await adapter.get("SELECT 1 FROM pg_namespace WHERE nspname::text = ?", [storedName]); + if (exists) { + const oldQuoted = `"${storedName.replaceAll('"', '""')}"`; + await adapter.exec(`ALTER SCHEMA ${oldQuoted} RENAME TO ${quotedSchema(repaired)}`); + await adapter.run(`INSERT INTO public.schema_migrations (schema_name, version, applied_at) + SELECT ?, version, applied_at FROM public.schema_migrations WHERE schema_name IN (?, ?) + ON CONFLICT (schema_name, version) DO NOTHING`, [repaired, schemaName, storedName]); + await adapter.run("DELETE FROM public.schema_migrations WHERE schema_name IN (?, ?)", [schemaName, storedName]); + } + await adapter.run("UPDATE public.tenants SET schema_name = ? WHERE slug = ?", [repaired, tenant.slug]); + schemaName = repaired; + } + await createTenantSchema(adapter, tenant.slug, log, schemaName); + } + }); + } catch (error) { + await adapter.close(); + throw error; } const repo = new Repository(adapter, async (table, op, id) => { @@ -113,46 +98,31 @@ export async function initDb( /** * Create a new tenant schema and run all TENANT_MIGRATIONS inside it. */ -export async function createTenantSchema( - adapter: DbAdapter, - slug: string, - log: DbLog, -): Promise { - if (!/^[a-z0-9][a-z0-9_-]*$/.test(slug)) { - throw new Error(`invalid tenant slug: ${slug}`); +async function applyMigrations(adapter: DbAdapter, name: string, migrations: readonly string[], log: DbLog): Promise { + const version = await adapter.get<{ version: number }>( + "SELECT COALESCE(MAX(version), 0) AS version FROM public.schema_migrations WHERE schema_name = ?", [name], + ); + for (let i = version?.version ?? 0; i < migrations.length; i++) { + await adapter.transaction(async () => { + await adapter.exec(migrations[i]!); + await adapter.run("INSERT INTO public.schema_migrations (schema_name, version) VALUES (?, ?)", [name, i + 1]); + }); } - const schemaName = `tenant_${slug}`; - log.info(`creating tenant schema: ${schemaName}`); - - await adapter.exec(`CREATE SCHEMA IF NOT EXISTS ${schemaName}`); - await adapter.setSearchPath(schemaName); - - try { - const { TENANT_MIGRATIONS } = await import("./migrations-pg.js"); - - const versionRow = await adapter.get<{ version: number }>( - `SELECT COALESCE(MAX(version), 0) AS version FROM public.schema_migrations WHERE schema_name = ?`, - [schemaName], - ); - const currentVersion = versionRow?.version ?? 0; + log.info(`schema ${name} at migration ${migrations.length}`); +} - if (currentVersion < TENANT_MIGRATIONS.length) { - log.info(`running tenant migrations for ${schemaName} from ${currentVersion} to ${TENANT_MIGRATIONS.length}`); - for (let i = currentVersion; i < TENANT_MIGRATIONS.length; i++) { - try { - await adapter.exec(TENANT_MIGRATIONS[i]!); - } catch (err) { - log.warn(`tenant migration ${i} failed for ${schemaName}: ${(err as Error).message}`); - throw err; - } - await adapter.run( - `INSERT INTO public.schema_migrations (schema_name, version) VALUES (?, ?)`, - [schemaName, i + 1], - ); - } - } - await mirrorPlatformAdmins(adapter, [schemaName]); - } finally { - await adapter.setSearchPath("public"); - } +export async function createTenantSchema( + adapter: DbAdapter, slug: string, log: DbLog, existingSchemaName?: string, +): Promise { + const schemaName = existingSchemaName ?? tenantSchemaName(slug); + const quoted = quotedSchema(schemaName); + await adapter.transaction(async () => { + await adapter.get("SELECT pg_advisory_xact_lock(734192081)"); + await adapter.exec(`CREATE SCHEMA IF NOT EXISTS ${quoted}`); + await adapter.withSearchPath(schemaName, async () => { + const { TENANT_MIGRATIONS } = await import("./migrations-pg.js"); + await applyMigrations(adapter, schemaName, TENANT_MIGRATIONS, log); + await mirrorPlatformAdmins(adapter, [schemaName]); + }); + }); } diff --git a/server/src/shared/db/migrations-pg.ts b/server/src/shared/db/migrations-pg.ts index 835cddb7..b8ee70db 100644 --- a/server/src/shared/db/migrations-pg.ts +++ b/server/src/shared/db/migrations-pg.ts @@ -96,6 +96,12 @@ export const PUBLIC_MIGRATIONS: readonly string[] = [ `UPDATE iobox_serials SET model_id = 'ioBOX-WIFI' WHERE model_id = 'ioBOX-KB'`, `UPDATE iobox_serials SET model_id = 'ioBOX-ETHERNET' WHERE model_id = 'ioBOX-KB-E'`, `DELETE FROM iobox_models WHERE id IN ('ioBOX-KB', 'ioBOX-KB-E')`, + `CREATE TABLE IF NOT EXISTS public.iobox_pairing_claims ( + serial TEXT PRIMARY KEY REFERENCES public.iobox_serials(serial) ON DELETE CASCADE, + provisioning_secret_hash TEXT NOT NULL, + claim_encrypted TEXT, + acknowledged_at TIMESTAMPTZ + )`, ]; /** diff --git a/server/src/shared/db/pg-adapter.ts b/server/src/shared/db/pg-adapter.ts index 93ff085b..b951f1ee 100644 --- a/server/src/shared/db/pg-adapter.ts +++ b/server/src/shared/db/pg-adapter.ts @@ -16,7 +16,7 @@ export class PgAdapter implements DbAdapter { private readonly pool: Pool; private readonly context = new AsyncLocalStorage<{ searchPath: string; - transaction?: { client: PoolClient; depth: number }; + transaction?: { client: PoolClient; depth: number; callbacks: Array<() => void> }; }>(); constructor(connectionString: string, poolMax: number = 10) { @@ -71,11 +71,14 @@ export class PgAdapter implements DbAdapter { private async runner(fn: (c: PoolClient) => Promise): Promise { const current = this.context.getStore(); - if (current?.transaction) return fn(current.transaction.client); + if (current?.transaction) { + await current.transaction.client.query(`SET LOCAL search_path TO "${current.searchPath}", public`); + return fn(current.transaction.client); + } const client = await this.pool.connect(); try { const searchPath = current?.searchPath ?? "public"; - await client.query(`SET search_path TO ${searchPath}, public`); + await client.query(`SET search_path TO "${searchPath}", public`); return await fn(client); } finally { client.release(); @@ -123,6 +126,7 @@ export class PgAdapter implements DbAdapter { const current = this.context.getStore() ?? { searchPath: "public" }; if (current.transaction) { // Already in a transaction — use a savepoint. + const callbackCount = current.transaction.callbacks.length; current.transaction.depth += 1; const name = `sp_${current.transaction.depth}`; await current.transaction.client.query(`SAVEPOINT ${name}`); @@ -132,20 +136,25 @@ export class PgAdapter implements DbAdapter { current.transaction.depth -= 1; return result; } catch (err) { + current.transaction.callbacks.length = callbackCount; try { await current.transaction.client.query(`ROLLBACK TO SAVEPOINT ${name}`); } catch { /* ignore */ } current.transaction.depth -= 1; throw err; } } const client = await this.pool.connect(); + const callbacks: Array<() => void> = []; try { await client.query("BEGIN"); - await client.query(`SET LOCAL search_path TO ${current.searchPath}, public`); + await client.query(`SET LOCAL search_path TO "${current.searchPath}", public`); const result = await this.context.run( - { ...current, transaction: { client, depth: 1 } }, + { ...current, transaction: { client, depth: 1, callbacks } }, fn, ); await client.query("COMMIT"); + // Notifications are best-effort and must never turn a committed write into + // an apparent transaction failure. + for (const callback of callbacks) { try { callback(); } catch { /* best effort */ } } return result; } catch (err) { try { await client.query("ROLLBACK"); } catch { /* ignore */ } @@ -155,6 +164,12 @@ export class PgAdapter implements DbAdapter { } } + afterCommit(fn: () => void): void { + const transaction = this.context.getStore()?.transaction; + if (transaction) transaction.callbacks.push(fn); + else fn(); + } + dialect(): "postgres" { return "postgres"; } async setSearchPath(schema: string): Promise { diff --git a/server/src/shared/db/repository.ts b/server/src/shared/db/repository.ts index 94b7e39d..a31cd1c8 100644 --- a/server/src/shared/db/repository.ts +++ b/server/src/shared/db/repository.ts @@ -9,6 +9,7 @@ * cross workers with the same handle. */ import { randomBytes } from "node:crypto"; +import { tenantSchemaName } from "./tenant-schema.js"; import { uuidv7 } from "uuidv7"; import type { Observable } from "@bsb/base"; import type { DbAdapter, RunResult, Row } from "./db-adapter.js"; @@ -135,7 +136,11 @@ export class Repository { constructor(adapter: DbAdapter, notify: NotifyFn) { this.adapter = adapter; - this.notify = notify; + this.notify = async (table, op, id) => { + const deliver = () => { void Promise.resolve(notify(table, op, id)).catch(() => {}); }; + if (adapter.afterCommit) adapter.afterCommit(deliver); + else deliver(); + }; } /** Set a per-request observable for DB call tracing. */ @@ -234,7 +239,7 @@ export class Repository { max_cameras?: number | null; max_users?: number | null; }): Promise { - const schemaName = input.slug === "default" ? "public" : `tenant_${input.slug}`; + const schemaName = tenantSchemaName(input.slug); await this._run( `INSERT INTO public.tenants (name, slug, schema_name, is_active, max_kiosks, max_cameras, max_users) VALUES (?, ?, ?, true, ?, ?, ?)`, @@ -2489,8 +2494,8 @@ export class Repository { return rowToPairingCode(r as Record); } - async getPairingCode(code: string): Promise { - const r = await this._get(`SELECT * FROM ${this._pairingT} WHERE code = ?`, [code]); + async getPairingCode(code: string, lock = false): Promise { + const r = await this._get(`SELECT * FROM ${this._pairingT} WHERE code = ?${lock ? " FOR UPDATE" : ""}`, [code]); return r ? rowToPairingCode(r as Record) : null; } @@ -2509,14 +2514,15 @@ export class Repository { kioskId: string, extras: Record, ): Promise { - await this._run( + const result = await this._run( `UPDATE ${this._pairingT} SET consumed_at = ?, consumed_by_kiosk_id = ?, extras = ? - WHERE code = ?`, + WHERE code = ? AND consumed_at IS NULL`, [isoNow(), kioskId, J(extras), code], ); + if (result.changes !== 1) throw new Error("pairing code already used"); } async updatePairingCodeExtras(code: string, extras: Record): Promise { diff --git a/server/src/shared/db/tenant-schema.ts b/server/src/shared/db/tenant-schema.ts new file mode 100644 index 00000000..2062b837 --- /dev/null +++ b/server/src/shared/db/tenant-schema.ts @@ -0,0 +1,24 @@ +import { createHash } from "node:crypto"; + +/** Preserve existing safe schema names; punctuation/long slugs get an immutable digest. */ +export function tenantSchemaName(slug: string): string { + if (!/^[a-z0-9][a-z0-9_-]{0,127}$/.test(slug)) throw new Error("invalid tenant slug"); + if (slug === "default") return "public"; + if (/^[a-z0-9_]+$/.test(slug) && slug.length <= 56) return `tenant_${slug}`; + return legacyTenantSchemaName(slug); +} + +/** Existing registrations predate input limits; hash them without rejecting their slug. */ +export function legacyTenantSchemaName(slug: string): string { + return `tenant_${createHash("sha256").update(slug).digest("hex").slice(0, 48)}`; +} + +/** PostgreSQL truncates identifiers to 63 bytes without splitting UTF-8 characters. */ +export function storedPostgresIdentifier(identifier: string): string { + let stored = ""; + for (const character of identifier) { + if (Buffer.byteLength(stored + character, "utf8") > 63) break; + stored += character; + } + return stored; +} diff --git a/server/src/shared/iobox-pairing.ts b/server/src/shared/iobox-pairing.ts new file mode 100644 index 00000000..887efee1 --- /dev/null +++ b/server/src/shared/iobox-pairing.ts @@ -0,0 +1,82 @@ +import { randomBytes } from "node:crypto"; +import type { Repository } from "./db/repository.js"; +import type { AuthApi } from "./auth.js"; +import type { SecretsApi } from "./secrets.js"; +import { matchesSecret, secretHash } from "./pairing.js"; + +interface ClaimRow { provisioning_secret_hash: string; claim_encrypted: string | null; acknowledged_at: unknown } +interface Tenant { id: string | null; slug: string; schema_name: string } + +// Serial row locks serialize announce, claim and acknowledgement across API replicas. +export async function bindIoBoxProvisioning(repo: Repository, serial: string, secret?: string): Promise { + if (!secret) return; + await repo.transact(async () => { + const serialRow = await repo.adapter.get<{ paired_iobox_id: string | null }>( + "SELECT paired_iobox_id FROM public.iobox_serials WHERE serial = ? FOR UPDATE", [serial], + ); + if (!serialRow) throw new Error("unknown serial"); + const claim = await repo.adapter.get("SELECT * FROM public.iobox_pairing_claims WHERE serial = ?", [serial]); + if (claim) { + if (!matchesSecret(secret, claim.provisioning_secret_hash)) throw new Error("invalid provisioning secret"); + } else { + // Never retroactively grant access to credentials issued through legacy enrollment. + if (serialRow.paired_iobox_id) throw new Error("serial already paired; operator reset required"); + await repo.adapter.run("INSERT INTO public.iobox_pairing_claims (serial, provisioning_secret_hash) VALUES (?, ?)", [serial, secretHash(secret)]); + } + }); +} + +export async function claimIoBox( + repo: Repository, auth: AuthApi, secrets: SecretsApi, + input: { serial: string; provisioning_secret?: string; name?: string; assigned_display_id?: string | null }, tenant: Tenant, +): Promise> { + return repo.transact(async () => { + await repo.adapter.get("SELECT serial FROM public.iobox_serials WHERE serial = ? FOR UPDATE", [input.serial]); + const registered = await repo.getIoBoxSerial(input.serial); + if (!registered) throw new Error("unknown serial"); + await bindIoBoxProvisioning(repo, input.serial, input.provisioning_secret); + const claim = await repo.adapter.get("SELECT * FROM public.iobox_pairing_claims WHERE serial = ?", [input.serial]); + if (claim && !matchesSecret(input.provisioning_secret, claim.provisioning_secret_hash)) throw new Error("invalid provisioning secret"); + if (registered.paired_iobox_id) { + if (!claim?.claim_encrypted) throw new Error("serial already paired"); + const envelope = JSON.parse(secrets.decryptString(claim.claim_encrypted, "iobox-pairing-claim")); + const assignedTenant = registered.paired_tenant_id ? await repo.getTenantById(registered.paired_tenant_id) : null; + if (registered.paired_tenant_id && !assignedTenant?.is_active) throw new Error("tenant unavailable"); + const box = await repo.adapter.withSearchPath(assignedTenant?.schema_name ?? "public", () => repo.getIoBoxById(registered.paired_iobox_id!)); + if (!box?.enabled) throw new Error("device revoked"); + return envelope; + } + const model = await repo.getIoBoxModel(registered.model_id); + if (!model) throw new Error("serial model missing"); + return repo.adapter.withSearchPath(tenant.schema_name, async () => { + const plaintext = `bfio-${randomBytes(24).toString("base64url")}`; + const box = await repo.createIoBox({ + serial: input.serial, model_id: model.id, + name: input.name?.trim() || `${model.name} ${input.serial}`, + key_hash: await auth.hashPassword(plaintext), key_prefix: plaintext.slice(0, 8), + assigned_display_id: input.assigned_display_id ?? null, + }); + await repo.markIoBoxSerialPaired(input.serial, tenant.id, box.id); + const envelope = { + status: "claimed", tenant_slug: tenant.slug, iobox_id: box.id, iobox_key: plaintext, + config_url: "/api/iobox/config", heartbeat_url: "/api/iobox/heartbeat", + }; + if (claim) await repo.adapter.run("UPDATE public.iobox_pairing_claims SET claim_encrypted = ? WHERE serial = ?", [ + secrets.encryptString(JSON.stringify(envelope), "iobox-pairing-claim"), input.serial, + ]); + return envelope; + }); + }); +} + +export async function acknowledgeIoBox(repo: Repository, serial: string, deviceId: string, tenantId: string, secret?: string): Promise { + await repo.transact(async () => { + await repo.adapter.get("SELECT serial FROM public.iobox_serials WHERE serial = ? FOR UPDATE", [serial]); + const registered = await repo.getIoBoxSerial(serial); + const claim = await repo.adapter.get("SELECT * FROM public.iobox_pairing_claims WHERE serial = ?", [serial]); + if (registered?.paired_iobox_id !== deviceId || registered?.paired_tenant_id !== tenantId || !claim || !matchesSecret(secret, claim.provisioning_secret_hash)) { + throw new Error("invalid pairing acknowledgement"); + } + await repo.adapter.run("UPDATE public.iobox_pairing_claims SET claim_encrypted = NULL, acknowledged_at = COALESCE(acknowledged_at, now()) WHERE serial = ?", [serial]); + }); +} diff --git a/server/src/shared/kiosk-connections.ts b/server/src/shared/kiosk-connections.ts new file mode 100644 index 00000000..929769b5 --- /dev/null +++ b/server/src/shared/kiosk-connections.ts @@ -0,0 +1,29 @@ +export interface KioskSocket { + terminate(): void; +} + +export interface KioskConnection { + id: string; + name: string; + ws: S; + lastPong: number; +} + +/** Connection ownership survives delayed close events from a replaced socket. */ +export class KioskConnections extends Map> { + removeSocket(id: string, socket: S): boolean { + if (this.get(id)?.ws !== socket) return false; + return this.delete(id); + } + + pong(id: string, socket: S, now = Date.now()): void { + const connection = this.get(id); + if (connection?.ws === socket) connection.lastPong = now; + } + + terminateStale(now = Date.now(), timeoutMs = 90_000): void { + for (const connection of this.values()) { + if (now - connection.lastPong > timeoutMs) connection.ws.terminate(); + } + } +} diff --git a/server/src/shared/nodered-bridge.ts b/server/src/shared/nodered-bridge.ts index 784dab7e..dbc0d9d5 100644 --- a/server/src/shared/nodered-bridge.ts +++ b/server/src/shared/nodered-bridge.ts @@ -80,6 +80,7 @@ export function initNoderedBridge(config: NoderedConfig, log: NoderedLog): Noder fetch(`${base}/api/internal/${encodeURIComponent(topic)}`, { method: "POST", headers: { + authorization: `Bearer ${managerToken}`, "content-type": "application/json", "x-betterframe-tenant": tenant.tenant_id ?? tenant.tenant_slug, }, diff --git a/server/src/shared/pairing.ts b/server/src/shared/pairing.ts index db99af3f..a3fcef08 100644 --- a/server/src/shared/pairing.ts +++ b/server/src/shared/pairing.ts @@ -6,12 +6,24 @@ * 2. Kiosk polls claim → 202 until admin confirms, then 200 + credentials * 3. Admin enters code in UI → confirmPairing creates kiosk + kiosk_key */ -import { randomBytes } from "node:crypto"; +import { randomBytes, createHash, timingSafeEqual } from "node:crypto"; import type { Observable } from "@bsb/base"; import type { Repository } from "./db/repository.js"; import type { AuthApi } from "./auth.js"; import { CLUSTER_SECRET_CONTEXT, type SecretsApi } from "./secrets.js"; -import type { PairingCode } from "./types.js"; + +export function secretHash(secret: string): string { + return createHash("sha256").update(secret).digest("hex"); +} +export function matchesSecret(secret: string | undefined, hash: unknown): boolean { + if (typeof hash !== "string" || !secret) return false; + const expected = Buffer.from(hash, "hex"); + const actual = Buffer.from(secretHash(secret), "hex"); + return expected.length === actual.length && timingSafeEqual(expected, actual); +} +const DELIVERY_GRACE_MS = 15 * 60_000; +export const PAIR_POLL_AFTER_MS = 2_000; + const CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; // no 0/O/1/I const CODE_LENGTH = 8; @@ -33,11 +45,14 @@ export interface PairingInitiateInput { /** True iff kiosk runs our pre-built Pi image with the apply-config helper. */ managedImage?: boolean; codeTtlSeconds: number; + secureClaim?: boolean; } export interface PairingInitiateResult { code: string; expiresAt: string; + pollingSecret?: string; + expiresInSeconds: number; } export async function initiatePairing( @@ -54,6 +69,7 @@ export async function initiatePairing( const expiresAt = new Date(Date.now() + input.codeTtlSeconds * 1000).toISOString(); + const pollingSecret = input.secureClaim ? randomBytes(32).toString("base64url") : undefined; await repo.createPairingCode({ code, kiosk_proposed_name: input.proposedName, @@ -61,14 +77,15 @@ export async function initiatePairing( kiosk_firmware_target: input.firmwareTarget ?? null, kiosk_capabilities: input.capabilities, expires_at: expiresAt, - extras: input.managedImage ? { managed_image: true } : {}, + extras: { managed_image: input.managedImage === true, ...(pollingSecret ? { polling_secret_hash: secretHash(pollingSecret) } : {}) }, }); - return { code, expiresAt }; + return { code, expiresAt, pollingSecret, expiresInSeconds: input.codeTtlSeconds }; } export interface PairingClaimResult { - status: "pending" | "claimed"; + status: "pending" | "claimed" | "expired" | "failed" | "acknowledged" | "revoked"; + expiresInSeconds?: number; kioskId?: string; kioskName?: string; kioskKey?: string; @@ -82,13 +99,22 @@ export async function claimPairing( code: string, secrets: SecretsApi, obs?: Observable, + pollingSecret?: string, ): Promise { const pc = await repo.getPairingCode(code); - if (!pc) { obs?.log.info("claim {code}: code not found", { code }); return { status: "pending" }; } - if (new Date(pc.expires_at) < new Date()) { obs?.log.info("claim {code}: expired", { code }); return { status: "pending" }; } - if (!pc.consumed_at) { obs?.log.info("claim {code}: not yet consumed", { code }); return { status: "pending" }; } - + if (!pc) return { status: "expired" }; const extras = pc.extras as Record; + if (extras["polling_secret_hash"] && !matchesSecret(pollingSecret, extras["polling_secret_hash"])) { + return { status: "failed" }; + } + if (extras["acknowledged_at"]) return { status: "acknowledged" }; + const expiry = pc.consumed_at + ? String(extras["claim_expires_at"] ?? pc.expires_at) : pc.expires_at; + const remaining = Date.parse(expiry) - Date.now(); + if (!Number.isFinite(remaining)) return { status: "failed" }; + if (remaining <= 0) return { status: "expired" }; + if (!pc.consumed_at) return { status: "pending", expiresInSeconds: Math.ceil(remaining / 1000) }; + let claim: { kioskKey?: string; clusterKey?: string; encryptKey?: string } = {}; const encryptedClaim = extras["pairing_claim_encrypted"]; if (typeof encryptedClaim === "string") { @@ -100,8 +126,8 @@ export async function claimPairing( encryptKey: typeof parsed["encryptKey"] === "string" ? parsed["encryptKey"] : undefined, }; } catch { - obs?.log.warn("claim {code}: encrypted credentials unreadable", { code }); - return { status: "pending" }; + obs?.log.warn("pairing credentials unreadable"); + return { status: "failed" }; } } else { // Compatibility with pairing codes confirmed by older server builds. @@ -113,15 +139,17 @@ export async function claimPairing( } const kioskKey = claim.kioskKey; - if (!kioskKey || !pc.consumed_by_kiosk_id) { obs?.log.warn("claim {code}: consumed but missing key/id", { code }); return { status: "pending" }; } + if (!kioskKey || !pc.consumed_by_kiosk_id) return { status: "failed" }; const tenantSchema = typeof extras["tenant_schema"] === "string" ? extras["tenant_schema"] : "public"; const kiosk = await repo.adapter.withSearchPath( tenantSchema, () => repo.getKioskById(pc.consumed_by_kiosk_id!), ); + if (!kiosk || kiosk.enabled === false) return { status: "revoked" }; return { status: "claimed", + expiresInSeconds: Math.ceil(remaining / 1000), kioskId: pc.consumed_by_kiosk_id, kioskName: kiosk?.name ?? pc.kiosk_proposed_name ?? "kiosk", kioskKey, @@ -154,132 +182,170 @@ export async function confirmPairing( input: PairingConfirmInput, obs?: Observable, ): Promise<{ kioskId: string; kioskName: string }> { - obs?.log.info("confirm pairing for code {code}", { code: input.code }); - const pc = await repo.getPairingCode(input.code); - if (!pc) throw new Error("pairing code not found"); - if (pc.consumed_at) throw new Error("pairing code already used"); - if (new Date(pc.expires_at) < new Date()) throw new Error("pairing code expired"); + return repo.adapter.withSearchPath(input.tenant?.schemaName ?? "public", () => repo.transact(async () => { + const pc = await repo.getPairingCode(input.code, true); + if (!pc) throw new Error("pairing code not found"); + if (pc.consumed_at) { + // Retry only the same operation in the same tenant. Never leak another + // tenant's claimed kiosk through an operator's repeated submission. + if (pc.extras["tenant_schema"] !== (input.tenant?.schemaName ?? "public") + || pc.extras["confirmation_request"] !== JSON.stringify({ + replaceKioskId: input.replaceKioskId ?? null, + nameOverride: input.nameOverride ?? null, + initialLabels: input.initialLabels ?? [], + })) throw new Error("pairing code already used"); + const kiosk = pc.consumed_by_kiosk_id ? await repo.getKioskById(pc.consumed_by_kiosk_id) : null; + if (!kiosk) throw new Error("paired kiosk no longer exists"); + return { kioskId: kiosk.id, kioskName: kiosk.name }; + } + if (new Date(pc.expires_at) < new Date()) throw new Error("pairing code expired"); - const kioskKeyPlaintext = `bf-${randomBytes(24).toString("base64url")}`; - const kioskKeyHash = await auth.hashPassword(kioskKeyPlaintext); - const kioskKeyPrefix = kioskKeyPlaintext.slice(0, 8); + const kioskKeyPlaintext = `bf-${randomBytes(24).toString("base64url")}`; + const kioskKeyHash = await auth.hashPassword(kioskKeyPlaintext); + const kioskKeyPrefix = kioskKeyPlaintext.slice(0, 8); - let kioskId: string; - let kioskName: string; + let kioskId: string; + let kioskName: string; - if (input.replaceKioskId != null) { - const existing = await repo.getKioskById(input.replaceKioskId); - if (!existing) throw new Error("replacement target kiosk not found"); + if (input.replaceKioskId != null) { + const existing = await repo.getKioskById(input.replaceKioskId); + if (!existing) throw new Error("replacement target kiosk not found"); - // Sanity-check the incoming device matches the slot it's replacing. - // Identity-bearing fields (hardware_model, managed_image) shouldn't drift - // on a like-for-like swap. Capabilities CAN narrow legitimately, so warn - // but don't block. force=1 from the form bypasses the whole check. - if (!input.force) { - const mismatches: string[] = []; - const newHw = pc.kiosk_hardware_model ?? null; - if (existing.hardware_model && newHw && existing.hardware_model !== newHw) { - mismatches.push(`hardware_model: ${existing.hardware_model} → ${newHw}`); + // Sanity-check the incoming device matches the slot it's replacing. + // Identity-bearing fields (hardware_model, managed_image) shouldn't drift + // on a like-for-like swap. Capabilities CAN narrow legitimately, so warn + // but don't block. force=1 from the form bypasses the whole check. + if (!input.force) { + const mismatches: string[] = []; + const newHw = pc.kiosk_hardware_model ?? null; + if (existing.hardware_model && newHw && existing.hardware_model !== newHw) { + mismatches.push(`hardware_model: ${existing.hardware_model} → ${newHw}`); + } + const newManaged = pc.extras?.["managed_image"] === true; + if (existing.managed_image !== newManaged) { + mismatches.push(`managed_image: ${existing.managed_image} → ${newManaged}`); + } + const lostCaps = existing.capabilities.filter((c) => !pc.kiosk_capabilities.includes(c)); + if (lostCaps.length > 0) { + mismatches.push(`lost capabilities: ${lostCaps.join(", ")}`); + } + if (mismatches.length > 0) { + throw new Error( + `replacement device differs from existing kiosk — ${mismatches.join("; ")}. ` + + `Re-submit with "force replace" to override.`, + ); + } } - const newManaged = pc.extras?.["managed_image"] === true; - if (existing.managed_image !== newManaged) { - mismatches.push(`managed_image: ${existing.managed_image} → ${newManaged}`); + + await repo.replaceKioskKey(existing.id, { + key_hash: kioskKeyHash, + key_prefix: kioskKeyPrefix, + capabilities: pc.kiosk_capabilities, + hardware_model: pc.kiosk_hardware_model, + firmware_target: pc.kiosk_firmware_target, + }); + // managed_image flag follows the new device (handled on row above via + // capabilities/hw, but the explicit column is updated separately because + // replaceKioskKey doesn't touch it). + if (existing.managed_image !== (pc.extras?.["managed_image"] === true)) { + await repo.updateKiosk(existing.id, { managed_image: pc.extras?.["managed_image"] === true } as any); } - const lostCaps = existing.capabilities.filter((c) => !pc.kiosk_capabilities.includes(c)); - if (lostCaps.length > 0) { - mismatches.push(`lost capabilities: ${lostCaps.join(", ")}`); + kioskId = existing.id; + kioskName = existing.name; + } else { + const baseName = input.nameOverride || pc.kiosk_proposed_name || `kiosk-${input.code.toLowerCase()}`; + let candidate = baseName; + let suffix = 2; + while (await repo.getKioskByName(candidate)) { + candidate = `${baseName}-${suffix}`; + suffix++; + if (suffix > 100) throw new Error("could not generate unique kiosk name"); } - if (mismatches.length > 0) { - throw new Error( - `replacement device differs from existing kiosk — ${mismatches.join("; ")}. ` - + `Re-submit with "force replace" to override.`, - ); + + const kiosk = await repo.createKiosk({ + name: candidate, + key_hash: kioskKeyHash, + key_prefix: kioskKeyPrefix, + capabilities: pc.kiosk_capabilities, + hardware_model: pc.kiosk_hardware_model, + firmware_target: pc.kiosk_firmware_target, + managed_image: pc.extras?.["managed_image"] === true, + }); + + await repo.createDisplayForKiosk(kiosk.id, { + name: `${candidate}: HDMI-0`, + }); + + if (input.initialLabels?.length) { + for (const labelName of input.initialLabels) { + const trimmed = labelName.trim().toLowerCase(); + if (!trimmed) continue; + const label = await repo.ensureLabel(trimmed); + await repo.attachKioskLabel(kiosk.id, label.id, "consume"); + } } - } - await repo.replaceKioskKey(existing.id, { - key_hash: kioskKeyHash, - key_prefix: kioskKeyPrefix, - capabilities: pc.kiosk_capabilities, - hardware_model: pc.kiosk_hardware_model, - firmware_target: pc.kiosk_firmware_target, - }); - // managed_image flag follows the new device (handled on row above via - // capabilities/hw, but the explicit column is updated separately because - // replaceKioskKey doesn't touch it). - if (existing.managed_image !== (pc.extras?.["managed_image"] === true)) { - await repo.updateKiosk(existing.id, { managed_image: pc.extras?.["managed_image"] === true } as any); - } - kioskId = existing.id; - kioskName = existing.name; - } else { - const baseName = input.nameOverride || pc.kiosk_proposed_name || `kiosk-${input.code.toLowerCase()}`; - let candidate = baseName; - let suffix = 2; - while (await repo.getKioskByName(candidate)) { - candidate = `${baseName}-${suffix}`; - suffix++; - if (suffix > 100) throw new Error("could not generate unique kiosk name"); + kioskId = kiosk.id; + kioskName = candidate; } - const kiosk = await repo.createKiosk({ - name: candidate, - key_hash: kioskKeyHash, - key_prefix: kioskKeyPrefix, - capabilities: pc.kiosk_capabilities, - hardware_model: pc.kiosk_hardware_model, - firmware_target: pc.kiosk_firmware_target, - managed_image: pc.extras?.["managed_image"] === true, - }); + // Per-kiosk encryption key: generate a fresh 32-byte key for this kiosk and + // deliver it through the server-encrypted, retryable pairing claim. + const kioskEncryptKey = randomBytes(32).toString("base64url"); + const kioskEncryptKeyEncrypted = secrets.encryptString(kioskEncryptKey, "kiosk-encrypt"); + await repo.updateKiosk(kioskId, { encrypt_key_encrypted: kioskEncryptKeyEncrypted } as any); - await repo.createDisplayForKiosk(kiosk.id, { - name: `${candidate}: HDMI-0`, - }); - - if (input.initialLabels?.length) { - for (const labelName of input.initialLabels) { - const trimmed = labelName.trim().toLowerCase(); - if (!trimmed) continue; - const label = await repo.ensureLabel(trimmed); - await repo.attachKioskLabel(kiosk.id, label.id, "consume"); + // Still deliver cluster_key for backward compat (old kiosk binaries + // that don't understand encrypt_key yet). Remove once all kiosks are + // on the new binary. + const clusterKeyEncrypted = await repo.getSetupExtra("cluster_key_encrypted") as string | undefined; + let clusterKey: string | undefined; + if (clusterKeyEncrypted) { + try { + clusterKey = secrets.decryptString(clusterKeyEncrypted, CLUSTER_SECRET_CONTEXT); + } catch { + throw new Error("server cluster encryption key is unreadable"); } } - kioskId = kiosk.id; - kioskName = candidate; - } + const pairingClaimEncrypted = secrets.encryptString(JSON.stringify({ + kioskKey: kioskKeyPlaintext, + clusterKey, + encryptKey: kioskEncryptKey, + }), "pairing-claim"); + await repo.markPairingCodeClaimed(input.code, kioskId, { + ...pc.extras, + pairing_claim_encrypted: pairingClaimEncrypted, + claim_expires_at: new Date(Date.now() + DELIVERY_GRACE_MS).toISOString(), + confirmation_request: JSON.stringify({ + replaceKioskId: input.replaceKioskId ?? null, + nameOverride: input.nameOverride ?? null, + initialLabels: input.initialLabels ?? [], + }), + tenant_id: input.tenant?.id ?? "default", + tenant_slug: input.tenant?.slug ?? "default", + tenant_schema: input.tenant?.schemaName ?? "public", + }); - // Per-kiosk encryption key: generate a fresh 32-byte key for this kiosk and - // deliver it through the server-encrypted, retryable pairing claim. - const kioskEncryptKey = randomBytes(32).toString("base64url"); - const kioskEncryptKeyEncrypted = secrets.encryptString(kioskEncryptKey, "kiosk-encrypt"); - await repo.updateKiosk(kioskId, { encrypt_key_encrypted: kioskEncryptKeyEncrypted } as any); + obs?.log.info("created kiosk {name} id {id}", { name: kioskName, id: String(kioskId) }); + return { kioskId, kioskName }; + })); +} - // Still deliver cluster_key for backward compat (old kiosk binaries - // that don't understand encrypt_key yet). Remove once all kiosks are - // on the new binary. - const clusterKeyEncrypted = await repo.getSetupExtra("cluster_key_encrypted") as string | undefined; - let clusterKey: string | undefined; - if (clusterKeyEncrypted) { - try { - clusterKey = secrets.decryptString(clusterKeyEncrypted, CLUSTER_SECRET_CONTEXT); - } catch { - clusterKey = undefined; +/** Credentials are removed only after the authenticated kiosk confirms durable storage. */ +export async function acknowledgePairing( + repo: Repository, code: string, kioskId: string, schemaName: string, pollingSecret?: string, +): Promise { + await repo.transact(async () => { + const pc = await repo.getPairingCode(code, true); + if (!pc || pc.consumed_by_kiosk_id !== kioskId || (pc.extras["tenant_schema"] ?? "public") !== schemaName) { + throw new Error("pairing session does not belong to device"); } - } - - const pairingClaimEncrypted = secrets.encryptString(JSON.stringify({ - kioskKey: kioskKeyPlaintext, - clusterKey, - encryptKey: kioskEncryptKey, - }), "pairing-claim"); - await repo.markPairingCodeClaimed(input.code, kioskId, { - pairing_claim_encrypted: pairingClaimEncrypted, - tenant_id: input.tenant?.id ?? "default", - tenant_slug: input.tenant?.slug ?? "default", - tenant_schema: input.tenant?.schemaName ?? "public", + if (pc.extras["polling_secret_hash"] && !matchesSecret(pollingSecret, pc.extras["polling_secret_hash"])) { + throw new Error("invalid pairing session secret"); + } + const extras: Record = { ...pc.extras, acknowledged_at: new Date().toISOString() }; + for (const key of ["pairing_claim_encrypted", "kiosk_key_plaintext", "cluster_key", "encrypt_key"]) delete extras[key]; + await repo.updatePairingCodeExtras(code, extras); }); - - obs?.log.info("created kiosk {name} id {id}", { name: kioskName, id: String(kioskId) }); - return { kioskId, kioskName }; } diff --git a/server/src/shared/rate-limit.ts b/server/src/shared/rate-limit.ts index cbc5012f..9359756a 100644 --- a/server/src/shared/rate-limit.ts +++ b/server/src/shared/rate-limit.ts @@ -1,56 +1,77 @@ /** - * In-memory sliding-window rate limiter. Per-key bucket holds timestamps of - * recent hits; we trim entries older than the window on every check. - * - * Suits single-process BSB; if we scale horizontally later swap in Redis. - * - * const guard = createRateLimiter({ windowMs: 60_000, max: 5 }); - * if (guard.take(`login:${ip}`)) ... // false = rate-limited + * Bounded in-memory sliding-window rate limiter. Active buckets are never + * evicted to admit new keys, so rotating keys cannot reset an existing quota. + * Global expiry sweeps run on traffic once per second (or once per window for + * shorter windows); idle storage remains bounded. Each process has its own quotas. */ export interface RateLimitConfig { windowMs: number; max: number; + /** Maximum distinct active keys; new keys are denied when full. */ + maxBuckets?: number; + /** Maximum stored hit timestamps across all keys. */ + maxEntries?: number; } export interface RateLimiter { - /** Returns true if allowed, false if over limit. */ + /** Returns true if allowed, false if over limit or storage is full. */ take(key: string): boolean; - /** How many hits remain in the current window for this key. */ + /** How many hits can currently be admitted for this key. */ remaining(key: string): number; /** Clear a specific key (e.g. after a successful auth). */ reset(key: string): void; } -export function createRateLimiter(config: RateLimitConfig): RateLimiter { +export function createRateLimiter( + config: RateLimitConfig, + clock: () => number = () => performance.now(), +): RateLimiter { + const maxBuckets = config.maxBuckets ?? 10_000; + const maxEntries = config.maxEntries ?? 100_000; + for (const value of [config.windowMs, config.max, maxBuckets, maxEntries]) { + if (!Number.isSafeInteger(value) || value <= 0) throw new Error("invalid rate limit configuration"); + } const buckets = new Map(); + let entries = 0; + let nextSweep = 0; function trim(key: string, now: number): number[] { - const cutoff = now - config.windowMs; - const arr = buckets.get(key) ?? []; - const filtered = arr.filter((ts) => ts >= cutoff); - if (filtered.length === 0) { - buckets.delete(key); - } else { - buckets.set(key, filtered); - } - return filtered; + const previous = buckets.get(key) ?? []; + const live = previous.filter((timestamp) => timestamp > now - config.windowMs); + entries -= previous.length - live.length; + if (live.length === 0) buckets.delete(key); + else buckets.set(key, live); + return live; + } + + function sweep(now: number): void { + if (now < nextSweep) return; + for (const key of buckets.keys()) trim(key, now); + nextSweep = now + Math.min(config.windowMs, 1_000); } return { take(key: string): boolean { - const now = Date.now(); - const arr = trim(key, now); - if (arr.length >= config.max) return false; - arr.push(now); - buckets.set(key, arr); + const now = clock(); + sweep(now); + const hits = trim(key, now); + if (hits.length >= config.max || entries >= maxEntries) return false; + if (hits.length === 0 && buckets.size >= maxBuckets) return false; + hits.push(now); + entries++; + buckets.set(key, hits); return true; }, remaining(key: string): number { - const arr = trim(key, Date.now()); - return Math.max(0, config.max - arr.length); + const now = clock(); + sweep(now); + const hits = trim(key, now); + if (hits.length === 0 && buckets.size >= maxBuckets) return 0; + return Math.max(0, Math.min(config.max - hits.length, maxEntries - entries)); }, reset(key: string): void { + entries -= buckets.get(key)?.length ?? 0; buckets.delete(key); }, }; diff --git a/server/src/web-templates/admin-pages.tsx b/server/src/web-templates/admin-pages.tsx index 2224b14f..5c87202d 100644 --- a/server/src/web-templates/admin-pages.tsx +++ b/server/src/web-templates/admin-pages.tsx @@ -4738,44 +4738,16 @@ export function BackupPage(props: BackupPageProps) { : undefined } > -

- Encrypted snapshot of the SQLite DB + master secret + firmware signing - key. Passphrase protects the file (AES-256-GCM, PBKDF2 200k). Lose - the passphrase = lose the backup. Firmware binaries are excluded. +

+ Back up PostgreSQL, server keys, and Node-RED data together during a + maintenance window. Use the stack backup command on the deployment host + and follow the recovery guide supplied with this release. +

+

+ Older .bfbak files do not contain the PostgreSQL database and cannot be + restored through this page. Restoring their keys alone would make the + active database unreadable.

- -
-
-

Download backup

-
-
- - -
Min 8 chars. Store somewhere safe.
-
- -
-
- -
-

Restore from backup

-
-
- - -
-
- - -
-
- Warning: overwrites DB and master keys. - Restart the server immediately after restore. -
- -
-
-
); } diff --git a/server/tests/backup-safety.test.ts b/server/tests/backup-safety.test.ts new file mode 100644 index 00000000..5b96d2d0 --- /dev/null +++ b/server/tests/backup-safety.test.ts @@ -0,0 +1,17 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { H3 } from "h3"; +import { registerAdminRoutes } from "../src/plugins/service-admin-http/routes-admin.js"; + +test("legacy backup routes reject before touching uploads, storage or keys", async () => { + const app = new H3(); + const forbidden = new Proxy({}, { get: () => { throw new Error("backup touched deployment state"); } }); + registerAdminRoutes(app, forbidden as never); + for (const action of ["download", "restore"]) { + const response = await app.fetch(new Request(`http://localhost/admin/backup/${action}`, { + method: "POST", body: "not a valid backup archive", + })); + assert.equal(response.status, 409); + assert.match(await response.text(), /PostgreSQL/); + } +}); diff --git a/server/tests/backup-stack.test.ts b/server/tests/backup-stack.test.ts new file mode 100644 index 00000000..a7b0a9aa --- /dev/null +++ b/server/tests/backup-stack.test.ts @@ -0,0 +1,98 @@ +import assert from "node:assert/strict"; +import { mkdtemp, mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; + +const backupScript = fileURLToPath(new URL("../../deploy/scripts/backup-stack.sh", import.meta.url)); + +async function backupHarness(failure: string, run: (fixture: { + output: string; stagingRoot: string; log: string; invoke(): ReturnType; +}) => Promise): Promise { + const dir = await mkdtemp(join(tmpdir(), "bf-backup-test-")); + const bin = join(dir, "bin"); + const stagingRoot = join(dir, "staging"); + const log = join(dir, "calls.jsonl"); + const output = join(dir, "stack.tar.age"); + try { + await mkdir(bin); await mkdir(stagingRoot); + await writeFile(join(bin, "docker"), `#!${process.execPath} +const fs = require("node:fs"); +const path = require("node:path"); +const args = process.argv.slice(2); +fs.appendFileSync(process.env.BF_BACKUP_TEST_LOG, JSON.stringify(args) + "\\n"); +if (args[0] === "inspect") { + process.stdout.write(args.at(-1) === "server-id" ? "true\\n" : "false\\n"); +} else if (args[0] === "compose" && args[1] === "ps") { + process.stdout.write(args.at(-1) + "-id\\n"); +} else if (args[0] === "compose" && args[1] === "exec") { + if (process.env.BF_BACKUP_TEST_FAILURE === "dump") process.exit(17); + process.stdout.write("test PostgreSQL dump\\n"); +} else if (args[0] === "cp") { + if (process.env.BF_BACKUP_TEST_FAILURE === "copy") process.exit(18); + const dest = args.at(-1); + fs.mkdirSync(dest, { recursive: true }); + const server = args[2].startsWith("server-id:"); + fs.writeFileSync(path.join(dest, server ? "secret.key" : "manager-state.json"), server ? "test-only-key" : "{}"); +} +`, { mode: 0o700 }); + await writeFile(join(bin, "age"), `#!${process.execPath} +const fs = require("node:fs"); +const args = process.argv.slice(2); +fs.writeFileSync(args[args.indexOf("-o") + 1], fs.readFileSync(0)); +if (process.env.BF_BACKUP_TEST_FAILURE === "encrypt") process.exit(19); +`, { mode: 0o700 }); + await run({ output, stagingRoot, log, invoke: () => spawnSync("bash", [backupScript, output], { + cwd: dir, encoding: "utf8", timeout: 10_000, + env: { ...process.env, PATH: `${bin}:${process.env["PATH"]}`, TMPDIR: stagingRoot, + BF_BACKUP_TEST_LOG: log, BF_BACKUP_TEST_FAILURE: failure }, + }) }); + } finally { await rm(dir, { recursive: true, force: true }); } +} + +async function assertRestartedOnlyRunningServer(log: string): Promise { + const calls = (await readFile(log, "utf8")).trim().split("\n").map((line) => JSON.parse(line) as string[]); + assert.deepEqual(calls.filter((args) => args[0] === "compose" && args[1] === "start"), [["compose", "start", "server"]]); +} + +test("stack backup captures database and both volumes, then restarts only previously running services", async () => { + await backupHarness("", async ({ output, stagingRoot, log, invoke }) => { + const result = invoke(); + assert.equal(result.status, 0, String(result.stderr)); + const archive = spawnSync("tar", ["-tf", output], { encoding: "utf8" }); + assert.equal(archive.status, 0, archive.stderr); + assert.deepEqual(archive.stdout.trim().split("\n").sort(), [ + "MANIFEST.txt", "postgres.dump", "server-data/", "server-data/secret.key", + "nodered-data/", "nodered-data/manager-state.json", + ].sort()); + await assertRestartedOnlyRunningServer(log); + assert.deepEqual(await readdir(stagingRoot), []); + await assert.rejects(readFile(`${output}.partial`), { code: "ENOENT" }); + }); +}); + +for (const failure of ["dump", "copy", "encrypt"]) { + test(`stack backup cleans staging and restores service state after ${failure} failure`, async () => { + await backupHarness(failure, async ({ output, stagingRoot, log, invoke }) => { + const result = invoke(); + assert.notEqual(result.status, 0); + await assertRestartedOnlyRunningServer(log); + assert.deepEqual(await readdir(stagingRoot), []); + await assert.rejects(readFile(output), { code: "ENOENT" }); + await assert.rejects(readFile(`${output}.partial`), { code: "ENOENT" }); + }); + }); +} + +test("stack backup preserves an existing output before touching containers", async () => { + await backupHarness("", async ({ output, stagingRoot, log, invoke }) => { + await writeFile(output, "existing archive"); + const result = invoke(); + assert.equal(result.status, 2); + assert.equal(await readFile(output, "utf8"), "existing archive"); + await assert.rejects(readFile(log), { code: "ENOENT" }); + assert.deepEqual(await readdir(stagingRoot), []); + }); +}); diff --git a/server/tests/coordinator-websocket.test.ts b/server/tests/coordinator-websocket.test.ts new file mode 100644 index 00000000..00ad0ef9 --- /dev/null +++ b/server/tests/coordinator-websocket.test.ts @@ -0,0 +1,57 @@ +import assert from "node:assert/strict"; +import { once } from "node:events"; +import { createServer } from "node:http"; +import test, { type TestContext } from "node:test"; +import { WebSocket } from "ws"; +import { createCoordinatorWebSocketServer, COORDINATOR_MAX_PAYLOAD_BYTES } from "../src/shared/coordinator-websocket.js"; + +async function connect(t: TestContext) { + const server = createServer(); + const wss = createCoordinatorWebSocketServer(); + server.on("upgrade", (request, socket, head) => { + wss.handleUpgrade(request, socket, head, (peer) => wss.emit("connection", peer)); + }); + t.after(async () => { + for (const peer of wss.clients) peer.terminate(); + await new Promise((resolve) => wss.close(() => resolve())); + await new Promise((resolve) => server.close(() => resolve())); + }); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const address = server.address(); + assert.ok(address && typeof address !== "string"); + const connected = once(wss, "connection"); + const client = new WebSocket(`ws://127.0.0.1:${address.port}`); + t.after(() => client.terminate()); + const [peer] = await connected as [WebSocket]; + await once(client, "open"); + return { client, peer }; +} + +test("coordinator accepts a full 10 MiB camera snapshot and keeps the socket usable", { timeout: 10_000 }, async (t) => { + const { client, peer } = await connect(t); + const body = Buffer.alloc(10 * 1024 * 1024, 0xa5); + const message = JSON.stringify({ + type: "camera-proxy-response", request_id: "d8228824-cbf6-4057-9cd9-47caa378bcec", + status: 200, content_type: "image/jpeg", body_b64: body.toString("base64"), + }); + const received = once(peer, "message"); + client.send(message); + const [payload] = await received; + const response = JSON.parse(payload.toString()); + assert.equal(response.type, "camera-proxy-response"); + assert.deepEqual(Buffer.from(response.body_b64, "base64"), body); + const pong = once(peer, "message"); + client.send('{"type":"pong"}'); + assert.equal((await pong)[0].toString(), '{"type":"pong"}'); + assert.equal(client.readyState, WebSocket.OPEN); +}); + +test("coordinator still closes messages above the finite transport limit", { timeout: 10_000 }, async (t) => { + const { client, peer } = await connect(t); + const error = once(peer, "error"); + const closed = once(client, "close"); + client.send(Buffer.alloc(COORDINATOR_MAX_PAYLOAD_BYTES + 1)); + assert.equal(((await error)[0] as NodeJS.ErrnoException).code, "WS_ERR_UNSUPPORTED_MESSAGE_LENGTH"); + assert.equal((await closed)[0], 1009); +}); diff --git a/server/tests/kiosk-connections.test.ts b/server/tests/kiosk-connections.test.ts new file mode 100644 index 00000000..800f665a --- /dev/null +++ b/server/tests/kiosk-connections.test.ts @@ -0,0 +1,26 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { KioskConnections } from "../src/shared/kiosk-connections.js"; + +test("delayed close and pong from a previous socket cannot affect its replacement", () => { + const sockets = new KioskConnections(); + const old = { terminate() {} }, current = { terminate() {} }; + sockets.set("k1", { id: "k1", name: "test", ws: old, lastPong: 1 }); + sockets.set("k1", { id: "k1", name: "test", ws: current, lastPong: 2 }); + assert.equal(sockets.removeSocket("k1", old), false); + sockets.pong("k1", old, 100); + assert.equal(sockets.get("k1")?.lastPong, 2); + assert.equal(sockets.get("k1")?.ws, current); + assert.equal(sockets.removeSocket("k1", current), true); +}); + +test("a missed heartbeat terminates stale connections while active ones survive", () => { + const terminated: string[] = []; + const sockets = new KioskConnections(); + sockets.set("old", { id: "old", name: "old", ws: { terminate: () => terminated.push("old") }, lastPong: 0 }); + const live = { terminate: () => terminated.push("live") }; + sockets.set("live", { id: "live", name: "live", ws: live, lastPong: 0 }); + sockets.pong("live", live, 90_000); + sockets.terminateStale(100_000); + assert.deepEqual(terminated, ["old"]); +}); diff --git a/server/tests/nodered-events.test.ts b/server/tests/nodered-events.test.ts new file mode 100644 index 00000000..1d8bc2c4 --- /dev/null +++ b/server/tests/nodered-events.test.ts @@ -0,0 +1,33 @@ +import assert from "node:assert/strict"; +import { createRequire } from "node:module"; +import test from "node:test"; + +const require = createRequire(import.meta.url); +const { subscribeEvent } = require("../../nodered/src/_event-dispatch.js"); + +test("internal events require a runtime credential and fan out to every subscriber", async () => { + const prior = process.env["BF_NODERED_INTERNAL_TOKEN"]; + process.env["BF_NODERED_INTERNAL_TOKEN"] = "test-only-runtime-token-000000000000000"; + try { + let dispatch: any; + let registrations = 0; + const RED = { httpNode: { post: (_path: string, fn: unknown) => { dispatch = fn; registrations++; } } }; + const seen: string[] = []; + const off = subscribeEvent(RED, "/api/internal/event", async (_req: unknown, res: any) => { seen.push("first"); res.status(200).end(); }); + subscribeEvent(RED, "/api/internal/event", async () => { seen.push("second"); }); + const response = { code: 0, status(n: number) { this.code = n; return this; }, end() {} }; + await dispatch({ headers: {}, body: { tenant_slug: "default" } }, response); + assert.equal(response.code, 403); + assert.deepEqual(seen, []); + const request = { headers: { "x-betterframe-runtime-token": process.env["BF_NODERED_INTERNAL_TOKEN"] }, body: {} }; + await dispatch(request, response); + assert.equal(registrations, 1); + assert.deepEqual(seen, ["first", "second"]); + off(); seen.length = 0; + await dispatch(request, response); + assert.deepEqual(seen, ["second"]); + } finally { + if (prior === undefined) delete process.env["BF_NODERED_INTERNAL_TOKEN"]; + else process.env["BF_NODERED_INTERNAL_TOKEN"] = prior; + } +}); diff --git a/server/tests/pairing-postgres.test.ts b/server/tests/pairing-postgres.test.ts new file mode 100644 index 00000000..0f712393 --- /dev/null +++ b/server/tests/pairing-postgres.test.ts @@ -0,0 +1,142 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { randomBytes } from "node:crypto"; +import { PgAdapter } from "../src/shared/db/pg-adapter.js"; +import { initDb, createTenantSchema } from "../src/shared/db/init.js"; +import { Repository } from "../src/shared/db/repository.js"; +import { initiatePairing, confirmPairing, claimPairing, acknowledgePairing } from "../src/shared/pairing.js"; +import { claimIoBox, acknowledgeIoBox } from "../src/shared/iobox-pairing.js"; +import { tenantSchemaName, legacyTenantSchemaName, storedPostgresIdentifier } from "../src/shared/db/tenant-schema.js"; + +const url = process.env["BF_TEST_PG_URL"]; +const log = { info() {}, warn() {} }; +const secrets = { encryptString: (value: string) => `encrypted:${value}`, decryptString: (value: string) => { + if (!value.startsWith("encrypted:")) throw Error("wrong key"); + return value.slice(10); +} }; +const auth = { hashPassword: async (password: string) => `hash:${password}` }; + +test("PostgreSQL migrations, tenant transactions and concurrent credential delivery", { skip: !url }, async () => { + // A fresh throwaway database prevents touching the supplied database's tables. + const admin = new PgAdapter(url!); + const dbName = `bf_test_${randomBytes(8).toString("hex")}`; + await admin.exec(`CREATE DATABASE "${dbName}"`); + const testUrl = new URL(url!); testUrl.pathname = `/${dbName}`; + const config = { url: testUrl.toString(), host: "", port: 5432, user: "", password: "", database: dbName, poolMax: 6 }; + const handles: Array<{ close(): Promise }> = []; + try { + const [first, second] = await Promise.all([initDb(config, log), initDb(config, log)]); + handles.push(first, second); + const { repo } = first; + const notifications: string[] = []; + const notifyingRepo = new Repository(repo.adapter, async (table) => { notifications.push(table); }); + const tenant = await repo.transact(async () => { + const t = await repo.createTenant({ name: "Branch one", slug: "branch-one" }); + await createTenantSchema(repo.adapter, t.slug, log); + return t; + }); + assert.equal(tenant.schema_name, tenantSchemaName("branch-one")); + assert.notEqual(tenantSchemaName("branch-one"), tenantSchemaName("branch_one")); + const publicCount = await repo.adapter.get<{ count: string }>("SELECT count(*) FROM public.kiosks"); + await assert.rejects(repo.transact(async () => { + await repo.createTenant({ name: "Rollback", slug: "rollback-tenant" }); + await createTenantSchema(repo.adapter, "rollback-tenant", log); + throw Error("injected provisioning failure"); + })); + assert.equal(await repo.getTenantBySlug("rollback-tenant"), null); + assert.equal(await repo.adapter.get("SELECT 1 FROM pg_namespace WHERE nspname = ?", [tenantSchemaName("rollback-tenant")]), undefined); + const start = await initiatePairing(repo, { proposedName: "Lobby", hardwareModel: null, capabilities: [], codeTtlSeconds: 600, secureClaim: true }); + const input = { code: start.code, tenant: { id: tenant.id, slug: tenant.slug, schemaName: tenant.schema_name } }; + const confirm = (r: Repository) => r.adapter.withSearchPath(tenant.schema_name, () => confirmPairing(r, auth as never, secrets as never, input)); + const results = await Promise.all([confirm(repo), confirm(second.repo)]); + assert.deepEqual(results[0], results[1]); + assert.equal((await repo.adapter.get<{ count: string }>(`SELECT count(*) FROM "${tenant.schema_name}".kiosks`))?.count, "1"); + assert.equal((await repo.adapter.get<{ count: string }>("SELECT count(*) FROM public.kiosks"))?.count, publicCount?.count); + assert.equal((await claimPairing(repo, start.code, secrets as never)).status, "failed"); + const delivered = await claimPairing(repo, start.code, secrets as never, undefined, start.pollingSecret); + const retry = await claimPairing(second.repo, start.code, secrets as never, undefined, start.pollingSecret); + assert.equal(delivered.kioskKey, retry.kioskKey); + await acknowledgePairing(repo, start.code, results[0]!.kioskId, tenant.schema_name, start.pollingSecret); + await acknowledgePairing(repo, start.code, results[0]!.kioskId, tenant.schema_name, start.pollingSecret); + assert.equal((await claimPairing(repo, start.code, secrets as never, undefined, start.pollingSecret)).status, "acknowledged"); + assert.equal((await repo.getPairingCode(start.code))?.extras["pairing_claim_encrypted"], undefined); + + // Every write before final claim consumption rolls back, including rekeys. + for (const failure of ["display", "envelope"]) { + notifications.length = 0; + const next = await initiatePairing(repo, { proposedName: `Fail-${failure}`, hardwareModel: null, capabilities: [], codeTtlSeconds: 600 }); + const originalDisplay = notifyingRepo.createDisplayForKiosk.bind(notifyingRepo); + if (failure === "display") notifyingRepo.createDisplayForKiosk = async () => { throw Error("injected display failure"); }; + const failureSecrets = failure === "envelope" ? { ...secrets, encryptString: (value: string, context: string) => { if (context === "pairing-claim") throw Error("injected envelope failure"); return secrets.encryptString(value); } } : secrets; + await assert.rejects(repo.adapter.withSearchPath(tenant.schema_name, () => confirmPairing(notifyingRepo, auth as never, failureSecrets as never, { ...input, code: next.code }))); + notifyingRepo.createDisplayForKiosk = originalDisplay; + assert.equal((await repo.getPairingCode(next.code))?.consumed_at, null); + assert.equal((await repo.adapter.get<{ count: string }>(`SELECT count(*) FROM "${tenant.schema_name}".kiosks`))?.count, "1"); + assert.deepEqual(notifications, []); + } + const existing = await repo.adapter.withSearchPath(tenant.schema_name, () => repo.getKioskById(results[0]!.kioskId)); + const replacement = await initiatePairing(repo, { proposedName: "Replacement", hardwareModel: null, capabilities: [], codeTtlSeconds: 600 }); + const badSecrets = { ...secrets, encryptString: (value: string, context: string) => { + if (context === "pairing-claim") throw Error("injected replacement failure"); + return secrets.encryptString(value); + } }; + await assert.rejects(repo.adapter.withSearchPath(tenant.schema_name, () => confirmPairing(repo, auth as never, badSecrets as never, { + ...input, code: replacement.code, replaceKioskId: results[0]!.kioskId, + }))); + const unchanged = await repo.adapter.withSearchPath(tenant.schema_name, () => repo.getKioskById(results[0]!.kioskId)); + assert.equal(unchanged?.key_hash, existing?.key_hash); + assert.equal(unchanged?.encrypt_key_encrypted, existing?.encrypt_key_encrypted); + assert.equal((await repo.getPairingCode(replacement.code))?.consumed_at, null); + + const serial = "test-serial"; + await repo.registerIoBoxSerial({ serial, model_id: "ioBOX-WIFI" }); + const secret = randomBytes(32).toString("hex"); + const claimInput = { serial, provisioning_secret: secret }; + const ioTenant = { id: tenant.id, slug: tenant.slug, schema_name: tenant.schema_name }; + const ioResults = await Promise.all([claimIoBox(repo, auth as never, secrets as never, claimInput, ioTenant), claimIoBox(second.repo, auth as never, secrets as never, claimInput, ioTenant)]); + assert.deepEqual(ioResults[0], ioResults[1]); + await assert.rejects(claimIoBox(repo, auth as never, secrets as never, { serial }, ioTenant)); + await assert.rejects(claimIoBox(repo, auth as never, secrets as never, { serial, provisioning_secret: "wrong" }, ioTenant)); + await acknowledgeIoBox(repo, serial, String(ioResults[0]!["iobox_id"]), tenant.id, secret); + await acknowledgeIoBox(repo, serial, String(ioResults[0]!["iobox_id"]), tenant.id, secret); + await assert.rejects(claimIoBox(repo, auth as never, secrets as never, claimInput, ioTenant)); + // The old route committed a hyphenated registration before schema DDL failed. + await repo.adapter.run("INSERT INTO public.tenants (name, slug, schema_name) VALUES (?, ?, ?)", ["Legacy broken", "legacy-broken", "tenant_legacy-broken"]); + // Old CREATE SCHEMA and SET search_path silently truncated long names, + // while tenant registration and migration records kept the full string. + const legacySlug = "long" + "a".repeat(140); + const legacySchema = `tenant_${legacySlug}`; + const storedSchema = storedPostgresIdentifier(legacySchema); + await repo.adapter.run("INSERT INTO public.tenants (name, slug, schema_name) VALUES (?, ?, ?)", ["Long legacy", legacySlug, legacySchema]); + await createTenantSchema(repo.adapter, legacySlug, log, storedSchema); + await repo.adapter.exec(`CREATE TABLE "${storedSchema}".preserved_marker (value TEXT NOT NULL)`); + await repo.adapter.run(`INSERT INTO "${storedSchema}".preserved_marker (value) VALUES ('existing tenant data')`); + await repo.adapter.run("UPDATE public.schema_migrations SET schema_name = ? WHERE schema_name = ?", [legacySchema, storedSchema]); + const third = await initDb(config, log); handles.push(third); + assert.ok(await third.repo.getTenantBySlug("branch-one")); + assert.equal((await third.repo.getTenantBySlug("legacy-broken"))?.schema_name, tenantSchemaName("legacy-broken")); + const repairedSchema = legacyTenantSchemaName(legacySlug); + assert.equal((await third.repo.getTenantBySlug(legacySlug))?.schema_name, repairedSchema); + assert.deepEqual(await repo.adapter.get(`SELECT value FROM "${repairedSchema}".preserved_marker`), { value: "existing tenant data" }); + assert.equal(await repo.adapter.get("SELECT 1 FROM pg_namespace WHERE nspname::text = ?", [storedSchema]), undefined); + assert.equal(await repo.adapter.get("SELECT 1 FROM public.schema_migrations WHERE schema_name = ?", [legacySchema]), undefined); + + const collisionPrefix = "collision" + "z".repeat(60); + const collisionOne = `tenant_${collisionPrefix}one`; + const collisionTwo = `tenant_${collisionPrefix}two`; + const collisionStored = storedPostgresIdentifier(collisionOne); + await repo.adapter.exec(`CREATE SCHEMA "${collisionStored}"`); + await repo.adapter.exec(`CREATE TABLE "${collisionStored}".preserved_marker (value TEXT NOT NULL)`); + await repo.adapter.run(`INSERT INTO "${collisionStored}".preserved_marker (value) VALUES ('ambiguous data retained')`); + for (const schema of [collisionOne, collisionTwo]) { + await repo.adapter.run("INSERT INTO public.tenants (name, slug, schema_name) VALUES (?, ?, ?)", [schema, schema.slice(7), schema]); + } + await assert.rejects(initDb(config, log), /ambiguous legacy tenant schema/); + assert.deepEqual(await repo.adapter.get(`SELECT value FROM "${collisionStored}".preserved_marker`), { value: "ambiguous data retained" }); + assert.equal((await repo.getTenantBySlug(collisionOne.slice(7)))?.schema_name, collisionOne); + } finally { + await Promise.all(handles.map((handle) => handle.close())); + await admin.exec(`DROP DATABASE "${dbName}" WITH (FORCE)`); + await admin.close(); + } +}); diff --git a/server/tests/pairing.test.ts b/server/tests/pairing.test.ts index 96e71817..a9720eb6 100644 --- a/server/tests/pairing.test.ts +++ b/server/tests/pairing.test.ts @@ -34,3 +34,25 @@ test("a claimed pairing code can be retried until it expires", async () => { assert.deepEqual(retry, first); assert.equal(retry.kioskKey, "bf-test"); }); + +test("claim reports expiry, corrupt envelopes and missing sessions explicitly", async () => { + const pc = { expires_at: new Date(Date.now() - 1000).toISOString(), consumed_at: null, extras: {} }; + const repo = { getPairingCode: async () => pc }; + assert.equal((await claimPairing(repo as never, "ABCDEFGH", {} as never)).status, "expired"); + pc.expires_at = new Date(Date.now() + 60_000).toISOString(); + Object.assign(pc, { consumed_at: new Date().toISOString(), extras: { pairing_claim_encrypted: "corrupt" } }); + assert.equal((await claimPairing(repo as never, "ABCDEFGH", { decryptString: () => { throw Error("corrupt"); } } as never)).status, "failed"); + assert.equal((await claimPairing({ getPairingCode: async () => null } as never, "ABCDEFGH", {} as never)).status, "expired"); +}); + +test("confirmed claim uses delivery grace instead of the original code deadline", async () => { + const repo = { + getPairingCode: async () => ({ + expires_at: new Date(Date.now() - 1000).toISOString(), consumed_at: new Date().toISOString(), consumed_by_kiosk_id: "kiosk-1", + extras: { claim_expires_at: new Date(Date.now() + 60_000).toISOString(), pairing_claim_encrypted: "envelope" }, + }), + adapter: { withSearchPath: async (_s: string, fn: () => unknown) => fn() }, + getKioskById: async () => ({ name: "Lobby" }), + }; + assert.equal((await claimPairing(repo as never, "ABCDEFGH", { decryptString: () => '{"kioskKey":"key"}' } as never)).status, "claimed"); +}); diff --git a/server/tests/rate-limit.test.ts b/server/tests/rate-limit.test.ts new file mode 100644 index 00000000..e42aff48 --- /dev/null +++ b/server/tests/rate-limit.test.ts @@ -0,0 +1,99 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createRateLimiter } from "../src/shared/rate-limit.js"; + +test("rotating untrusted keys cannot grow storage or evict an active quota", () => { + let now = 0; + const limiter = createRateLimiter({ windowMs: 1_000, max: 2, maxBuckets: 2 }, () => now); + assert.equal(limiter.take("serial:registered"), true); + assert.equal(limiter.take("serial:registered"), true); + assert.equal(limiter.take("session:known"), true); + for (let i = 0; i < 20_000; i++) assert.equal(limiter.take(`untrusted:${i}`), false); + assert.equal(limiter.remaining("untrusted:new"), 0); + assert.equal(limiter.take("serial:registered"), false); + // Other live keys keep their remaining quota when key storage is full. + assert.equal(limiter.take("session:known"), true); + now = 1_000; + // Expired buckets disappear without ever accessing their keys again. + assert.equal(limiter.take("new:first"), true); + assert.equal(limiter.take("new:second"), true); + assert.equal(limiter.take("new:third"), false); +}); + +test("aggregate hit storage is bounded and reset releases its capacity", () => { + const limiter = createRateLimiter({ windowMs: 1_000, max: 10, maxBuckets: 10, maxEntries: 3 }, () => 0); + assert.equal(limiter.take("a"), true); + assert.equal(limiter.take("a"), true); + assert.equal(limiter.take("b"), true); + assert.equal(limiter.take("b"), false); + assert.equal(limiter.take("c"), false); + assert.equal(limiter.remaining("a"), 0); + limiter.reset("a"); + limiter.reset("a"); + assert.equal(limiter.remaining("b"), 2); + assert.equal(limiter.take("c"), true); + assert.equal(limiter.take("c"), true); + assert.equal(limiter.take("c"), false); +}); + +test("global sweeps reclaim expired hits within a still-active sliding window", () => { + let now = 0; + const limiter = createRateLimiter({ windowMs: 2_000, max: 3, maxEntries: 3 }, () => now); + assert.equal(limiter.take("old"), true); + now = 1_000; + assert.equal(limiter.take("old"), true); + assert.equal(limiter.take("other"), true); + now = 2_000; + assert.equal(limiter.take("new"), true); + assert.equal(limiter.take("new"), false); + assert.equal(limiter.remaining("old"), 0); + now = 3_000; + assert.equal(limiter.remaining("old"), 2); + assert.equal(limiter.take("new"), true); + assert.equal(limiter.take("new"), true); + assert.equal(limiter.take("new"), false); +}); + +test("default limits also bound serial and polling-secret churn", () => { + const limiter = createRateLimiter({ windowMs: 60_000, max: 60 }, () => 0); + for (let i = 0; i < 10_000; i++) assert.equal(limiter.take(`session:${i}`), true); + for (let i = 10_000; i < 20_000; i++) assert.equal(limiter.take(`serial:${i}`), false); + assert.equal(limiter.remaining("session:0"), 59); +}); + +test("target expiry between global sweeps releases capacity without double-counting later", () => { + let now = 0; + const limiter = createRateLimiter({ windowMs: 2_000, max: 3, maxBuckets: 2, maxEntries: 3 }, () => now); + assert.equal(limiter.take("a"), true); + assert.equal(limiter.take("a"), true); + now = 1_500; + assert.equal(limiter.take("b"), true); // Next global sweep is at 2,500. + now = 2_000; + assert.equal(limiter.remaining("a"), 2); // Exact boundary expires both old hits. + assert.equal(limiter.take("c"), true); // Both timestamp and bucket capacity were released. + assert.equal(limiter.take("c"), true); + assert.equal(limiter.take("c"), false); + now = 2_500; + assert.equal(limiter.remaining("b"), 0); // Global sweep must not subtract old hits twice. + limiter.reset("c"); + assert.equal(limiter.remaining("b"), 2); + assert.equal(limiter.take("b"), true); + assert.equal(limiter.take("b"), true); + assert.equal(limiter.take("b"), false); +}); + +test("querying unknown keys does not allocate buckets", () => { + const limiter = createRateLimiter({ windowMs: 1_000, max: 1, maxBuckets: 1 }, () => 0); + for (let i = 0; i < 1_000; i++) assert.equal(limiter.remaining(`unknown:${i}`), 1); + assert.equal(limiter.take("known"), true); + assert.equal(limiter.remaining("unknown:new"), 0); + assert.equal(limiter.take("known"), false); +}); + +test("invalid limits fail before admitting traffic", () => { + for (const invalid of [0, -1, 0.5, NaN, Infinity, Number.MAX_SAFE_INTEGER + 1]) { + for (const field of ["windowMs", "max", "maxBuckets", "maxEntries"]) { + assert.throws(() => createRateLimiter({ windowMs: 1_000, max: 2, [field]: invalid }), /invalid rate limit configuration/); + } + } +}); diff --git a/server/tests/run-tests.ts b/server/tests/run-tests.ts new file mode 100644 index 00000000..cb3b7480 --- /dev/null +++ b/server/tests/run-tests.ts @@ -0,0 +1,5 @@ +// Import tests explicitly so every node:test case is reported in this process. +import { readdir } from "node:fs/promises"; +for (const file of (await readdir(new URL(".", import.meta.url))).filter((file) => file.endsWith(".test.ts")).sort()) { + await import(new URL(file, import.meta.url).href); +} diff --git a/server/tests/tenant-auth.test.ts b/server/tests/tenant-auth.test.ts index 9c0003e6..6d2d263d 100644 --- a/server/tests/tenant-auth.test.ts +++ b/server/tests/tenant-auth.test.ts @@ -145,6 +145,8 @@ test("tenant schema bootstrap mirrors platform admins", async () => { const writes: string[] = []; const adapter = { exec: async () => {}, + transaction: async (fn: () => unknown) => fn(), + withSearchPath: async (_schema: string, fn: () => unknown) => fn(), setSearchPath: async () => {}, get: async () => ({ version: Number.MAX_SAFE_INTEGER }), run: async (sql: string) => { @@ -237,7 +239,7 @@ test("audit inserts provide the required entry id", async () => { resource_id: null, ip: null, metadata: {}, - result: "success", + result: "ok", }); assert.match(insert!.sql, /\(id, actor_type/);