1274 lines
46 KiB
Bash
1274 lines
46 KiB
Bash
#!/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'
|
|
<?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];
|
|
}
|
|
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($event['event_time'])?></td>
|
|
<td><span class="badge text-bg-secondary"><?=h($event['severity'])?></span></td>
|
|
<td><?=h($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(date('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($e['event_time'])?></td><td><?=h($e['severity'])?></td><td><?=h($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'
|
|
|
|
|
|
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 <<EOF
|
|
[Unit]
|
|
Description=OpenCanary daemon for BaldCanary
|
|
After=network.target baldcanary-decoylog.service
|
|
|
|
[Service]
|
|
Type=simple
|
|
User=root
|
|
ExecStart=${APP_ROOT}/venv/bin/opencanaryd --start --uid=root --gid=root
|
|
Restart=always
|
|
RestartSec=5
|
|
WorkingDirectory=${APP_ROOT}
|
|
|
|
[Install]
|
|
WantedBy=multi-user.target
|
|
EOF
|
|
|
|
cat > /etc/systemd/system/baldcanary-decoylog.service <<EOF
|
|
[Unit]
|
|
Description=BaldCanary local event collector and alert dispatcher
|
|
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};
|
|
}
|
|
}
|
|
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)"
|
|
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" <<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
|
|
}
|
|
|
|
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 <<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 "$@"
|