#!/usr/bin/env php
<?php
/* ============================================================
   alpha-call — AlphaCall (WebRTC video/voice calling + VoIP) for the panel's add-on.

   Four pieces, all on this server, all behind ONE public name (<sub>.<primary domain>):
     alpha-call.service   the Node app (no npm deps): web client, REST API, signaling WebSocket,
                          as user `alphacall` on 127.0.0.1:3095 (code in /opt/alpha-call/app,
                          data in /var/lib/alpha-call/data)
     coturn               STUN + TURN (UDP/TCP 3478, TLS 5349, relay UDP 49160-49359), REST-API
                          shared-secret credentials, private ranges denied as relay peers
     janus                the SFU for group rooms (VideoRoom only), WebSocket on 127.0.0.1:8188,
                          signed-token auth, RTP UDP 20000-20199 on eth0
     asterisk             the PBX: SIP UDP/TCP 5060 + TLS 5061 for phones and IP dialing, WebRTC
                          SIP over wss://<name>/sip (http on 127.0.0.1:8089), RTP UDP 10000-10199,
                          voicemail, forwarding, PSTN trunk; AMI on 127.0.0.1:5038 for the app

   ⚠️ ROOT-OWNED AND SELF-CONTAINED. It only COPIES the app from the panel tree; it never
      includes or executes panel PHP.
   ⚠️ nginx is reloaded only after `nginx -t` passes; a refused vhost is rolled back.
   ⚠️ 8088 belongs to LiteSpeed on this box — Asterisk's http server is moved to 127.0.0.1:8089
      and Janus's http transport is disabled (WebSocket only).
   ⚠️ apt would START janus/asterisk with their SAMPLE configs (demo rooms, 0.0.0.0 listeners);
      the install job blocks service starts with policy-rc.d until our configs are written.

     status                      every component: installed / active / version / memory, the name, cert, ports
     preflight                   what an install needs against what this server has now
     install [core,sfu,voip]     packages + app + configs + firewall + start (background job)
     job <id>                    state, progress and log tail of a job
     deploy                      copy the newest app from the panel tree and restart it
     configure                   (re)write every config from settings; restarts what changed
     start|stop|restart [svc]    app | turn | sfu | voip | all
     set <key> <value>           see SETTINGS below
     trunk <json>|trunk-clear    the PSTN/SIP trunk (provider host, user, secret, caller id, DID → ext)
     publish <sub> | unpublish   serve at <sub>.<domain> (vhost + certificate; TURN/SIP TLS use it too)
     logs <svc> [n]              last n journal lines of a component
     token                       print the admin token (for the panel; root only)
     remove [purge]              stop + disable everything (purge also deletes data and packages)
     unit-text | unit-check      the alpha-call.service unit / whether the installed one matches
   ============================================================ */

const STATE    = '/var/lib/alpha-call';
const APPDIR   = '/opt/alpha-call/app';
const PANELSRC = '/var/www/alpha/Addons/alphacall';
const PRIV     = '/var/www/alpha-private/alphacall';
const ENVFILE  = '/etc/alpha-call.env';
const UNIT     = 'alpha-call';
const UNITFILE = '/etc/systemd/system/alpha-call.service';
const USER     = 'alphacall';
const PORT     = 3095;
const VHOST    = '/etc/nginx/sites-available/sub-alphacall';
const VHOST_ON = '/etc/nginx/sites-enabled/sub-alphacall';
const ACME     = '/var/www/alpha';
const TURNCONF = '/etc/turnserver.conf';
const TURNCERT = '/etc/coturn/alphacall';
const JANUSDIR = '/etc/janus';
const ASTDIR   = '/etc/asterisk';
const ASTINC   = '/etc/asterisk/alphacall';
const ASTCERT  = '/etc/asterisk/keys/alphacall';
const VMDIR    = '/var/spool/asterisk/voicemail/alphacall';
// the panel's Firebase service account (Notifications ▸ Mobile Push) — AlphaCall gets its own copy for app push
const FCMSRC   = '/var/www/alpha-private/fcm/service-account.json';
const FCMDST   = STATE . '/fcm-service-account.json';
const HOOK     = '/etc/letsencrypt/renewal-hooks/deploy/alpha-call';
const MAX_JOBS = 20;
const PKGS     = ['core' => ['coturn', 'acl'], 'sfu' => ['janus'], 'voip' => ['asterisk', 'asterisk-core-sounds-en-wav', 'asterisk-moh-opsound-wav']];
const SVC      = ['app' => 'alpha-call', 'turn' => 'coturn', 'sfu' => 'janus', 'voip' => 'asterisk'];

function fail($m, $c = 2) { fwrite(STDERR, $m . "\n"); exit($c); }
function out($a) { echo json_encode($a, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE), "\n"; exit(0); }
function sh($cmd) { $o = []; $rc = 1; @exec($cmd . ' 2>&1', $o, $rc); return [implode("\n", $o), $rc]; }
function q($s) { return escapeshellarg((string) $s); }
function bin($n) { [$o, $rc] = sh('command -v ' . q($n)); return $rc === 0 ? trim($o) : ''; }
function meta($d) { $m = @json_decode((string) @file_get_contents("$d/meta.json"), true); return is_array($m) ? $m : []; }
function ensure_state() {
  foreach ([STATE, STATE . '/jobs'] as $d) if (!is_dir($d)) { @mkdir($d, 0700, true); @chmod($d, 0700); }
  @chmod(STATE, 0711);                         // the app user must reach its data/ below; settings.json and jobs/ stay root-only
}
function panel_conf() {
  $c = []; foreach (@file('/etc/alpha-panel.conf', FILE_IGNORE_NEW_LINES) ?: [] as $l) if (preg_match('/^\s*([A-Z_]+)=["\']?([^"\']*)["\']?\s*$/', $l, $m)) $c[$m[1]] = $m[2];
  return $c;
}
function primary_domain() { $d = panel_conf()['PRIMARY_DOMAIN'] ?? ''; return preg_match('/^[a-z0-9.-]+\.[a-z]{2,}$/i', $d) ? strtolower($d) : ''; }
/** Write a file only when its content changes; returns true when it did. */
function put($path, $text, $mode = 0644, $owner = null) {
  if (is_file($path) && file_get_contents($path) === $text) return false;
  @mkdir(dirname($path), 0755, true);
  file_put_contents($path, $text); @chmod($path, $mode);
  if ($owner) sh('chown ' . q($owner) . ' ' . q($path));
  return true;
}
function installed($pkg) { [$o] = sh('dpkg-query -W -f=\'${Status}\' ' . q($pkg)); return strpos($o, 'install ok installed') !== false; }
function svc_active($s) { [$o] = sh('systemctl is-active ' . q($s)); return trim($o) === 'active'; }
function svc_enabled($s) { [$o] = sh('systemctl is-enabled ' . q($s)); return trim($o) === 'enabled'; }
function svc_mem_mb($s) { [$o] = sh('systemctl show -p MemoryCurrent --value ' . q($s)); $v = trim($o); return ctype_digit($v) ? (int) round($v / 1048576) : 0; }
function public_ip() {
  [$o] = sh("ip -4 -o addr show scope global | awk '{print \$4}' | cut -d/ -f1");
  foreach (preg_split('/\s+/', trim($o)) as $ip) if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 | FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) return $ip;
  [$o] = sh('curl -4 -fsS --max-time 6 https://api.ipify.org'); return filter_var(trim($o), FILTER_VALIDATE_IP) ? trim($o) : '';
}
function public_ip6() {
  [$o] = sh("ip -6 -o addr show scope global | awk '{print \$4}' | cut -d/ -f1");
  foreach (preg_split('/\s+/', trim($o)) as $ip) if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6 | FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) return $ip;
  return '';
}
/** The interface carrying the public IPv4 (Janus/Asterisk must not advertise docker/lxd/wg addresses). */
function public_iface($ip) { [$o] = sh('ip -4 -o addr show | awk \'$4 ~ /^' . preg_quote($ip, '/') . '\\// {print $2}\''); return trim($o) ?: 'eth0'; }

/* ---------- settings ---------- */
const SETTINGS = [
  'name' => 'AlphaCall', 'registration' => 'open', 'auth' => 'portal', 'ring' => 45, 'upload' => 50, 'privacy' => false, 'history' => 365, 'files' => 0, 'debug' => false,
  'turn_port' => 3478, 'turns_port' => 5349, 'relay_min' => 49160, 'relay_max' => 49359,
  'sip_port' => 5060, 'sip_tls_port' => 5061, 'rtp_min' => 10000, 'rtp_max' => 10199, 'sfu_min' => 20000, 'sfu_max' => 20199,
  'room_max' => 16, 'ip_calls' => true, 'android_links' => '', 'stun_extra' => '', 'embed_origins' => '', 'sub' => '', 'components' => ['core' => true, 'sfu' => true, 'voip' => true],
];
function settings() {
  $s = @json_decode((string) @file_get_contents(STATE . '/settings.json'), true); $s = is_array($s) ? $s : [];
  $out = array_merge(SETTINGS, array_intersect_key($s, SETTINGS));
  $out['secrets'] = is_array($s['secrets'] ?? null) ? $s['secrets'] : [];
  foreach (['turn', 'admin', 'janus_admin', 'janus_token', 'ami', 'sip'] as $k) if (empty($out['secrets'][$k])) $out['secrets'][$k] = bin2hex(random_bytes(24));
  $out['ip'] = (string) ($s['ip'] ?? '') ?: public_ip();
  $out['ip6'] = array_key_exists('ip6', $s) ? (string) $s['ip6'] : public_ip6();
  $out['trunk'] = is_array($s['trunk'] ?? null) ? $s['trunk'] : null;
  return $out;
}
function write_settings(array $s) { ensure_state(); file_put_contents(STATE . '/settings.json', json_encode($s, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); @chmod(STATE . '/settings.json', 0600); }
function fqdn(array $s = null) { $s = $s ?? settings(); $d = primary_domain(); return $s['sub'] !== '' && $d !== '' ? $s['sub'] . '.' . $d : ''; }
function public_settings(array $s) { $p = $s; unset($p['secrets']); if ($p['trunk']) $p['trunk']['secret'] = $p['trunk']['secret'] !== '' ? '••••' : ''; return $p; }

/* ---------- the app ---------- */
function newest_src() {
  $best = null; $bv = '0.0.0';
  foreach (glob(PANELSRC . '/v*', GLOB_ONLYDIR) ?: [] as $d) { $v = substr(basename($d), 1); if (preg_match('/^\d+\.\d+\.\d+$/', $v) && is_file("$d/server/server.js") && version_compare($v, $bv, '>')) { $bv = $v; $best = $d; } }
  return $best ? [$best . '/server', $bv] : [null, ''];
}
function app_version() { $m = @file_get_contents(APPDIR . '/.version'); return $m ? trim($m) : ''; }
/* ---------- the meeting whiteboard: built from the Excalidraw add-on's packages ----------
   board/ (next to server/ in the add-on) holds a small host app; it is built with the Excalidraw add-on's own
   node_modules (as www-data, like that add-on) into STATE/board, and every deploy copies it to public/board.
   No Excalidraw add-on → no whiteboard (the meeting's Notes still work). Rebuilt only when the sources change. */
const EXSRC = '/var/www/alpha-private/excalidraw/src';
const BOARDOUT = STATE . '/board';
function board_build($force = false) {
  [$src] = newest_src(); if (!$src) return ['ok' => false, 'msg' => 'no add-on source'];
  $bsrc = dirname($src) . '/board';
  if (!is_file("$bsrc/main.jsx")) return ['ok' => false, 'msg' => 'no board source'];
  if (!is_dir(EXSRC . '/node_modules/@excalidraw/excalidraw')) return ['ok' => false, 'msg' => 'install the Excalidraw add-on to get the meeting whiteboard'];
  $files = glob("$bsrc/*") ?: []; sort($files);
  $all = []; foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($bsrc, FilesystemIterator::SKIP_DOTS)) as $f) $all[substr($f->getPathname(), strlen($bsrc))] = md5_file($f->getPathname());
  ksort($all);
  $hash = md5(json_encode($all) . @md5_file(EXSRC . '/package-lock.json'));
  if (!$force && is_file(BOARDOUT . '/.hash') && trim((string) file_get_contents(BOARDOUT . '/.hash')) === $hash && is_file(BOARDOUT . '/index.html')) return ['ok' => true, 'cached' => true];
  $node = dirname((string) realpath(trim(sh('command -v node')[0])));
  $W = 'sudo -u www-data env HOME=' . q(dirname(EXSRC) . '/home') . ' PATH=' . q($node . ':/usr/local/bin:/usr/bin:/bin') . ' NODE_OPTIONS=--max-old-space-size=2048';
  $work = EXSRC . '/alphacall-board';
  sh('rm -rf ' . q($work) . ' && mkdir -p ' . q($work) . ' && cp -r ' . implode(' ', array_map('q', $files)) . ' ' . q($work) . ' && chown -R www-data:www-data ' . q($work));
  [$o, $rc] = sh('cd ' . q(EXSRC) . ' && timeout 600 ' . $W . ' BOARD_OUT=' . q("$work/dist") . ' npx vite build --config alphacall-board/vite.config.js --logLevel warn');
  if ($rc !== 0 || !is_file("$work/dist/index.html")) { sh('rm -rf ' . q($work)); return ['ok' => false, 'msg' => 'whiteboard build failed: ' . substr($o, -400)]; }
  sh('cp -r ' . q(EXSRC . '/node_modules/@excalidraw/excalidraw/dist/prod/fonts') . ' ' . q("$work/dist/fonts"));      // self-hosted fonts
  file_put_contents("$work/dist/.hash", $hash . "
");
  sh('rm -rf ' . q(BOARDOUT . '.new') . ' && cp -a ' . q("$work/dist") . ' ' . q(BOARDOUT . '.new') . ' && chown -R root:root ' . q(BOARDOUT . '.new')
     . ' && rm -rf ' . q(BOARDOUT) . ' && mv ' . q(BOARDOUT . '.new') . ' ' . q(BOARDOUT) . ' && rm -rf ' . q($work));
  return ['ok' => true, 'built' => true];
}

function deploy_app() {
  [$src, $v] = newest_src(); if (!$src) fail('the AlphaCall add-on files are not in the panel (' . PANELSRC . ')');
  @mkdir(dirname(APPDIR), 0755, true);
  $tmp = APPDIR . '.new'; sh('rm -rf ' . q($tmp)); sh('cp -a ' . q($src) . ' ' . q($tmp));
  sh('rm -rf ' . q($tmp . '/data'));                                  // never ship a dev database
  $b = board_build();                                                  // the meeting whiteboard (if the Excalidraw add-on is there)
  if (!empty($b['ok']) && is_dir(BOARDOUT)) sh('rm -rf ' . q("$tmp/public/board") . ' && cp -a ' . q(BOARDOUT) . ' ' . q("$tmp/public/board"));
  file_put_contents("$tmp/.version", $v . "\n");
  sh('chown -R root:root ' . q($tmp) . ' && chmod -R go-w ' . q($tmp) . ' && find ' . q($tmp) . ' -type d -exec chmod 755 {} + && find ' . q($tmp) . ' -type f -exec chmod 644 {} +');
  sh('rm -rf ' . q(APPDIR . '.old') . '; [ -d ' . q(APPDIR) . ' ] && mv ' . q(APPDIR) . ' ' . q(APPDIR . '.old') . '; mv ' . q($tmp) . ' ' . q(APPDIR) . '; rm -rf ' . q(APPDIR . '.old'));
  return $v;
}
function ensure_user() {
  [, $rc] = sh('id -u ' . USER);
  if ($rc !== 0) sh('useradd --system --home-dir ' . STATE . ' --shell /usr/sbin/nologin --user-group ' . USER);
  if (installed('asterisk')) sh('usermod -aG asterisk ' . USER);
  foreach ([STATE . '/data' => 0750] as $d => $m) { @mkdir($d, $m, true); sh('chown ' . USER . ':' . USER . ' ' . q($d)); @chmod($d, $m); }
  @mkdir(PRIV, 0750, true); sh('chown root:www-data ' . q(PRIV)); @chmod(PRIV, 0750);
}
function env_text(array $s) {
  $f = fqdn($s); $sec = $s['secrets'];
  $kv = [
    'AC_HOST' => '127.0.0.1', 'AC_PORT' => PORT, 'AC_DATA' => STATE . '/data', 'AC_PUBLIC' => APPDIR . '/public', 'AC_FQDN' => $f ?: 'localhost',
    'AC_PUBLIC_IP' => $s['ip'], 'AC_PUBLIC_IP6' => $s['ip6'], 'AC_NAME' => $s['name'], 'AC_DEBUG' => $s['debug'] ? '1' : '0', 'AC_TRUST_PROXY' => '1',
    'AC_REGISTRATION' => $s['registration'], 'AC_ADMIN_TOKEN' => $sec['admin'], 'AC_AUTH' => $s['auth'],
    'AC_PORTAL' => primary_domain() !== '' ? 'https://accounts.' . primary_domain() . '/v1' : '', 'AC_PORTAL_API' => primary_domain() !== '' ? 'https://api.' . primary_domain() . '/v1/alphacall/handoff' : '',
    'AC_PUSH_CONTACT' => panel_conf()['ADMIN_EMAIL'] ?? '', 'AC_ANDROID_LINKS' => $s['android_links'], 'AC_RING_SECONDS' => $s['ring'], 'AC_MAX_UPLOAD_MB' => $s['upload'],
    'AC_FCM' => is_file(FCMDST) ? FCMDST : '', 'AC_HISTORY_DAYS' => $s['history'], 'AC_FILE_DAYS' => $s['files'], 'AC_IP_PRIVACY' => $s['privacy'] ? '1' : '0',
    'AC_TURN_SECRET' => installed('coturn') ? $sec['turn'] : '', 'AC_TURN_HOST' => $f ?: $s['ip'], 'AC_TURN_PORT' => $s['turn_port'], 'AC_TURNS_PORT' => $s['turns_port'],
    'AC_STUN_EXTRA' => $s['stun_extra'], 'AC_EMBED_ORIGINS' => $s['embed_origins'],
    'AC_JANUS_WS' => installed('janus') ? 'ws://127.0.0.1:8188' : '', 'AC_JANUS_ADMIN_KEY' => $sec['janus_admin'], 'AC_JANUS_TOKEN_SECRET' => $sec['janus_token'], 'AC_ROOM_MAX' => $s['room_max'],
    'AC_AMI_SECRET' => installed('asterisk') ? $sec['ami'] : '', 'AC_AMI_USER' => 'alphacall', 'AC_SIP_SECRET' => $sec['sip'], 'AC_SIP_DIR' => ASTINC, 'AC_SIP_REALM' => 'alphacall',
    'AC_SIP_DOMAIN' => $f ?: $s['ip'], 'AC_SIP_PORT' => $s['sip_port'], 'AC_VOICEMAIL_DIR' => VMDIR, 'AC_IP_CALLS' => $s['ip_calls'] ? '1' : '0',
  ];
  $t = "# AlphaCall — written by /usr/local/sbin/alpha-call from " . STATE . "/settings.json. Edit with `alpha-call set`, not by hand.\n";
  foreach ($kv as $k => $v) $t .= $k . '=' . str_replace(["\n", "\r"], '', (string) $v) . "\n";
  return $t;
}
function unit_text() {
  return "[Unit]\n"
       . "Description=AlphaCall — WebRTC calling, chat, rooms and VoIP (Alpha Panel add-on)\n"
       . "After=network-online.target coturn.service janus.service asterisk.service\n"
       . "Wants=network-online.target\n"
       . "StartLimitIntervalSec=60\nStartLimitBurst=5\n\n"
       . "[Service]\n"
       . "Type=simple\n"
       . "User=" . USER . "\nGroup=" . USER . "\n"
       . "EnvironmentFile=" . ENVFILE . "\n"
       . "WorkingDirectory=" . APPDIR . "\n"
       . "ExecStart=/usr/bin/node --experimental-sqlite --max-old-space-size=256 " . APPDIR . "/server.js\n"
       . "Restart=always\nRestartSec=2\n"
       . "# sandbox: it may write only its data, the PBX's include dir and the voicemail spool\n"
       . "NoNewPrivileges=yes\nProtectSystem=strict\nProtectHome=yes\nPrivateTmp=yes\nPrivateDevices=yes\n"
       . "ProtectKernelTunables=yes\nProtectKernelModules=yes\nProtectControlGroups=yes\nRestrictSUIDSGID=yes\nLockPersonality=yes\n"
       . "ReadWritePaths=" . STATE . "/data -" . ASTINC . " -" . VMDIR . "\n"
       . "MemoryMax=600M\nTasksMax=256\n\n"
       . "[Install]\nWantedBy=multi-user.target\n";
}
function ensure_unit() { if (put(UNITFILE, unit_text())) sh('systemctl daemon-reload'); }
/** Keep AlphaCall's copy of the Firebase service account in step with the panel's (or drop it). */
function fcm_sync() {
  if (is_file(FCMSRC)) {
    $j = json_decode((string) @file_get_contents(FCMSRC), true);
    if (is_array($j) && !empty($j['private_key']) && !empty($j['client_email'])) {
      if (!is_file(FCMDST) || md5_file(FCMDST) !== md5_file(FCMSRC)) { copy(FCMSRC, FCMDST); }
      sh('chown ' . USER . ':' . USER . ' ' . q(FCMDST)); @chmod(FCMDST, 0600);
      return true;
    }
  }
  if (is_file(FCMDST)) @unlink(FCMDST);
  return false;
}
function write_env(array $s) {
  fcm_sync();
  $ch = put(ENVFILE, env_text($s), 0640); sh('chown root:' . USER . ' ' . q(ENVFILE));
  // the panel reads the admin API with this token (loopback only, and refused through nginx)
  put(PRIV . '/admin.token', $s['secrets']['admin'] . "\n", 0640); sh('chown root:www-data ' . q(PRIV . '/admin.token'));
  return $ch;
}

/* ---------- certificates for TURN-over-TLS and SIP-over-TLS ---------- */
function copy_certs(array $s) {
  $f = fqdn($s); $live = "/etc/letsencrypt/live/$f";
  if ($f === '' || !is_file("$live/fullchain.pem")) return false;
  $ch = false;
  if (installed('coturn')) {
    @mkdir(dirname(TURNCERT), 0755, true); @chmod(dirname(TURNCERT), 0755);   // turnserver must traverse /etc/coturn
    @mkdir(TURNCERT, 0750, true);
    $ch = put(TURNCERT . '/fullchain.pem', file_get_contents("$live/fullchain.pem"), 0644) | $ch;
    $ch = put(TURNCERT . '/privkey.pem', file_get_contents("$live/privkey.pem"), 0640) | $ch;
    sh('chown -R turnserver:turnserver ' . q(TURNCERT));
  }
  if (installed('asterisk')) {
    @mkdir(dirname(ASTCERT), 0755, true); sh('chown asterisk:asterisk ' . q(dirname(ASTCERT))); @chmod(dirname(ASTCERT), 0750);
    @mkdir(ASTCERT, 0750, true);
    $ch = put(ASTCERT . '/fullchain.pem', file_get_contents("$live/fullchain.pem"), 0644) | $ch;
    $ch = put(ASTCERT . '/privkey.pem', file_get_contents("$live/privkey.pem"), 0640) | $ch;
    sh('chown -R asterisk:asterisk ' . q(ASTCERT));
  }
  put(HOOK, "#!/bin/sh\n# AlphaCall: renewed certificates reach coturn and Asterisk (they cannot read /etc/letsencrypt)\n"
          . "case \"\$RENEWED_DOMAINS\" in *" . $f . "*) /usr/local/sbin/alpha-call configure >/dev/null 2>&1 ;; esac\n", 0755);
  return $ch;
}

/* ---------- coturn ---------- */
function turn_conf(array $s) {
  $f = fqdn($s); $tls = $f !== '' && is_file(TURNCERT . '/fullchain.pem');
  $deny = ['0.0.0.0-0.255.255.255', '10.0.0.0-10.255.255.255', '100.64.0.0-100.127.255.255', '127.0.0.0-127.255.255.255', '169.254.0.0-169.254.255.255',
           '172.16.0.0-172.31.255.255', '192.0.0.0-192.0.0.255', '192.168.0.0-192.168.255.255', '198.18.0.0-198.19.255.255', '224.0.0.0-255.255.255.255',
           '::1', '::ffff:0.0.0.0-::ffff:255.255.255.255', 'fc00::-fdff:ffff:ffff:ffff:ffff:ffff:ffff:ffff', 'fe80::-febf:ffff:ffff:ffff:ffff:ffff:ffff:ffff'];
  return "# AlphaCall TURN/STUN — written by /usr/local/sbin/alpha-call\n"
    . "listening-port=" . $s['turn_port'] . "\n"
    . ($tls ? "tls-listening-port=" . $s['turns_port'] . "\ncert=" . TURNCERT . "/fullchain.pem\npkey=" . TURNCERT . "/privkey.pem\nno-tlsv1\nno-tlsv1_1\n" : "no-tls\nno-dtls\n")
    . "listening-ip=" . $s['ip'] . "\nrelay-ip=" . $s['ip'] . "\n"
    . ($s['ip6'] ? "listening-ip=" . $s['ip6'] . "\nrelay-ip=" . $s['ip6'] . "\n" : '')
    . "min-port=" . $s['relay_min'] . "\nmax-port=" . $s['relay_max'] . "\n"
    . "fingerprint\nuse-auth-secret\nstatic-auth-secret=" . $s['secrets']['turn'] . "\nrealm=" . ($f ?: 'alphacall') . "\n"
    . "# a relay is for call media only: never towards this server's private side or anyone's LAN\n"
    . implode('', array_map(fn($r) => "denied-peer-ip=$r\n", $deny))
    . "allowed-peer-ip=" . $s['ip'] . "\n"
    . "no-multicast-peers\nno-cli\nno-software-attribute\nstale-nonce=600\nuser-quota=12\ntotal-quota=400\nmax-bps=3000000\n"
    . "syslog\nsimple-log\nno-stdout-log\n";
}

/* ---------- janus (SFU) ---------- */
function janus_plugin_dir() { foreach (glob('/usr/lib/*/janus/plugins') ?: [] as $d) return $d; return '/usr/lib/x86_64-linux-gnu/janus/plugins'; }
function janus_files(array $s) {
  $lib = dirname(janus_plugin_dir());
  $plug = array_map('basename', glob(janus_plugin_dir() . '/*.so') ?: []);
  $off = array_values(array_filter($plug, fn($p) => $p !== 'libjanus_videoroom.so'));
  $tr = array_map('basename', glob($lib . '/transports/*.so') ?: []);
  $troff = array_values(array_filter($tr, fn($p) => $p !== 'libjanus_websockets.so'));
  $ev = array_map('basename', glob($lib . '/events/*.so') ?: []);
  $lg = array_map('basename', glob($lib . '/loggers/*.so') ?: []);
  return [
    'janus.jcfg' => "# AlphaCall SFU — written by /usr/local/sbin/alpha-call\n"
      . "general: {\n\tconfigs_folder = \"" . JANUSDIR . "\"\n\tplugins_folder = \"" . janus_plugin_dir() . "\"\n\ttransports_folder = \"$lib/transports\"\n\tevents_folder = \"$lib/events\"\n\tloggers_folder = \"$lib/loggers\"\n"
      . "\tdebug_level = 4\n\tserver_name = \"AlphaCall SFU\"\n\tsession_timeout = 60\n\treclaim_session_timeout = 0\n\tcandidates_timeout = 45\n"
      . "\ttoken_auth = true\n\ttoken_auth_secret = \"" . $s['secrets']['janus_token'] . "\"\n\tadmin_secret = \"" . bin2hex(random_bytes(8)) . $s['secrets']['janus_admin'] . "\"\n\tprotected_folders = [\"/bin\",\"/boot\",\"/dev\",\"/etc\",\"/initrd\",\"/lib\",\"/lib32\",\"/lib64\",\"/proc\",\"/sbin\",\"/sys\",\"/usr\",\"/var\",\"/opt/janus/bin\",\"/opt/janus/etc\",\"/opt/janus/include\",\"/opt/janus/lib\",\"/opt/janus/lib32\",\"/opt/janus/lib64\",\"/opt/janus/sbin\"]\n}\n"
      . "certificates: {\n}\n"
      . "media: {\n\tipv6 = " . ($s['ip6'] ? 'true' : 'false') . "\n\trtp_port_range = \"" . $s['sfu_min'] . '-' . $s['sfu_max'] . "\"\n\tdtls_timeout = 500\n\tslowlink_threshold = 4\n\ttwcc_period = 200\n}\n"
      . "nat: {\n\tnice_debug = false\n\tfull_trickle = true\n\tice_enforce_list = \"" . public_iface($s['ip']) . "\"\n\tignore_mdns = true\n}\n"
      . "plugins: {\n\tdisable = \"" . implode(',', $off) . "\"\n}\n"
      . "transports: {\n\tdisable = \"" . implode(',', $troff) . "\"\n}\n"
      . "loggers: {\n\tdisable = \"" . implode(',', $lg) . "\"\n}\n"
      . "events: {\n\tbroadcast = false\n\tdisable = \"" . implode(',', $ev) . "\"\n}\n",
    'janus.transport.websockets.jcfg' => "# AlphaCall — WebSocket only, loopback only (nginx carries wss://<name>/janus)\n"
      . "general: {\n\tjson = \"compact\"\n\tws = true\n\tws_port = 8188\n\tws_ip = \"127.0.0.1\"\n\twss = false\n}\nadmin: {\n\tadmin_ws = false\n\tadmin_wss = false\n}\n",
    'janus.plugin.videoroom.jcfg' => "# AlphaCall — rooms are created on demand by the app, protected by the admin key + per-room pins\n"
      . "general: {\n\tadmin_key = \"" . $s['secrets']['janus_admin'] . "\"\n\tlock_rtp_forward = true\n\tstring_ids = false\n}\n",
  ];
}

/* ---------- asterisk (PBX) ---------- */
function ast_files(array $s) {
  $f = fqdn($s) ?: $s['ip']; $ip = $s['ip'];
  $tls = is_file(ASTCERT . '/fullchain.pem');
  $codecs = "disallow=all\nallow=opus,g722,ulaw,alaw,vp8,h264\n";
  $nat = "direct_media=no\nrtp_symmetric=yes\nforce_rport=yes\nrewrite_contact=yes\n";
  return [
    'modules.conf' => "; AlphaCall — written by /usr/local/sbin/alpha-call (the package's own noloads + ours)
[modules]
autoload=yes
load=res_musiconhold.so
" . implode('', array_map(fn($m) => "noload=$m.so
", ['pbx_gtkconsole','pbx_kdeconsole','app_intercom','chan_modem','chan_capi','chan_alsa','chan_console','chan_oss','cdr_sqlite','app_directory_odbc','res_config_odbc','res_config_pgsql','app_voicemail_imap','app_voicemail_odbc','chan_sip','pbx_ael','pbx_lua','chan_skinny','chan_mgcp','chan_unistim','chan_iax2','chan_ooh323','chan_motif','res_xmpp','pbx_dundi','res_phoneprov','res_pjsip_phoneprov_provider','cdr_tds','cdr_pgsql','cdr_radius','cdr_sqlite3_custom','cel_tds','cel_pgsql','cel_radius','cel_sqlite3_custom','res_config_ldap','res_hep','res_hep_pjsip','res_hep_rtcp','res_statsd','res_calendar','res_calendar_exchange','res_calendar_icalendar','res_calendar_ews','res_calendar_caldav','res_snmp','app_festival','res_pjsip_geolocation','res_geolocation','cdr_csv'])),
    'http.conf' => "; AlphaCall — the WebSocket for WebRTC SIP, loopback only (nginx carries wss://<name>/sip). 8088 is LiteSpeed's.\n[general]\nenabled=yes\nbindaddr=127.0.0.1\nbindport=8089\ntlsenable=no\nsessionlimit=200\n",
    'rtp.conf' => "; AlphaCall\n[general]\nrtpstart=" . $s['rtp_min'] . "\nrtpend=" . $s['rtp_max'] . "\nicesupport=yes\nstrictrtp=seqno\n",
    'manager.conf' => "; AlphaCall — the app talks to the PBX here; loopback only\n[general]\nenabled=yes\nport=5038\nbindaddr=127.0.0.1\nwebenabled=no\n\n"
      . "[alphacall]\nsecret=" . $s['secrets']['ami'] . "\ndeny=0.0.0.0/0.0.0.0\npermit=127.0.0.1/255.255.255.255\nread=system,call,cdr,dialplan,reporting,originate,dtmf\nwrite=system,call,originate,reporting,config,command\nwriteperm=system,call,originate,reporting,config,command\n",
    'cdr.conf' => "[general]\nenable=yes\nunanswered=yes\ncongestion=yes\n",
    'cdr_manager.conf' => "; AlphaCall reads phone-line call records from these AMI events\n[general]\nenabled=yes\n",
    'logger.conf' => "[general]\ndateformat=%F %T\n[logfiles]\nconsole => notice,warning,error\nmessages.log => notice,warning,error\nsecurity.log => security\n",
    'musiconhold.conf' => "[default]\nmode=files\ndirectory=moh\n",
    'confbridge.conf' => "[general]\n[default_bridge]\ntype=bridge\nmax_members=30\nvideo_mode=sfu\n[default_user]\ntype=user\nannounce_user_count=yes\nmusic_on_hold_when_empty=yes\ndsp_drop_silence=yes\n",
    'features.conf' => "[general]\n[featuremap]\nblindxfer=##\natxfer=*2\n",
    'voicemail.conf' => "; AlphaCall — mailboxes come from the app (alphacall/voicemail.conf)\n[general]\nformat=wav49|wav\nattach=no\nmaxmsg=100\nmaxsecs=180\nminsecs=2\nmaxlogins=3\nsendvoicemail=no\nreview=no\nsaycid=no\nenvelope=yes\n\n[zonemessages]\n\n[alphacall]\n#tryinclude alphacall/voicemail.conf\n",
    'pjsip.conf' => "; AlphaCall PBX — written by /usr/local/sbin/alpha-call. Users: alphacall/users.conf (from the app). Trunk: alphacall/trunk.conf.\n"
      . "[global]\ntype=global\nuser_agent=AlphaCall PBX\nendpoint_identifier_order=username,ip,anonymous\nmax_forwards=20\n\n"
      . "[system]\ntype=system\ntimer_t1=500\n\n"
      . "[transport-udp]\ntype=transport\nprotocol=udp\nbind=0.0.0.0:" . $s['sip_port'] . "\nexternal_media_address=$ip\nexternal_signaling_address=$ip\n\n"
      . "[transport-tcp]\ntype=transport\nprotocol=tcp\nbind=0.0.0.0:" . $s['sip_port'] . "\nexternal_media_address=$ip\nexternal_signaling_address=$ip\n\n"
      . ($tls ? "[transport-tls]\ntype=transport\nprotocol=tls\nbind=0.0.0.0:" . $s['sip_tls_port'] . "\ncert_file=" . ASTCERT . "/fullchain.pem\npriv_key_file=" . ASTCERT . "/privkey.pem\nmethod=tlsv1_2\nexternal_media_address=$ip\nexternal_signaling_address=$ip\n\n" : '')
      . "[transport-wss]\ntype=transport\nprotocol=ws\nbind=127.0.0.1\n\n"
      . "; templates the app's users.conf builds on\n"
      . "[ac-phone](!)\ntype=endpoint\ncontext=alphacall-route\n$codecs$nat" . "media_encryption=sdes\nmedia_encryption_optimistic=yes\ndtmf_mode=rfc4733\nallow_transfer=yes\nmax_audio_streams=1\nmax_video_streams=1\n\n"
      . "[ac-web](!)\ntype=endpoint\ncontext=alphacall-route\n$codecs" . "webrtc=yes\ndtls_auto_generate_cert=yes\ndirect_media=no\nrtp_symmetric=yes\nforce_rport=yes\nrewrite_contact=yes\ndtmf_mode=rfc4733\nallow_transfer=yes\nmax_audio_streams=1\nmax_video_streams=1\n\n"
      . "[ac-aor](!)\ntype=aor\nmax_contacts=5\nremove_existing=yes\nqualify_frequency=60\n\n"
      . "[ac-auth](!)\ntype=auth\nauth_type=md5\nrealm=alphacall\n\n"
      . "; anyone on the internet dialing ext@" . $ip . " (only extensions that accept IP calls; nothing else)\n"
      . "[anonymous]\ntype=endpoint\ncontext=alphacall-inbound\n$codecs$nat" . "media_encryption_optimistic=yes\n\n"
      . "; our users dialing a SIP address / an IP directly\n"
      . "[ip-out]\ntype=endpoint\ncontext=alphacall-inbound\n$codecs$nat" . "media_encryption_optimistic=yes\nfrom_domain=$f\n\n"
      . "; users are named <ext>@<domain>: only callers claiming THIS domain are treated as our users; an outside PBX whose\n"
      . "; own extension happens to be 1001 arrives as an anonymous IP call instead of being challenged for a password\n"
      . ($f !== $ip ? "[$ip]\ntype=domain_alias\ndomain=$f\n\n" : '')
      . ($s['ip6'] && $f !== $s['ip6'] ? "[" . $s['ip6'] . "]\ntype=domain_alias\ndomain=$f\n\n" : '')
      . "#tryinclude alphacall/users.conf\n#tryinclude alphacall/trunk.conf\n",
    'extensions.conf' => ext_conf($s),
  ];
}
function ext_conf(array $s) {
  return "; AlphaCall dialplan — written by /usr/local/sbin/alpha-call.\n"
    . "; Per-extension settings live in AstDB, written by the app: AC/<ext>/{name,fwd,fwdwhen,vm,ring,ipok,dnd} and ACU/<username> = ext.\n"
    . "[general]\nstatic=yes\nwriteprotect=yes\nclearglobalvars=no\n\n"
    . "[globals]\nDOM=" . (fqdn($s) ?: $s['ip']) . "\n\n"
    . "; ---------- calls placed by OUR users (web softphone or a desk/mobile SIP phone) ----------\n"
    . "[alphacall-users]\n"
    . "exten => 600,1,NoOp(echo test)\n same => n,Answer()\n same => n,Playback(demo-echotest)\n same => n,Echo()\n same => n,Hangup()\n"
    . "exten => *97,1,Answer()\n same => n,VoiceMailMain(\${CALLERID(num)}@alphacall,s)\n same => n,Hangup()\n"
    . "exten => *98,1,Answer()\n same => n,VoiceMailMain(@alphacall)\n same => n,Hangup()\n"
    . "; phone conference bridges 8000-8999\n"
    . "exten => _8XXX,1,Answer()\n same => n,ConfBridge(\${EXTEN})\n same => n,Hangup()\n"
    . "; another user's extension\n"
    . "exten => _[1-7]XXX,1,Goto(alphacall-ring,\${EXTEN},1)\n"
    . "; a username typed into a SIP phone\n"
    . "exten => _[a-zA-Z].,1,Set(TGT=\${DB(ACU/\${TOLOWER(\${EXTEN})})})\n same => n,GotoIf(\$[\"\${TGT}\" = \"\"]?nouser)\n same => n,Goto(alphacall-ring,\${TGT},1)\n same => n(nouser),Playback(ss-noservice)\n same => n,Hangup(1)\n"
    . "; anything else: a foreign host in the request-URI = dial that SIP address / IP directly; a long number = the PSTN trunk\n"
    . "exten => _[+0-9*#].,1,GotoIf(\$[\"\${DB(ACT/enabled)}\" = \"1\"]?alphacall-pstn,\${EXTEN},1)\n same => n,Playback(ss-noservice)\n same => n,Hangup(1)\n"
    . "exten => s,1,Hangup(1)\n"
    . "exten => ipdial,1,NoOp(IP dial \${DIALUSER}@\${HOSTOF})\n same => n,Set(CALLERID(name)=\${DB(AC/\${CALLERID(num)}/name)})\n same => n,Dial(PJSIP/ip-out/sip:\${IF(\$[\"\${DIALUSER}\" = \"s\"]?:\${DIALUSER}@)}\${HOSTOF},60,tT)\n same => n,Hangup()\n"
    . "\n"
    . "; ---------- ring one of our users ----------\n"
    . "[alphacall-ring]\n"
    . "exten => _[1-7]XXX,1,Set(EXT=\${EXTEN})\n"
    . " same => n,GotoIf(\$[\"\${DB_EXISTS(AC/\${EXT}/name)}\" = \"0\"]?nouser)\n"
    . " same => n,Set(__HOPS=\$[\${IF(\$[\"\${HOPS}\" = \"\"]?0:\${HOPS})} + 1])\n"
    . " same => n,GotoIf(\$[\${HOPS} > 4]?nouser)\n"
    . " same => n,Set(FWD=\${DB(AC/\${EXT}/fwd)})\n same => n,Set(FWW=\${DB(AC/\${EXT}/fwdwhen)})\n"
    . " same => n,GotoIf(\$[\"\${FWD}\" != \"\" & \"\${FWW}\" = \"always\"]?fwd)\n"
    . " same => n,GotoIf(\$[\"\${DB(AC/\${EXT}/dnd)}\" = \"1\"]?vmu)\n"
    . " same => n,Set(D1=\${PJSIP_DIAL_CONTACTS(\${EXT}@\${GLOBAL(DOM)})})\n same => n,Set(D2=\${PJSIP_DIAL_CONTACTS(\${EXT}-web@\${GLOBAL(DOM)})})\n"
    . " same => n,Set(DST=\${D1}\${IF(\$[\"\${D1}\" != \"\" & \"\${D2}\" != \"\"]?&)}\${D2})\n"
    . " same => n,GotoIf(\$[\"\${DST}\" = \"\"]?unavail)\n"
    . " same => n,Set(RING=\${DB(AC/\${EXT}/ring)})\n"
    . " same => n,Dial(\${DST},\${IF(\$[\"\${RING}\" = \"\"]?45:\${RING})},tTr)\n"
    . " same => n,GotoIf(\$[\"\${DIALSTATUS}\" = \"BUSY\"]?busy)\n"
    . " same => n,GotoIf(\$[\"\${DIALSTATUS}\" = \"CHANUNAVAIL\"]?unavail)\n"
    . " same => n,GotoIf(\$[\"\${FWD}\" != \"\" & \"\${FWW}\" = \"noanswer\"]?fwd)\n"
    . " same => n,Goto(vmu)\n"
    . " same => n(busy),GotoIf(\$[\"\${FWD}\" != \"\" & \"\${FWW}\" = \"busy\"]?fwd)\n same => n,GotoIf(\$[\"\${DB(AC/\${EXT}/vm)}\" = \"1\"]?vmb)\n same => n,Busy(10)\n same => n,Hangup()\n"
    . " same => n(unavail),GotoIf(\$[\"\${FWD}\" != \"\" & \"\${FWW}\" = \"unavailable\"]?fwd)\n same => n,Goto(vmu)\n"
    . " same => n(vmu),GotoIf(\$[\"\${DB(AC/\${EXT}/vm)}\" = \"1\"]?:novm)\n same => n,Answer()\n same => n,VoiceMail(\${EXT}@alphacall,u)\n same => n,Hangup()\n"
    . " same => n(vmb),Answer()\n same => n,VoiceMail(\${EXT}@alphacall,b)\n same => n,Hangup()\n"
    . " same => n(novm),Playback(vm-nobodyavail)\n same => n,Hangup(17)\n"
    . " same => n(fwd),NoOp(forward \${EXT} to \${FWD})\n"
    . " same => n,GotoIf(\$[\"\${FWD}\" : \"^[1-7][0-9][0-9][0-9]\$\" > 0]?alphacall-ring,\${FWD},1)\n"
    . " same => n,GotoIf(\$[\"\${DB(ACU/\${TOLOWER(\${FWD})})}\" != \"\"]?alphacall-ring,\${DB(ACU/\${TOLOWER(\${FWD})})},1)\n"
    . " same => n,GotoIf(\$[\"\${FWD}\" : \".*@.*\" > 0]?:fwdnum)\n"
    . " same => n,Dial(PJSIP/ip-out/sip:\${CUT(FWD,:,2)}\${IF(\$[\"\${CUT(FWD,:,2)}\" = \"\"]?\${FWD})},60,tT)\n same => n,Hangup()\n"
    . " same => n(fwdnum),GotoIf(\$[\"\${DB(ACT/enabled)}\" = \"1\"]?alphacall-pstn,\${FWD},1)\n same => n,Goto(vmu)\n"
    . " same => n(nouser),Playback(ss-noservice)\n same => n,Hangup(1)\n\n"
    . "; ---------- from the internet, no account: only extensions that accept IP calls ----------\n"
    . "[alphacall-inbound]\n"
    . "exten => _[1-7]XXX,1,Gosub(alphacall-who,s,1)\n same => n,GotoIf(\$[\"\${DB(AC/\${EXTEN}/ipok)}\" = \"1\"]?alphacall-ring,\${EXTEN},1)\n same => n,Hangup(21)\n"
    . "exten => _[a-zA-Z].,1,Gosub(alphacall-who,s,1)\n same => n,Set(TGT=\${DB(ACU/\${TOLOWER(\${EXTEN})})})\n same => n,GotoIf(\$[\"\${TGT}\" != \"\" & \"\${DB(AC/\${TGT}/ipok)}\" = \"1\"]?alphacall-ring,\${TGT},1)\n same => n,Hangup(21)\n"
    . "exten => i,1,Hangup(21)\n\n"
    . "; the outside caller's name gets the host they called from (CUT takes a variable NAME: one step per variable)\n"
    . "[alphacall-who]\n"
    . "exten => s,1,Set(RU=\${CHANNEL(pjsip,remote_uri)})\n"
    . " same => n,Set(RH=\${CUT(RU,@,2)})\n same => n,Set(RH=\${CUT(RH,>,1)})\n same => n,Set(RH=\${CUT(RH,\\;,1)})\n same => n,Set(RH=\${CUT(RH,:,1)})\n"
    . " same => n,ExecIf(\$[\"\${RH}\" != \"\"]?Set(CALLERID(name)=\${IF(\$[\"\${CALLERID(name)}\" = \"\"]?\${CALLERID(num)}:\${CALLERID(name)})} · \${RH}))\n"
    . " same => n,Return()\n\n"
    . "; ---------- the PSTN trunk (when configured) ----------\n"
    . "[alphacall-pstn]\n"
    . "exten => _[+0-9].,1,Set(CALLERID(num)=\${DB(ACT/cid)})\n same => n,Dial(PJSIP/\${DB(ACT/prefix)}\${EXTEN}@alphacall-trunk,90,T)\n same => n,Hangup()\n"
    . "[alphacall-from-trunk]\n"
    . "exten => _[+0-9a-zA-Z].,1,Goto(alphacall-ring,\${DB(ACT/did)},1)\n"
    . "exten => s,1,Goto(alphacall-ring,\${DB(ACT/did)},1)\n";
}
function ast_predial(array $s) {
  $mine = array_values(array_unique(array_filter([fqdn($s), $s['ip'], $s['ip6'], 'localhost', '127.0.0.1'], fn($x) => $x !== '')));
  $cond = implode(' | ', array_map(fn($x) => '"${HB}" = "' . $x . '"', $mine));
  // ⚠️ CUT() takes a VARIABLE NAME, not an expression — every step lands in its own variable
  return "\n; ---------- the door for our users: a foreign host in the request-URI = dial that SIP address / IP directly ----------\n"
    . "[alphacall-route]\n"
    . "exten => h,1,Hangup()\n"
    . "exten => _.,1,Set(RURI=\${CHANNEL(pjsip,request_uri)})\n"
    . " same => n,ExecIf(\$[\"\${RURI}\" = \"\"]?Set(TOH=\${PJSIP_HEADER(read,To)}))\n"
    . " same => n,ExecIf(\$[\"\${RURI}\" = \"\"]?Set(RURI=\${CUT(TOH,<,2)}))\n"
    . " same => n,Set(RURI=\${CUT(RURI,>,1)})\n"
    . " same => n,Set(RURI=\${CUT(RURI,\\;,1)})\n"
    . " same => n,Set(R2=\${CUT(RURI,:,2-)})\n"
    . " same => n,Set(H=\${IF(\$[\"\${R2}\" : \".*@\" > 0]?\${CUT(R2,@,2)}:\${R2})})\n"
    . " same => n,Set(HB=\${CUT(H,:,1)})\n"
    . " same => n,NoOp(route: uri=\${RURI} host=\${H})\n"
    . " same => n,GotoIf(\$[\"\${HB}\" = \"\" | " . $cond . "]?local)\n"
    . " same => n,Set(HOSTOF=\${H})\n same => n,Set(DIALUSER=\${EXTEN})\n"
    . " same => n,Goto(alphacall-users,ipdial,1)\n"
    . " same => n(local),Goto(alphacall-users,\${EXTEN},1)\n";
}

function configure(array $s, $restart = true) {
  ensure_user(); $GLOBALS['__certs_changed'] = copy_certs($s);
  $changed = ['app' => false, 'turn' => false, 'sfu' => false, 'voip' => false];
  $changed['app'] = write_env($s) | false;
  ensure_unit();
  if (installed('coturn')) {
    $changed['turn'] = put(TURNCONF, turn_conf($s), 0640, 'root:turnserver');
    put('/etc/default/coturn', "# AlphaCall\nTURNSERVER_ENABLED=1\n");
  }
  if (installed('janus')) {
    foreach (janus_files($s) as $n => $txt) {
      // the admin_secret line is random per write: compare without it so a no-op configure restarts nothing
      $p = JANUSDIR . '/' . $n; $old = is_file($p) ? preg_replace('/admin_secret = "[^"]*"/', '', file_get_contents($p)) : null;
      if ($old !== preg_replace('/admin_secret = "[^"]*"/', '', $txt)) { put($p, $txt, 0640, 'root:root'); $changed['sfu'] = true; }
    }
    put('/etc/systemd/system/janus.service.d/alpha.conf', "[Service]\n# AlphaCall: keep the SFU small and restart it if it dies\nMemoryMax=400M\nRestart=always\nRestartSec=3\n");
  }
  if (installed('asterisk')) {
    if (!is_dir(ASTDIR . '/.alpha-orig')) { @mkdir(ASTDIR . '/.alpha-orig', 0750); sh('cp -a ' . ASTDIR . '/*.conf ' . ASTDIR . '/.alpha-orig/ 2>/dev/null'); }
    $GLOBALS['__pjsip_before'] = (string) @file_get_contents(ASTDIR . '/pjsip.conf');
    $files = ast_files($s); $files['extensions.conf'] .= ast_predial($s);
    foreach ($files as $n => $txt) if (put(ASTDIR . '/' . $n, $txt, 0640, 'asterisk:asterisk')) $changed['voip'] = true;
    // the app writes users/voicemail/trunk includes here; Asterisk reads them
    @mkdir(ASTINC, 02770, true); sh('chown ' . USER . ':asterisk ' . q(ASTINC)); @chmod(ASTINC, 02770);
    foreach (['users.conf', 'voicemail.conf', 'trunk.conf'] as $n) if (!is_file(ASTINC . "/$n")) { file_put_contents(ASTINC . "/$n", "; written by the AlphaCall app\n"); sh('chown ' . USER . ':asterisk ' . q(ASTINC . "/$n")); @chmod(ASTINC . "/$n", 0660); }
    trunk_conf($s);
    @mkdir(VMDIR, 02770, true); sh('chown asterisk:asterisk ' . q(VMDIR)); @chmod(VMDIR, 02770);
    sh('setfacl -R -m u:' . USER . ':rwX ' . q(VMDIR) . ' && setfacl -R -d -m u:' . USER . ':rwX ' . q(VMDIR));
    sh('usermod -aG asterisk ' . USER);
  }
  firewall($s);
  f2b_apply();
  if (!$restart) return $changed;
  if (($changed['turn'] || $GLOBALS['__certs_changed']) && installed('coturn')) sh('systemctl enable coturn 2>&1; systemctl restart coturn 2>&1');
  if ($changed['sfu'] && installed('janus')) sh('systemctl daemon-reload; systemctl enable janus 2>&1; systemctl restart janus 2>&1');
  if ($changed['voip'] && installed('asterisk')) {
    // a new/changed TRANSPORT (TLS after publish, a port) is only picked up by a restart; dialplan and the rest reload in place
    $tr = fn($t) => implode("
", array_filter(explode("
", (string) $t), fn($l) => preg_match('/^(bind|protocol|cert_file|priv_key_file|external_)/', $l)));
    if (!svc_active('asterisk') || $tr($GLOBALS['__pjsip_before'] ?? '') !== $tr(@file_get_contents(ASTDIR . '/pjsip.conf'))) sh('systemctl enable asterisk 2>&1; systemctl restart asterisk 2>&1');
    else sh('asterisk -rx "core reload" 2>&1; asterisk -rx "dialplan reload" 2>&1');
  }
  if (is_dir(APPDIR) && ($changed['app'] || !svc_active(UNIT))) sh('systemctl enable ' . UNIT . ' 2>&1; systemctl restart ' . UNIT . ' 2>&1');
  return $changed;
}
function trunk_conf(array $s) {
  $t = $s['trunk']; $p = ASTINC . '/trunk.conf';
  if (!$t || empty($t['host'])) {
    put($p, "; no PSTN trunk configured\n", 0660); sh('chown ' . USER . ':asterisk ' . q($p));
    if (svc_active('asterisk')) sh('asterisk -rx "database put ACT enabled 0"');
    return;
  }
  $txt = "; PSTN / SIP trunk — written by /usr/local/sbin/alpha-call (trunk)\n"
    . "[alphacall-trunk]\ntype=registration\noutbound_auth=alphacall-trunk-auth\nserver_uri=sip:" . $t['host'] . "\nclient_uri=sip:" . $t['user'] . '@' . $t['host'] . "\nretry_interval=60\nexpiration=600\ncontact_user=" . $t['user'] . "\n\n"
    . "[alphacall-trunk-auth]\ntype=auth\nauth_type=userpass\nusername=" . $t['user'] . "\npassword=" . $t['secret'] . "\n\n"
    . "[alphacall-trunk]\ntype=aor\ncontact=sip:" . $t['host'] . "\nqualify_frequency=60\n\n"
    . "[alphacall-trunk]\ntype=endpoint\ncontext=alphacall-from-trunk\ndisallow=all\nallow=ulaw,alaw,g722\noutbound_auth=alphacall-trunk-auth\naors=alphacall-trunk\nfrom_user=" . $t['user'] . "\nfrom_domain=" . $t['host'] . "\ndirect_media=no\nrtp_symmetric=yes\nforce_rport=yes\nrewrite_contact=yes\nsend_pai=yes\n\n"
    . "[alphacall-trunk]\ntype=identify\nendpoint=alphacall-trunk\nmatch=" . $t['host'] . "\n";
  if (put($p, $txt, 0660)) { sh('chown ' . USER . ':asterisk ' . q($p)); if (svc_active('asterisk')) sh('asterisk -rx "module reload res_pjsip.so"'); }
  if (svc_active('asterisk')) {
    foreach (['enabled' => '1', 'cid' => $t['cid'] ?? '', 'prefix' => $t['prefix'] ?? '', 'did' => (string) ($t['did'] ?? '1001')] as $k => $v) sh('asterisk -rx ' . q("database put ACT $k \"" . $v . "\""));
  }
}

/* ---------- fail2ban ----------
   Two credential jails, written here and not by provisioning because each needs the thing it watches:
   alpha-call-sip   Asterisk's security log (bad SIP passwords, unknown accounts, ACL hits)
   alpha-call-auth  the app's own "AUTHFAIL ip=…" journal lines (sign-in failures at the call address)
   Like every credential jail they get no admin-IP pass.
   ⚠️ alpha-call-sip must NOT say `protocol = all`: the nftables ban action (the default on this
   box) expands that to `all dport {…}`, an invalid rule, so the set existed but no rule and no
   address ever landed in it — every ban was a silent no-op. Two SIP scanners then hammered
   5060 for days while fail2ban logged "already banned" ~500×/s: security.log 15 GB, messages.log
   5 GB, fail2ban.log 6 GB (2026-10-08). allports+drop blocks the scanner outright, for a day. */
const F2B_JAIL = '/etc/fail2ban/jail.d/alpha-call.local';
function f2b_text() {
  $t = "# AlphaCall — written by /usr/local/sbin/alpha-call for the components that are installed\n";
  if (installed('asterisk')) $t .= "[alpha-call-sip]\nenabled  = true\nfilter   = asterisk\nbackend  = polling\nlogpath  = /var/log/asterisk/security.log\nport     = 5060,5061\nprotocol = tcp,udp\nbanaction = nftables[type=allports, blocktype=drop]\nmaxretry = 6\nfindtime = 600\nbantime  = 86400\n\n";
  if (is_file(UNITFILE) && is_file('/etc/fail2ban/filter.d/alpha-call.conf')) $t .= "[alpha-call-auth]\nenabled  = true\nfilter   = alpha-call\nbackend  = systemd\njournalmatch = _SYSTEMD_UNIT=alpha-call.service\nport     = http,https\nmaxretry = 8\nfindtime = 600\nbantime  = 3600\n";
  return $t;
}
function f2b_apply() {
  if (bin('fail2ban-client') === '') return false;
  if (installed('asterisk') && !is_file('/var/log/asterisk/security.log')) { @touch('/var/log/asterisk/security.log'); sh('chown asterisk:asterisk /var/log/asterisk/security.log'); }
  if (!put(F2B_JAIL, f2b_text())) return false;
  sh('fail2ban-client reload 2>&1');
  // ⚠️ a plain reload keeps a jail's RUNNING action when only its parameters changed — the
  // broken `protocol = all` nft rule survived a reload and bans stayed no-ops until the jail
  // was restarted (2026-10-08). --restart tears the action down and rebuilds it; bans are
  // restored from fail2ban's DB, so nothing is lost.
  if (installed('asterisk')) sh('fail2ban-client reload --restart alpha-call-sip 2>&1');
  return true;
}

/* ---------- firewall ---------- */
function fw_rules(array $s) {
  $r = [];
  if (installed('coturn')) { $r[] = $s['turn_port'] . '/udp'; $r[] = $s['turn_port'] . '/tcp'; $r[] = $s['turns_port'] . '/tcp'; $r[] = $s['relay_min'] . ':' . $s['relay_max'] . '/udp'; }
  if (installed('janus')) $r[] = $s['sfu_min'] . ':' . $s['sfu_max'] . '/udp';
  if (installed('asterisk')) { $r[] = $s['sip_port'] . '/udp'; $r[] = $s['sip_port'] . '/tcp'; $r[] = $s['sip_tls_port'] . '/tcp'; $r[] = $s['rtp_min'] . ':' . $s['rtp_max'] . '/udp'; }
  return $r;
}
function firewall(array $s) {
  if (bin('ufw') === '') return;
  [$st] = sh('ufw status'); if (strpos($st, 'Status: active') === false) return;
  $want = fw_rules($s);
  // drop our own rules that no longer apply (a changed port), add the missing ones
  [$num] = sh('ufw status numbered');
  $have = [];
  foreach (explode("\n", $num) as $l) if (preg_match('/^\[\s*(\d+)\]\s+(\S+)(?:\s+\(v6\))?\s+ALLOW IN\s+.*# alpha-call/', $l, $m)) $have[(int) $m[1]] = $m[2];
  krsort($have);
  foreach ($have as $n => $rule) if (!in_array(preg_replace('/\s*\(v6\)/', '', $rule), $want, true)) sh('ufw --force delete ' . $n);
  foreach ($want as $w) sh('ufw allow ' . q($w) . ' comment alpha-call');
}

/* ---------- nginx ---------- */
function vhost_text($f, $tls) {
  $http = "# ALPHA-CALL — AlphaCall (app 127.0.0.1:" . PORT . ", Janus 127.0.0.1:8188, Asterisk WS 127.0.0.1:8089). Written by /usr/local/sbin/alpha-call.\n"
    . "server {\n    listen 80; listen [::]:80;\n    server_name $f;\n"
    . "    location ^~ /.well-known/acme-challenge/ { allow all; root " . ACME . "; default_type \"text/plain\"; }\n"
    . "    location / { return 301 https://\$host\$request_uri; }\n}\n";
  if (!$tls) return $http;
  $ws = "        proxy_http_version 1.1;\n        proxy_set_header Upgrade \$http_upgrade;\n        proxy_set_header Connection \"upgrade\";\n        proxy_set_header Host \$host;\n"
      . "        proxy_set_header X-Real-IP \$remote_addr;\n        proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;\n        proxy_read_timeout 1h;\n        proxy_send_timeout 1h;\n        proxy_buffering off;\n";
  return $http
    . "server {\n    listen 443 ssl http2; listen [::]:443 ssl http2;\n    server_name $f;\n"
    . "    ssl_certificate     /etc/letsencrypt/live/$f/fullchain.pem;\n    ssl_certificate_key /etc/letsencrypt/live/$f/privkey.pem;\n"
    . "    client_max_body_size " . (settings()['upload'] + 5) . "m;\n"
    . "    add_header Strict-Transport-Security \"max-age=31536000\" always;\n    add_header X-Robots-Tag \"noindex, nofollow\" always;\n"
    . "    location @ac_down { default_type text/html; return 503 '<!doctype html><meta charset=utf-8><title>AlphaCall</title><body style=\"margin:0;display:grid;place-items:center;height:100vh;background:#0b0f15;color:#8b97a8;font-family:system-ui\"><p>AlphaCall is restarting — try again in a moment.</p>'; }\n"
    . "    # the SFU (group rooms): Janus WebSocket, signed tokens minted by the app\n"
    . "    location = /janus {\n        proxy_pass http://127.0.0.1:8188;\n" . $ws . "    }\n"
    . "    # the phone line: SIP over secure WebSocket for the web softphone\n"
    . "    location = /sip {\n        proxy_pass http://127.0.0.1:8089/ws;\n" . $ws . "    }\n"
    . "    # admin + metrics are loopback-only in the app as well; never reachable from here\n"
    . "    location ^~ /admin/ { return 404; }\n    location = /metrics { return 404; }\n"
    . "    location / {\n        error_page 502 504 = @ac_down;\n        proxy_pass http://127.0.0.1:" . PORT . ";\n" . $ws . "    }\n}\n";
}
function nginx_apply($text) {
  $bak = is_file(VHOST) ? file_get_contents(VHOST) : null;
  file_put_contents(VHOST, $text); @chmod(VHOST, 0644);
  if (!is_link(VHOST_ON)) @symlink(VHOST, VHOST_ON);
  [$o, $rc] = sh('nginx -t');
  if ($rc !== 0) { if ($bak !== null) file_put_contents(VHOST, $bak); else { @unlink(VHOST_ON); @unlink(VHOST); } return [false, $o]; }
  sh('systemctl reload nginx');
  return [true, ''];
}

/* ---------- jobs ---------- */
function job_state($d) {
  $rc = is_file("$d/rc") ? (int) trim((string) @file_get_contents("$d/rc")) : null;
  $pid = (int) trim((string) @file_get_contents("$d/pid"));
  if ($rc === null && $pid > 0 && @file_exists("/proc/$pid")) return ['running', null];
  return $rc === null ? ['lost', null] : ['done', $rc];
}
function job_dir($id) { if (!preg_match('/^[a-z0-9]{8,24}$/', (string) $id)) fail('bad job id'); $d = STATE . '/jobs/' . $id; if (!is_dir($d)) fail('no such job'); return $d; }
function sys_load() {
  $la = array_map('floatval', array_slice(explode(' ', (string) @file_get_contents('/proc/loadavg')), 0, 3));
  $mi = (string) @file_get_contents('/proc/meminfo'); $kb = fn($k) => preg_match('/^' . $k . ':\s+(\d+)/m', $mi, $x) ? (int) $x[1] : 0;
  [$o] = sh('nproc'); [$df] = sh('df -Pm / | tail -1'); $p = preg_split('/\s+/', trim($df));
  return ['load1' => $la[0] ?? 0, 'cores' => max(1, (int) trim($o)), 'mem_total_mb' => intdiv($kb('MemTotal'), 1024), 'mem_avail_mb' => intdiv($kb('MemAvailable'), 1024), 'disk_free_mb' => (int) ($p[3] ?? 0)];
}
function job_view($d) {
  $m = meta($d); [$st, $rc] = job_state($d);
  $raw = str_replace("\r", "\n", (string) @file_get_contents("$d/log"));
  $pct = 1; $phase = 'Starting';
  if (preg_match_all('/^== (\d+) (.+)$/m', $raw, $mm, PREG_SET_ORDER)) { $last = end($mm); $pct = (int) $last[1]; $phase = $last[2]; }
  // apt's own "Unpacking"/"Setting up" lines move the bar inside the long package stage (8 → 60)
  if ($pct >= 8 && $pct < 60) { $n = preg_match_all('/^(Unpacking|Setting up) /m', $raw); if ($n) $pct = min(59, 8 + (int) round($n * 0.45)); }
  if ($st === 'done') { $pct = $rc === 0 ? 100 : $pct; $phase = $rc === 0 ? 'Finished' : 'Failed — ' . $phase; }
  $lines = array_values(array_filter(explode("\n", rtrim($raw)), fn($l) => trim($l) !== '' && !preg_match('/^(\(Reading database|Selecting previously|Preparing to unpack|Get:\d+)/', $l)));
  $tail = implode("\n", array_slice($lines, -40)); if (strlen($tail) > 9000) $tail = substr($tail, -9000);
  $elapsed = max(0, (is_file("$d/rc") ? (int) @filemtime("$d/rc") : time()) - (int) ($m['started'] ?? time()));
  $eta = ($st === 'running' && $pct >= 6) ? (int) round($elapsed * (100 - $pct) / $pct) : null;
  return $m + ['state' => $st, 'rc' => $rc, 'pct' => $pct, 'phase' => $phase, 'eta' => $eta, 'elapsed' => $elapsed, 'tail' => $tail, 'load' => sys_load()];
}
function job_start($kind, $cmd) {
  ensure_state();
  foreach (glob(STATE . '/jobs/*', GLOB_ONLYDIR) ?: [] as $d) { [$st] = job_state($d); if ($st === 'running') { $m = meta($d); out(['ok' => true, 'job' => $m['id'], 'already' => true]); } }
  $id = date('ymdHis') . substr(bin2hex(random_bytes(3)), 0, 6);
  $d = STATE . '/jobs/' . $id; @mkdir($d, 0700, true);
  file_put_contents("$d/meta.json", json_encode(['id' => $id, 'kind' => $kind, 'started' => time()]));
  $inner = 'echo $$ > ' . q("$d/pid") . '; ( ' . $cmd . ' ) > ' . q("$d/log") . ' 2>&1; echo $? > ' . q("$d/rc");
  exec('setsid nohup bash -c ' . q($inner) . ' > /dev/null 2>&1 &');
  $dirs = glob(STATE . '/jobs/*', GLOB_ONLYDIR) ?: []; rsort($dirs);
  foreach (array_slice($dirs, MAX_JOBS) as $old) { foreach (glob("$old/*") ?: [] as $f) @unlink($f); @rmdir($old); }
  return $id;
}
function running_job() { foreach (glob(STATE . '/jobs/*', GLOB_ONLYDIR) ?: [] as $d) { [$st] = job_state($d); if ($st === 'running') { $m = meta($d); return ['id' => $m['id'], 'kind' => $m['kind'], 'started' => $m['started']]; } } return null; }

function comp_status() {
  $c = [];
  foreach (['app' => null, 'turn' => 'coturn', 'sfu' => 'janus', 'voip' => 'asterisk'] as $k => $pkg) {
    $svc = SVC[$k];
    $inst = $k === 'app' ? is_file(APPDIR . '/server.js') : installed($pkg);
    $ver = '';
    if ($inst) {
      if ($k === 'app') $ver = app_version();
      else { [$o] = sh('dpkg-query -W -f=\'${Version}\' ' . q($pkg)); $ver = preg_replace('/^\d+:/', '', preg_replace('/[~+].*$/', '', trim($o))); }
    }
    $c[$k] = ['installed' => $inst, 'service' => $svc, 'active' => $inst && svc_active($svc), 'enabled' => $inst && svc_enabled($svc), 'mem_mb' => $inst ? svc_mem_mb($svc) : 0, 'version' => $ver];
  }
  return $c;
}

/* ============================================================ verbs */
ensure_state();
$cmd = (string) ($_SERVER['argv'][1] ?? '');
$arg = (string) ($_SERVER['argv'][2] ?? '');
switch ($cmd) {
  case 'status': {
    $s = settings(); $f = fqdn($s);
    [$nv] = sh('node -v'); [$src, $sv] = newest_src();
    $comp = comp_status();
    $ports = [];
    [$ss] = sh('ss -lntuH');
    foreach (['sip' => ':' . $s['sip_port'], 'sip_tls' => ':' . $s['sip_tls_port'], 'turn' => ':' . $s['turn_port'], 'turns' => ':' . $s['turns_port'], 'janus_ws' => '127.0.0.1:8188', 'ast_ws' => '127.0.0.1:8089', 'ami' => '127.0.0.1:5038', 'app' => '127.0.0.1:' . PORT] as $k => $p)
      $ports[$k] = strpos($ss, $p . ' ') !== false;
    out(['ok' => true, 'node' => trim($nv), 'node_ok' => version_compare(ltrim(trim($nv), 'v'), '22.5.0', '>='), 'components' => $comp, 'installed' => $comp['app']['installed'],
         'app_version' => app_version(), 'src_version' => $sv, 'update' => $sv !== '' && app_version() !== '' && version_compare($sv, app_version(), '>'),
         'settings' => public_settings($s), 'domain' => primary_domain(), 'sub' => $s['sub'], 'fqdn' => $f,
         'cert' => $f !== '' && is_file("/etc/letsencrypt/live/$f/fullchain.pem"), 'vhost' => is_file(VHOST) && strpos((string) file_get_contents(VHOST), 'ALPHA-CALL') !== false,
         'ip' => $s['ip'], 'ip6' => $s['ip6'], 'ports' => $ports, 'firewall' => fw_rules($s), 'unit_current' => is_file(UNITFILE) && file_get_contents(UNITFILE) === unit_text(),
         'trunk' => $s['trunk'] ? public_settings($s)['trunk'] : null, 'running' => running_job()]);
  }
  case 'preflight': {
    $have = []; foreach (PKGS as $k => $list) foreach ($list as $p) $have[$p] = installed($p);
    out(['ok' => true, 'packages' => $have, 'need_mb' => ['core' => 30, 'sfu' => 60, 'voip' => 260], 'run_mb' => ['app' => 60, 'turn' => 15, 'sfu' => 40, 'voip' => 70],
         'download_mb' => ['core' => 3, 'sfu' => 6, 'voip' => 75], 'node_ok' => bin('node') !== '', 'ip' => public_ip(), 'ip6' => public_ip6(), 'typical_secs' => 150] + sys_load());
  }
  case 'install': {
    if (bin('node') === '') fail('Node.js 22.5 or newer is required (provisioning installs it)');
    $want = $arg === '' ? ['core', 'sfu', 'voip'] : array_values(array_intersect(['core', 'sfu', 'voip'], explode(',', $arg)));
    if (!in_array('core', $want, true)) array_unshift($want, 'core');
    $pk = []; foreach ($want as $w) foreach (PKGS[$w] as $p) $pk[] = $p;
    $s = settings(); foreach (['core', 'sfu', 'voip'] as $k) $s['components'][$k] = in_array($k, $want, true); write_settings($s);
    $body = 'set -e; export DEBIAN_FRONTEND=noninteractive; '
      . 'trap "rm -f /usr/sbin/policy-rc.d" EXIT; '
      . 'echo "== 3 Preparing"; '
      . "printf '#!/bin/sh\\nexit 101\\n' > /usr/sbin/policy-rc.d; chmod 755 /usr/sbin/policy-rc.d; "
      . 'echo "== 5 Refreshing the package lists"; apt-get update -q 2>&1 | tail -3 || true; '
      . 'echo "== 8 Installing ' . implode(' ', $pk) . '"; apt-get install -y -q --no-install-recommends ' . implode(' ', array_map('escapeshellarg', $pk)) . '; '
      . 'rm -f /usr/sbin/policy-rc.d; '
      . 'echo "== 62 Copying the AlphaCall app"; /usr/local/sbin/alpha-call deploy-only; '
      . 'echo "== 72 Writing the TURN, SFU and PBX configuration"; /usr/local/sbin/alpha-call configure-norestart; '
      . 'echo "== 84 Opening the call ports in the firewall"; /usr/local/sbin/alpha-call firewall; '
      . 'echo "== 90 Starting the services"; '
      . 'systemctl daemon-reload; for s in coturn janus asterisk; do if dpkg -s "$s" >/dev/null 2>&1; then systemctl enable "$s" >/dev/null 2>&1; systemctl restart "$s" || echo "!! $s did not start"; fi; done; '
      . 'systemctl enable ' . UNIT . ' >/dev/null 2>&1; systemctl restart ' . UNIT . '; '
      . 'echo "== 96 Checking that it answers"; for i in $(seq 1 30); do curl -fs -o /dev/null http://127.0.0.1:' . PORT . '/api/health && break; sleep 1; done; curl -fsS http://127.0.0.1:' . PORT . '/api/health; echo; '
      . 'echo "== 99 Done"';
    out(['ok' => true, 'job' => job_start('install', $body), 'components' => $want]);
  }
  case 'job': out(['ok' => true] + job_view(job_dir($arg)));
  case 'board': { $r = board_build($arg === 'force'); out($r + ['ok' => false]); }
  case 'deploy-only': { ensure_user(); $v = deploy_app(); out(['ok' => true, 'version' => $v]); }
  case 'deploy': {
    ensure_user(); $v = deploy_app(); configure(settings(), false); ensure_unit();
    sh('systemctl enable ' . UNIT . ' && systemctl restart ' . UNIT);
    $ok = false; for ($i = 0; $i < 30; $i++) { [, $rc] = sh('curl -fs -o /dev/null http://127.0.0.1:' . PORT . '/api/health'); if ($rc === 0) { $ok = true; break; } usleep(500000); }
    out(['ok' => true, 'version' => $v, 'ready' => $ok]);
  }
  case 'configure-norestart': { $c = configure(settings(), false); out(['ok' => true, 'changed' => $c]); }
  case 'configure': { $c = configure(settings(), true); out(['ok' => true, 'changed' => $c]); }
  case 'firewall': { firewall(settings()); out(['ok' => true, 'rules' => fw_rules(settings())]); }
  case 'start': case 'stop': case 'restart': {
    $which = $arg === '' || $arg === 'all' ? array_keys(SVC) : [$arg];
    foreach ($which as $w) if (!isset(SVC[$w])) fail('start|stop|restart app|turn|sfu|voip|all');
    $res = [];
    foreach ($which as $w) {
      $svc = SVC[$w];
      if ($w !== 'app' && !installed(['turn' => 'coturn', 'sfu' => 'janus', 'voip' => 'asterisk'][$w])) continue;
      if ($w === 'app' && !is_dir(APPDIR)) continue;
      if ($cmd === 'stop') sh('systemctl disable --now ' . q($svc));
      else { if ($cmd === 'start') configure(settings(), false); sh('systemctl enable ' . q($svc) . ' && systemctl ' . $cmd . ' ' . q($svc)); }
      $res[$w] = svc_active($svc);
    }
    out(['ok' => true, 'active' => $res]);
  }
  case 'set': {
    $k = $arg; $v = (string) ($_SERVER['argv'][3] ?? ''); $s = settings();
    $int = function ($lo, $hi) use ($v, $k) { if (!preg_match('/^\d+$/', $v) || (int) $v < $lo || (int) $v > $hi) fail("$k: $lo-$hi"); return (int) $v; };
    switch ($k) {
      case 'name': $v = trim(preg_replace('/[^\w .\'-]/u', '', $v)); if ($v === '' || mb_strlen($v) > 40) fail('name: 1-40 letters'); $s['name'] = $v; break;
      case 'registration': if (!in_array($v, ['open', 'invite', 'closed'], true)) fail('registration: open|invite|closed'); $s['registration'] = $v; break;
      case 'auth': if (!in_array($v, ['portal', 'local'], true)) fail('auth: portal|local'); $s['auth'] = $v; break;
      case 'android_links':
        foreach (array_filter(explode(',', $v)) as $x) if (!preg_match('/^[a-z][a-z0-9_]*(\.[a-z0-9_]+)+\|([0-9A-F]{2}:){31}[0-9A-F]{2}$/', $x)) fail('android_links: package|SHA256 fingerprint (AA:BB:…), comma separated');
        $s['android_links'] = $v; break;
      case 'ring': $s['ring'] = $int(10, 120); break;
      case 'upload': $s['upload'] = $int(1, 500); break;
      case 'history': $s['history'] = $int(0, 3650); break;
      case 'files': $s['files'] = $int(0, 3650); break;                  // days a chat file is kept (0 = until an admin deletes it)
      case 'room_max': $s['room_max'] = $int(2, 50); break;
      case 'privacy': case 'debug': case 'ip_calls': if (!in_array($v, ['on', 'off'], true)) fail("$k: on|off"); $s[$k] = $v === 'on'; break;
      case 'stun_extra': if ($v !== '' && !preg_match('/^[a-z0-9.:,-]{3,300}$/i', $v)) fail('stun_extra: host:port[,host:port]'); $s['stun_extra'] = $v; break;
      case 'embed_origins':   // sites allowed to put a meeting in an <iframe> (MeetPress for WordPress) — https origins only
        $xs = [];
        foreach (array_filter(array_map('trim', explode(',', $v))) as $x) { if (!preg_match('#^https://[a-z0-9.-]+(:\d+)?$#i', $x)) fail('embed_origins: https://host[:port][,https://host] — origins only, no path'); $xs[] = strtolower($x); }
        $s['embed_origins'] = implode(',', array_unique($xs)); break;
      case 'turn_port': case 'turns_port': case 'sip_port': case 'sip_tls_port': $s[$k] = $int(1024, 65535); break;
      case 'relay': case 'rtp': case 'sfu':
        if (!preg_match('/^(\d+)-(\d+)$/', $v, $m) || (int) $m[1] < 1024 || (int) $m[2] > 65535 || (int) $m[2] - (int) $m[1] < 20 || (int) $m[2] - (int) $m[1] > 10000) fail("$k: LOW-HIGH (1024-65535, 20-10000 ports)");
        $s[$k . '_min'] = (int) $m[1]; $s[$k . '_max'] = (int) $m[2]; break;
      case 'ip': if (!filter_var($v, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) fail('ip: an IPv4 address'); $s['ip'] = $v; break;
      case 'ip6': if ($v !== '' && !filter_var($v, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) fail('ip6: an IPv6 address or empty'); $s['ip6'] = $v; break;
      default: fail('set name|registration|auth|android_links|ring|upload|history|files|room_max|privacy|debug|ip_calls|stun_extra|turn_port|turns_port|sip_port|sip_tls_port|relay|rtp|sfu|ip|ip6 <value>');
    }
    // two components must never share a port range
    $rng = ['relay' => [$s['relay_min'], $s['relay_max']], 'rtp' => [$s['rtp_min'], $s['rtp_max']], 'sfu' => [$s['sfu_min'], $s['sfu_max']]];
    foreach ($rng as $a => $x) foreach ($rng as $b => $y) if ($a < $b && $x[0] <= $y[1] && $y[0] <= $x[1]) fail("the $a and $b port ranges overlap");
    write_settings($s);
    $c = configure($s, true);
    out(['ok' => true, 'settings' => public_settings($s), 'changed' => $c]);
  }
  case 'trunk': {
    $t = json_decode($arg, true); if (!is_array($t)) fail('trunk <json>');
    $s = settings(); $old = $s['trunk'] ?: [];
    $host = strtolower(trim((string) ($t['host'] ?? '')));
    if (!preg_match('/^[a-z0-9.-]+(:\d{2,5})?$/', $host)) fail('host: the provider\'s SIP server, e.g. sip.provider.com');
    $user = (string) ($t['user'] ?? ''); if (!preg_match('/^[\w.+@-]{1,64}$/', $user)) fail('user: the account / username');
    $sec = (string) ($t['secret'] ?? ''); if ($sec === '' && !empty($old['secret'])) $sec = $old['secret'];
    if ($sec === '' || strlen($sec) > 128 || preg_match('/[\r\n]/', $sec)) fail('secret: the account password');
    $cid = preg_replace('/[^+\d]/', '', (string) ($t['cid'] ?? '')); $prefix = preg_replace('/[^+\d]/', '', (string) ($t['prefix'] ?? ''));
    $did = preg_replace('/\D/', '', (string) ($t['did'] ?? '1001')) ?: '1001';
    $s['trunk'] = ['host' => $host, 'user' => $user, 'secret' => $sec, 'cid' => $cid, 'prefix' => $prefix, 'did' => $did];
    write_settings($s); trunk_conf($s); firewall($s);
    out(['ok' => true, 'trunk' => public_settings($s)['trunk']]);
  }
  case 'trunk-clear': { $s = settings(); $s['trunk'] = null; write_settings($s); trunk_conf($s); out(['ok' => true]); }
  case 'trunk-status': {
    if (!svc_active('asterisk')) out(['ok' => true, 'registered' => false, 'state' => 'PBX not running']);
    [$o] = sh('asterisk -rx "pjsip show registrations"');
    $st = preg_match('/alphacall-trunk\/\S+\s+\S+\s+(\w+)/', $o, $m) ? $m[1] : 'not configured';
    out(['ok' => true, 'registered' => $st === 'Registered', 'state' => $st]);
  }
  case 'publish': {
    $sub = strtolower($arg);
    if (!preg_match('/^[a-z0-9](?:[a-z0-9-]{0,38}[a-z0-9])?$/', $sub)) fail('use letters, digits and dashes (1-40), e.g. call');
    $dom = primary_domain(); if ($dom === '') fail('PRIMARY_DOMAIN is not set in /etc/alpha-panel.conf');
    $f = "$sub.$dom";
    [$who] = sh('grep -Rls -E ' . q('server_name[^;]*[[:space:]]' . preg_quote($f, '/') . '[[:space:];]') . ' /etc/nginx/sites-enabled/');
    foreach (array_filter(explode("\n", trim($who))) as $w) if (realpath($w) !== realpath(VHOST)) fail("$f is already served by " . basename($w));
    [$ip] = sh('getent hosts ' . q($f)); if (trim($ip) === '') fail("$f does not resolve — add a DNS record (or a * wildcard) for it first");
    $s = settings(); $old = fqdn($s); $s['sub'] = $sub; write_settings($s);
    if (!is_file("/etc/letsencrypt/live/$f/fullchain.pem")) {
      [$ok, $err] = nginx_apply(vhost_text($f, false)); if (!$ok) fail('nginx refused the server block: ' . $err);
      $em = panel_conf()['ADMIN_EMAIL'] ?? '';
      [$o, $rc] = sh('certbot certonly --webroot -w ' . q(ACME) . ' -d ' . q($f) . ' --non-interactive --agree-tos ' . ($em !== '' ? '-m ' . q($em) : '--register-unsafely-without-email'));
      if ($rc !== 0) fail('could not get a certificate for ' . $f . ': ' . trim(substr($o, -400)));
    }
    [$ok, $err] = nginx_apply(vhost_text($f, true)); if (!$ok) fail('nginx refused the server block: ' . $err);
    if ($old !== '' && $old !== $f) sh('certbot delete --cert-name ' . q($old) . ' --non-interactive');
    $c = configure($s, true);                      // the name is in the env, TURN realm, TLS certs and the dialplan
    out(['ok' => true, 'fqdn' => $f, 'url' => "https://$f/", 'changed' => $c]);
  }
  case 'unpublish': {
    $s = settings(); $f = fqdn($s);
    @unlink(VHOST_ON); @unlink(VHOST); [, $rc] = sh('nginx -t'); if ($rc === 0) sh('systemctl reload nginx');
    if ($f !== '') sh('certbot delete --cert-name ' . q($f) . ' --non-interactive');
    $s['sub'] = ''; write_settings($s); configure($s, true);
    out(['ok' => true]);
  }
  case 'logs': {
    $svc = SVC[$arg] ?? null; if (!$svc) fail('logs app|turn|sfu|voip [lines]');
    $n = max(10, min(500, (int) ($_SERVER['argv'][3] ?? 120)));
    [$o] = sh('journalctl -u ' . q($svc) . ' -n ' . $n . ' --no-pager -o short-iso');
    if ($arg === 'voip' && is_file('/var/log/asterisk/messages.log')) { [$m] = sh('tail -n ' . $n . ' /var/log/asterisk/messages.log'); $o .= "\n--- /var/log/asterisk/messages.log ---\n" . $m; }
    // secrets never leave through the log view
    $sec = settings()['secrets']; foreach ($sec as $x) $o = str_replace($x, '••••', $o);
    out(['ok' => true, 'log' => $o]);
  }
  case 'token': { echo settings()['secrets']['admin'], "\n"; exit(0); }
  case 'token-for': {   // mint an API token that acts as <username> (MeetPress for WordPress, scripts) — printed once, never stored in clear
    $u = $arg; $nm = (string) ($_SERVER['argv'][3] ?? 'integration');
    if ($u === '') fail('usage: alpha-call token-for <username> [name]');
    [$o, $rc] = sh('curl -fsS -X POST -H ' . q('Authorization: Bearer ' . settings()['secrets']['admin']) . ' -H "Content-Type: application/json" -d ' . q(json_encode(['username' => $u, 'name' => $nm])) . ' http://127.0.0.1:' . PORT . '/admin/tokens');
    if ($rc !== 0) fail('the app did not answer (is it running? does that user exist?)');
    echo $o, "\n"; exit(0);
  }
  case 'remove': {
    $purge = $arg === 'purge';
    foreach (SVC as $svc) sh('systemctl disable --now ' . q($svc));
    @unlink(VHOST_ON); @unlink(VHOST); [, $rc] = sh('nginx -t'); if ($rc === 0) sh('systemctl reload nginx');
    if (bin('ufw') !== '') { [$num] = sh('ufw status numbered'); $ns = []; foreach (explode("\n", $num) as $l) if (preg_match('/^\[\s*(\d+)\].*# alpha-call/', $l, $m)) $ns[] = (int) $m[1]; rsort($ns); foreach ($ns as $n) sh('ufw --force delete ' . $n); }
    @unlink(HOOK);
    if (is_file(F2B_JAIL)) { @unlink(F2B_JAIL); sh('fail2ban-client reload 2>&1'); }
    if ($purge) {
      $f = fqdn(); if ($f !== '') sh('certbot delete --cert-name ' . q($f) . ' --non-interactive');
      sh('rm -rf ' . q(APPDIR) . ' ' . q(STATE . '/data') . ' ' . q(ASTINC) . ' ' . q(TURNCERT) . ' ' . q(ASTCERT) . ' ' . q(ENVFILE) . ' ' . q(UNITFILE) . ' ' . q(PRIV));
      sh('DEBIAN_FRONTEND=noninteractive apt-get purge -y -q janus asterisk asterisk-core-sounds-en-wav asterisk-moh-opsound-wav coturn');
      sh('systemctl daemon-reload'); @unlink(STATE . '/settings.json');
    }
    out(['ok' => true, 'purged' => $purge]);
  }
  case 'android-publish': {
    // the signed release APK from the fixed build output (never a caller-supplied path) → served at /download/android
    $src = '/root/androidbuild/AlphaCall/app/build/outputs/apk/release/app-release.apk';
    if (!is_file($src)) fail('no release APK at ' . $src . ' — build Apps/Android/AlphaCall first');
    $dir = STATE . '/data/app'; @mkdir($dir, 0750, true);
    copy($src, $dir . '/AlphaCall.apk'); sh('chown -R ' . USER . ':' . USER . ' ' . q($dir)); @chmod($dir . '/AlphaCall.apk', 0644);
    out(['ok' => true, 'bytes' => filesize($dir . '/AlphaCall.apk'), 'sha256' => hash_file('sha256', $dir . '/AlphaCall.apk')]);
  }
  case 'unit-text': { echo unit_text(); exit(0); }
  case 'unit-check': { out(['ok' => is_file(UNITFILE) && file_get_contents(UNITFILE) === unit_text(), 'installed' => is_file(UNITFILE)]); }
  default: fail('usage: alpha-call status|preflight|install [core,sfu,voip]|job <id>|deploy|configure|start|stop|restart [app|turn|sfu|voip|all]|set <key> <value>|trunk <json>|trunk-clear|trunk-status|publish <sub>|unpublish|logs <svc> [n]|token|token-for <user> [name]|remove [purge]|unit-text|unit-check');
}
