From 87981a62fbea9d1dc76f89725ea55c65062aa077 Mon Sep 17 00:00:00 2001 From: Twarner491 Date: Thu, 28 May 2026 15:15:22 -0700 Subject: [PATCH] [PROD] --- .gitignore | 1 + README.md | 2 + SECURITY.md | 64 +++++++ avian/api/birdnet-status.php | 332 +++++++++++++++++++++++++++++++++++ avian/api/config.php | 194 ++++++++++++++++++++ avian/api/menu.php | 17 +- avian/api/recording.php | 5 +- avian/api/spectrogram.php | 5 +- avian/assets/favicon.png | Bin 0 -> 32820 bytes avian/frontend/apt.js | 79 ++++----- avian/frontend/index.html | 14 +- scripts/install_services.sh | 39 +++- 12 files changed, 697 insertions(+), 55 deletions(-) create mode 100644 SECURITY.md create mode 100644 avian/api/birdnet-status.php create mode 100644 avian/api/config.php create mode 100644 avian/assets/favicon.png diff --git a/.gitignore b/.gitignore index 339d6a8..39e9b4a 100644 --- a/.gitignore +++ b/.gitignore @@ -25,6 +25,7 @@ .idea/**/dbnavigator.xml *.pyc +__pycache__/ *.flac .vscode model/labels.txt diff --git a/README.md b/README.md index 85afc44..0fe55f8 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,8 @@ See [`avian/forwarding/`](avian/forwarding/) for three independent recipes: - **Home Assistant REST sensor** that exposes the latest detection. - **MQTT bridge** that publishes every new detection. +If anyone other than you can reach the Pi at the network layer — a shared apartment LAN, dorm wifi, an open hotspot, a forwarded port — read [`SECURITY.md`](SECURITY.md) first. The default install assumes a trusted home network; the admin overlay's settings/system/logs panels need a Caddy basic_auth gate before they're safe outside that. + --- ## Repo layout diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..6fce72c --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,64 @@ +# Security + +AvianVisitors is designed for a Raspberry Pi listening on a trusted home network. If anyone other than you can reach the Pi at the network layer — a shared apartment LAN, dorm wifi, an open hotspot, a forwarded port — read this first. + +## What the default install leaves open + +The public-facing endpoints (the collage HTML, the read-only birdnet-api.php aggregations, cutout/spectrogram/recording PHPs, the wiki proxy) are intentionally unauthenticated. They serve display data and a few thousand bytes of JSON each. Treat them as public. + +The **admin endpoints** are not safe to expose to untrusted networks: + +- `/avian/api/config.php` — reads and writes a whitelisted slice of `birdnet.conf` and restarts the analyzer when settings change. +- `/avian/api/birdnet-status.php` — returns system metrics (CPU, mem, disk, uptime), service state, journalctl output, and accepts `?action=restart` to bounce a whitelisted unit. + +By default these are reachable on the same `http://birdnet.local/` listener as everything else. On a shared LAN, anyone on the same network can hit them. The frontend's "lock screen" is cosmetic without a Caddy basic_auth gate in front. + +## What can go wrong + +The upstream BirdNET-Pi installer drops a `caddy ALL=(ALL) NOPASSWD: ALL` sudoers rule (see `scripts/install_services.sh`). PHP-FPM runs as the `caddy` user. That means anything reachable from a PHP shim has full root via `sudo`. + +The shims here use a tight allowlist for `systemctl restart` and `journalctl -u`, and they validate every input against a whitelist + `escapeshellarg`. The string fields written to `birdnet.conf` are escaped against `$`, backtick, backslash, and double-quote, and rejected outright if they contain anything outside `[A-Za-z0-9 _.,'-]`. So even on a flat-trust LAN, the public-by-default admin endpoints don't *currently* offer an obvious path to RCE. + +But the surface is wide. New shims, future config keys, or a regression in `quote_val` would be enough. The safe default is: **lock the admin endpoints down on every install that isn't a fully trusted home network**. + +## Locking it down + +Add a Caddy basic_auth block in front of the admin shims. Example, dropped into the bottom of `/etc/caddy/Caddyfile` (or a snippet in `conf.d/`): + +``` +basicauth /avian/api/config.php* /avian/api/birdnet-status.php* { + birdnet $2a$14$... # caddy hash-password --plaintext '' +} +``` + +Then in `/etc/avian/env` (or your php-fpm pool env block): + +``` +AV_REQUIRE_AUTH=1 +``` + +The env flag makes the PHP shims refuse to respond unless an `Authorization` header reached them — which it will, when basic_auth in Caddy passes the request through, and won't, when basic_auth rejects it. + +Reload Caddy and php-fpm: + +``` +sudo systemctl reload caddy +sudo systemctl reload "$(systemctl list-unit-files 'php*-fpm.service' --no-legend | awk '{print $1; exit}')" +``` + +After that: + +- The collage and read-only APIs still serve to anyone. +- The admin overlay's settings/system/logs/tools panels prompt for the password the first time the drawer opens, then the browser caches it. +- `curl http://birdnet.local/avian/api/birdnet-status.php?action=diag` returns 401 without credentials. + +If you also need the Pi reachable from outside the home network (Cloudflare Tunnel, reverse proxy, port-forward), the basic_auth gate above is the bare minimum — consider also limiting `/avian/api/config.php` and `/avian/api/birdnet-status.php` to specific source IPs at the Caddy layer, or moving them onto a separate listener that's only bound to a Tailscale interface. + +## What I'd love a contribution on + +- Auto-generating a unique basic_auth password during `install_services.sh`, writing it to a file the user reads once after install, and emitting the matching Caddy block — so the secure default is the path of least resistance. +- A smaller-radius sudoers replacement that drops the blanket `caddy ALL=(ALL) NOPASSWD: ALL` rule and grants only the specific commands AvianVisitors actually needs. + +## Reporting + +If you find a security issue, open a GitHub issue with the `security` label or email `teddy@theodore.net`. There's no bug bounty — this is a side project — but I'll prioritize a fix and credit you in the release notes. diff --git a/avian/api/birdnet-status.php b/avian/api/birdnet-status.php new file mode 100644 index 0000000..7d36c41 --- /dev/null +++ b/avian/api/birdnet-status.php @@ -0,0 +1,332 @@ +&lines=N: last N lines of that unit's journal +// restart - GET/POST &unit=: restart a single service (whitelisted) +// diag - everything in one go (system + services + recent logs) +// +// Default LAN deploy: returns data immediately, no auth. +// Forwarded deploy: set AV_REQUIRE_AUTH=1 (env) AND configure Caddy +// basic_auth on /avian/api/ to gate everything. +// +// Service restart + journalctl need passwordless sudo for the caddy +// user that runs php-fpm. install_services.sh drops the matching +// sudoers rule at /etc/sudoers.d/020_avian-admin with an explicit +// command allowlist. + +declare(strict_types=1); +header('Content-Type: application/json; charset=utf-8'); +header('Cache-Control: no-store'); + +if (getenv('AV_REQUIRE_AUTH') === '1' && empty($_SERVER['HTTP_AUTHORIZATION'])) { + http_response_code(401); + echo json_encode(['error' => 'unauthorized']); + exit; +} + +$action = $_GET['action'] ?? 'diag'; + +// Path layout: /home/{USER}/BirdNET-Pi/avian/api/birdnet-status.php +// __DIR__ -> .../BirdNET-Pi/avian/api +// dirname(__DIR__, 2) -> .../BirdNET-Pi +// dirname(__DIR__, 3) -> /home/{USER} +$BIRDNETPI_DIR = dirname(__DIR__, 2); +$BIRDSONGS_DIR = dirname(__DIR__, 3) . '/BirdSongs'; +$DB_PATH = "$BIRDNETPI_DIR/scripts/birds.db"; +$CONF_PATH = "$BIRDNETPI_DIR/birdnet.conf"; +$STREAM_DIR = "$BIRDSONGS_DIR/StreamData"; + +function shellout(string $cmd): string { + // Always merge stderr so a broken command shows what failed. + $rc = 0; $out = []; + exec($cmd . ' 2>&1', $out, $rc); + return implode("\n", $out); +} + +function read_uptime(): array { + $up = @file_get_contents('/proc/uptime'); + $sec = $up ? (float)explode(' ', trim($up))[0] : 0; + return [ + 'seconds' => $sec, + 'pretty' => human_duration((int)$sec), + 'load' => sys_getloadavg(), + 'now' => date('c'), + ]; +} + +function human_duration(int $s): string { + $d = intdiv($s, 86400); $s -= $d * 86400; + $h = intdiv($s, 3600); $s -= $h * 3600; + $m = intdiv($s, 60); + $parts = []; + if ($d) $parts[] = $d . 'd'; + if ($h) $parts[] = $h . 'h'; + if ($m && !$d) $parts[] = $m . 'm'; + return $parts ? implode(' ', $parts) : '<1m'; +} + +function read_mem(): array { + $info = @file_get_contents('/proc/meminfo') ?: ''; + preg_match('/MemTotal:\s+(\d+)/', $info, $t); + preg_match('/MemAvailable:\s+(\d+)/', $info, $a); + $tot = isset($t[1]) ? (int)$t[1] * 1024 : 0; + $avail = isset($a[1]) ? (int)$a[1] * 1024 : 0; + $used = $tot - $avail; + return [ + 'total_bytes' => $tot, + 'used_bytes' => $used, + 'used_pct' => $tot ? round($used / $tot * 100, 1) : 0, + ]; +} + +function read_disk(string $path): array { + if (!is_dir($path)) return ['path' => $path, 'error' => 'not found']; + $tot = @disk_total_space($path); + $free = @disk_free_space($path); + if (!$tot) return ['path' => $path, 'error' => 'stat failed']; + return [ + 'path' => $path, + 'total_bytes' => (int)$tot, + 'free_bytes' => (int)$free, + 'used_pct' => round(($tot - $free) / $tot * 100, 1), + ]; +} + +function read_temp(): ?float { + $f = '/sys/class/thermal/thermal_zone0/temp'; + if (!is_readable($f)) return null; + $raw = trim((string)@file_get_contents($f)); + return $raw === '' ? null : round((int)$raw / 1000, 1); +} + +function read_audio(): array { + // Read /proc/asound/cards directly - works even when the capture + // device is busy (arecord -l would fail with "no soundcards" if + // birdnet_recording holds the mic). The file is two lines per card. + $raw = @file_get_contents('/proc/asound/cards') ?: ''; + $lines = array_values(array_filter(array_map('rtrim', explode("\n", $raw)), 'strlen')); + $cards = []; + for ($i = 0; $i < count($lines); $i += 2) { + $head = trim($lines[$i]); + $detail = isset($lines[$i + 1]) ? trim($lines[$i + 1]) : ''; + $cards[] = $detail !== '' ? "$head — $detail" : $head; + } + $usb = shellout('lsusb'); + return [ + 'arecord_l' => $cards, + 'usb' => array_values(array_filter(explode("\n", $usb), function ($l) { + return $l !== '' && ( + stripos($l, 'audio') !== false || + stripos($l, 'microphone') !== false || + stripos($l, 'mic') !== false + ); + })), + ]; +} + +function read_streamdata(string $dir): array { + if (!is_dir($dir)) return ['exists' => false]; + $files = @scandir($dir, SCANDIR_SORT_DESCENDING) ?: []; + $wav = array_values(array_filter($files, function ($f) { + return $f !== '.' && $f !== '..' && preg_match('/\.(wav|mp3|raw)$/i', $f); + })); + $newest_age = null; + if (count($wav) > 0) { + $newest_age = time() - (int)@filemtime("$dir/" . $wav[0]); + } + return [ + 'exists' => true, + 'file_count' => count($wav), + 'newest_age_s' => $newest_age, + 'newest_name' => $wav[0] ?? null, + ]; +} + +function read_db_age(string $db): array { + if (!is_file($db)) return ['exists' => false]; + return [ + 'exists' => true, + 'size_bytes' => (int)filesize($db), + 'modified_s' => time() - (int)filemtime($db), + 'mtime' => date('c', (int)filemtime($db)), + ]; +} + +function read_conf_summary(string $p): array { + if (!is_readable($p)) return ['readable' => false]; + $keys = [ + 'CONFIDENCE','SENSITIVITY','OVERLAP','REC_CARD','LATITUDE','LONGITUDE', + 'MODEL','SITE_NAME','RTSP_STREAM', + ]; + $vals = []; + foreach (file($p, FILE_IGNORE_NEW_LINES) as $line) { + if (!$line || $line[0] === '#') continue; + if (preg_match('/^\s*([A-Z_][A-Z0-9_]*)\s*=\s*(.*)$/i', $line, $m)) { + if (in_array($m[1], $keys, true)) { + $v = trim($m[2]); + if (strlen($v) >= 2 && $v[0] === '"' && substr($v, -1) === '"') $v = substr($v, 1, -1); + $vals[$m[1]] = $v; + } + } + } + return ['readable' => true, 'values' => $vals]; +} + +// Whitelisted units we'll surface in the system page + allow restart on. +// Includes both 8.2 and 8.4 php-fpm so older Debian + Trixie both report +// the right unit name; missing units come back as "inactive (not-found)". +const ALLOWED_UNITS = [ + 'birdnet_recording', + 'birdnet_analysis', + 'birdnet_log', + 'birdnet_stats', + 'spectrogram_viewer', + 'livestream', + 'chart_viewer', + 'icecast2', + 'caddy', + 'php8.4-fpm', + 'php8.3-fpm', + 'php8.2-fpm', +]; + +function services_status(): array { + $out = []; + foreach (ALLOWED_UNITS as $u) { + $state = trim(shellout('systemctl is-active ' . escapeshellarg($u))); + // Skip units that systemd doesn't know about at all (e.g. php8.2-fpm + // on a Trixie box that ships php8.4). Keeps the table tidy. + if ($state === 'inactive') { + $exists = trim(shellout('systemctl cat ' . escapeshellarg($u) . ' >/dev/null 2>&1 && echo Y || echo N')); + if ($exists !== 'Y') continue; + } + $enabled = trim(shellout('systemctl is-enabled ' . escapeshellarg($u))); + $since = trim(shellout("systemctl show -p ActiveEnterTimestamp --value " . escapeshellarg($u))); + $out[$u] = [ + 'active' => $state, + 'enabled' => $enabled, + 'since' => $since ?: null, + ]; + } + return $out; +} + +function logs_for(string $unit, int $lines): array { + if (!in_array($unit, ALLOWED_UNITS, true)) { + http_response_code(400); + return ['error' => 'unit not allowed', 'allowed' => ALLOWED_UNITS]; + } + $lines = max(10, min(500, $lines)); + $out = shellout( + 'sudo /bin/journalctl -u ' . escapeshellarg($unit) . + ' --no-pager -n ' . $lines . ' -o short-iso' + ); + return [ + 'unit' => $unit, + 'lines' => $lines, + 'text' => $out, + ]; +} + +switch ($action) { + + case 'system': { + echo json_encode([ + 'uptime' => read_uptime(), + 'mem' => read_mem(), + 'disk_root' => read_disk('/'), + 'disk_birds' => read_disk($BIRDSONGS_DIR), + 'temp_c' => read_temp(), + 'audio' => read_audio(), + 'stream_data' => read_streamdata($STREAM_DIR), + 'birds_db' => read_db_age($DB_PATH), + 'conf' => read_conf_summary($CONF_PATH), + 'hostname' => trim(shellout('hostname')), + 'kernel' => trim(shellout('uname -r')), + 'as_of' => date('c'), + ]); + break; + } + + case 'services': { + echo json_encode(['services' => services_status(), 'as_of' => date('c')]); + break; + } + + case 'logs': { + $unit = (string)($_GET['unit'] ?? 'birdnet_recording'); + $lines = (int)($_GET['lines'] ?? 60); + echo json_encode(logs_for($unit, $lines)); + break; + } + + case 'restart': { + // POST-only: blocks a stray + // tag on any LAN-reachable page from disrupting the recording + // pipeline. The frontend already POSTs; only thing this rejects + // is a passive cross-page GET. + if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') { + http_response_code(405); + echo json_encode(['error' => 'POST required']); + break; + } + $unit = (string)($_GET['unit'] ?? ''); + if (!in_array($unit, ALLOWED_UNITS, true)) { + http_response_code(400); + echo json_encode(['error' => 'unit not allowed', 'allowed' => ALLOWED_UNITS]); + break; + } + // Sudoers rule (dropped in by install_services.sh): + // caddy ALL=(root) NOPASSWD: /bin/systemctl restart birdnet_*, ... + $rc = 0; $out = []; + exec('sudo /bin/systemctl restart ' . escapeshellarg($unit) . ' 2>&1', $out, $rc); + echo json_encode([ + 'unit' => $unit, + 'ok' => $rc === 0, + 'rc' => $rc, + 'out' => implode("\n", $out), + ]); + break; + } + + case 'diag': { + // Everything a /system page wants in one fetch. + $svc = services_status(); + $key_units = ['birdnet_recording', 'birdnet_analysis']; + $recent_logs = []; + foreach ($key_units as $u) { + $recent_logs[$u] = trim(shellout( + 'sudo /bin/journalctl -u ' . escapeshellarg($u) . + ' --no-pager -n 20 -o short-iso' + )); + } + echo json_encode([ + 'system' => [ + 'uptime' => read_uptime(), + 'mem' => read_mem(), + 'disk_root' => read_disk('/'), + 'disk_birds' => read_disk($BIRDSONGS_DIR), + 'temp_c' => read_temp(), + 'audio' => read_audio(), + 'stream_data' => read_streamdata($STREAM_DIR), + 'birds_db' => read_db_age($DB_PATH), + 'conf' => read_conf_summary($CONF_PATH), + 'hostname' => trim(shellout('hostname')), + 'kernel' => trim(shellout('uname -r')), + ], + 'services' => $svc, + 'recent_logs' => $recent_logs, + 'as_of' => date('c'), + ]); + break; + } + + default: + http_response_code(404); + echo json_encode(['error' => 'unknown action']); +} diff --git a/avian/api/config.php b/avian/api/config.php new file mode 100644 index 0000000..44153b7 --- /dev/null +++ b/avian/api/config.php @@ -0,0 +1,194 @@ + returns current values as JSON. +// POST -> JSON body with any whitelisted key. Writes through to +// birdnet.conf and restarts birdnet_analysis + birdnet_recording +// so the changes take effect immediately. +// +// Default LAN deploy: returns data immediately, no auth. +// Forwarded deploy: set AV_REQUIRE_AUTH=1 (env) AND configure Caddy +// basic_auth on /avian/api/. +// +// Restart requires passwordless sudo for the caddy user that runs +// php-fpm, dropped in place by install_services.sh at +// /etc/sudoers.d/020_avian-admin. + +declare(strict_types=1); +header('Content-Type: application/json; charset=utf-8'); + +if (getenv('AV_REQUIRE_AUTH') === '1' && empty($_SERVER['HTTP_AUTHORIZATION'])) { + http_response_code(401); + echo json_encode(['error' => 'unauthorized']); + exit; +} + +// Path layout: /home/{USER}/BirdNET-Pi/avian/api/config.php +$BIRDNETPI_DIR = dirname(__DIR__, 2); +$CONF_PATH = "$BIRDNETPI_DIR/birdnet.conf"; + +// Whitelist: { config_key => { type, min?, max?, restart? } } +$ALLOWED = [ + 'CONFIDENCE' => ['type' => 'float', 'min' => 0.05, 'max' => 0.99, 'restart' => true], + 'SENSITIVITY' => ['type' => 'float', 'min' => 0.5, 'max' => 1.5, 'restart' => true], + 'SF_THRESH' => ['type' => 'float', 'min' => 0.0, 'max' => 1.0, 'restart' => true], + 'OVERLAP' => ['type' => 'float', 'min' => 0.0, 'max' => 2.5, 'restart' => true], + 'MAX_FILES_SPECIES' => ['type' => 'int', 'min' => 0, 'max' => 100000], + 'FULL_DISK' => ['type' => 'enum', 'values' => ['purge', 'keep']], + 'PURGE_THRESHOLD' => ['type' => 'int', 'min' => 50, 'max' => 99], + 'LATITUDE' => ['type' => 'float', 'min' => -90, 'max' => 90, 'restart' => true], + 'LONGITUDE' => ['type' => 'float', 'min' => -180, 'max' => 180, 'restart' => true], + 'SITE_NAME' => ['type' => 'string', 'maxlen' => 60], +]; + +function read_conf(string $path): array { + if (!is_readable($path)) return []; + $out = []; + foreach (file($path, FILE_IGNORE_NEW_LINES) as $line) { + if (!$line || $line[0] === '#') continue; + if (preg_match('/^\s*([A-Z_][A-Z0-9_]*)\s*=\s*(.*)$/i', $line, $m)) { + $val = trim($m[2]); + if (strlen($val) >= 2 && $val[0] === '"' && substr($val, -1) === '"') { + $val = substr($val, 1, -1); + } + $out[$m[1]] = $val; + } + } + return $out; +} + +function write_conf(string $path, array $updates): bool { + if (!is_writable($path) && !is_writable(dirname($path))) return false; + $lines = is_readable($path) ? file($path, FILE_IGNORE_NEW_LINES) : []; + $seen = []; + foreach ($lines as $i => $line) { + if (preg_match('/^\s*([A-Z_][A-Z0-9_]*)\s*=/i', $line, $m)) { + $k = $m[1]; + if (array_key_exists($k, $updates)) { + $lines[$i] = $k . '=' . quote_val($updates[$k]); + $seen[$k] = true; + } + } + } + foreach ($updates as $k => $v) { + if (empty($seen[$k])) $lines[] = $k . '=' . quote_val($v); + } + $tmp = $path . '.tmp.' . getmypid(); + if (file_put_contents($tmp, implode("\n", $lines) . "\n") === false) return false; + return rename($tmp, $path); +} + +function quote_val($v): string { + $s = (string)$v; + // Bare value if it's all "shell-safe" characters. + if ($s === '' || preg_match('/[^A-Za-z0-9._\/+-]/', $s)) { + // Quoted form: escape backslash, double-quote, dollar, and backtick. + // birdnet.conf is `source`d by BirdNET-Pi shell scripts and bash + // expands $(), $VAR, `cmd` inside double-quotes - without these + // escapes a controlled string field becomes command injection. + return '"' . addcslashes($s, "\\\"\$`") . '"'; + } + return $s; +} + +// Tight allowlist for string fields (currently SITE_NAME). Defence in +// depth on top of quote_val: even if shell escaping ever regresses, the +// only characters that reach birdnet.conf are letters, digits, and a +// short list of punctuation that shell won't interpret. +function safe_string_value(string $v): bool { + return (bool)preg_match("/^[A-Za-z0-9 _.,'-]*$/u", $v); +} + +$method = $_SERVER['REQUEST_METHOD'] ?? 'GET'; + +if ($method === 'GET') { + $conf = read_conf($CONF_PATH); + $out = []; + foreach ($ALLOWED as $k => $spec) { + if (!array_key_exists($k, $conf)) continue; + $v = $conf[$k]; + if ($spec['type'] === 'float') $v = (float)$v; + elseif ($spec['type'] === 'int') $v = (int)$v; + $out[$k] = $v; + } + echo json_encode([ + 'values' => $out, + 'meta' => $ALLOWED, + 'preserve' => (int)($conf['MAX_FILES_SPECIES'] ?? 0) >= 10000, + ]); + exit; +} + +if ($method === 'POST') { + $raw = file_get_contents('php://input'); + $body = json_decode((string)$raw, true); + if (!is_array($body)) { + http_response_code(400); + echo json_encode(['error' => 'bad json']); + exit; + } + $updates = []; + $errors = []; + foreach ($body as $k => $v) { + // 'preserve' is a UI-side convenience flag handled below - skip it here. + if ($k === 'preserve') continue; + if (!isset($ALLOWED[$k])) { $errors[$k] = 'unknown'; continue; } + $spec = $ALLOWED[$k]; + if ($spec['type'] === 'float') { + $v = (float)$v; + if ($v < ($spec['min'] ?? -INF) || $v > ($spec['max'] ?? INF)) { $errors[$k] = 'out of range'; continue; } + } elseif ($spec['type'] === 'int') { + $v = (int)$v; + if ($v < ($spec['min'] ?? -PHP_INT_MAX) || $v > ($spec['max'] ?? PHP_INT_MAX)) { $errors[$k] = 'out of range'; continue; } + } elseif ($spec['type'] === 'enum') { + if (!in_array($v, $spec['values'], true)) { $errors[$k] = 'invalid value'; continue; } + } elseif ($spec['type'] === 'string') { + $v = (string)$v; + if (strlen($v) > ($spec['maxlen'] ?? 200)) { $errors[$k] = 'too long'; continue; } + // String fields land in birdnet.conf which is sourced by bash; + // reject anything outside a known-safe punctuation set so a + // bash metacharacter can't get there even if quote_val regresses. + if (!safe_string_value($v)) { $errors[$k] = 'invalid characters'; continue; } + } + $updates[$k] = $v; + } + if ($errors) { + http_response_code(400); + echo json_encode(['error' => 'validation', 'fields' => $errors]); + exit; + } + + // Convenience flag: "preserve" toggle in the UI sets a high recording cap. + if (isset($body['preserve'])) { + $updates['MAX_FILES_SPECIES'] = $body['preserve'] ? 99999 : 50; + } + + if (!write_conf($CONF_PATH, $updates)) { + http_response_code(500); + echo json_encode(['error' => 'write failed (check perms on birdnet.conf)']); + exit; + } + + // Restart services if any setting requires it. + $needsRestart = false; + foreach (array_keys($updates) as $k) { + if (!empty($ALLOWED[$k]['restart'])) { $needsRestart = true; break; } + } + $restarted = []; + if ($needsRestart) { + foreach (['birdnet_analysis', 'birdnet_recording'] as $svc) { + // Pre-baked sudoers rule: caddy NOPASSWD: /bin/systemctl restart birdnet_* + $rc = 0; $out = []; + exec('sudo /bin/systemctl restart ' . escapeshellarg($svc) . ' 2>&1', $out, $rc); + $restarted[$svc] = $rc === 0; + } + } + echo json_encode(['ok' => true, 'updates' => $updates, 'restarted' => $restarted]); + exit; +} + +http_response_code(405); +echo json_encode(['error' => 'method not allowed']); diff --git a/avian/api/menu.php b/avian/api/menu.php index 83dc680..d1138af 100644 --- a/avian/api/menu.php +++ b/avian/api/menu.php @@ -23,15 +23,16 @@ if (getenv('AV_REQUIRE_AUTH') === '1' && empty($_SERVER['HTTP_AUTHORIZATION'])) exit; } -// All hrefs are the canonical paths BirdNET-Pi's views.php recognises -// (lifted from homepage/views.php). AvianVisitors took over `/`, so -// the stock home is at /index.php. +// All four items are in-app overlays. `native: true` tells the FE to +// route via `#admin=
` rather than opening a new window. We +// deliberately don't link out to BirdNET-Pi's stock pages - those stay +// reachable at /index.php, and the github link lives in the drawer +// footer next to "built by teddy". echo json_encode([ 'items' => [ - ['label' => 'birdnet-pi', 'href' => '/index.php', 'native' => false], - ['label' => 'detections', 'href' => '/views.php?view=Todays+Detections', 'native' => false], - ['label' => 'log', 'href' => '/views.php?view=View+Log', 'native' => false], - ['label' => 'system', 'href' => '/views.php?view=Services', 'native' => false], - ['label' => 'github', 'href' => 'https://github.com/Twarner491/AvianVisitors', 'native' => false], + ['label' => 'settings', 'href' => '/#admin=settings', 'native' => true], + ['label' => 'system', 'href' => '/#admin=system', 'native' => true], + ['label' => 'logs', 'href' => '/#admin=logs', 'native' => true], + ['label' => 'tools', 'href' => '/#admin=tools', 'native' => true], ], ]); diff --git a/avian/api/recording.php b/avian/api/recording.php index c3baa0c..1b7a59d 100644 --- a/avian/api/recording.php +++ b/avian/api/recording.php @@ -42,7 +42,10 @@ $BY_DATE = dirname(__DIR__, 3) . '/BirdSongs/Extracted/By_Date'; // directory under By_Date/. Whitelisted character set keeps this // safe against path-traversal payloads. if ($file !== '') { - if (!preg_match('/^[A-Za-z0-9_.:-]+\.mp3$/', $file)) { + // BirdNET-Pi keeps apostrophes in some common names (e.g. + // Anna's_Hummingbird-...mp3), so allow ' in the whitelist; the + // regex still blocks "/" and ".." so path traversal isn't reachable. + if (!preg_match("/^[A-Za-z0-9_.:'-]+\\.mp3$/", $file)) { http_response_code(400); echo 'invalid file name'; exit; diff --git a/avian/api/spectrogram.php b/avian/api/spectrogram.php index 3552499..8e782e6 100644 --- a/avian/api/spectrogram.php +++ b/avian/api/spectrogram.php @@ -38,7 +38,10 @@ $BY_DATE = dirname(__DIR__, 3) . '/BirdSongs/Extracted/By_Date'; // By_Date///. So we accept the mp3 filename, swap // the extension, and search the same way recording.php does. if ($file !== '') { - if (!preg_match('/^[A-Za-z0-9_.:-]+\.(mp3|png)$/', $file)) { + // BirdNET-Pi keeps apostrophes in some common names (e.g. + // Anna's_Hummingbird-...mp3.png), so allow ' in the whitelist; the + // regex still blocks "/" and ".." so path traversal isn't reachable. + if (!preg_match("/^[A-Za-z0-9_.:'-]+\\.(mp3|png)$/", $file)) { http_response_code(400); echo 'invalid file name'; exit; diff --git a/avian/assets/favicon.png b/avian/assets/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..7a8171c920d56ee825ab0d505fc2c464522e49d0 GIT binary patch literal 32820 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

#nin2f>Id^R_4{1y~v{wucn;IR``B+{3aNheKc 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 literal 0 HcmV?d00001 diff --git a/avian/frontend/apt.js b/avian/frontend/apt.js index e835a89..c6f3a2f 100644 --- a/avian/frontend/apt.js +++ b/avian/frontend/apt.js @@ -1209,19 +1209,12 @@ document.addEventListener('click', function (e) { if (!dd.contains(e.target) && e.target !== menuBtn) closeDd(); }); document.addEventListener('keydown', function (e) { if (e.key === 'Escape') closeDd(); }); - // Session is now an HTTP-only cookie set by /api/auth/login — we no - // longer cache the Basic-auth password in sessionStorage where any - // page-scoped script could read it. authHdr is kept around as a - // transient value for the legacy-UI warm-up POST only, and is never - // persisted. - // Drop any prior sessionStorage we left behind (security cleanup — - // previous builds stored Basic auth across reloads). - try { sessionStorage.removeItem('apt-birds-auth'); } catch (e) {} - var authHdr = null; - - // Probe the cookie session: hit /api/menu without any header. If the - // worker accepts the cookie we skip the lock screen; if it 401s we - // show the lock as usual. + // Probe menu.php with no Authorization header. On a LAN deploy + // (AV_REQUIRE_AUTH=0) it returns 200 immediately so the drawer + // renders directly. On a forwarded deploy with Caddy basic_auth in + // front, Caddy will already have validated credentials before this + // request reaches PHP - so a 200 here means we're authed, a 401 + // means Caddy rejected and we need the lock-screen flow. function tryAutoUnlock() { fetch('./avian/api/menu.php', { credentials: 'same-origin' }).then(function (r) { if (r.status === 200) { @@ -1233,23 +1226,22 @@ document.getElementById('unlockForm').addEventListener('submit', function (e) { e.preventDefault(); - var u = 'monalisa'; + // BirdNET-Pi's upstream Caddyfile basicauth user is `birdnet`. + // If your install changed it (custom Caddyfile), set window.AV_AUTH_USER + // before this script loads - e.g. an inline