#!/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'); SQL chown -R "$APP_USER:$APP_GROUP" "$APP_ROOT/db" chmod 775 "$APP_ROOT/db" chmod 660 "$APP_DB" } write_common_php() { log "Writing shared PHP helpers..." cat > "$APP_ROOT/app/common/db.php" <<'PHP' 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' 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' => [''; echo '
Internal tools and staging links.