| 1 | #!/bin/bash |
| 2 | # đŸ¤– First-boot init for a CARLOS scanner host (Debian arm64). Rendered by |
| 3 | # infra/modules/scanner-host. Each step says where it got to on the serial |
| 4 | # console, so `aws ec2 get-console-output` answers "how far did it get". |
| 5 | # |
| 6 | # The order is the design: |
| 7 | # 1. SSM agent, because with no SSH it is the only way in to fix the rest. |
| 8 | # 2. Packages installed but NOT started. |
| 9 | # 3. Config, the self-test and the gate units. |
| 10 | # 4. First signatures, alone, before anything else touches the database. |
| 11 | # 5. Start: clamd, then the self-test, then (only on a pass) the port. |
| 12 | # 6. Verify from the outside of the gate, and say READY or FAILED. |
| 13 | set -euo pipefail |
| 14 | |
| 15 | # Logging must never be what stops the script: a failed write inside say() |
| 16 | # would exit under set -e without the ERR trap (functions do not inherit it), |
| 17 | # so without a FAILED marker. stdout goes to cloud-init's log; the serial |
| 18 | # console copy is best effort. |
| 19 | say() { |
| 20 | echo "scanner-init: $*" || true |
| 21 | { echo "scanner-init: $*" > /dev/console; } 2>/dev/null || true |
| 22 | } |
| 23 | # A failure in step 2 could leave policy-rc.d behind, and then nobody fixing |
| 24 | # the box over SSM could start a service. Remove it on every way out. |
| 25 | fail() { rm -f /usr/sbin/policy-rc.d || true; say "SCANNER-HOST FAILED: $*"; exit 1; } |
| 26 | trap 'fail "line $LINENO"' ERR |
| 27 | export DEBIAN_FRONTEND=noninteractive |
| 28 | |
| 29 | # ---- 1. the way in --------------------------------------------------------- |
| 30 | say "1/6 SSM agent" |
| 31 | apt-get update -q |
| 32 | apt-get install -qy curl unattended-upgrades |
| 33 | curl -fsSL -o /var/tmp/amazon-ssm-agent.deb \ |
| 34 | "https://s3.${region}.amazonaws.com/amazon-ssm-${region}/latest/debian_arm64/amazon-ssm-agent.deb" |
| 35 | dpkg -i /var/tmp/amazon-ssm-agent.deb |
| 36 | rm -f /var/tmp/amazon-ssm-agent.deb |
| 37 | systemctl enable --now amazon-ssm-agent |
| 38 | systemctl enable --now unattended-upgrades |
| 39 | |
| 40 | # ---- 2. packages, not started ---------------------------------------------- |
| 41 | # Debian's postinst starts clamav-freshclam and the daemon. Neither may run |
| 42 | # before step 3 has configured it, and freshclam's service would fight the |
| 43 | # foreground run in step 4 for the database lock. |
| 44 | say "2/6 clamav packages" |
| 45 | printf '#!/bin/sh\nexit 101\n' > /usr/sbin/policy-rc.d |
| 46 | chmod +x /usr/sbin/policy-rc.d |
| 47 | apt-get install -qy clamav-daemon clamav-freshclam clamdscan python3 iproute2 |
| 48 | rm -f /usr/sbin/policy-rc.d |
| 49 | |
| 50 | # ---- 3. config, self-test, gate -------------------------------------------- |
| 51 | say "3/6 config and gate" |
| 52 | conf=/etc/clamav/clamd.conf |
| 53 | [ -f "$conf" ] || fail "the package wrote no $conf" |
| 54 | # With debconf on (the package default), the postinst regenerates clamd.conf |
| 55 | # on every configure, unattended upgrades included. The file it generated at |
| 56 | # install is the base; from here on it is ours. With debconf off, the postinst |
| 57 | # leaves an existing clamd.conf alone. |
| 58 | echo 'clamav-daemon clamav-daemon/debconf boolean false' | debconf-set-selections |
| 59 | # clamd has no include directory: replace each key in place. The ERR trap is |
| 60 | # not inherited by functions, so a failure in here reports itself. |
| 61 | setkey() { |
| 62 | { sed -i "/^$1[[:space:]]/d" "$conf" && echo "$1 $2" >> "$conf"; } \ |
| 63 | || fail "could not set $1 in $conf" |
| 64 | } |
| 65 | # In M, never bytes: if the postinst ever does regenerate this file, it reads |
| 66 | # the number as megabytes and appends M (26214400 would become 25 TiB). |
| 67 | setkey StreamMaxLength ${stream_max_mib}M |
| 68 | setkey AlertExceedsMax yes |
| 69 | setkey MaxFileSize ${scan_limit} |
| 70 | setkey MaxScanSize ${scan_limit} |
| 71 | setkey ConcurrentDatabaseReload ${concurrent_reload} |
| 72 | # Debian 13 mounts /tmp as tmpfs: extraction of up to the scan limit per scan |
| 73 | # belongs on disk, not in memory. |
| 74 | setkey TemporaryDirectory /var/tmp |
| 75 | # clamd itself never listens on TCP. The gated proxy does. |
| 76 | sed -i '/^TCPSocket[[:space:]]/d;/^TCPAddr[[:space:]]/d' "$conf" |
| 77 | |
| 78 | # Debian's AppArmor profile for clamd is enforced and allows temp files under |
| 79 | # /tmp only, and clamd writes every INSTREAM to a temp file. Without this, |
| 80 | # every scan errors and the self-test fails. The profile includes this local |
| 81 | # file. It is loaded here, before clamd first starts. |
| 82 | if [ -e /etc/apparmor.d/usr.sbin.clamd ]; then |
| 83 | mkdir -p /etc/apparmor.d/local |
| 84 | cat > /etc/apparmor.d/local/usr.sbin.clamd <<'EOF' |
| 85 | # scanner-host: clamd's TemporaryDirectory is /var/tmp. |
| 86 | /var/tmp/ rw, |
| 87 | /var/tmp/** krw, |
| 88 | EOF |
| 89 | if command -v apparmor_parser >/dev/null && [ -d /sys/kernel/security/apparmor ]; then |
| 90 | apparmor_parser -r /etc/apparmor.d/usr.sbin.clamd |
| 91 | fi |
| 92 | fi |
| 93 | |
| 94 | echo '${selftest_b64}' | base64 -d > /usr/local/sbin/scanner-selftest |
| 95 | chmod 0755 /usr/local/sbin/scanner-selftest |
| 96 | echo '${selftest_unit_b64}' | base64 -d > /etc/systemd/system/scanner-selftest.service |
| 97 | echo '${tcp_service_b64}' | base64 -d > /etc/systemd/system/scanner-tcp.service |
| 98 | |
| 99 | tok=$(curl -fsS -X PUT http://169.254.169.254/latest/api/token \ |
| 100 | -H 'X-aws-ec2-metadata-token-ttl-seconds: 300') |
| 101 | ip=$(curl -fsS -H "X-aws-ec2-metadata-token: $tok" \ |
| 102 | http://169.254.169.254/latest/meta-data/local-ipv4) |
| 103 | case $ip in |
| 104 | [0-9]*.[0-9]*.[0-9]*.[0-9]*) ;; |
| 105 | *) fail "no private IPv4 from instance metadata: '$ip'" ;; |
| 106 | esac |
| 107 | echo '${tcp_socket_b64}' | base64 -d | sed "s/@PRIVATE_IP@/$ip/" \ |
| 108 | > /etc/systemd/system/scanner-tcp.socket |
| 109 | grep -q "^ListenStream=$ip:3310\$" /etc/systemd/system/scanner-tcp.socket \ |
| 110 | || fail "the listener is not bound to the private address" |
| 111 | |
| 112 | mkdir -p /etc/systemd/system/clamav-daemon.service.d |
| 113 | cat > /etc/systemd/system/clamav-daemon.service.d/10-scanner.conf <<EOF |
| 114 | # Every start of clamd, an automatic restart included, asks for the gate again. |
| 115 | [Unit] |
| 116 | Wants=scanner-tcp.socket |
| 117 | |
| 118 | # The kernel kills clamd under memory pressure, never the SSM agent. |
| 119 | [Service] |
| 120 | MemoryMax=${memory_max} |
| 121 | MemoryHigh=${memory_high} |
| 122 | Restart=on-failure |
| 123 | |
| 124 | # The same two lines as Debian's extend.conf drop-in, which the postinst moves |
| 125 | # aside on its first configure run with debconf off: an upgrade or a |
| 126 | # dpkg-reconfigure, whichever comes first. |
| 127 | ExecStartPre=-/bin/mkdir -p /run/clamav |
| 128 | ExecStartPre=/bin/chown clamav /run/clamav |
| 129 | EOF |
| 130 | systemctl daemon-reload |
| 131 | |
| 132 | # ---- 4. first signatures --------------------------------------------------- |
| 133 | say "4/6 first signatures" |
| 134 | # The packaged config, unchanged. Debian's AppArmor profile confines freshclam |
| 135 | # to its config under /etc/clamav, so a copy anywhere else cannot be read. With |
| 136 | # clamd not running yet, the NotifyClamd step only warns, and freshclam still |
| 137 | # exits 0. |
| 138 | # ClamAV's mirror often rate-limits a new address. Three tries, a minute apart. |
| 139 | tries=1 |
| 140 | until freshclam; do |
| 141 | [ $tries -lt 3 ] || fail "first freshclam failed 3 times, 60s apart (rate-limited by ClamAV's mirror? an AppArmor denial shows in: journalctl -k | grep freshclam)" |
| 142 | say "freshclam try $tries failed, retrying in 60s" |
| 143 | tries=$((tries + 1)) |
| 144 | sleep 60 |
| 145 | done |
| 146 | for db in main daily bytecode; do |
| 147 | ls /var/lib/clamav/$db.c[vl]d >/dev/null 2>&1 || fail "no $db signature database after freshclam" |
| 148 | done |
| 149 | systemctl enable --now clamav-freshclam |
| 150 | |
| 151 | # ---- 5. start, in the order the units enforce ------------------------------ |
| 152 | say "5/6 clamd, self-test, listener (several minutes)" |
| 153 | systemctl enable clamav-daemon.service scanner-selftest.service scanner-tcp.socket |
| 154 | systemctl start clamav-daemon.socket clamav-daemon.service |
| 155 | # Blocks until the self-test has finished. Fails if it failed. |
| 156 | systemctl start scanner-tcp.socket \ |
| 157 | || fail "self-test or listener did not start: $(systemctl is-active clamav-daemon.service scanner-selftest.service scanner-tcp.socket | tr '\n' ' ')" |
| 158 | |
| 159 | # ---- 6. verify from outside the gate --------------------------------------- |
| 160 | say "6/6 verify" |
| 161 | [ "$(systemctl is-active scanner-selftest.service)" = active ] || fail "self-test is not active" |
| 162 | [ "$(systemctl is-active scanner-tcp.socket)" = active ] || fail "listener is not active" |
| 163 | bound=$(ss -ltnH 'sport = :3310' | awk '{print $4}' | sort -u | tr '\n' ' ') |
| 164 | [ "$bound" = "$ip:3310 " ] || fail "3310 is bound to '$bound', want only $ip:3310" |
| 165 | exec 3<>"/dev/tcp/$ip/3310" |
| 166 | printf 'zVERSION\0' >&3 |
| 167 | ver=$(timeout 20 head -c 200 <&3 | tr -d '\0') |
| 168 | exec 3>&- |
| 169 | case $ver in |
| 170 | ClamAV*) ;; |
| 171 | *) fail "no VERSION answer through the listener: '$ver'" ;; |
| 172 | esac |
| 173 | mkdir -p /var/lib/scanner-host |
| 174 | echo "$ver" > /var/lib/scanner-host/ready |
| 175 | say "SCANNER-HOST READY $ver" |
| 176 | |