#!/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' 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' => [' ['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' 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 'Admin Mode Disabled

Admin Mode Disabled

BaldCanary is returning to Monitor Mode.

To re-enable the admin console, access the appliance console directly and run:

sudo baldcanary admin on
'; 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(); ?> BaldCanary Admin

First-run setup

Create the first admin account and choose the initial deception profile.

Admin login

Admin Mode

Port:

Auto shutoff:

When disabled, the admin console is removed from the network. Re-enable it only from the appliance console with sudo baldcanary admin on.

Deception Profile

Add Webhook


Recent Events

Printable Report
TimeSeverityTypeSource IPPath
No events yet.
PHP cat > "$APP_ROOT/app/admin/report.php" <<'PHP' query('SELECT * FROM events ORDER BY event_time ASC')->fetchAll(); $profile = bc_setting('active_profile', 'Not set'); ?> BaldCanary Report

BaldCanary Pentest Validation Report

Generated: format('F j, Y g:i A'))?>

Active profile:

Total Events

Unique Source IPs

High Interest Events

in_array($e['severity'],['high','critical'],true)))?>

Executive Summary

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.

Event Timeline

TimeSeverityTypeSource IPMethodPathBait
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' '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 '' . h($title) . ''; } function render_style(): void { echo ''; } if ($path === '/sessions/' || str_starts_with($path, '/sessions/sess_')) { render_header('Index of /sessions/', 'Apache/2.4.57'); render_style(); echo '

Index of /sessions/

../\nsess_8f2e9a7c1d4b0e921.php      2026-05-08 14:31      1.2K\nsess_c91a2f778db0a114.dat          2026-05-08 14:36      984
'; if (str_starts_with($path, '/sessions/sess_')) { echo '
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))) . '";
'; } echo '
'; exit; } if ($path === '/phpinfo.php') { render_header('phpinfo()', 'Apache/2.4.57'); echo '

PHP Version 8.2.18

Loaded Configuration File/etc/php/8.2/apache2/php.ini
session.save_path/sessions
'; exit; } if ($_SERVER['REQUEST_METHOD'] === 'POST') { render_header('Login', 'nginx'); render_style(); echo '

Sign in

The username or password was not accepted.

'; exit; } switch ($profile) { case 'legacy-lamp': render_header('Client Portal', 'Apache/2.4.57'); render_style(); echo ''; echo '

Client Portal

Maintenance window in progress. Some services may be unavailable.

'; break; case 'backup-nas': render_header('Backup Repository', 'nginx'); render_style(); echo ''; echo '

Backup Repository

Repository browser requires authentication.

'; break; case 'database-utility': render_header('Database Utility', 'nginx'); render_style(); echo ''; echo '

Database Utility

'; break; case 'dev-server': default: render_header('Development Resources', 'nginx'); render_style(); echo ''; echo ''; echo '

Development Resources

Internal tools and staging links.

'; 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' 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): return { 'type': 'message', 'attachments': [{ 'contentType': 'application/vnd.microsoft.card.adaptive', 'content': { '$schema': 'http://adaptivecards.io/schemas/adaptive-card.json', 'type': 'AdaptiveCard', 'version': '1.4', 'body': [ {'type': 'TextBlock', 'text': 'BaldCanary Alert', 'weight': 'Bolder', 'size': 'Large'}, {'type': 'FactSet', 'facts': [ {'title': 'Severity', 'value': event.get('severity') or ''}, {'title': 'Type', 'value': event.get('event_type') or ''}, {'title': 'Source IP', 'value': event.get('src_ip') or ''}, {'title': 'Path', 'value': event.get('path') or ''}, {'title': 'Time', 'value': event.get('event_time') or ''}, ]} ] } }] } def generic_payload(event): 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 < /etc/systemd/system/baldcanary-decoylog.service < /etc/systemd/system/baldcanary-dispatcher.service < /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" < /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" </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 <" >&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 " >&2; exit 1 ;; esac ;; status) status_all ;; *) cat < 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 <