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' => [''; + echo '
Internal tools and staging links.