Files
bigbrother/operator_setup.sh
T
Cobra 84f0775f76 Align device ID with MAC profile OUI for consistent network identity
Device ID is now a MAC-format hex string (12 chars) using the same
OUI as the randomly selected device profile. If the device gets an
Amazon OUI for its MAC, the device ID starts with fc65de. Hostname,
MAC, and device ID all point to the same vendor — no inconsistency
for anyone watching the network.
2026-04-07 14:48:22 -04:00

767 lines
30 KiB
Bash
Executable File

#!/usr/bin/env bash
# BigBrother Operator Setup Wizard
#
# Single interactive script for prepping Orange Pi Zero 3 (or compatible Debian) SD cards.
# Operator runs this on their machine with an SD card inserted.
#
# What this does:
# 1. Detects block devices and has operator pick SD card target
# 2. Downloads/caches Armbian minimal image for Orange Pi Zero 3
# 3. Flashes image to card with verification
# 4. Mounts card and collects configuration (WiFi, SSH, VPN, alerter, etc.)
# 5. Applies all config to card in a single batch after operator confirms summary
# 6. Sets up first-boot systemd service for autonomous installation
# 7. Unmounts card — ready to insert and power on
#
# Style: bash only, ANSI colors, numbered menus, c2itall-style (no preamble, direct action)
# Root required for: mount, unmount, dd, partprobe, rsync to /mnt, chown, chmod on mounted fs
#
set -euo pipefail
# ══════════════════════════════════════════════════════════════════════════════
# COLORS (bash ANSI only)
# ══════════════════════════════════════════════════════════════════════════════
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
BLUE='\033[0;34m'
MAGENTA='\033[0;35m'
BOLD='\033[1m'
NC='\033[0m'
# ══════════════════════════════════════════════════════════════════════════════
# HELPERS
# ══════════════════════════════════════════════════════════════════════════════
info() { echo -e "${GREEN}[+]${NC} $*"; }
warn() { echo -e "${YELLOW}[!]${NC} $*"; }
error() { echo -e "${RED}[-]${NC} $*"; exit 1; }
step() { echo -e "${CYAN}[*]${NC} $*"; }
section() {
echo ""
echo -e "${BOLD}${BLUE}─────────────────────────────────────────────${NC}"
echo -e "${BOLD}$*${NC}"
echo -e "${BOLD}${BLUE}─────────────────────────────────────────────${NC}"
echo ""
}
banner() {
echo -e "${BOLD}${MAGENTA}╔════════════════════════════════════════════╗${NC}"
echo -e "${BOLD}${MAGENTA}║ BIGBROTHER // OPERATOR SETUP ║${NC}"
echo -e "${BOLD}${MAGENTA}╚════════════════════════════════════════════╝${NC}"
echo ""
}
prompt_default() {
local var="$1" prompt="$2" default="$3"
echo -n -e "${CYAN}${prompt}${NC}"
[[ -n "$default" ]] && echo -n -e " ${BOLD}[${default}]${NC}"
echo -n ": "
read -r input
if [[ -z "$input" ]]; then
printf -v "$var" '%s' "$default"
else
printf -v "$var" '%s' "$input"
fi
}
# ══════════════════════════════════════════════════════════════════════════════
# PREFLIGHT
# ══════════════════════════════════════════════════════════════════════════════
[[ $EUID -eq 0 ]] || error "Must run as root (use: sudo bash operator_setup.sh)"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# When run via sudo, use the invoking user's home — not /root
REAL_HOME=$(getent passwd "${SUDO_USER:-$(whoami)}" | cut -d: -f6)
CACHE_DIR="${REAL_HOME}/.bigbrother/images"
CREDS="${REAL_HOME}/.local/bin/creds"
MOUNT_POINT="/mnt/opi"
mkdir -p "$CACHE_DIR"
# ══════════════════════════════════════════════════════════════════════════════
# 1. BANNER
# ══════════════════════════════════════════════════════════════════════════════
clear
banner
# ══════════════════════════════════════════════════════════════════════════════
# 2. SD CARD DETECTION
# ══════════════════════════════════════════════════════════════════════════════
section "SD Card Detection"
info "Scanning block devices..."
echo ""
lsblk -ndo NAME,SIZE,LABEL,TYPE | grep -E "^(sd|mmc)" | nl -v 1 -w1 -s') ' || error "No block devices found. Insert SD card and try again."
echo ""
echo -n -e "${CYAN}Enter device number (1-9):${NC} "
read -r device_num
[[ "$device_num" =~ ^[1-9]$ ]] || error "Invalid selection"
device_row=$(lsblk -ndo NAME,SIZE,LABEL,TYPE | grep -E "^(sd|mmc)" | sed -n "${device_num}p" || true)
DEVICE=$(echo "$device_row" | awk '{print $1}')
DEVICE_SIZE=$(echo "$device_row" | awk '{print $2}')
[[ -n "$DEVICE" ]] || error "Invalid selection — no device at that number"
info "Selected: /dev/${DEVICE} (${DEVICE_SIZE})"
warn "This will OVERWRITE all data on /dev/${DEVICE}"
echo -n -e "${YELLOW}Type device name to confirm (${DEVICE}):${NC} "
read -r confirm_dev
[[ "$confirm_dev" == "$DEVICE" ]] || error "Device name mismatch. Aborting."
info "Device confirmed: /dev/${DEVICE}"
# ══════════════════════════════════════════════════════════════════════════════
# 3. IMAGE MANAGEMENT
# ══════════════════════════════════════════════════════════════════════════════
section "Image Management"
# Look for existing cached image
CACHED_IMAGE=$(find "$CACHE_DIR" -maxdepth 1 -name 'Armbian_*_Orangepizero3_bookworm_current_*_minimal.img.xz' 2>/dev/null | head -1 || true)
if [[ -n "$CACHED_IMAGE" ]]; then
info "Found cached image: $(basename $CACHED_IMAGE)"
echo -n -e "${CYAN}Use cached image? (y/n):${NC} "
read -r use_cached
if [[ "$use_cached" =~ ^[yY]$ ]]; then
IMAGE="$CACHED_IMAGE"
else
IMAGE=""
fi
else
IMAGE=""
fi
if [[ -z "$IMAGE" ]]; then
step "Downloading Armbian image..."
# Hardcoded known-good URL (operator can update)
IMAGE_URL="https://dl.armbian.com/orangepizero3/Armbian_25.5.1_Orangepizero3_bookworm_current_6.12.23_minimal.img.xz"
IMAGE_FILENAME=$(basename "$IMAGE_URL")
IMAGE="${CACHE_DIR}/${IMAGE_FILENAME}"
if [[ ! -f "$IMAGE" ]]; then
curl -L --progress-bar "$IMAGE_URL" -o "$IMAGE" || error "Failed to download image"
# Verify download: file exists and is > 50MB
if [[ ! -s "$IMAGE" ]] || [[ $(stat -c%s "$IMAGE" 2>/dev/null || echo 0) -lt 52428800 ]]; then
rm -f "$IMAGE"
error "Download failed or file too small (expected > 50MB)"
fi
info "Image downloaded: $IMAGE_FILENAME"
else
info "Image already cached: $IMAGE_FILENAME"
fi
fi
# SHA256 verification (optional, skip if no .sha file)
SHA_FILE="${IMAGE}.sha"
if [[ -f "$SHA_FILE" ]]; then
step "Verifying SHA256..."
cd "$CACHE_DIR"
sha256sum -c "$(basename $SHA_FILE)" >/dev/null 2>&1 && info "SHA256 verified" || warn "SHA256 mismatch (proceeding anyway)"
cd - >/dev/null
fi
# ══════════════════════════════════════════════════════════════════════════════
# 4. CONFIGURATION WIZARD
# ══════════════════════════════════════════════════════════════════════════════
section "Configuration Wizard"
# WiFi (optional)
step "WiFi Configuration"
prompt_default enable_wifi "Configure WiFi? (y/n)" "n"
if [[ "$enable_wifi" =~ ^[yY]$ ]]; then
prompt_default wifi_ssid "WiFi SSID" ""
while true; do
echo -n -e "${CYAN}WiFi Passphrase${NC}: "
read -s -r wifi_pass; echo
echo -n -e "${CYAN}Confirm Passphrase${NC}: "
read -s -r wifi_pass2; echo
if [[ "$wifi_pass" == "$wifi_pass2" ]]; then
break
else
warn "Passphrases do not match. Try again."
fi
done
WIFI_SSID="$wifi_ssid"
WIFI_PASS="$wifi_pass"
unset wifi_pass wifi_pass2
else
WIFI_SSID=""
WIFI_PASS=""
fi
# Root password (required)
step "Root Password"
DEFAULT_ROOT_PASS=$(openssl rand -base64 16 | tr -d '=+/')
ROOT_PASS_IS_AUTO=false
echo -e "${YELLOW}Auto-generated password: ${BOLD}${DEFAULT_ROOT_PASS}${NC}"
prompt_default root_pass "Enter custom password or press Enter to use auto-generated" ""
if [[ -z "$root_pass" ]]; then
ROOT_PASS="$DEFAULT_ROOT_PASS"
ROOT_PASS_IS_AUTO=true
else
ROOT_PASS="$root_pass"
fi
unset root_pass
# SSH Key
step "SSH Key Configuration"
echo " [1] Generate new keypair (stored in Infisical)"
echo " [2] Use existing public key file"
prompt_default ssh_opt "SSH option (1-2)" "1"
if [[ "$ssh_opt" == "1" ]]; then
SSH_MODE="generate"
elif [[ "$ssh_opt" == "2" ]]; then
SSH_MODE="existing"
prompt_default ssh_pubkey_path "Path to public key file" ""
[[ -f "$ssh_pubkey_path" ]] || error "File not found: $ssh_pubkey_path"
SSH_PUBKEY=$(cat "$ssh_pubkey_path")
else
error "Invalid SSH option"
fi
# Device identity — pick profile first, derive hostname + device ID from same OUI
step "Device Identity"
PROFILE_NAMES=(
"Fire TV Stick 4K|amazon-firetv|FC:65:DE"
"Fire TV Stick Lite|amazon-lite|FC:65:DE"
"Fire TV Cube|amazon-cube|FC:65:DE"
"Roku Ultra|roku-ultra|B8:3E:59"
"Roku Express|roku-express|B8:3E:59"
"Chromecast|chromecast|54:60:09"
"Chromecast with Google TV|chromecast-gtv|54:60:09"
"Apple TV 4K|apple-tv-4k|F0:B4:79"
"Apple TV HD|apple-tv-hd|F0:B4:79"
"Samsung Smart TV|samsung-tv|8C:79:F0"
"Samsung QLED TV|samsung-qled|8C:79:F0"
"LG webOS TV|lg-tv-webos|A8:23:FE"
"LG OLED TV|lg-tv-oled|A8:23:FE"
"Vizio SmartCast TV|vizio-tv|00:E0:36"
"TCL Roku TV|tcl-tv|54:B7:B2"
"Sony Bravia Android TV|sony-tv|F0:D7:AA"
"Sonos One|sonos-one|94:9F:3E"
"Sonos Beam|sonos-beam|94:9F:3E"
"Google Home Mini|google-home|54:60:09"
"Google Nest Hub|google-nest|54:60:09"
"Echo Dot|echo-dot|FC:65:DE"
"Echo Show|echo-show|FC:65:DE"
"Ring Doorbell|ring-doorbell|B0:09:DA"
"Nest Thermostat|nest-thermo|18:B4:30"
"Nest Protect|nest-protect|18:B4:30"
"Philips Hue Bridge|philips-hue|00:17:88"
"TP-Link Kasa Smart Plug|tplink-kasa|50:C7:BF"
"Wyze Cam v3|wyze-cam|2C:AA:8E"
"Ecobee Thermostat|ecobee-thermo|44:61:32"
"Roomba i7|roomba-i7|80:91:33"
"Roomba j7|roomba-j7|80:91:33"
"Raspberry Pi IoT Sensor|raspberrypi-sensor|DC:A6:32"
"Raspberry Pi IoT Gateway|raspberrypi-gateway|DC:A6:32"
"Raspberry Pi IoT Controller|raspberrypi-ctrl|DC:A6:32"
"HP LaserJet Pro|hp-laserjet|3C:D9:2B"
"HP OfficeJet Pro|hp-officejet|3C:D9:2B"
"Brother HL-L2350DW|brother-hl|00:80:77"
"Brother MFC-L2750DW|brother-mfc|00:80:77"
"Brother DCP-L2550DW|brother-dcp|00:80:77"
"Epson EcoTank ET-4760|epson-ecotank|00:26:AB"
"Canon PIXMA G6020|canon-pixma|00:1E:8F"
"Xbox Series X|xbox-series-x|7C:1E:52"
"PlayStation 5|ps5|BC:60:A7"
"PlayStation 4|ps4|BC:60:A7"
"Nintendo Switch|nintendo-switch|98:B6:E9"
"iPhone 15 Pro|iphone-15|3C:22:FB"
"iPad Air|ipad-air|3C:22:FB"
"Galaxy Tab S9|galaxy-tab|8C:BE:BE"
"Galaxy S24|galaxy-s24|8C:BE:BE"
"UniFi AP|unifi-ap|24:A4:3C"
"USG Gateway|usg-gateway|24:A4:3C"
"TP-Link Archer AX50|archer-ax50|50:C7:BF"
"TP-Link Deco M5|deco-m5|50:C7:BF"
"Netgear Orbi|orbi|9C:3D:CF"
"Netgear Nighthawk|nighthawk|9C:3D:CF"
)
PROFILE_IDX=$(( RANDOM % ${#PROFILE_NAMES[@]} ))
SELECTED_PROFILE="${PROFILE_NAMES[$PROFILE_IDX]}"
PROFILE_DISPLAY="${SELECTED_PROFILE%%|*}"
_prest="${SELECTED_PROFILE#*|}"
DEFAULT_HOSTNAME="${_prest%%|*}"
PROFILE_OUI="${SELECTED_PROFILE##*|}"
echo -e " Profile: ${BOLD}${PROFILE_DISPLAY}${NC} (OUI: ${PROFILE_OUI})"
prompt_default bb_hostname "Hostname" "$DEFAULT_HOSTNAME"
BB_HOSTNAME="$bb_hostname"
# Device ID derived from same profile OUI — MAC-format, consistent with network identity
OUI_CLEAN=$(echo "$PROFILE_OUI" | tr -d ':' | tr '[:upper:]' '[:lower:]')
RAND_SUFFIX=$(printf '%02x%02x%02x' $(( RANDOM % 256 )) $(( RANDOM % 256 )) $(( RANDOM % 256 )))
DEFAULT_DEVICE_ID="${OUI_CLEAN}${RAND_SUFFIX}"
prompt_default device_id "Device ID" "$DEFAULT_DEVICE_ID"
DEVICE_ID="$device_id"
# Auto-start mode
step "Auto-start Mode on Boot"
echo " [1] Passive only (capture + analysis, no active offense)"
echo " [2] Full autonomous (all modules)"
echo " [3] Manual (operator SSHs in to start)"
prompt_default mode_opt "Select mode (1-3)" "1"
if [[ "$mode_opt" == "1" ]]; then
BB_MODE="passive"
elif [[ "$mode_opt" == "2" ]]; then
BB_MODE="autonomous"
elif [[ "$mode_opt" == "3" ]]; then
BB_MODE="manual"
else
error "Invalid mode"
fi
# VPN tunnel
step "VPN Tunnel"
echo " [1] None"
echo " [2] Tailscale"
echo " [3] WireGuard"
echo " [4] Reverse SSH Tunnel (autossh) — no traffic until operator connects"
prompt_default vpn_opt "Select VPN (1-4)" "2"
BB_VPN="none"
TAILSCALE_KEY=""
WG_CONFIG=""
AUTOSSH_SERVER=""
AUTOSSH_USER="root"
AUTOSSH_PORT="22"
AUTOSSH_REMOTE_PORT=""
AUTOSSH_PRIVKEY=""
AUTOSSH_PUBKEY=""
if [[ "$vpn_opt" == "1" ]]; then
BB_VPN="none"
elif [[ "$vpn_opt" == "2" ]]; then
BB_VPN="tailscale"
echo -e " ${YELLOW}Use a reusable, non-expiring key from Tailscale admin → Settings → Auth keys${NC}"
prompt_default tailscale_key "Tailscale auth key" ""
TAILSCALE_KEY="$tailscale_key"
elif [[ "$vpn_opt" == "3" ]]; then
BB_VPN="wireguard"
prompt_default wg_config_path "Path to WireGuard config (wg0.conf)" ""
[[ -f "$wg_config_path" ]] || error "File not found: $wg_config_path"
WG_CONFIG=$(cat "$wg_config_path")
elif [[ "$vpn_opt" == "4" ]]; then
BB_VPN="autossh"
prompt_default AUTOSSH_SERVER "Operator SSH server (host or IP)" ""
[[ -n "$AUTOSSH_SERVER" ]] || error "Server address required"
prompt_default AUTOSSH_USER "SSH user on server" "root"
prompt_default AUTOSSH_PORT "SSH port on server" "22"
DEFAULT_REMOTE_PORT=$(( 20000 + RANDOM % 10000 ))
prompt_default AUTOSSH_REMOTE_PORT "Remote tunnel port (device SSH appears here on server)" "$DEFAULT_REMOTE_PORT"
# Generate dedicated tunnel keypair — keeps it separate from device management key
_TMP_TKEY=$(mktemp)
ssh-keygen -t ed25519 -N "" -C "bb-tunnel-${DEVICE_ID}" -f "$_TMP_TKEY" -q
AUTOSSH_PRIVKEY=$(cat "$_TMP_TKEY")
AUTOSSH_PUBKEY=$(cat "${_TMP_TKEY}.pub")
rm -f "$_TMP_TKEY" "${_TMP_TKEY}.pub"
echo ""
echo -e " ${YELLOW}Add this public key to ${AUTOSSH_USER}@${AUTOSSH_SERVER}:~/.ssh/authorized_keys before powering on:${NC}"
echo ""
echo -e " ${BOLD}${AUTOSSH_PUBKEY}${NC}"
echo ""
echo -n -e "${CYAN} Press Enter once the key is on the server:${NC} "
read -r
else
error "Invalid VPN option"
fi
# Network alerter webhook
step "Network Alerter"
prompt_default enable_alerter "Enable alerter webhook? (y/n)" "n"
if [[ "$enable_alerter" =~ ^[yY]$ ]]; then
prompt_default webhook_url "Webhook URL (Slack/Discord/Matrix)" ""
BB_ALERTER_WEBHOOK="$webhook_url"
else
BB_ALERTER_WEBHOOK=""
fi
# ══════════════════════════════════════════════════════════════════════════════
# 5. CONFIG SUMMARY
# ══════════════════════════════════════════════════════════════════════════════
section "Configuration Summary"
echo "Device ID: ${BOLD}${DEVICE_ID}${NC}"
echo "Hostname: ${BOLD}${BB_HOSTNAME}${NC}"
echo "Target: ${BOLD}/dev/${DEVICE}${NC}"
echo "Image: ${BOLD}$(basename $IMAGE)${NC}"
echo ""
if [[ "$ROOT_PASS_IS_AUTO" == true ]]; then
echo "Root Pass: ${BOLD}${RED}AUTO-GENERATED — RECORD THIS: ${DEFAULT_ROOT_PASS}${NC}"
else
echo "Root Pass: ${BOLD}[custom]${NC}"
fi
echo "WiFi: ${BOLD}$([ -n "$WIFI_SSID" ] && echo "$WIFI_SSID" || echo "disabled")${NC}"
echo "SSH Mode: ${BOLD}${SSH_MODE}${NC}"
echo "Auto-start: ${BOLD}${BB_MODE}${NC}"
if [[ "$BB_VPN" == "autossh" ]]; then
echo "VPN: ${BOLD}autossh → ${AUTOSSH_USER}@${AUTOSSH_SERVER}:${AUTOSSH_PORT} (remote port ${AUTOSSH_REMOTE_PORT})${NC}"
else
echo "VPN: ${BOLD}${BB_VPN}${NC}"
fi
echo "Alerter: ${BOLD}$([ -n "$BB_ALERTER_WEBHOOK" ] && echo "enabled" || echo "disabled")${NC}"
echo ""
echo -n -e "${CYAN}Proceed with flashing and configuration? (y/n):${NC} "
read -r confirm_proceed
[[ "$confirm_proceed" =~ ^[yY]$ ]] || { info "Aborted"; exit 0; }
# ══════════════════════════════════════════════════════════════════════════════
# 6. FLASH AND MOUNT
# ══════════════════════════════════════════════════════════════════════════════
section "Flashing SD Card"
step "Unmounting any existing mounts on /dev/${DEVICE}..."
for part in /dev/${DEVICE}*; do
[[ -b "$part" ]] && umount "$part" 2>/dev/null || true
done
step "Flashing image to /dev/${DEVICE}..."
xzcat "$IMAGE" | dd of="/dev/${DEVICE}" bs=4M status=progress conv=fsync || error "Flash failed"
info "Flash complete"
step "Running partprobe..."
partprobe "/dev/${DEVICE}" || warn "partprobe failed (non-critical)"
sleep 3
step "Mounting to ${MOUNT_POINT}..."
mkdir -p "$MOUNT_POINT"
# Try /dev/${DEVICE}1 (most common for single-partition images)
mount "/dev/${DEVICE}1" "$MOUNT_POINT" 2>/dev/null || mount "/dev/${DEVICE}p1" "$MOUNT_POINT" || error "Mount failed. Check device partitioning."
info "Mounted at ${MOUNT_POINT}"
# ══════════════════════════════════════════════════════════════════════════════
# 7. APPLY CONFIGURATION TO CARD
# ══════════════════════════════════════════════════════════════════════════════
section "Applying Configuration"
# WiFi profile
if [[ -n "$WIFI_SSID" ]]; then
step "Writing WiFi profile..."
NM_DIR="${MOUNT_POINT}/etc/NetworkManager/system-connections"
NM_FILE="${NM_DIR}/wifi-bb.nmconnection"
mkdir -p "$NM_DIR"
WIFI_UUID=$(cat /proc/sys/kernel/random/uuid)
cat > "$NM_FILE" << EOF
[connection]
id=wifi-bb
uuid=${WIFI_UUID}
type=wifi
autoconnect=true
autoconnect-priority=10
[wifi]
mode=infrastructure
ssid=${WIFI_SSID}
[wifi-security]
auth-alg=open
key-mgmt=wpa-psk
psk=${WIFI_PASS}
[ipv4]
method=auto
[ipv6]
addr-gen-mode=stable-privacy
method=auto
EOF
chmod 600 "$NM_FILE"
chown root:root "$NM_FILE"
info "WiFi profile written (SSID: ${WIFI_SSID}, UUID: ${WIFI_UUID})"
fi
# Root password
step "Setting root password..."
ROOT_PASS_HASH=$(openssl passwd -6 "$ROOT_PASS")
sed -i "s|^root:[^:]*:|root:${ROOT_PASS_HASH}:|" "${MOUNT_POINT}/etc/shadow"
info "Root password set"
unset ROOT_PASS ROOT_PASS_HASH
# SSH keys
step "Configuring SSH..."
SSH_DIR="${MOUNT_POINT}/root/.ssh"
mkdir -p "$SSH_DIR"
if [[ "$SSH_MODE" == "generate" ]]; then
TMP_KEY=$(mktemp)
ssh-keygen -t ed25519 -N "" -C "" -f "$TMP_KEY" -q
PRIVKEY=$(cat "$TMP_KEY")
PUBKEY=$(cat "${TMP_KEY}.pub")
rm -f "$TMP_KEY" "${TMP_KEY}.pub"
echo "$PUBKEY" > "${SSH_DIR}/authorized_keys"
INFISICAL_KEY="SSH_PRIVKEY_$(echo "$DEVICE_ID" | tr '[:lower:]-' '[:upper:]_')"
if [[ -x "$CREDS" ]] && "$CREDS" set "$INFISICAL_KEY" "$PRIVKEY" bigbrother 2>/dev/null; then
info "Private key stored in Infisical: bigbrother/${INFISICAL_KEY}"
else
warn "Could not store in Infisical. SAVE THIS PRIVATE KEY:"
echo "---"
echo "$PRIVKEY"
echo "---"
fi
unset PRIVKEY
else
echo "$SSH_PUBKEY" > "${SSH_DIR}/authorized_keys"
fi
chmod 700 "$SSH_DIR"
chmod 600 "${SSH_DIR}/authorized_keys"
info "SSH authorized_keys configured"
# Hostname
step "Setting hostname..."
echo "$BB_HOSTNAME" > "${MOUNT_POINT}/etc/hostname"
sed -i "s/orangepi/$BB_HOSTNAME/g" "${MOUNT_POINT}/etc/hosts" 2>/dev/null || true
info "Hostname set to: $BB_HOSTNAME"
# Disable first-login wizard
rm -f "${MOUNT_POINT}/root/.not_logged_in_yet"
# Copy BigBrother source
step "Copying BigBrother source to card..."
SRC_DEST="${MOUNT_POINT}/root/bb-src"
rm -rf "$SRC_DEST"
rsync -a \
--exclude='.git' \
--exclude='.venv' \
--exclude='storage/' \
--exclude='__pycache__' \
--exclude='*.pyc' \
--exclude='*.db' \
"${SCRIPT_DIR}/" "$SRC_DEST/" || error "rsync failed"
info "Source copied to /root/bb-src/"
# Write bb-config.env
step "Writing configuration to card..."
cat > "${MOUNT_POINT}/root/bb-config.env" << EOF
BB_MODE="${BB_MODE}"
BB_VPN="${BB_VPN}"
BB_DEVICE_ID="${DEVICE_ID}"
EOF
if [[ -n "$BB_ALERTER_WEBHOOK" ]]; then
echo "BB_ALERTER_WEBHOOK=\"${BB_ALERTER_WEBHOOK}\"" >> "${MOUNT_POINT}/root/bb-config.env"
fi
# Store Tailscale auth key if needed
if [[ "$BB_VPN" == "tailscale" && -n "$TAILSCALE_KEY" ]]; then
echo "BB_TAILSCALE_KEY=\"${TAILSCALE_KEY}\"" >> "${MOUNT_POINT}/root/bb-config.env"
fi
# Store WireGuard config if needed
if [[ "$BB_VPN" == "wireguard" && -n "$WG_CONFIG" ]]; then
echo "$WG_CONFIG" > "${MOUNT_POINT}/etc/wireguard/wg0.conf"
chmod 600 "${MOUNT_POINT}/etc/wireguard/wg0.conf"
fi
# Store autossh config if needed
if [[ "$BB_VPN" == "autossh" ]]; then
echo "BB_AUTOSSH_SERVER=\"${AUTOSSH_SERVER}\"" >> "${MOUNT_POINT}/root/bb-config.env"
echo "BB_AUTOSSH_USER=\"${AUTOSSH_USER}\"" >> "${MOUNT_POINT}/root/bb-config.env"
echo "BB_AUTOSSH_PORT=\"${AUTOSSH_PORT}\"" >> "${MOUNT_POINT}/root/bb-config.env"
echo "BB_AUTOSSH_REMOTE_PORT=\"${AUTOSSH_REMOTE_PORT}\"" >> "${MOUNT_POINT}/root/bb-config.env"
# Write tunnel private key — separate from device management key
mkdir -p "${MOUNT_POINT}/root/.ssh"
echo "$AUTOSSH_PRIVKEY" > "${MOUNT_POINT}/root/.ssh/bb-tunnel-key"
chmod 600 "${MOUNT_POINT}/root/.ssh/bb-tunnel-key"
info "Tunnel key written to /root/.ssh/bb-tunnel-key"
unset AUTOSSH_PRIVKEY
fi
chmod 600 "${MOUNT_POINT}/root/bb-config.env"
info "Configuration written"
# First-boot script
step "Installing first-boot service..."
cat > "${MOUNT_POINT}/root/bb-firstboot-run.sh" << 'RUNEOF'
#!/usr/bin/env bash
# Runs once on first boot, then disables itself
set -euo pipefail
LOG=/root/bb-firstboot.log
exec >> "$LOG" 2>&1
echo "=== BigBrother first-boot setup: $(date) ==="
# Wait for internet connectivity
echo "[*] Waiting for internet..."
for i in $(seq 1 30); do
if curl -sf --max-time 5 http://deb.debian.org/debian > /dev/null 2>&1; then
echo "[+] Internet OK"
break
fi
if [[ $i -eq 30 ]]; then
echo "[-] No internet after 90s"
exit 1
fi
sleep 3
done
source /root/bb-config.env
# Install BigBrother
cd /root/bb-src
bash setup.sh --enable-service
# VPN setup
if [[ "${BB_VPN:-}" == "tailscale" && -n "${BB_TAILSCALE_KEY:-}" ]]; then
echo "[*] Installing Tailscale..."
curl -fsSL https://tailscale.com/install.sh | sh
echo "[*] Connecting to Tailscale..."
tailscale up --auth-key="${BB_TAILSCALE_KEY}" --accept-routes || echo "[-] Tailscale auth failed"
# Remove auth key from config after use
sed -i '/BB_TAILSCALE_KEY/d' /root/bb-config.env
elif [[ "${BB_VPN:-}" == "wireguard" ]]; then
echo "[*] Installing WireGuard..."
apt-get update >/dev/null 2>&1
apt-get install -y wireguard >/dev/null 2>&1
systemctl enable --now wg-quick@wg0 || echo "[-] WireGuard startup failed"
elif [[ "${BB_VPN:-}" == "autossh" ]]; then
echo "[*] Installing autossh..."
apt-get update >/dev/null 2>&1
apt-get install -y autossh >/dev/null 2>&1
chmod 600 /root/.ssh/bb-tunnel-key 2>/dev/null || true
cat > /etc/systemd/system/bb-tunnel.service << SVCEOF
[Unit]
Description=BigBrother reverse SSH tunnel
After=network-online.target
Wants=network-online.target
[Service]
Environment="AUTOSSH_GATETIME=0"
ExecStart=/usr/bin/autossh -M 0 -N \
-o ServerAliveInterval=30 \
-o ServerAliveCountMax=3 \
-o StrictHostKeyChecking=no \
-o ExitOnForwardFailure=yes \
-i /root/.ssh/bb-tunnel-key \
-p ${BB_AUTOSSH_PORT} \
-R ${BB_AUTOSSH_REMOTE_PORT}:localhost:22 \
${BB_AUTOSSH_USER}@${BB_AUTOSSH_SERVER}
Restart=always
RestartSec=30
User=root
[Install]
WantedBy=multi-user.target
SVCEOF
systemctl daemon-reload
systemctl enable --now bb-tunnel.service || echo "[-] Tunnel service startup failed"
echo "[+] Reverse SSH tunnel configured (remote port ${BB_AUTOSSH_REMOTE_PORT})"
fi
# Network alerter
if [[ -n "${BB_ALERTER_WEBHOOK:-}" ]]; then
echo "[*] Sending online alert..."
BB_IP=$(curl -sf --max-time 5 https://api.ipify.org 2>/dev/null || hostname -I | awk '{print $1}')
BB_HOSTNAME=$(hostname)
curl -sf -X POST "$BB_ALERTER_WEBHOOK" \
-H 'Content-Type: application/json' \
-d "{\"text\":\"BigBrother ${BB_DEVICE_ID} online | ip: ${BB_IP} | host: ${BB_HOSTNAME}\"}" || true
fi
echo "[+] First-boot complete: $(date)"
# Disable ourselves
rm -f /root/.bb-firstboot-needed
systemctl disable bb-firstboot.service
RUNEOF
chmod 755 "${MOUNT_POINT}/root/bb-firstboot-run.sh"
# First-boot systemd service
cat > "${MOUNT_POINT}/etc/systemd/system/bb-firstboot.service" << 'SVCEOF'
[Unit]
Description=BigBrother first-boot setup
After=network-online.target
Wants=network-online.target
ConditionPathExists=/root/.bb-firstboot-needed
[Service]
Type=oneshot
ExecStart=/bin/bash /root/bb-firstboot-run.sh
RemainAfterExit=yes
TimeoutStartSec=900
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target
SVCEOF
# Enable service symlink
WANTS_DIR="${MOUNT_POINT}/etc/systemd/system/multi-user.target.wants"
mkdir -p "$WANTS_DIR"
ln -sf /etc/systemd/system/bb-firstboot.service "${WANTS_DIR}/bb-firstboot.service"
# Firstboot sentinel
touch "${MOUNT_POINT}/root/.bb-firstboot-needed"
info "First-boot service installed and enabled"
# ══════════════════════════════════════════════════════════════════════════════
# 8. UNMOUNT
# ══════════════════════════════════════════════════════════════════════════════
section "Finalizing"
step "Syncing and unmounting..."
sync
umount "$MOUNT_POINT" || warn "umount failed (non-critical)"
info "SD card ready to remove"
# ══════════════════════════════════════════════════════════════════════════════
# 9. DONE
# ══════════════════════════════════════════════════════════════════════════════
section "Setup Complete"
echo "${GREEN}Insert SD card into device and power on.${NC}"
echo ""
echo "The device will:"
echo " 1. Boot and connect to WiFi (if configured)"
echo " 2. Run /root/bb-firstboot-run.sh automatically"
echo " 3. Install BigBrother source and dependencies"
echo " 4. Set up systemd services"
echo " 5. Configure VPN (if selected)"
echo " 6. Send alerter webhook (if configured)"
echo ""
echo "No further operator interaction required. Device walks away autonomously."
echo ""
echo "${CYAN}To SSH after first-boot:${NC}"
if [[ "$SSH_MODE" == "generate" ]]; then
INFISICAL_KEY="SSH_PRIVKEY_$(echo "$DEVICE_ID" | tr '[:lower:]-' '[:upper:]_')"
if [[ -x "$CREDS" ]]; then
echo " ${CREDS} get ${INFISICAL_KEY} bigbrother > /tmp/${DEVICE_ID}.key"
echo " chmod 600 /tmp/${DEVICE_ID}.key"
echo " ssh -i /tmp/${DEVICE_ID}.key root@<DEVICE_IP>"
fi
else
echo " ssh -i $(dirname $ssh_pubkey_path)/$(basename $ssh_pubkey_path .pub) root@<DEVICE_IP>"
fi
echo ""
if [[ "$BB_VPN" == "autossh" ]]; then
echo ""
echo "${CYAN}Tunnel connection (after first boot):${NC}"
echo " From operator server: ssh -p ${AUTOSSH_REMOTE_PORT} root@127.0.0.1"
echo " One-liner from anywhere (requires GatewayPorts yes on server):"
echo " ssh -p ${AUTOSSH_REMOTE_PORT} root@${AUTOSSH_SERVER}"
fi
echo ""
echo "${CYAN}Monitor first-boot:${NC}"
echo " ssh root@<DEVICE_IP> 'tail -f /root/bb-firstboot.log'"
echo ""