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']);