#!/usr/bin/env php
<?php
/* ============================================================
   alpha-clamav — ClamAV on this box, as root.

   Called by the panel's ClamAV add-on through sudo. www-data cannot apt-get,
   cannot read every directory worth scanning, and must never be able to move
   arbitrary files around — so each verb does one narrow thing and prints JSON.

   ⚠️ ROOT-OWNED AND SELF-CONTAINED ON PURPOSE. It must never include anything
   from /var/www/alpha (www-data-writable): root sourcing www-data-writable code
   is a straight path from "web bug" to "root shell".

   ⚠️ IT NEVER PRINTS FILE CONTENTS. Scan results are paths + signature names.
   The only file it moves is one a FINISHED SCAN reported as infected, and only
   into its own quarantine — there is no "move any file" verb.

     status                    engine, signatures, daemon, memory
     install                   clamav + clamav-freshclam + first signatures (background job)
     update                    refresh the signatures now (background job)
     scan <path> [sync [since]] scan a tree (background job; `sync` waits — the cron worker;
                               `since` = unix ts → only files changed since then)
     job <id>                  state + parsed result + log tail of one job
     jobs                      the recent jobs
     cancel <id>               stop a running scan
     quarantine <job> <path>   move a file THAT JOB reported infected into the quarantine
     qlist | restore <q> | qdelete <q>
     daemon on|off             clamd for fast scans (~1.3 GB RAM) — refused when memory is short
     freshlog                  the tail of freshclam's log

   State: /var/lib/alpha-clamav (root, 0700): jobs/<id>/{meta.json,log,rc,pid},
   quarantine/<q>.bin + <q>.json. Output: one JSON object on stdout.
   ============================================================ */

const STATE      = '/var/lib/alpha-clamav';
const MIN_FREE_SCAN_KB   = 1000000;   // clamscan loads ~1.2 GB of signatures: below 1 GB free it would swap the box
const MIN_FREE_DAEMON_KB = 2000000;   // clamd stays resident
const MAX_JOBS   = 40;
const TAIL_LINES = 60;

/* The only trees a scan may be pointed at. Resolved before use. */
const ALLOW = ['/var/www', '/home', '/srv', '/tmp', '/var/tmp', '/opt', '/root', '/etc', '/usr/local',
               '/var/mail', '/var/spool', '/mnt', '/media', '/var/backups', '/var/lib/docker'];

function fail($msg, $code = 2) { fwrite(STDERR, $msg . "\n"); exit($code); }
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 bin($name) { [$o, $rc] = sh('command -v ' . escapeshellarg($name)); return $rc === 0 ? trim($o) : ''; }
function mem_available_kb() { foreach (@file('/proc/meminfo') ?: [] as $l) if (strpos($l, 'MemAvailable:') === 0) return (int) preg_replace('/\D/', '', $l); return 0; }
function svc_active($u) { [$o] = sh('systemctl is-active ' . escapeshellarg($u)); return trim($o) === 'active'; }
function ensure_state() {
  foreach ([STATE, STATE . '/jobs', STATE . '/quarantine'] as $d) if (!is_dir($d)) { @mkdir($d, 0700, true); @chmod($d, 0700); }
}
function jail($arg) {
  if ($arg === '' || $arg[0] !== '/') fail('path must be absolute');
  if (trim($arg, '/') === '') fail('refusing to scan /');
  $real = realpath(rtrim($arg, '/'));
  if ($real === false) fail('no such path');
  $p = rtrim($real, '/');
  if ($p === '') fail('refusing to scan /');
  foreach (ALLOW as $a) if ($p === $a || strpos($p . '/', $a . '/') === 0) return $p;
  fail('that path is not in the allow-list (' . implode(', ', ALLOW) . ')');
}
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 meta($dir) { $m = @json_decode((string) @file_get_contents("$dir/meta.json"), true); return is_array($m) ? $m : []; }
function pid_alive($pid) { return $pid > 0 && @file_exists('/proc/' . (int) $pid); }
function job_state($dir) {
  $m = meta($dir);
  $rc = is_file("$dir/rc") ? (int) trim((string) @file_get_contents("$dir/rc")) : null;
  $pid = (int) trim((string) @file_get_contents("$dir/pid"));
  if ($rc === null && pid_alive($pid)) return ['running', null];
  if ($rc === null) return ['lost', null];                           // started, never finished, process gone (reboot)
  return ['done', $rc];
}
/** Start a detached job: $cmd runs under bash, its output goes to the job log, the exit code to rc. */
function job_start($kind, array $m, $cmd) {
  ensure_state();
  $id = date('ymdHis') . substr(bin2hex(random_bytes(3)), 0, 6);
  $d = STATE . '/jobs/' . $id; @mkdir($d, 0700, true);
  $m += ['id' => $id, 'kind' => $kind, 'started' => time()];
  file_put_contents("$d/meta.json", json_encode($m, JSON_UNESCAPED_SLASHES));
  /* ⚠️ The body runs in a SUBSHELL. Appending the redirect to a compound command only redirects
     its last simple command, and an `exit` inside it ends the job shell before the rc is written —
     the first install ran to completion with an empty log and was reported "lost". */
  $inner = 'echo $$ > ' . escapeshellarg("$d/pid") . '; ( ' . $cmd . ' ) > ' . escapeshellarg("$d/log") . ' 2>&1; echo $? > ' . escapeshellarg("$d/rc");
  exec('setsid nohup bash -c ' . escapeshellarg($inner) . ' > /dev/null 2>&1 &');
  prune_jobs();
  return $id;
}
function prune_jobs() {
  $dirs = glob(STATE . '/jobs/*', GLOB_ONLYDIR) ?: []; rsort($dirs);
  foreach (array_slice($dirs, MAX_JOBS) as $d) { [$st] = job_state($d); if ($st !== 'running') { foreach (glob("$d/*") ?: [] as $f) @unlink($f); @rmdir($d); } }
}
/** Parse a clamscan / clamdscan log: infected files, errors, the summary block. */
function parse_log($dir) {
  $raw = is_file("$dir/log") ? (string) @file_get_contents("$dir/log") : '';
  $inf = []; $errors = 0; $sum = [];
  foreach (explode("\n", $raw) as $l) {
    $l = rtrim($l, "\r");
    if (preg_match('/^(.+?): (.+) FOUND$/', $l, $m)) { $inf[] = ['path' => $m[1], 'sig' => $m[2]]; continue; }
    if (preg_match('/^(.+?): (.+) ERROR$/', $l, $m)) { $errors++; continue; }
    if (preg_match('/^(Scanned files|Infected files|Data scanned|Time|Known viruses|Engine version|Scanned directories):\s*(.+)$/', $l, $m)) $sum[$m[1]] = trim($m[2]);
  }
  $lines = explode("\n", rtrim($raw));
  $tail = implode("\n", array_slice($lines, -TAIL_LINES));
  if (strlen($tail) > 8192) $tail = substr($tail, -8192);
  return ['infected' => $inf, 'errors' => $errors, 'summary' => $sum, 'tail' => $tail, 'log_bytes' => strlen($raw)];
}
function job_view($dir) {
  $m = meta($dir); [$st, $rc] = job_state($dir); $p = parse_log($dir);
  $ended = is_file("$dir/rc") ? (int) @filemtime("$dir/rc") : 0;
  $scanned = isset($p['summary']['Scanned files']) ? (int) preg_replace('/\D/', '', $p['summary']['Scanned files']) : (int) ($m['files_total'] ?? 0);
  $secs = 0; if (isset($p['summary']['Time']) && preg_match('/([\d.]+)\s*sec/', $p['summary']['Time'], $mm)) $secs = (float) $mm[1];
  elseif ($ended && !empty($m['started'])) $secs = max(0, $ended - (int) $m['started']);
  return $m + ['state' => $st, 'rc' => $rc, 'ended' => $ended, 'infected' => $p['infected'], 'infected_n' => count($p['infected']), 'errors' => $p['errors'],
               'scanned' => $scanned, 'secs' => $secs, 'data' => (string) ($p['summary']['Data scanned'] ?? ''), 'engine' => (string) ($p['summary']['Engine version'] ?? ''),
               'known' => (int) preg_replace('/\D/', '', (string) ($p['summary']['Known viruses'] ?? '0')), 'tail' => $p['tail'], 'log_bytes' => $p['log_bytes'],
               'elapsed' => max(0, time() - (int) ($m['started'] ?? time()))];
}
function db_info() {
  $rows = []; $sigtool = bin('sigtool'); $newest = 0; $total = 0;
  foreach (['main', 'daily', 'bytecode'] as $n) {
    $f = ''; foreach (['cld', 'cvd'] as $e) if (is_file("/var/lib/clamav/$n.$e")) { $f = "/var/lib/clamav/$n.$e"; break; }
    if ($f === '') { $rows[] = ['name' => $n, 'present' => false]; continue; }
    $r = ['name' => $n, 'present' => true, 'file' => basename($f), 'bytes' => (int) filesize($f), 'mtime' => (int) filemtime($f), 'version' => 0, 'sigs' => 0];
    if ($sigtool !== '') { [$o] = sh(escapeshellarg($sigtool) . ' --info ' . escapeshellarg($f)); if (preg_match('/Version:\s*(\d+)/', $o, $m)) $r['version'] = (int) $m[1]; if (preg_match('/Signatures:\s*(\d+)/', $o, $m)) $r['sigs'] = (int) $m[1]; }
    $newest = max($newest, $r['mtime']); $total += $r['sigs']; $rows[] = $r;
  }
  return ['rows' => $rows, 'updated' => $newest, 'signatures' => $total];
}

ensure_state();
$argv = $_SERVER['argv']; $cmd = (string) ($argv[1] ?? '');

switch ($cmd) {
  case 'status': {
    $clamscan = bin('clamscan'); $version = ''; $dbver = '';
    if ($clamscan !== '') { [$o] = sh(escapeshellarg($clamscan) . ' --version'); if (preg_match('/ClamAV ([\d.]+)(?:\/(\d+)\/(.+))?/', trim($o), $m)) { $version = $m[1]; $dbver = $m[2] ?? ''; } }
    $daemonRss = 0; if (svc_active('clamav-daemon')) { [$o] = sh('systemctl show -p MemoryCurrent clamav-daemon'); $daemonRss = (int) preg_replace('/\D/', '', $o); }
    $running = []; foreach (glob(STATE . '/jobs/*', GLOB_ONLYDIR) ?: [] as $d) { [$st] = job_state($d); if ($st === 'running') { $m = meta($d); $running[] = ['id' => $m['id'] ?? basename($d), 'kind' => $m['kind'] ?? '', 'path' => $m['path'] ?? '', 'started' => (int) ($m['started'] ?? 0)]; } }
    $db = $clamscan !== '' ? db_info() : ['rows' => [], 'updated' => 0, 'signatures' => 0];
    out(['installed' => $clamscan !== '', 'version' => $version, 'db_version' => $dbver, 'db' => $db['rows'], 'db_updated' => $db['updated'], 'signatures' => $db['signatures'],
         'db_present' => $db['updated'] > 0, 'freshclam' => svc_active('clamav-freshclam'), 'daemon_installed' => bin('clamd') !== '', 'daemon' => svc_active('clamav-daemon'),
         'daemon_rss' => $daemonRss, 'mem_available_kb' => mem_available_kb(), 'min_scan_kb' => MIN_FREE_SCAN_KB, 'min_daemon_kb' => MIN_FREE_DAEMON_KB,
         'running' => $running, 'quarantine' => count(glob(STATE . '/quarantine/*.json') ?: [])]);
  }
  case 'install': {
    if (bin('apt-get') === '') fail('apt-get not found — this helper installs ClamAV from the Ubuntu/Debian archive');
    foreach (glob(STATE . '/jobs/*', GLOB_ONLYDIR) ?: [] as $d) { [$st] = job_state($d); $m = meta($d); if ($st === 'running' && ($m['kind'] ?? '') === 'install') out(['ok' => true, 'job' => $m['id'], 'already' => true]); }
    // freshclam's service grabs the log lock the moment the package lands, so the first signature download is done with it stopped
    $cmd = 'export DEBIAN_FRONTEND=noninteractive; echo "== apt-get install clamav clamav-freshclam"; apt-get install -y -qq --no-install-recommends clamav clamav-freshclam && '
         . 'echo "== first signature download (this takes a few minutes)"; systemctl stop clamav-freshclam 2>/dev/null; freshclam --stdout; rc=$?; '
         . 'systemctl enable --now clamav-freshclam >/dev/null 2>&1; echo "== freshclam service: $(systemctl is-active clamav-freshclam)"; exit $rc';
    out(['ok' => true, 'job' => job_start('install', ['label' => 'Install ClamAV'], $cmd)]);
  }
  case 'update': {
    if (bin('freshclam') === '') fail('freshclam is not installed');
    $cmd = 'systemctl stop clamav-freshclam 2>/dev/null; freshclam --stdout; rc=$?; systemctl start clamav-freshclam 2>/dev/null; exit $rc';
    out(['ok' => true, 'job' => job_start('update', ['label' => 'Update signatures'], $cmd)]);
  }
  case 'scan': {
    $path = jail((string) ($argv[2] ?? ''));
    $sync = (string) ($argv[3] ?? '') === 'sync';
    $since = $sync ? (string) ($argv[4] ?? '') : '';
    if ($since !== '' && !preg_match('/^\d{9,11}$/', $since)) fail('bad since timestamp');
    $clamscan = bin('clamscan'); if ($clamscan === '') fail('clamscan is not installed');
    $flags = '--infected --max-filesize=64M --max-scansize=1024M --max-recursion=24';
    if ($since !== '') {
      /* Incremental (the nightly worker): only files whose inode changed since the last clean pass.
         ctime, not mtime — `tar x` / `cp -p` back-date mtime but cannot back-date ctime. When nothing
         changed the signature database is never loaded at all, and that load IS the ~1 GB, hour-long
         memory spike a full nightly pass put on the dashboard. A weekly full pass (worker side) covers
         new signatures against old files. */
      ensure_state();
      $id = date('ymdHis') . substr(bin2hex(random_bytes(3)), 0, 6);
      $d = STATE . '/jobs/' . $id; @mkdir($d, 0700, true);
      sh('find ' . escapeshellarg($path) . ' -xdev -type f -newerct ' . escapeshellarg('@' . $since) . ' -print > ' . escapeshellarg("$d/files.txt"));
      $n = 0; $fh = @fopen("$d/files.txt", 'r'); if ($fh) { while (fgets($fh) !== false) $n++; fclose($fh); }
      if ($n > 0 && mem_available_kb() < MIN_FREE_SCAN_KB) { @unlink("$d/files.txt"); @rmdir($d); fail('not enough free memory for a scan right now (' . round(mem_available_kb() / 1024) . ' MB available, needs ' . round(MIN_FREE_SCAN_KB / 1024) . ' MB)'); }
      $useDaemon = svc_active('clamav-daemon') && bin('clamdscan') !== '';
      $m = ['label' => 'Scan ' . $path . ' (changed files)', 'path' => $path, 'files_total' => $n, 'scanner' => $useDaemon ? 'clamdscan' : 'clamscan',
            'mode' => 'incremental', 'since' => (int) $since];
      file_put_contents("$d/meta.json", json_encode($m + ['id' => $id, 'kind' => 'scan', 'started' => time(), 'sync' => true], JSON_UNESCAPED_SLASHES));
      file_put_contents("$d/pid", (string) getmypid());
      $rc = 0;
      if ($n === 0) file_put_contents("$d/log", "Scanned files: 0\nInfected files: 0\nData scanned: 0 B\nTime: 0.000 sec (0 m 0 s)\n");
      else passthru(($useDaemon ? 'clamdscan --fdpass --multiscan --infected' : 'nice -n 15 ionice -c3 clamscan ' . $flags)
                    . ' --file-list=' . escapeshellarg("$d/files.txt") . ' > ' . escapeshellarg("$d/log") . ' 2>&1', $rc);
      file_put_contents("$d/rc", (string) $rc);
      prune_jobs();
      out(['ok' => true] + job_view($d));
    }
    if (mem_available_kb() < MIN_FREE_SCAN_KB) fail('not enough free memory for a scan right now (' . round(mem_available_kb() / 1024) . ' MB available, needs ' . round(MIN_FREE_SCAN_KB / 1024) . ' MB) — the signature database alone needs ~1.2 GB while a scan runs');
    foreach (glob(STATE . '/jobs/*', GLOB_ONLYDIR) ?: [] as $d) { [$st] = job_state($d); $m = meta($d); if ($st === 'running' && ($m['kind'] ?? '') === 'scan' && ($m['path'] ?? '') === $path) out(['ok' => true, 'job' => $m['id'], 'already' => true]); }
    [$cnt] = sh('find ' . escapeshellarg($path) . ' -xdev -type f 2>/dev/null | wc -l');
    $useDaemon = svc_active('clamav-daemon') && bin('clamdscan') !== '';
    $scanner = $useDaemon
      ? 'clamdscan --fdpass --multiscan --infected ' . escapeshellarg($path)
      : 'nice -n 15 ionice -c3 clamscan -r ' . $flags . ' --cross-fs=no ' . escapeshellarg($path);
    $m = ['label' => 'Scan ' . $path, 'path' => $path, 'files_total' => (int) trim($cnt), 'scanner' => $useDaemon ? 'clamdscan' : 'clamscan'];
    if (!$sync) out(['ok' => true, 'job' => job_start('scan', $m, $scanner)]);
    // synchronous: the nightly worker waits for the result
    ensure_state();
    $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($m + ['id' => $id, 'kind' => 'scan', 'started' => time(), 'sync' => true], JSON_UNESCAPED_SLASHES));
    file_put_contents("$d/pid", (string) getmypid());
    passthru($scanner . ' > ' . escapeshellarg("$d/log") . ' 2>&1', $rc);
    file_put_contents("$d/rc", (string) $rc);
    prune_jobs();
    out(['ok' => true] + job_view($d));
  }
  case 'job':    out(['ok' => true] + job_view(job_dir((string) ($argv[2] ?? ''))));
  case 'jobs': {
    $dirs = glob(STATE . '/jobs/*', GLOB_ONLYDIR) ?: []; rsort($dirs); $rows = [];
    foreach (array_slice($dirs, 0, MAX_JOBS) as $d) { $v = job_view($d); unset($v['tail'], $v['infected']); $rows[] = $v; }
    out(['ok' => true, 'jobs' => $rows]);
  }
  case 'cancel': {
    $d = job_dir((string) ($argv[2] ?? '')); [$st] = job_state($d);
    if ($st !== 'running') out(['ok' => false, 'msg' => 'that job is not running']);
    $pid = (int) trim((string) @file_get_contents("$d/pid"));
    /* ⚠️ posix_kill, not `kill -TERM -- -pid` through exec: /bin/sh is dash, whose kill rejects `--`
       ("Illegal number") — Cancel reported success for months while the scan kept running. */
    $sig = function ($n) use ($pid) { if (!@posix_kill(-$pid, $n)) @posix_kill($pid, $n); };
    $sig(15); usleep(300000); if (pid_alive($pid)) $sig(9);
    if (!is_file("$d/rc")) file_put_contents("$d/rc", '130');
    @file_put_contents("$d/log", "\n== cancelled by the operator\n", FILE_APPEND);
    out(['ok' => true]);
  }
  case 'quarantine': {
    /* Only a path that a FINISHED scan reported as infected. That is the whole safety of this verb:
       without it, "quarantine" is a sudo rule that moves any file on the box. */
    $d = job_dir((string) ($argv[2] ?? '')); [$st] = job_state($d);
    if ($st !== 'done') fail('that scan has not finished');
    $path = (string) ($argv[3] ?? ''); $hit = null;
    foreach (parse_log($d)['infected'] as $i) if ($i['path'] === $path) { $hit = $i; break; }
    if (!$hit) fail('that file was not reported infected by that scan');
    if (!is_file($path) || is_link($path)) fail('the file is no longer there (or is a symlink)');
    $st2 = stat($path); $q = date('ymdHis') . substr(bin2hex(random_bytes(3)), 0, 6);
    $dest = STATE . '/quarantine/' . $q . '.bin';
    if (!@rename($path, $dest)) { [$o, $rc] = sh('mv -f ' . escapeshellarg($path) . ' ' . escapeshellarg($dest)); if ($rc !== 0) fail('could not move the file: ' . $o); }
    @chmod($dest, 0600);
    file_put_contents(STATE . '/quarantine/' . $q . '.json', json_encode(['id' => $q, 'path' => $path, 'sig' => $hit['sig'], 'uid' => $st2['uid'], 'gid' => $st2['gid'], 'mode' => $st2['mode'] & 07777, 'bytes' => $st2['size'], 'at' => time(), 'job' => basename($d)], JSON_UNESCAPED_SLASHES));
    out(['ok' => true, 'id' => $q]);
  }
  case 'qlist': {
    $rows = []; foreach (glob(STATE . '/quarantine/*.json') ?: [] as $f) { $m = @json_decode((string) @file_get_contents($f), true); if (is_array($m)) $rows[] = $m; }
    usort($rows, fn($a, $b) => ($b['at'] ?? 0) <=> ($a['at'] ?? 0));
    out(['ok' => true, 'items' => $rows]);
  }
  case 'restore': case 'qdelete': {
    $q = (string) ($argv[2] ?? ''); if (!preg_match('/^[a-z0-9]{8,24}$/', $q)) fail('bad quarantine id');
    $mf = STATE . '/quarantine/' . $q . '.json'; $bf = STATE . '/quarantine/' . $q . '.bin';
    $m = @json_decode((string) @file_get_contents($mf), true); if (!is_array($m) || !is_file($bf)) fail('no such quarantined file');
    if ($cmd === 'qdelete') { @unlink($bf); @unlink($mf); out(['ok' => true]); }
    if (file_exists($m['path'])) fail('something already exists at the original path — not overwriting it');
    if (!is_dir(dirname($m['path']))) fail('the original directory no longer exists');
    if (!@rename($bf, $m['path'])) { [$o, $rc] = sh('mv -f ' . escapeshellarg($bf) . ' ' . escapeshellarg($m['path'])); if ($rc !== 0) fail('could not move the file back: ' . $o); }
    @chown($m['path'], (int) $m['uid']); @chgrp($m['path'], (int) $m['gid']); @chmod($m['path'], (int) $m['mode']);
    @unlink($mf);
    out(['ok' => true, 'path' => $m['path']]);
  }
  case 'daemon': {
    $on = (string) ($argv[2] ?? '') === 'on';
    if ($on) {
      if (mem_available_kb() < MIN_FREE_DAEMON_KB) fail('not enough free memory to keep clamd resident (' . round(mem_available_kb() / 1024) . ' MB available, needs ' . round(MIN_FREE_DAEMON_KB / 1024) . ' MB)');
      $cmd = 'export DEBIAN_FRONTEND=noninteractive; apt-get install -y -qq --no-install-recommends clamav-daemon && systemctl enable --now clamav-daemon && echo "== clamd: $(systemctl is-active clamav-daemon)"';
      out(['ok' => true, 'job' => job_start('daemon', ['label' => 'Start the ClamAV daemon'], $cmd)]);
    }
    sh('systemctl disable --now clamav-daemon');
    out(['ok' => true, 'daemon' => svc_active('clamav-daemon')]);
  }
  case 'freshlog': {
    $f = '/var/log/clamav/freshclam.log'; $lines = is_file($f) ? array_slice(@file($f, FILE_IGNORE_NEW_LINES) ?: [], -25) : [];
    out(['ok' => true, 'lines' => $lines, 'active' => svc_active('clamav-freshclam')]);
  }
  default:
    fail('usage: alpha-clamav status|install|update|scan <path> [sync [since]]|job <id>|jobs|cancel <id>|quarantine <job> <path>|qlist|restore <id>|qdelete <id>|daemon on|off|freshlog');
}
