#!/bin/bash
# BaldCanary Appliance Installer
# An Open Source Canary Honeypot Appliance from Robbie Ferguson
# (c) 2026 Robbie Ferguson
#
# This installer builds a Debian Stable appliance foundation.
# It is intended to be run by the appliance builder before distribution.

set -euo pipefail

APP_NAME="BaldCanary"
APP_SLUG="baldcanary"
APP_USER="baldcanary"
APP_GROUP="baldcanary"
APP_ROOT="/opt/baldcanary"
APP_DB="${APP_ROOT}/db/baldcanary.sqlite"
APP_LOG="${APP_ROOT}/logs/baldcanary.log"
INSTALL_LOG="/var/log/baldcanary-install.log"
NGINX_SITES_AVAILABLE="/etc/nginx/sites-available"
NGINX_SITES_ENABLED="/etc/nginx/sites-enabled"
ADMIN_NGINX_CONF="${NGINX_SITES_AVAILABLE}/baldcanary-admin.conf"
DECOY_NGINX_CONF="${NGINX_SITES_AVAILABLE}/baldcanary-decoy.conf"
OPENCANARY_CONF_DIR="/etc/opencanaryd"
OPENCANARY_CONF="${OPENCANARY_CONF_DIR}/opencanary.conf"
SSL_DIR="/etc/ssl/baldcanary"
PHP_FPM_SOCK=""

export DEBIAN_FRONTEND=noninteractive

log() {
  echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a "$INSTALL_LOG"
}

fail() {
  echo "ERROR: $*" >&2
  exit 1
}

require_root() {
  if [[ "${EUID}" -ne 0 ]]; then
    fail "This installer must be run as root."
  fi
}

usage() {
  cat <<'USAGE'
Usage:
  ./installer.sh          Install or update BaldCanary Appliance files
  ./installer.sh --purge  Remove BaldCanary Appliance files and packages installed specifically for it

Note:
  This installer is for building a distributable appliance. End-user setup happens on first access.
USAGE
}

purge() {
  require_root
  log "Purging BaldCanary Appliance..."

  systemctl disable --now baldcanary-admin.service 2>/dev/null || true
  systemctl disable --now baldcanary-admin-timeout.timer 2>/dev/null || true
  systemctl disable --now baldcanary-decoylog.service 2>/dev/null || true
  systemctl disable --now opencanary.service 2>/dev/null || true

  rm -f /etc/systemd/system/baldcanary-admin.service
  rm -f /etc/systemd/system/baldcanary-admin-off.service
  rm -f /etc/systemd/system/baldcanary-admin-timeout.timer
  rm -f /etc/systemd/system/baldcanary-decoylog.service
  rm -f /etc/systemd/system/opencanary.service
  systemctl daemon-reload || true

  rm -f "$NGINX_SITES_ENABLED/baldcanary-admin.conf" "$NGINX_SITES_ENABLED/baldcanary-decoy.conf"
  rm -f "$ADMIN_NGINX_CONF" "$DECOY_NGINX_CONF"
  rm -f /usr/local/bin/baldcanary
  rm -rf "$APP_ROOT" "$SSL_DIR" "$OPENCANARY_CONF_DIR"

  if id "$APP_USER" >/dev/null 2>&1; then
    userdel "$APP_USER" 2>/dev/null || true
  fi

  systemctl reload nginx 2>/dev/null || true
  log "Purge complete. Packages were left installed to avoid removing shared dependencies."
}

find_php_fpm_sock() {
  local sock
  sock=$(find /run/php -maxdepth 1 -type s -name 'php*-fpm.sock' 2>/dev/null | sort -Vr | head -n1 || true)
  if [[ -z "$sock" ]]; then
    fail "Could not find PHP-FPM socket under /run/php. Is php-fpm installed and running?"
  fi
  PHP_FPM_SOCK="$sock"
}

install_packages() {
  log "Installing Debian packages..."
  apt-get update
  apt-get install -y \
    ca-certificates curl jq openssl sqlite3 sudo \
    nginx \
    php-fpm php-cli php-sqlite3 php-curl php-mbstring php-xml php-zip \
    python3 python3-dev python3-pip python3-venv python3-virtualenv python3-scapy \
    libssl-dev libpcap-dev build-essential \
    samba-common-bin logrotate

  systemctl enable --now nginx
  systemctl enable --now php*-fpm 2>/dev/null || true
  find_php_fpm_sock
}

create_user_and_dirs() {
  log "Creating application user and directory layout..."

  if ! id "$APP_USER" >/dev/null 2>&1; then
    useradd --system --home "$APP_ROOT" --shell /usr/sbin/nologin "$APP_USER"
  fi

  if id www-data >/dev/null 2>&1; then
    usermod -aG "$APP_GROUP" www-data
  fi

  mkdir -p \
    "$APP_ROOT/app/admin" \
    "$APP_ROOT/app/decoy" \
    "$APP_ROOT/app/common" \
    "$APP_ROOT/app/reports" \
    "$APP_ROOT/config" \
    "$APP_ROOT/db" \
    "$APP_ROOT/logs" \
    "$APP_ROOT/profiles" \
    "$APP_ROOT/scripts" \
    "$APP_ROOT/venv" \
    "$SSL_DIR" \
    "$OPENCANARY_CONF_DIR"

  touch "$APP_LOG"

  chown -R "$APP_USER:$APP_GROUP" "$APP_ROOT"

  chmod 755 "$APP_ROOT"
  chmod 755 "$APP_ROOT/app"
  chmod 755 "$APP_ROOT/app/admin"
  chmod 755 "$APP_ROOT/app/decoy"
  chmod 755 "$APP_ROOT/app/common"
  chmod 755 "$APP_ROOT/app/reports"

  chmod 775 "$APP_ROOT/db" "$APP_ROOT/logs" "$APP_ROOT/config"
}

install_python_env() {
  log "Installing OpenCanary Python environment..."

  if [[ ! -x "$APP_ROOT/venv/bin/python" ]]; then
    python3 -m venv "$APP_ROOT/venv"
  fi

  "$APP_ROOT/venv/bin/pip" install --upgrade pip wheel setuptools
  "$APP_ROOT/venv/bin/pip" install --upgrade opencanary requests scapy pcapy-ng
  chown -R "$APP_USER:$APP_GROUP" "$APP_ROOT/venv"
}

init_sqlite() {
  log "Creating SQLite schema..."

  sqlite3 "$APP_DB" <<'SQL'
PRAGMA journal_mode=WAL;

CREATE TABLE IF NOT EXISTS settings (
  key TEXT PRIMARY KEY,
  value TEXT NOT NULL,
  updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE IF NOT EXISTS users (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  username TEXT NOT NULL UNIQUE,
  password_hash TEXT NOT NULL,
  created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
  last_login_at TEXT
);

CREATE TABLE IF NOT EXISTS events (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  event_time TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
  source TEXT NOT NULL,
  severity TEXT NOT NULL DEFAULT 'info',
  event_type TEXT NOT NULL,
  src_ip TEXT,
  user_agent TEXT,
  method TEXT,
  path TEXT,
  query TEXT,
  post_data TEXT,
  matched_bait TEXT,
  raw_json TEXT
);

CREATE INDEX IF NOT EXISTS idx_events_time ON events(event_time);
CREATE INDEX IF NOT EXISTS idx_events_type ON events(event_type);
CREATE INDEX IF NOT EXISTS idx_events_src_ip ON events(src_ip);

CREATE TABLE IF NOT EXISTS canary_tokens (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  token_id TEXT NOT NULL UNIQUE,
  profile TEXT NOT NULL,
  token_type TEXT NOT NULL,
  fake_value_hash TEXT NOT NULL,
  display_context TEXT,
  created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE IF NOT EXISTS webhook_targets (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  name TEXT NOT NULL,
  type TEXT NOT NULL,
  url TEXT NOT NULL,
  enabled INTEGER NOT NULL DEFAULT 1,
  created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE IF NOT EXISTS alert_log (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  event_id INTEGER,
  target_id INTEGER,
  attempted_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
  ok INTEGER NOT NULL DEFAULT 0,
  response_code INTEGER,
  response_body TEXT,
  error TEXT
);

INSERT OR IGNORE INTO settings(key, value) VALUES('setup_complete', '0');
INSERT OR IGNORE INTO settings(key, value) VALUES('admin_enabled', '1');
INSERT OR IGNORE INTO settings(key, value) VALUES('active_profile', '');
INSERT OR IGNORE INTO settings(key, value) VALUES('admin_port', '');
INSERT OR IGNORE INTO settings(key, value) VALUES('admin_expires_at', '');
INSERT OR IGNORE INTO settings(key, value) VALUES('appliance_name', 'BaldCanary Appliance');
INSERT OR IGNORE INTO settings(key, value) VALUES('timezone', '');
SQL

chown -R "$APP_USER:$APP_GROUP" "$APP_ROOT/db"
chmod 775 "$APP_ROOT/db"
chmod 660 "$APP_DB"
}

write_opencanary_event_map() {
  log "Generating OpenCanary event label map..."

  "$APP_ROOT/venv/bin/python" <<'PY'
import inspect
import json
import re
from pathlib import Path

from opencanary import logger

out = {}

def humanize(name: str) -> str:
    name = re.sub(r'^LOG_', '', name)
    parts = name.split('_')

    pretty = []
    acronyms = {
        'FTP', 'HTTP', 'HTTPS', 'SSH', 'SMB', 'MYSQL', 'MSSQL',
        'RDP', 'VNC', 'NTP', 'SNMP', 'TFTP', 'TCP', 'UDP',
        'DNS', 'LDAP', 'REDIS', 'SIP'
    }

    for part in parts:
        if part in acronyms:
            pretty.append(part)
        elif part == 'LOGIN':
            pretty.append('Login')
        elif part == 'ATTEMPT':
            pretty.append('Attempt')
        elif part == 'CONNECTION':
            pretty.append('Connection')
        elif part == 'NEW':
            pretty.append('New')
        else:
            pretty.append(part.capitalize())

    label = ' '.join(pretty)

    # A few nicer aliases.
    label = label.replace('RDP Connection Made', 'RDP Connection')
    label = label.replace('HTTP GET', 'HTTP GET Request')
    label = label.replace('HTTP POST', 'HTTP POST Request')

    return label

for cls_name in ('CanaryLogger', 'PyLogger'):
    cls = getattr(logger, cls_name, None)
    if not cls:
        continue

    for name, value in inspect.getmembers(cls):
        if not name.startswith('LOG_'):
            continue
        if isinstance(value, int):
            out[str(value)] = humanize(name)

Path('/opt/baldcanary/config/opencanary_event_labels.json').write_text(
    json.dumps(out, indent=2, sort_keys=True)
)
PY

  chown "$APP_USER:$APP_GROUP" "$APP_ROOT/config/opencanary_event_labels.json"
  chmod 664 "$APP_ROOT/config/opencanary_event_labels.json"
}

write_common_php() {
  log "Writing shared PHP helpers..."

  cat > "$APP_ROOT/app/common/db.php" <<'PHP'
<?php
const BC_DB = '/opt/baldcanary/db/baldcanary.sqlite';

function bc_db(): PDO {
    static $pdo = null;
    if ($pdo instanceof PDO) {
        return $pdo;
    }
    $pdo = new PDO('sqlite:' . BC_DB, null, null, [
        PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
        PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
    ]);
    $pdo->exec('PRAGMA journal_mode=WAL');
    return $pdo;
}

function bc_setting(string $key, ?string $default = null): ?string {
    $stmt = bc_db()->prepare('SELECT value FROM settings WHERE key = ?');
    $stmt->execute([$key]);
    $row = $stmt->fetch();
    return $row ? $row['value'] : $default;
}

function bc_set_setting(string $key, string $value): void {
    $stmt = bc_db()->prepare('INSERT INTO settings(key, value, updated_at) VALUES(?, ?, CURRENT_TIMESTAMP)
        ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=CURRENT_TIMESTAMP');
    $stmt->execute([$key, $value]);
}
PHP

  cat > "$APP_ROOT/app/common/functions.php" <<'PHP'
<?php
require_once __DIR__ . '/db.php';

function h(?string $value): string {
    return htmlspecialchars((string)$value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}

function bc_client_ip(): string {
    foreach (['HTTP_CF_CONNECTING_IP', 'HTTP_X_FORWARDED_FOR', 'REMOTE_ADDR'] as $key) {
        if (!empty($_SERVER[$key])) {
            $ip = explode(',', $_SERVER[$key])[0];
            return trim($ip);
        }
    }
    return 'unknown';
}

function bc_log_event(array $event): void {
    $db = bc_db();
    $stmt = $db->prepare('INSERT INTO events
        (source, severity, event_type, src_ip, user_agent, method, path, query, post_data, matched_bait, raw_json)
        VALUES (:source, :severity, :event_type, :src_ip, :user_agent, :method, :path, :query, :post_data, :matched_bait, :raw_json)');
    $stmt->execute([
        ':source' => $event['source'] ?? 'web',
        ':severity' => $event['severity'] ?? 'info',
        ':event_type' => $event['event_type'] ?? 'page_view',
        ':src_ip' => $event['src_ip'] ?? bc_client_ip(),
        ':user_agent' => $event['user_agent'] ?? ($_SERVER['HTTP_USER_AGENT'] ?? ''),
        ':method' => $event['method'] ?? ($_SERVER['REQUEST_METHOD'] ?? ''),
        ':path' => $event['path'] ?? parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH),
        ':query' => $event['query'] ?? ($_SERVER['QUERY_STRING'] ?? ''),
        ':post_data' => $event['post_data'] ?? null,
        ':matched_bait' => $event['matched_bait'] ?? null,
        ':raw_json' => json_encode($event, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE),
    ]);
}

function bc_detection_for_request(): array {
    $uri = $_SERVER['REQUEST_URI'] ?? '/';
    $ua = $_SERVER['HTTP_USER_AGENT'] ?? '';
    $cookie = $_SERVER['HTTP_COOKIE'] ?? '';
    $body = '';
    if ($_SERVER['REQUEST_METHOD'] === 'POST') {
        $body = file_get_contents('php://input') ?: '';
    }
    $haystack = strtolower($uri . "\n" . $ua . "\n" . $cookie . "\n" . $body);

    $rules = [
        'xss_probe' => ['<script', 'onerror=', 'javascript:', 'onload=', 'alert('],
        'sql_injection_probe' => ['union select', "' or '1'='1", ' or 1=1', 'sleep(', 'benchmark('],
        'command_injection_probe' => [';id', '|id', '&& id', 'cmd.exe', 'powershell', '/bin/sh', 'wget ', 'curl '],
        'path_traversal_probe' => ['../', '..%2f', '%2e%2e%2f', '/etc/passwd', 'boot.ini'],
        'sensitive_file_probe' => ['.env', 'wp-config.php', 'id_rsa', 'composer.json', 'config.php', '.git/config'],
        'session_file_probe' => ['/sessions/', 'sess_', '/var/lib/php/sessions', '/tmp/sessions'],
    ];

    foreach ($rules as $type => $patterns) {
        foreach ($patterns as $pattern) {
            if (str_contains($haystack, strtolower($pattern))) {
                return [$type, $pattern];
            }
        }
    }

    return ['page_view', null];
}

function bc_local_timezone(): string {
    $tz = bc_setting('timezone', '');
    if ($tz !== '') {
        return $tz;
    }

    $systemTz = trim((string)@shell_exec('timedatectl show -p Timezone --value 2>/dev/null'));
    if ($systemTz !== '') {
        return $systemTz;
    }

    return date_default_timezone_get() ?: 'UTC';
}

function bc_local_time(?string $utcTime): string {
    $utcTime = trim((string)$utcTime);
    if ($utcTime === '') {
        return '';
    }

    try {
        $dt = new DateTime($utcTime, new DateTimeZone('UTC'));
        $dt->setTimezone(new DateTimeZone(bc_local_timezone()));
        return $dt->format('F j, Y g:i:s A');
    } catch (Throwable $e) {
        return $utcTime;
    }
}

function bc_event_label(?string $type): string {
    $type = trim((string)$type);

    $labels = [
        'page_view' => 'Page View',
        'form_submit' => 'Form Submission',
        'xss_probe' => 'XSS Probe',
        'sql_injection_probe' => 'SQL Injection Probe',
        'command_injection_probe' => 'Command Injection Probe',
        'path_traversal_probe' => 'Path Traversal Probe',
        'sensitive_file_probe' => 'Sensitive File Probe',
        'session_file_probe' => 'Session File Probe',
        'exposed_session_directory' => 'Exposed Session Directory',
        'php_session_file' => 'PHP Session File Access',
        'backup_directory' => 'Backup Directory Access',
        'mysql_backup_directory' => 'MySQL Backup Directory Access',
        'api_docs' => 'API Documentation Access',
        'swagger_docs' => 'Swagger Documentation Access',
        'phpinfo_probe' => 'PHP Info Probe',
        'env_file_probe' => 'Environment File Probe',
        'config_file_probe' => 'Config File Probe',
    ];

    $mapFile = '/opt/baldcanary/config/opencanary_event_labels.json';
    if (is_readable($mapFile)) {
        $oc = json_decode((string)file_get_contents($mapFile), true);
        if (is_array($oc)) {
            $labels = $oc + $labels;
        }
    }

    if (isset($labels[$type])) {
        return $labels[$type];
    }

    if (preg_match('/^[a-z0-9_\-\.]+$/i', $type)) {
        return ucwords(str_replace(['_', '-', '.'], ' ', $type));
    }

    return $type !== '' ? $type : 'Unknown Event';
}
PHP

  chown -R "$APP_USER:$APP_GROUP" "$APP_ROOT/app/common"
}

write_admin_app() {
  log "Writing admin console scaffold..."

  cat > "$APP_ROOT/app/admin/index.php" <<'PHP'
<?php
session_start();
require_once __DIR__ . '/../common/functions.php';

$db = bc_db();
$setupComplete = bc_setting('setup_complete', '0') === '1';
$error = '';
$message = '';

function require_login(): void {
    if (empty($_SESSION['bc_user'])) {
        header('Location: /?login=1');
        exit;
    }
}

if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['setup'])) {
    $username = trim($_POST['username'] ?? '');
    $password = (string)($_POST['password'] ?? '');
    $confirm = (string)($_POST['confirm'] ?? '');
    $profile = trim($_POST['profile'] ?? '');

    if ($username === '' || strlen($password) < 12 || $password !== $confirm || $profile === '') {
        $error = 'Create an admin user, choose a profile, and use a matching password of at least 12 characters.';
    } else {
        $hash = password_hash($password, PASSWORD_DEFAULT);
        $stmt = $db->prepare('INSERT INTO users(username, password_hash) VALUES(?, ?)');
        $stmt->execute([$username, $hash]);
        bc_set_setting('setup_complete', '1');
        bc_set_setting('active_profile', $profile);
        $_SESSION['bc_user'] = $username;
        $setupComplete = true;
        $message = 'Setup complete. Configure alert integrations before disabling Admin Mode.';
    }
}

if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['login'])) {
    $username = trim($_POST['username'] ?? '');
    $password = (string)($_POST['password'] ?? '');
    $stmt = $db->prepare('SELECT * FROM users WHERE username = ?');
    $stmt->execute([$username]);
    $user = $stmt->fetch();
    if ($user && password_verify($password, $user['password_hash'])) {
        $_SESSION['bc_user'] = $username;
        $db->prepare('UPDATE users SET last_login_at = CURRENT_TIMESTAMP WHERE id = ?')->execute([$user['id']]);
        header('Location: /');
        exit;
    }
    $error = 'Invalid username or password.';
}

if (isset($_GET['logout'])) {
    session_destroy();
    header('Location: /');
    exit;
}

if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['save_profile'])) {
    require_login();
    $profile = trim($_POST['profile'] ?? '');
    if ($profile !== '') {
        bc_set_setting('active_profile', $profile);
        $message = 'Active deception profile saved.';
    }
}

if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['add_webhook'])) {
    require_login();
    $stmt = $db->prepare('INSERT INTO webhook_targets(name, type, url, enabled) VALUES(?, ?, ?, 1)');
    $stmt->execute([
        trim($_POST['name'] ?? 'Webhook'),
        trim($_POST['type'] ?? 'generic'),
        trim($_POST['url'] ?? ''),
    ]);
    $message = 'Webhook target saved.';
}

if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['disable_admin'])) {
    require_login();
    $profile = trim($_POST['profile'] ?? bc_setting('active_profile', ''));
    if ($profile === '') {
        $error = 'Choose a deception profile before disabling Admin Mode.';
    } else {
        bc_set_setting('active_profile', $profile);
        shell_exec('sudo /usr/local/bin/baldcanary admin off 2>&1');
        echo '<!doctype html><title>Admin Mode Disabled</title><body style="font-family:sans-serif;padding:2rem"><h1>Admin Mode Disabled</h1><p>BaldCanary is returning to Monitor Mode.</p><p>To re-enable the admin console, access the appliance console directly and run:</p><pre>sudo baldcanary admin on</pre></body>';
        exit;
    }
}

$profiles = [
    'dev-server' => 'Multi-Link Dev Server',
    'legacy-lamp' => 'Legacy LAMP Server',
    'backup-nas' => 'Backup / NAS Server',
    'database-utility' => 'Database Utility Server',
];
$activeProfile = bc_setting('active_profile', '');
$adminExpires = bc_setting('admin_expires_at', '');
$adminPort = bc_setting('admin_port', '');
$events = $db->query('SELECT * FROM events ORDER BY id DESC LIMIT 50')->fetchAll();
$webhooks = $db->query('SELECT * FROM webhook_targets ORDER BY id DESC')->fetchAll();
?>
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>BaldCanary Admin</title>
  <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
  <style>
    body { background:#f6f7fb; }
    .card { border:0; border-radius:1rem; box-shadow:0 0.5rem 1.5rem rgba(0,0,0,.06); }
    .badge-high { background:#dc3545; }
    .badge-medium { background:#fd7e14; }
    .badge-low { background:#0d6efd; }
  </style>
</head>
<body>
<nav class="navbar navbar-dark bg-dark">
  <div class="container-fluid">
    <span class="navbar-brand mb-0 h1">BaldCanary Appliance</span>
    <?php if (!empty($_SESSION['bc_user'])): ?><a class="btn btn-sm btn-outline-light" href="?logout=1">Logout</a><?php endif; ?>
  </div>
</nav>
<main class="container py-4">
<?php if ($error): ?><div class="alert alert-danger"><?=h($error)?></div><?php endif; ?>
<?php if ($message): ?><div class="alert alert-success"><?=h($message)?></div><?php endif; ?>

<?php if (!$setupComplete): ?>
  <div class="card p-4 mx-auto" style="max-width:720px">
    <h1 class="h3">First-run setup</h1>
    <p class="text-muted">Create the first admin account and choose the initial deception profile.</p>
    <form method="post">
      <input type="hidden" name="setup" value="1">
      <div class="mb-3"><label class="form-label">Admin username</label><input class="form-control" name="username" required></div>
      <div class="mb-3"><label class="form-label">Password</label><input type="password" class="form-control" name="password" minlength="12" required></div>
      <div class="mb-3"><label class="form-label">Confirm password</label><input type="password" class="form-control" name="confirm" minlength="12" required></div>
      <div class="mb-3"><label class="form-label">Initial deception profile</label><select class="form-select" name="profile" required><option value="">Choose...</option><?php foreach ($profiles as $k=>$v): ?><option value="<?=h($k)?>"><?=h($v)?></option><?php endforeach; ?></select></div>
      <button class="btn btn-primary">Complete setup</button>
    </form>
  </div>
<?php elseif (empty($_SESSION['bc_user'])): ?>
  <div class="card p-4 mx-auto" style="max-width:480px">
    <h1 class="h3">Admin login</h1>
    <form method="post">
      <input type="hidden" name="login" value="1">
      <div class="mb-3"><label class="form-label">Username</label><input class="form-control" name="username" required></div>
      <div class="mb-3"><label class="form-label">Password</label><input type="password" class="form-control" name="password" required></div>
      <button class="btn btn-primary">Login</button>
    </form>
  </div>
<?php else: ?>
  <div class="row g-4">
    <div class="col-lg-4">
      <div class="card p-4 mb-4">
        <h2 class="h5">Admin Mode</h2>
        <p class="mb-1"><strong>Port:</strong> <?=h($adminPort ?: 'unknown')?></p>
        <p><strong>Auto shutoff:</strong> <?=h($adminExpires ?: 'not scheduled')?></p>
        <div class="alert alert-warning small">When disabled, the admin console is removed from the network. Re-enable it only from the appliance console with <code>sudo baldcanary admin on</code>.</div>
        <form method="post">
          <input type="hidden" name="disable_admin" value="1">
          <label class="form-label">Profile to expose in Monitor Mode</label>
          <select class="form-select mb-3" name="profile" required>
            <option value="">Choose...</option>
            <?php foreach ($profiles as $k=>$v): ?><option value="<?=h($k)?>" <?=$activeProfile===$k?'selected':''?>><?=h($v)?></option><?php endforeach; ?>
          </select>
          <button class="btn btn-danger w-100">Disable Admin Mode</button>
        </form>
      </div>
      <div class="card p-4 mb-4">
        <h2 class="h5">Deception Profile</h2>
        <form method="post">
          <input type="hidden" name="save_profile" value="1">
          <select class="form-select mb-3" name="profile" required>
            <?php foreach ($profiles as $k=>$v): ?><option value="<?=h($k)?>" <?=$activeProfile===$k?'selected':''?>><?=h($v)?></option><?php endforeach; ?>
          </select>
          <button class="btn btn-outline-primary">Save Profile</button>
        </form>
      </div>
      <div class="card p-4">
        <h2 class="h5">Add Webhook</h2>
        <form method="post">
          <input type="hidden" name="add_webhook" value="1">
          <div class="mb-2"><input class="form-control" name="name" placeholder="Name" required></div>
          <div class="mb-2"><select class="form-select" name="type"><option value="teams">Microsoft Teams / Power Automate</option><option value="generic">Generic JSON</option><option value="slack">Slack-compatible</option><option value="discord">Discord-compatible</option></select></div>
          <div class="mb-2"><input class="form-control" name="url" placeholder="Webhook URL" required></div>
          <button class="btn btn-outline-primary">Save Webhook</button>
        </form>
        <?php if ($webhooks): ?><hr><ul class="small mb-0"><?php foreach ($webhooks as $w): ?><li><?=h($w['name'])?> — <?=h($w['type'])?></li><?php endforeach; ?></ul><?php endif; ?>
      </div>
    </div>
    <div class="col-lg-8">
      <div class="card p-4">
        <div class="d-flex justify-content-between align-items-center mb-3">
          <h2 class="h5 mb-0">Recent Events</h2>
          <a class="btn btn-sm btn-outline-secondary" href="/report.php" target="_blank">Printable Report</a>
        </div>
        <div class="table-responsive">
          <table class="table table-sm align-middle">
            <thead><tr><th>Time</th><th>Severity</th><th>Type</th><th>Source IP</th><th>Path</th></tr></thead>
            <tbody>
              <?php foreach ($events as $event): ?>
                <tr>
                  <td><?=h(bc_local_time($event['event_time']))?></td>
                  <td><span class="badge text-bg-secondary"><?=h($event['severity'])?></span></td>
                  <td><?=h(bc_event_label($event['event_type']))?></td>
                  <td><?=h($event['src_ip'])?></td>
                  <td class="text-break"><?=h($event['path'])?></td>
                </tr>
              <?php endforeach; ?>
              <?php if (!$events): ?><tr><td colspan="5" class="text-muted">No events yet.</td></tr><?php endif; ?>
            </tbody>
          </table>
        </div>
      </div>
    </div>
  </div>
<?php endif; ?>
</main>
</body>
</html>
PHP

  cat > "$APP_ROOT/app/admin/report.php" <<'PHP'
<?php
require_once __DIR__ . '/../common/functions.php';
$db = bc_db();
$events = $db->query('SELECT * FROM events ORDER BY event_time ASC')->fetchAll();
$profile = bc_setting('active_profile', 'Not set');
?><!doctype html>
<html><head><meta charset="utf-8"><title>BaldCanary Report</title>
<style>
body{font-family:Arial,sans-serif;margin:40px;color:#222}.cover{border-bottom:4px solid #222;margin-bottom:24px;padding-bottom:24px}.card{display:inline-block;border:1px solid #ddd;border-radius:10px;padding:16px;margin:8px 8px 8px 0;min-width:160px}.small{color:#666;font-size:12px}table{border-collapse:collapse;width:100%;margin-top:20px}th,td{border-bottom:1px solid #ddd;text-align:left;padding:8px;font-size:12px}th{background:#f1f1f1}@media print{button{display:none}}
</style></head><body>
<button onclick="window.print()">Print / Save as PDF</button>
<div class="cover"><h1>BaldCanary Pentest Validation Report</h1><p>Generated: <?=h((new DateTime('now', new DateTimeZone(bc_local_timezone())))->format('F j, Y g:i A'))?></p><p>Active profile: <strong><?=h($profile)?></strong></p></div>
<div class="card"><div class="small">Total Events</div><h2><?=count($events)?></h2></div>
<div class="card"><div class="small">Unique Source IPs</div><h2><?=count(array_unique(array_filter(array_column($events,'src_ip'))))?></h2></div>
<div class="card"><div class="small">High Interest Events</div><h2><?=count(array_filter($events, fn($e)=>in_array($e['severity'],['high','critical'],true)))?></h2></div>
<h2>Executive Summary</h2>
<p>BaldCanary recorded interaction with the selected deception profile and exposed canary services. Events below may indicate penetration test activity, unauthorized curiosity, automated scanning, or attempted exploitation.</p>
<h2>Event Timeline</h2>
<table><thead><tr><th>Time</th><th>Severity</th><th>Type</th><th>Source IP</th><th>Method</th><th>Path</th><th>Bait</th></tr></thead><tbody>
<?php foreach ($events as $e): ?><tr><td><?=h(bc_local_time($e['event_time']))?></td><td><?=h($e['severity'])?></td><td><?=h(bc_event_label($e['event_type']))?></td><td><?=h($e['src_ip'])?></td><td><?=h($e['method'])?></td><td><?=h($e['path'])?></td><td><?=h($e['matched_bait'])?></td></tr><?php endforeach; ?>
</tbody></table>
</body></html>
PHP

  chown -R "$APP_USER:$APP_GROUP" "$APP_ROOT/app/admin"
}

write_decoy_app() {
  log "Writing decoy web profile engine..."

  cat > "$APP_ROOT/app/decoy/index.php" <<'PHP'
<?php
require_once __DIR__ . '/../common/functions.php';

[$detectedType, $matched] = bc_detection_for_request();
$severity = $detectedType === 'page_view' ? 'low' : 'high';
$path = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/';
$profile = bc_setting('active_profile', 'dev-server') ?: 'dev-server';

$baitPaths = [
    '/sessions/' => 'exposed_session_directory',
    '/sessions/sess_8f2e9a7c1d4b0e921.php' => 'php_session_file',
    '/backup/' => 'backup_directory',
    '/backup/mysql/' => 'mysql_backup_directory',
    '/api/docs' => 'api_docs',
    '/swagger' => 'swagger_docs',
    '/phpinfo.php' => 'phpinfo_probe',
    '/.env' => 'env_file_probe',
    '/config.php' => 'config_file_probe',
];

if (isset($baitPaths[$path])) {
    $detectedType = $baitPaths[$path];
    $severity = 'high';
    $matched = $path;
}

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $detectedType = $detectedType === 'page_view' ? 'form_submit' : $detectedType;
    $severity = 'high';
}

bc_log_event([
    'source' => 'decoy-web',
    'severity' => $severity,
    'event_type' => $detectedType,
    'matched_bait' => $matched,
    'post_data' => $_SERVER['REQUEST_METHOD'] === 'POST' ? substr(file_get_contents('php://input') ?: '', 0, 4000) : null,
]);

function fake_key(string $prefix, int $length = 32): string {
    $chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789';
    $out = '';
    for ($i=0; $i<$length; $i++) { $out .= $chars[random_int(0, strlen($chars)-1)]; }
    return $prefix . $out;
}

function render_header(string $title, string $serverHeader = 'nginx'): void {
    header('X-Powered-By: PHP/8.2');
    header('Server: ' . $serverHeader);
    echo '<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>' . h($title) . '</title>';
}

function render_style(): void {
    echo '<style>body{font-family:Arial,sans-serif;background:#f5f6f8;margin:0;color:#222}.wrap{max-width:1040px;margin:40px auto;padding:0 20px}.panel{background:white;border:1px solid #ddd;border-radius:8px;padding:22px;margin-bottom:18px}.muted{color:#666}.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:14px}.link{display:block;background:#fafafa;border:1px solid #ddd;border-radius:6px;padding:14px;text-decoration:none;color:#143b66}.link:hover{background:#f0f6ff}input{padding:10px;width:100%;box-sizing:border-box;margin:6px 0 12px;border:1px solid #bbb;border-radius:4px}button{padding:10px 16px;border:0;background:#2f5f8f;color:white;border-radius:4px}</style>';
}

if ($path === '/sessions/' || str_starts_with($path, '/sessions/sess_')) {
    render_header('Index of /sessions/', 'Apache/2.4.57');
    render_style();
    echo '</head><body><div class="wrap"><div class="panel"><h1>Index of /sessions/</h1><pre>../\nsess_8f2e9a7c1d4b0e921.php      2026-05-08 14:31      1.2K\nsess_c91a2f778db0a114.dat          2026-05-08 14:36      984</pre></div>';
    if (str_starts_with($path, '/sessions/sess_')) {
        echo '<div class="panel"><pre>user_id|i:1042;\nusername|s:7:"jmartin";\nrole|s:5:"admin";\nmfa_passed|b:0;\nlast_ip|s:12:"10.18.44.21";\ncsrf_token|s:32:"' . h(bin2hex(random_bytes(16))) . '";</pre></div>';
    }
    echo '</div></body></html>'; exit;
}

if ($path === '/phpinfo.php') {
    render_header('phpinfo()', 'Apache/2.4.57');
    echo '<style>body{font-family:Arial,sans-serif}.e{background:#ccf;font-weight:bold}.v{background:#ddd}</style></head><body><h1>PHP Version 8.2.18</h1><table><tr><td class="e">Loaded Configuration File</td><td class="v">/etc/php/8.2/apache2/php.ini</td></tr><tr><td class="e">session.save_path</td><td class="v">/sessions</td></tr></table></body></html>'; exit;
}

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    render_header('Login', 'nginx'); render_style();
    echo '</head><body><div class="wrap"><div class="panel"><h1>Sign in</h1><p class="muted">The username or password was not accepted.</p></div></div></body></html>'; exit;
}

switch ($profile) {
    case 'legacy-lamp':
        render_header('Client Portal', 'Apache/2.4.57'); render_style();
        echo '<!-- Array ( [route] => /portal [controller] => PortalController [api_packet] => Array ( [base] => https://api.github.com [auth] => ' . h(fake_key('ghp_', 36)) . ' [timeout] => 15 ) [session] => Array ( [uid] => 1042 [role] => admin [csrf] => ' . h(bin2hex(random_bytes(16))) . ' ) ) -->';
        echo '</head><body><div class="wrap"><div class="panel"><h1>Client Portal</h1><p class="muted">Maintenance window in progress. Some services may be unavailable.</p></div><div class="grid"><a class="link" href="/phpinfo.php">System Info</a><a class="link" href="/sessions/">Sessions</a><a class="link" href="/backup/mysql/">Database Backups</a><a class="link" href="/api/docs">API Docs</a></div></div></body></html>';
        break;
    case 'backup-nas':
        render_header('Backup Repository', 'nginx'); render_style();
        echo '<!-- Array ( [repo] => nightly [api] => https://api.twilio.com/2010-04-01 [sid] => AC' . h(bin2hex(random_bytes(16))) . ' [notify] => enabled ) -->';
        echo '</head><body><div class="wrap"><div class="panel"><h1>Backup Repository</h1><p class="muted">Repository browser requires authentication.</p><form method="post"><input name="username" placeholder="Username"><input name="password" type="password" placeholder="Password"><button>Sign in</button></form></div><div class="grid"><a class="link" href="/backup/">Backup Index</a><a class="link" href="/backup/mysql/">MySQL</a><a class="link" href="/config.php">Configuration</a></div></div></body></html>';
        break;
    case 'database-utility':
        render_header('Database Utility', 'nginx'); render_style();
        echo '<!-- Array ( [mail_api] => https://api.sendgrid.com/v3/mail/send [key] => ' . h(fake_key('SG.', 22) . '.' . fake_key('', 43)) . ' [cache] => /tmp/app-cache ) -->';
        echo '</head><body><div class="wrap"><div class="panel"><h1>Database Utility</h1><form method="post"><input name="server" value="db-prod-02"><input name="username" placeholder="Database user"><input name="password" type="password" placeholder="Password"><button>Connect</button></form></div></div></body></html>';
        break;
    case 'dev-server':
    default:
        render_header('Development Resources', 'nginx'); render_style();
        echo '<!-- Array ( [env] => staging [billing_api] => https://api.stripe.com/v1 [billing_key] => ' . h(fake_key('sk_live_', 28)) . ' [github_api] => https://api.github.com [github_token] => ' . h(fake_key('ghp_', 36)) . ' [session_path] => /sessions/ ) -->';
        echo '<script>window.appConfig={apiBase:"https://api.github.com",token:"' . h(fake_key('ghp_', 36)) . '",mailEndpoint:"https://api.sendgrid.com/v3/mail/send"};</script>';
        echo '</head><body><div class="wrap"><div class="panel"><h1>Development Resources</h1><p class="muted">Internal tools and staging links.</p></div><div class="grid"><a class="link" href="/api/docs">API Docs</a><a class="link" href="/swagger">Swagger</a><a class="link" href="/sessions/">Sessions</a><a class="link" href="/backup/">Backups</a><a class="link" href="/login">Staging Login</a><a class="link" href="/.env">Environment</a></div></div></body></html>';
        break;
}
PHP

  chown -R "$APP_USER:$APP_GROUP" "$APP_ROOT/app/decoy"
}

write_alert_dispatcher() {
  log "Writing alert dispatcher..."

  cat > "$APP_ROOT/scripts/dispatch_alerts.py" <<'PY'
#!/opt/baldcanary/venv/bin/python
import json
import sqlite3
import time
from urllib import request, error

DB = '/opt/baldcanary/db/baldcanary.sqlite'
LABEL_MAP = '/opt/baldcanary/config/opencanary_event_labels.json'

CUSTOM_LABELS = {
    'page_view': 'Page View',
    'form_submit': 'Form Submission',
    'xss_probe': 'XSS Probe',
    'sql_injection_probe': 'SQL Injection Probe',
    'command_injection_probe': 'Command Injection Probe',
    'path_traversal_probe': 'Path Traversal Probe',
    'sensitive_file_probe': 'Sensitive File Probe',
    'session_file_probe': 'Session File Probe',
    'exposed_session_directory': 'Exposed Session Directory',
    'php_session_file': 'PHP Session File Access',
    'backup_directory': 'Backup Directory Access',
    'mysql_backup_directory': 'MySQL Backup Directory Access',
    'api_docs': 'API Documentation Access',
    'swagger_docs': 'Swagger Documentation Access',
    'phpinfo_probe': 'PHP Info Probe',
    'env_file_probe': 'Environment File Probe',
    'config_file_probe': 'Config File Probe',
    'test_alert': 'Test Alert',
}

def event_label(event_type):
    event_type = str(event_type or '').strip()

    labels = dict(CUSTOM_LABELS)

    try:
        with open(LABEL_MAP, 'r') as f:
            oc_labels = json.load(f)
            if isinstance(oc_labels, dict):
                oc_labels.update(labels)
                labels = oc_labels
    except Exception:
        pass

    if event_type in labels:
        return labels[event_type]

    return event_type.replace('_', ' ').replace('-', ' ').replace('.', ' ').title() or 'Unknown Event'


def post_json(url, payload):
    data = json.dumps(payload).encode('utf-8')
    req = request.Request(url, data=data, headers={'Content-Type': 'application/json'}, method='POST')
    with request.urlopen(req, timeout=10) as resp:
        return resp.status, resp.read().decode('utf-8', errors='replace')[:1000]


def teams_payload(event):
    title = "Alert"
    severity = event.get('severity') or ''
    event_type = event_label(event.get('event_type'))
    src_ip = event.get('src_ip') or ''
    path = event.get('path') or ''
    event_time = event.get('event_time') or ''

    text = (
        f"{title}\n\n"
        f"Severity: {severity}\n"
        f"Type: {event_type}\n"
        f"Source IP: {src_ip}\n"
        f"Path: {path}\n"
        f"Time: {event_time}"
    )

    return {"text": text}

def generic_payload(event):
    event = dict(event)
    event['event_type_label'] = event_label(event.get('event_type'))
    return {'source': 'BaldCanary', 'event': event}

def main():
    con = sqlite3.connect(DB)
    con.row_factory = sqlite3.Row
    while True:
        events = con.execute('''
            SELECT * FROM events e
            WHERE e.severity IN ('medium','high','critical')
              AND NOT EXISTS (SELECT 1 FROM alert_log a WHERE a.event_id = e.id AND a.ok = 1)
            ORDER BY e.id ASC LIMIT 25
        ''').fetchall()
        targets = con.execute('SELECT * FROM webhook_targets WHERE enabled = 1').fetchall()
        for event in events:
            for target in targets:
                payload = teams_payload(dict(event)) if target['type'] == 'teams' else generic_payload(dict(event))
                ok, code, body, err = 0, None, None, None
                try:
                    code, body = post_json(target['url'], payload)
                    ok = 1 if 200 <= code < 300 else 0
                except Exception as exc:
                    err = str(exc)
                con.execute('INSERT INTO alert_log(event_id,target_id,ok,response_code,response_body,error) VALUES(?,?,?,?,?,?)',
                            (event['id'], target['id'], ok, code, body, err))
                con.commit()
        time.sleep(10)

if __name__ == '__main__':
    main()
PY

  chmod +x "$APP_ROOT/scripts/dispatch_alerts.py"
  chown -R "$APP_USER:$APP_GROUP" "$APP_ROOT/scripts"
}

write_opencanary_config() {
  log "Writing OpenCanary config..."

  cat > "$OPENCANARY_CONF" <<'JSON'
{
  "device.node_id": "baldcanary-1",
  "ip.ignorelist": [],
  "ftp.enabled": true,
  "ftp.port": 21,
  "ftp.banner": "FTP server ready",
  "ssh.enabled": false,
  "ssh.port": 22,
  "ssh.version": "SSH-2.0-OpenSSH_8.9p1 Ubuntu-3ubuntu0.6",
  "http.enabled": false,
  "https.enabled": false,
  "httpproxy.enabled": false,
  "mysql.enabled": true,
  "mysql.port": 3306,
  "mssql.enabled": true,
  "mssql.port": 1433,
  "telnet.enabled": false,
  "vnc.enabled": true,
  "vnc.port": 5900,
  "redis.enabled": true,
  "redis.port": 6379,
  "tcpbanner.enabled": true,
  "tcpbanner_1.enabled": true,
  "tcpbanner_1.port": 3389,
  "tcpbanner_1.datareceivedbanner": "",
  "tcpbanner_1.initbanner": "",
  "tcpbanner_1.alertstring.enabled": false,
  "logger": {
    "class": "PyLogger",
    "kwargs": {
      "formatters": {
        "plain": {"format": "%(message)s"}
      },
      "handlers": {
        "file": {
          "class": "logging.FileHandler",
          "filename": "/opt/baldcanary/logs/opencanary.log"
        }
      }
    }
  }
}
JSON

  chown -R "$APP_USER:$APP_GROUP" "$OPENCANARY_CONF_DIR"
}

write_collector() {
  log "Writing local OpenCanary collector..."

  cat > "$APP_ROOT/scripts/opencanary_collector.py" <<'PY'
#!/opt/baldcanary/venv/bin/python
import json
import os
import sqlite3
import time

DB = '/opt/baldcanary/db/baldcanary.sqlite'
LOG = '/opt/baldcanary/logs/opencanary.log'
STATE = '/opt/baldcanary/logs/opencanary.offset'

def read_offset():
    try:
        with open(STATE, 'r') as f:
            return int(f.read().strip() or '0')
    except Exception:
        return 0

def write_offset(offset):
    with open(STATE, 'w') as f:
        f.write(str(offset))

def parse_line(line):
    line = line.strip()
    if not line:
        return None

    try:
        return json.loads(line)
    except Exception:
        return {
            'message': line,
            'logtype': 'opencanary_event'
        }

def event_fields(event):
    event_type = str(
        event.get('logtype')
        or event.get('eventid')
        or event.get('event_type')
        or 'opencanary_event'
    )

    src_ip = str(
        event.get('src_host')
        or event.get('src_ip')
        or event.get('src_host_reverse')
        or ''
    )

    dst_port = str(
        event.get('dst_port')
        or event.get('local_port')
        or ''
    )

    logdata = event.get('logdata') or {}
    username = ''
    password = ''

    if isinstance(logdata, dict):
        username = str(logdata.get('USERNAME') or logdata.get('username') or '')
        password = str(logdata.get('PASSWORD') or logdata.get('password') or '')

    matched_bait = ''
    if username or password:
        matched_bait = f"username={username} password={password}".strip()

    path = f"port:{dst_port}" if dst_port else ''

    return event_type, src_ip, path, matched_bait

def insert_event(event):
    event_type, src_ip, path, matched_bait = event_fields(event)

    # Ignore OpenCanary internal/status noise.
    # logtype/event 1001 with dst_port -1 is not attacker activity.
    dst_port = str(event.get('dst_port') or event.get('local_port') or '')
    if str(event_type) == '1001' and dst_port in ('-1', '') and not src_ip:
        return

    con = sqlite3.connect(DB)
    con.execute(
        '''INSERT INTO events(source,severity,event_type,src_ip,path,matched_bait,raw_json)
           VALUES(?,?,?,?,?,?,?)''',
        (
            'opencanary',
            'high',
            event_type,
            src_ip,
            path,
            matched_bait,
            json.dumps(event)
        )
    )
    con.commit()
    con.close()

def main():
    os.makedirs(os.path.dirname(LOG), exist_ok=True)
    open(LOG, 'a').close()

    offset = read_offset()

    while True:
        try:
            size = os.path.getsize(LOG)
            if size < offset:
                offset = 0

            with open(LOG, 'r') as f:
                f.seek(offset)
                for line in f:
                    event = parse_line(line)
                    if event:
                        insert_event(event)
                offset = f.tell()
                write_offset(offset)

        except Exception as exc:
            # Avoid crashing the service; systemd should not need to restart us for transient parse/db issues.
            with open('/opt/baldcanary/logs/collector-error.log', 'a') as err:
                err.write(f"{time.strftime('%Y-%m-%d %H:%M:%S')} {exc}\\n")

        time.sleep(2)

if __name__ == '__main__':
    main()
PY

  chmod +x "$APP_ROOT/scripts/opencanary_collector.py"
  chown "$APP_USER:$APP_GROUP" "$APP_ROOT/scripts/opencanary_collector.py"
}

write_systemd_units() {
  log "Writing systemd units..."

  cat > /etc/systemd/system/opencanary.service <<EOF
[Unit]
Description=OpenCanary daemon for BaldCanary
After=network.target baldcanary-decoylog.service
Wants=baldcanary-decoylog.service

[Service]
Type=simple
User=root
WorkingDirectory=${APP_ROOT}
ExecStart=${APP_ROOT}/venv/bin/opencanaryd --dev
Restart=on-failure
RestartSec=5

[Install]
WantedBy=multi-user.target
EOF

  cat > /etc/systemd/system/baldcanary-decoylog.service <<EOF
[Unit]
Description=BaldCanary OpenCanary log ingestor
After=network.target

[Service]
Type=simple
User=${APP_USER}
Group=${APP_GROUP}
ExecStart=${APP_ROOT}/scripts/opencanary_collector.py
Restart=always
RestartSec=3

[Install]
WantedBy=multi-user.target
EOF

  cat > /etc/systemd/system/baldcanary-dispatcher.service <<EOF
[Unit]
Description=BaldCanary alert dispatcher
After=network.target

[Service]
Type=simple
User=${APP_USER}
Group=${APP_GROUP}
ExecStart=${APP_ROOT}/scripts/dispatch_alerts.py
Restart=always
RestartSec=3

[Install]
WantedBy=multi-user.target
EOF

  cat > /etc/systemd/system/baldcanary-admin.service <<'EOF'
[Unit]
Description=BaldCanary temporary admin console
After=network.target nginx.service

[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/bin/true
ExecStop=/usr/local/bin/baldcanary admin off --systemd

[Install]
WantedBy=multi-user.target
EOF

  cat > /etc/systemd/system/baldcanary-admin-off.service <<'EOF'
[Unit]
Description=Turn off BaldCanary admin console after timeout

[Service]
Type=oneshot
ExecStart=/usr/local/bin/baldcanary admin off --systemd
EOF

  cat > /etc/systemd/system/baldcanary-admin-timeout.timer <<'EOF'
[Unit]
Description=Automatically disable BaldCanary Admin Mode after 2 hours

[Timer]
OnActiveSec=2h
Unit=baldcanary-admin-off.service

[Install]
WantedBy=timers.target
EOF

  systemctl daemon-reload
  systemctl enable --now baldcanary-decoylog.service
  systemctl enable --now baldcanary-dispatcher.service
  systemctl enable --now opencanary.service || true
}

write_nginx_decoy() {
  log "Writing NGINX monitor-mode decoy config..."

  cat > "$DECOY_NGINX_CONF" <<EOF
server {
    listen 80 default_server;
    listen [::]:80 default_server;

    root ${APP_ROOT}/app/decoy;
    index index.php index.html;

    server_tokens off;

    location / {
        try_files \$uri /index.php\$is_args\$args;
    }

    location ~ \.php$ {
        include snippets/fastcgi-php.conf;
        fastcgi_pass unix:${PHP_FPM_SOCK};
    }
}

server {
    listen 443 ssl default_server;
    listen [::]:443 ssl default_server;

    root ${APP_ROOT}/app/decoy;
    index index.php index.html;

    server_tokens off;

    ssl_certificate ${SSL_DIR}/baldcanary.crt;
    ssl_certificate_key ${SSL_DIR}/baldcanary.key;

    location / {
        try_files \$uri /index.php\$is_args\$args;
    }

    location ~ \.php$ {
        include snippets/fastcgi-php.conf;
        fastcgi_pass unix:${PHP_FPM_SOCK};
    }
}
EOF

  rm -f "$NGINX_SITES_ENABLED/default"
  ln -sf "$DECOY_NGINX_CONF" "$NGINX_SITES_ENABLED/baldcanary-decoy.conf"
  nginx -t
  systemctl reload nginx
}

write_cli() {
  log "Writing /usr/local/bin/baldcanary CLI..."

  cat > /usr/local/bin/baldcanary <<'BASH'
#!/bin/bash
set -euo pipefail

APP_ROOT="/opt/baldcanary"
APP_DB="${APP_ROOT}/db/baldcanary.sqlite"
ADMIN_CONF="/etc/nginx/sites-available/baldcanary-admin.conf"
ADMIN_LINK="/etc/nginx/sites-enabled/baldcanary-admin.conf"
DECOY_LINK="/etc/nginx/sites-enabled/baldcanary-decoy.conf"
PHP_FPM_SOCK="$(find /run/php -maxdepth 1 -type s -name 'php*-fpm.sock' 2>/dev/null | sort -Vr | head -n1 || true)"

require_root() {
  [[ ${EUID} -eq 0 ]] || { echo "Run as root." >&2; exit 1; }
}

set_setting() {
  sqlite3 "$APP_DB" "INSERT INTO settings(key,value,updated_at) VALUES('$1','$2',CURRENT_TIMESTAMP) ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=CURRENT_TIMESTAMP;"
}

get_setting() {
  sqlite3 "$APP_DB" "SELECT value FROM settings WHERE key='$1';"
}

random_port() {
  while true; do
    port=$(shuf -i 20000-60999 -n 1)
    if ! ss -ltn | awk '{print $4}' | grep -qE ":${port}$"; then
      echo "$port"; return
    fi
  done
}

ip_addr() {
  hostname -I | awk '{print $1}'
}

admin_on() {
  require_root
  local profile
  profile="$(get_setting active_profile || true)"
  setup_complete="$(get_setting setup_complete || true)"

  if [[ -z "$profile" && "$setup_complete" == "1" ]]; then
    echo "No deception profile is set. Set one before enabling Admin Mode." >&2
    exit 1
  fi
  if [[ -z "$profile" ]]; then
    profile="First-run setup pending"
  fi
  if [[ -z "$PHP_FPM_SOCK" ]]; then
    echo "PHP-FPM socket not found." >&2
    exit 1
  fi
  port="$(random_port)"
  cat > "$ADMIN_CONF" <<EOF
server {
    listen ${port} ssl;
    listen [::]:${port} ssl;
    root ${APP_ROOT}/app/admin;
    index index.php index.html;
    server_tokens off;

    ssl_certificate /etc/ssl/baldcanary/baldcanary.crt;
    ssl_certificate_key /etc/ssl/baldcanary/baldcanary.key;

    location / {
        try_files \$uri /index.php\$is_args\$args;
    }

    location ~ \.php$ {
        include snippets/fastcgi-php.conf;
        fastcgi_pass unix:${PHP_FPM_SOCK};
    }
}
EOF
  ln -sf "$ADMIN_CONF" "$ADMIN_LINK"
  nginx -t >/dev/null
  systemctl reload nginx
  systemctl start baldcanary-admin.service || true
  systemctl restart baldcanary-admin-timeout.timer
  expires=$(date -d '+2 hours' '+%B %-d, %Y %-I:%M %p')
  set_setting admin_enabled 1
  set_setting admin_port "$port"
  set_setting admin_expires_at "$expires"

  echo
  echo "BaldCanary Admin Mode enabled."
  echo
  echo "Admin console:"
  echo "https://$(ip_addr):${port}/"
  echo
  echo "Admin Mode will automatically turn off in 2 hours."
  echo "Automatic shutoff scheduled for: ${expires}"
  echo
  echo "When Admin Mode turns off, BaldCanary will resume the previously selected decoy profile:"
  echo "${profile}"
  echo
}

admin_off() {
  require_root
  rm -f "$ADMIN_LINK"
  rm -f "$ADMIN_CONF"
  systemctl reload nginx || true
  systemctl stop baldcanary-admin-timeout.timer 2>/dev/null || true
  set_setting admin_enabled 0
  set_setting admin_port ""
  set_setting admin_expires_at ""
  if [[ "${1:-}" != "--systemd" ]]; then
    echo "BaldCanary Admin Mode disabled. Monitor Mode remains active."
  fi
}

admin_status() {
  echo "Admin enabled: $(get_setting admin_enabled)"
  echo "Admin port: $(get_setting admin_port)"
  echo "Admin expires: $(get_setting admin_expires_at)"
  echo "Active profile: $(get_setting active_profile)"
  systemctl status baldcanary-admin-timeout.timer --no-pager 2>/dev/null || true
}

profile_list() {
  cat <<EOF
Available profiles:
  dev-server          Multi-Link Dev Server
  legacy-lamp         Legacy LAMP Server
  backup-nas          Backup / NAS Server
  database-utility    Database Utility Server
EOF
}

profile_set() {
  [[ $# -ge 1 ]] || { echo "Usage: baldcanary profile set <profile>" >&2; exit 1; }
  case "$1" in
    dev-server|legacy-lamp|backup-nas|database-utility) set_setting active_profile "$1"; echo "Active profile set to $1" ;;
    *) echo "Unknown profile: $1" >&2; profile_list; exit 1 ;;
  esac
}

status_all() {
  admin_status
  echo
  systemctl --no-pager --full status nginx opencanary baldcanary-decoylog baldcanary-dispatcher 2>/dev/null || true
}

case "${1:-}" in
  admin)
    case "${2:-}" in
      on) admin_on ;;
      off) admin_off "${3:-}" ;;
      status) admin_status ;;
      *) echo "Usage: baldcanary admin on|off|status" >&2; exit 1 ;;
    esac
    ;;
  profile)
    case "${2:-}" in
      list) profile_list ;;
      set) shift 2; profile_set "$@" ;;
      *) echo "Usage: baldcanary profile list|set <profile>" >&2; exit 1 ;;
    esac
    ;;
  status) status_all ;;
  *)
    cat <<EOF
Usage:
  baldcanary admin on
  baldcanary admin off
  baldcanary admin status
  baldcanary profile list
  baldcanary profile set <profile>
  baldcanary status
EOF
    ;;
esac
BASH

  chmod +x /usr/local/bin/baldcanary
}

ensure_sudo_available() {
  log "Checking sudo support..."

  if ! command -v sudo >/dev/null 2>&1; then
    log "sudo is not installed. Installing sudo..."
    apt-get update
    apt-get install -y sudo
  fi

  if [[ ! -d /etc/sudoers.d ]]; then
    log "Creating /etc/sudoers.d..."
    mkdir -p /etc/sudoers.d
    chmod 750 /etc/sudoers.d
  fi

  if [[ ! -f /etc/sudoers ]]; then
    fail "/etc/sudoers does not exist after installing sudo. Cannot safely continue."
  fi

  if ! grep -Eq '^[[:space:]]*#includedir[[:space:]]+/etc/sudoers.d' /etc/sudoers; then
    log "Enabling /etc/sudoers.d include in /etc/sudoers..."
    printf '\n#includedir /etc/sudoers.d\n' >> /etc/sudoers
  fi

  if ! command -v visudo >/dev/null 2>&1; then
    fail "visudo is not available after installing sudo. Cannot safely continue."
  fi
}

write_sudoers() {
  log "Allowing web admin to disable Admin Mode safely..."

  ensure_sudo_available

  cat > /etc/sudoers.d/baldcanary <<'EOF'
www-data ALL=(root) NOPASSWD: /usr/local/bin/baldcanary admin off, /usr/local/bin/baldcanary admin off --systemd
EOF

  chmod 440 /etc/sudoers.d/baldcanary

  if ! visudo -cf /etc/sudoers >/dev/null; then
    rm -f /etc/sudoers.d/baldcanary
    fail "sudoers validation failed. Removed /etc/sudoers.d/baldcanary."
  fi
}

write_ssl() {
  log "Creating temporary self-signed certificate for appliance build..."
  if [[ ! -f "$SSL_DIR/baldcanary.crt" || ! -f "$SSL_DIR/baldcanary.key" ]]; then
    openssl req -x509 -nodes -newkey rsa:2048 \
      -keyout "$SSL_DIR/baldcanary.key" \
      -out "$SSL_DIR/baldcanary.crt" \
      -days 825 \
      -subj "/CN=BaldCanary Appliance" >/dev/null 2>&1
  fi
  chmod 600 "$SSL_DIR/baldcanary.key"
}

write_logrotate() {
  log "Writing logrotate config..."
  cat > /etc/logrotate.d/baldcanary <<'EOF'
/opt/baldcanary/logs/*.log {
    daily
    rotate 90
    compress
    missingok
    notifempty
    copytruncate
}
EOF
}

main() {
  require_root
  case "${1:-}" in
    --help|-h) usage; exit 0 ;;
    --purge) purge; exit 0 ;;
    "") ;;
    *) usage; exit 1 ;;
  esac

  log "Starting BaldCanary Appliance installation..."
  install_packages
  create_user_and_dirs
  install_python_env
  write_opencanary_event_map
  init_sqlite
  write_common_php
  write_admin_app
  write_decoy_app
  write_alert_dispatcher
  write_collector
  write_opencanary_config
  write_ssl
  write_nginx_decoy
  write_cli
  write_sudoers
  write_logrotate
  write_systemd_units

  log "Installation complete."
  cat <<EOF

BaldCanary Appliance foundation installed.

Initial web setup is available in Admin Mode. To enable the temporary admin console from the appliance console, run:

  sudo baldcanary admin on

The admin console will bind to a random high port and automatically disable itself after 2 hours.

Monitor Mode is already active on port 80 and will serve the selected deception profile after first-run setup.

EOF
}

main "$@"
