diff --git a/installer.sh b/installer.sh new file mode 100644 index 0000000..c3d98e8 --- /dev/null +++ b/installer.sh @@ -0,0 +1,1273 @@ +#!/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 \ + 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 + + 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 750 "$APP_ROOT" + chmod 770 "$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 "$APP_USER:$APP_GROUP" "$APP_DB" "$APP_ROOT/db" -R + 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' => [' ['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]; +} +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:

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": true, + "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" + }, + "Webhook": { + "class": "opencanary.logger.WebhookHandler", + "url": "http://127.0.0.1:65534/opencanary", + "method": "POST", + "data": {"message": "%(message)s"}, + "status_code": 200 + } + } + } + } +} +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 +from http.server import BaseHTTPRequestHandler, HTTPServer +import json +import sqlite3 + +DB = '/opt/baldcanary/db/baldcanary.sqlite' + +class Handler(BaseHTTPRequestHandler): + def do_POST(self): + length = int(self.headers.get('content-length', 0)) + body = self.rfile.read(length).decode('utf-8', errors='replace') + event = {'raw': body} + try: + outer = json.loads(body) + msg = outer.get('message', body) + if isinstance(msg, str): + try: + event = json.loads(msg) + except Exception: + event = {'message': msg} + elif isinstance(msg, dict): + event = msg + except Exception: + pass + + con = sqlite3.connect(DB) + con.execute('''INSERT INTO events(source,severity,event_type,src_ip,path,raw_json) + VALUES(?,?,?,?,?,?)''', ( + 'opencanary', 'high', str(event.get('logtype', 'opencanary_event')), + str(event.get('src_host', event.get('src_ip', ''))), + str(event.get('dst_port', '')), + json.dumps(event) + )) + con.commit() + con.close() + self.send_response(200) + self.end_headers() + self.wfile.write(b'ok') + + def log_message(self, fmt, *args): + return + +if __name__ == '__main__': + HTTPServer(('127.0.0.1', 65534), Handler).serve_forever() +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)" + if [[ -z "$profile" ]]; then + echo "No deception profile is set yet. Complete first-run setup before using temporary Admin Mode." >&2 + exit 1 + 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 +} + +write_sudoers() { + log "Allowing web admin to disable Admin Mode safely..." + cat > /etc/sudoers.d/baldcanary <<'EOF' +www-data ALL=(root) NOPASSWD: /usr/local/bin/baldcanary admin off +EOF + chmod 440 /etc/sudoers.d/baldcanary +} + +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 + 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 <