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 0000000..7a8171c Binary files /dev/null and b/avian/assets/favicon.png differ 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