#!/bin/bash # 🤖 First-boot init for a CARLOS scanner host (Debian arm64). Rendered by # infra/modules/scanner-host. Each step says where it got to on the serial # console, so `aws ec2 get-console-output` answers "how far did it get". # # The order is the design: # 1. SSM agent, because with no SSH it is the only way in to fix the rest. # 2. Packages installed but NOT started. # 3. Config, the self-test and the gate units. # 4. First signatures, alone, before anything else touches the database. # 5. Start: clamd, then the self-test, then (only on a pass) the port. # 6. Verify from the outside of the gate, and say READY or FAILED. set -euo pipefail # Logging must never be what stops the script: a failed write inside say() # would exit under set -e without the ERR trap (functions do not inherit it), # so without a FAILED marker. stdout goes to cloud-init's log; the serial # console copy is best effort. say() { echo "scanner-init: $*" || true { echo "scanner-init: $*" > /dev/console; } 2>/dev/null || true } # A failure in step 2 could leave policy-rc.d behind, and then nobody fixing # the box over SSM could start a service. Remove it on every way out. fail() { rm -f /usr/sbin/policy-rc.d || true; say "SCANNER-HOST FAILED: $*"; exit 1; } trap 'fail "line $LINENO"' ERR export DEBIAN_FRONTEND=noninteractive # ---- 1. the way in --------------------------------------------------------- say "1/6 SSM agent" apt-get update -q apt-get install -qy curl unattended-upgrades curl -fsSL -o /var/tmp/amazon-ssm-agent.deb \ "https://s3.${region}.amazonaws.com/amazon-ssm-${region}/latest/debian_arm64/amazon-ssm-agent.deb" dpkg -i /var/tmp/amazon-ssm-agent.deb rm -f /var/tmp/amazon-ssm-agent.deb systemctl enable --now amazon-ssm-agent systemctl enable --now unattended-upgrades # ---- 2. packages, not started ---------------------------------------------- # Debian's postinst starts clamav-freshclam and the daemon. Neither may run # before step 3 has configured it, and freshclam's service would fight the # foreground run in step 4 for the database lock. say "2/6 clamav packages" printf '#!/bin/sh\nexit 101\n' > /usr/sbin/policy-rc.d chmod +x /usr/sbin/policy-rc.d apt-get install -qy clamav-daemon clamav-freshclam clamdscan python3 iproute2 rm -f /usr/sbin/policy-rc.d # ---- 3. config, self-test, gate -------------------------------------------- say "3/6 config and gate" conf=/etc/clamav/clamd.conf [ -f "$conf" ] || fail "the package wrote no $conf" # With debconf on (the package default), the postinst regenerates clamd.conf # on every configure, unattended upgrades included. The file it generated at # install is the base; from here on it is ours. With debconf off, the postinst # leaves an existing clamd.conf alone. echo 'clamav-daemon clamav-daemon/debconf boolean false' | debconf-set-selections # clamd has no include directory: replace each key in place. The ERR trap is # not inherited by functions, so a failure in here reports itself. setkey() { { sed -i "/^$1[[:space:]]/d" "$conf" && echo "$1 $2" >> "$conf"; } \ || fail "could not set $1 in $conf" } # In M, never bytes: if the postinst ever does regenerate this file, it reads # the number as megabytes and appends M (26214400 would become 25 TiB). setkey StreamMaxLength ${stream_max_mib}M setkey AlertExceedsMax yes setkey MaxFileSize ${scan_limit} setkey MaxScanSize ${scan_limit} setkey ConcurrentDatabaseReload ${concurrent_reload} # Debian 13 mounts /tmp as tmpfs: extraction of up to the scan limit per scan # belongs on disk, not in memory. setkey TemporaryDirectory /var/tmp # clamd itself never listens on TCP. The gated proxy does. sed -i '/^TCPSocket[[:space:]]/d;/^TCPAddr[[:space:]]/d' "$conf" # Debian's AppArmor profile for clamd is enforced and allows temp files under # /tmp only, and clamd writes every INSTREAM to a temp file. Without this, # every scan errors and the self-test fails. The profile includes this local # file. It is loaded here, before clamd first starts. if [ -e /etc/apparmor.d/usr.sbin.clamd ]; then mkdir -p /etc/apparmor.d/local cat > /etc/apparmor.d/local/usr.sbin.clamd <<'EOF' # scanner-host: clamd's TemporaryDirectory is /var/tmp. /var/tmp/ rw, /var/tmp/** krw, EOF if command -v apparmor_parser >/dev/null && [ -d /sys/kernel/security/apparmor ]; then apparmor_parser -r /etc/apparmor.d/usr.sbin.clamd fi fi echo '${selftest_b64}' | base64 -d > /usr/local/sbin/scanner-selftest chmod 0755 /usr/local/sbin/scanner-selftest echo '${selftest_unit_b64}' | base64 -d > /etc/systemd/system/scanner-selftest.service echo '${tcp_service_b64}' | base64 -d > /etc/systemd/system/scanner-tcp.service tok=$(curl -fsS -X PUT http://169.254.169.254/latest/api/token \ -H 'X-aws-ec2-metadata-token-ttl-seconds: 300') ip=$(curl -fsS -H "X-aws-ec2-metadata-token: $tok" \ http://169.254.169.254/latest/meta-data/local-ipv4) case $ip in [0-9]*.[0-9]*.[0-9]*.[0-9]*) ;; *) fail "no private IPv4 from instance metadata: '$ip'" ;; esac echo '${tcp_socket_b64}' | base64 -d | sed "s/@PRIVATE_IP@/$ip/" \ > /etc/systemd/system/scanner-tcp.socket grep -q "^ListenStream=$ip:3310\$" /etc/systemd/system/scanner-tcp.socket \ || fail "the listener is not bound to the private address" mkdir -p /etc/systemd/system/clamav-daemon.service.d cat > /etc/systemd/system/clamav-daemon.service.d/10-scanner.conf </dev/null 2>&1 || fail "no $db signature database after freshclam" done systemctl enable --now clamav-freshclam # ---- 5. start, in the order the units enforce ------------------------------ say "5/6 clamd, self-test, listener (several minutes)" systemctl enable clamav-daemon.service scanner-selftest.service scanner-tcp.socket systemctl start clamav-daemon.socket clamav-daemon.service # Blocks until the self-test has finished. Fails if it failed. systemctl start scanner-tcp.socket \ || fail "self-test or listener did not start: $(systemctl is-active clamav-daemon.service scanner-selftest.service scanner-tcp.socket | tr '\n' ' ')" # ---- 6. verify from outside the gate --------------------------------------- say "6/6 verify" [ "$(systemctl is-active scanner-selftest.service)" = active ] || fail "self-test is not active" [ "$(systemctl is-active scanner-tcp.socket)" = active ] || fail "listener is not active" bound=$(ss -ltnH 'sport = :3310' | awk '{print $4}' | sort -u | tr '\n' ' ') [ "$bound" = "$ip:3310 " ] || fail "3310 is bound to '$bound', want only $ip:3310" exec 3<>"/dev/tcp/$ip/3310" printf 'zVERSION\0' >&3 ver=$(timeout 20 head -c 200 <&3 | tr -d '\0') exec 3>&- case $ver in ClamAV*) ;; *) fail "no VERSION answer through the listener: '$ver'" ;; esac mkdir -p /var/lib/scanner-host echo "$ver" > /var/lib/scanner-host/ready say "SCANNER-HOST READY $ver"