Files
BaldCanary/installer.sh

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 "$@"