scanner-host: a clamd box whose port is open only behind a passed self-test
One correction to the brief's init script: setkey reports its own failure (fail "could not set KEY"), because the ERR trap is not inherited by shell functions and a failing sed/echo inside it would otherwise exit without SCANNER-HOST FAILED. Tests as briefed, no assertion changed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
10 files changed,
+697
−0
infra/modules/scanner-host/files/scanner-selftest+122 −0infra/modules/scanner-host/files/scanner-selftest.service+16 −0infra/modules/scanner-host/files/scanner-tcp.service+15 −0infra/modules/scanner-host/files/scanner-tcp.socket+24 −0infra/modules/scanner-host/main.tf+157 −0infra/modules/scanner-host/outputs.tf+19 −0infra/modules/scanner-host/scanner-init.sh.tftpl+128 −0infra/modules/scanner-host/tests/scanner_host.tftest.hcl+148 −0infra/modules/scanner-host/variables.tf+59 −0infra/modules/scanner-host/versions.tf+9 −0
diff --git a/infra/modules/scanner-host/files/scanner-selftest b/infra/modules/scanner-host/files/scanner-selftest| new file mode 100755 |
| index 000000000..1773ab86f |
| --- /dev/null |
| +++ b/infra/modules/scanner-host/files/scanner-selftest |
| @@ -0,0 +1,122 @@ |
| +#!/bin/bash |
| +# 🤖 scanner-selftest: prove, on THIS engine, that clamd never answers OK for |
| +# an archive it could only partly scan. |
| +# |
| +# scanner-selftest.service runs this every time clamd starts, and the TCP |
| +# listener is bound to that unit, so a failure here keeps port 3310 shut. It |
| +# talks to clamd on its unix socket: the port it guards is not open yet. |
| +# |
| +# Why it exists: with ClamAV's packaged limits, a zip member that expands past |
| +# MaxFileSize is trimmed and the trimmed copy is scanned and answered OK, with |
| +# no limit raised. The box is configured so that cannot happen (both limits |
| +# equal, AlertExceedsMax on). This checks that the configuration still does |
| +# what it did when it was chosen, on whatever version is installed today. |
| +# |
| +# Fixtures are sized for a 100 MiB scan limit. Members are written from a temp |
| +# file so their zip headers are ordinary; streamed members get zip64 headers, |
| +# which clamd does not open at all. |
| +set -u |
| +sock=/run/clamav/clamd.ctl |
| + |
| +if [ "${SCANNER_SELFTEST_FORCE_FAIL:-}" = 1 ]; then |
| + echo "scanner-selftest: FAIL (forced, for the acceptance check)" |
| + exit 1 |
| +fi |
| + |
| +d=$(mktemp -d /var/tmp/scanner-selftest.XXXXXX) || exit 1 |
| +trap 'rm -rf "$d"' EXIT |
| +echo "LocalSocket $sock" > "$d/clamdscan.conf" |
| + |
| +python3 - "$d" <<'PY' || { echo "scanner-selftest: FAIL (could not build fixtures)"; exit 1; } |
| +import io, gzip, os, random, sys, tarfile, zipfile |
| +d = sys.argv[1] |
| +fx = os.path.join(d, 'fx') |
| +os.mkdir(fx) |
| +EICAR = rb'X5O!P%@AP[4\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*' |
| + |
| +def block(seed): |
| + # 1 MiB: a 16 KiB random block repeated. It compresses about 140 to 1 and |
| + # differs per seed, so clamd's clean cache cannot wave a member through. |
| + r = random.Random(seed) |
| + return bytes(r.getrandbits(8) for _ in range(16384)) * 64 |
| + |
| +def bigfile(name, mb, seed): |
| + p = os.path.join(d, name) |
| + blk = block(seed) |
| + with open(p, 'wb') as f: |
| + for _ in range(mb): |
| + f.write(blk) |
| + return p |
| + |
| +def mkzip(name, build): |
| + with zipfile.ZipFile(os.path.join(fx, name), 'w', zipfile.ZIP_DEFLATED) as z: |
| + build(z) |
| + |
| +open(os.path.join(fx, '01-clean.txt'), 'wb').write(b'hello world\n') |
| +open(os.path.join(fx, '02-eicar.txt'), 'wb').write(EICAR) |
| +big = bigfile('big.bin', 500, 1) |
| +mid = bigfile('mid.bin', 60, 5) |
| +mkzip('03-zip-one-500.zip', lambda z: z.write(big, 'big.bin')) |
| +with zipfile.ZipFile(os.path.join(d, 'inner.zip'), 'w', zipfile.ZIP_DEFLATED) as z: |
| + z.write(big, 'big.bin') |
| +mkzip('04-zip-nested-500.zip', lambda z: z.write(os.path.join(d, 'inner.zip'), 'inner.zip')) |
| +def many(z): |
| + for i in range(30): |
| + z.write(bigfile('m.bin', 20, 100 + i), 'm%02d.bin' % i) |
| +mkzip('05-zip-30x20.zip', many) |
| +with gzip.open(os.path.join(fx, '06-gzip-500.gz'), 'wb') as g: |
| + blk = block(2) |
| + for _ in range(500): |
| + g.write(blk) |
| +with tarfile.open(os.path.join(fx, '07-targz-5x40.tar.gz'), 'w:gz') as t: |
| + for i in range(5): |
| + data = block(200 + i) * 40 |
| + ti = tarfile.TarInfo('t%d.bin' % i) |
| + ti.size = len(data) |
| + t.addfile(ti, io.BytesIO(data)) |
| +def big_then_eicar(z): |
| + z.write(big, 'big.bin') |
| + z.writestr('eicar.com', EICAR) |
| +mkzip('08-zip-500-then-eicar.zip', big_then_eicar) |
| +def mid_then_eicar(z): |
| + z.write(mid, 'mid.bin') |
| + z.writestr('eicar.com', EICAR) |
| +mkzip('09-zip-60-then-eicar.zip', mid_then_eicar) |
| +mkzip('10-zip-one-60.zip', lambda z: z.write(mid, 'mid.bin')) |
| +PY |
| + |
| +# What each fixture must answer. "limit" is the one that matters: an OK there |
| +# is a truncated scan signed clean. |
| +want() { |
| + case $1 in |
| + 01 | 10) echo ok ;; |
| + 02 | 09) echo infected ;; |
| + *) echo limit ;; |
| + esac |
| +} |
| + |
| +fail=0 |
| +for f in "$d"/fx/*; do |
| + n=$(basename "$f") |
| + exp=$(want "${n%%-*}") |
| + reply=$(clamdscan --config-file="$d/clamdscan.conf" --stream --no-summary "$f" 2>&1 | tail -1) |
| + case $reply in |
| + *"Heuristics.Limits.Exceeded"*" FOUND") got=limit ;; |
| + *" FOUND") got=infected ;; |
| + *": OK") got=ok ;; |
| + *) got=error ;; |
| + esac |
| + if [ "$got" = "$exp" ]; then |
| + echo "scanner-selftest: pass $n ($got)" |
| + else |
| + echo "scanner-selftest: FAIL $n want $exp, got $got: $reply" |
| + fail=1 |
| + fi |
| +done |
| + |
| +if [ $fail -eq 0 ]; then |
| + echo "scanner-selftest: PASS" |
| +else |
| + echo "scanner-selftest: FAIL" |
| +fi |
| +exit $fail |
diff --git a/infra/modules/scanner-host/files/scanner-selftest.service b/infra/modules/scanner-host/files/scanner-selftest.service| new file mode 100644 |
| index 000000000..d1bd5285e |
| --- /dev/null |
| +++ b/infra/modules/scanner-host/files/scanner-selftest.service |
| @@ -0,0 +1,16 @@ |
| +# 🤖 Runs the self-test against the clamd that is running now. BindsTo makes it |
| +# go inactive the moment clamd stops or restarts, and the TCP listener is bound |
| +# to THIS unit, so the port follows the test, not the boot. |
| +[Unit] |
| +Description=Prove this clamd never answers OK for an over-limit archive |
| +BindsTo=clamav-daemon.service |
| +After=clamav-daemon.service |
| + |
| +[Service] |
| +Type=oneshot |
| +RemainAfterExit=yes |
| +ExecStart=/usr/local/sbin/scanner-selftest |
| +TimeoutStartSec=1800 |
| + |
| +[Install] |
| +WantedBy=multi-user.target |
diff --git a/infra/modules/scanner-host/files/scanner-tcp.service b/infra/modules/scanner-host/files/scanner-tcp.service| new file mode 100644 |
| index 000000000..65de5601a |
| --- /dev/null |
| +++ b/infra/modules/scanner-host/files/scanner-tcp.service |
| @@ -0,0 +1,15 @@ |
| +# 🤖 Carries each TCP connection to clamd's unix socket. Bound to the self-test |
| +# like the socket is, so connections in flight are cut when the test's unit |
| +# goes inactive. |
| +[Unit] |
| +Description=Proxy TCP 3310 to clamd's unix socket |
| +Requires=scanner-selftest.service |
| +After=scanner-selftest.service |
| +BindsTo=scanner-selftest.service |
| + |
| +[Service] |
| +ExecStart=/usr/lib/systemd/systemd-socket-proxyd /run/clamav/clamd.ctl |
| +DynamicUser=yes |
| +SupplementaryGroups=clamav |
| +PrivateTmp=yes |
| +NoNewPrivileges=yes |
diff --git a/infra/modules/scanner-host/files/scanner-tcp.socket b/infra/modules/scanner-host/files/scanner-tcp.socket| new file mode 100644 |
| index 000000000..b2233878a |
| --- /dev/null |
| +++ b/infra/modules/scanner-host/files/scanner-tcp.socket |
| @@ -0,0 +1,24 @@ |
| +# 🤖 clamd on TCP, for the console. Open only behind a passed self-test. |
| +# |
| +# DefaultDependencies=no is load-bearing. A socket unit is ordered before |
| +# sockets.target by default, sockets.target before basic.target, and every |
| +# service (the self-test included) after basic.target. A socket that must also |
| +# come AFTER the self-test closes that loop, and systemd breaks an ordering |
| +# cycle by dropping a job, which could leave this port shut after a reboot. |
| +# So it opts out, restores what it still needs, and is started by |
| +# multi-user.target and by clamd's Wants=, never by sockets.target. |
| +[Unit] |
| +Description=clamd on TCP, open only behind a passed self-test |
| +DefaultDependencies=no |
| +After=sysinit.target |
| +Requires=scanner-selftest.service |
| +After=scanner-selftest.service |
| +BindsTo=scanner-selftest.service |
| +Conflicts=shutdown.target |
| +Before=shutdown.target |
| + |
| +[Socket] |
| +ListenStream=@PRIVATE_IP@:3310 |
| + |
| +[Install] |
| +WantedBy=multi-user.target |
diff --git a/infra/modules/scanner-host/main.tf b/infra/modules/scanner-host/main.tf| new file mode 100644 |
| index 000000000..4ef391bd8 |
| --- /dev/null |
| +++ b/infra/modules/scanner-host/main.tf |
| @@ -0,0 +1,157 @@ |
| +# 🤖 A CARLOS scanner host: one box running clamd for a deployment's console. |
| +# |
| +# The console reads each object from S3 and streams it here. This box reads |
| +# nothing for itself: its role has Session Manager and nothing else, so a |
| +# compromised clamd cannot fetch a tenant object or a key. |
| +# |
| +# What makes it more than "install clamav" is in scanner-init.sh.tftpl and |
| +# files/: TCP 3310 is open only while a self-test has passed against the clamd |
| +# process running now. See README.md. |
| + |
| +data "aws_security_group" "client" { |
| + id = var.client_security_group_id |
| +} |
| + |
| +data "aws_vpc" "client" { |
| + id = data.aws_security_group.client.vpc_id |
| +} |
| + |
| +locals { |
| + # Fixed in this release. The self-test's fixtures and expected answers are |
| + # sized for it; making it a variable means deriving both. |
| + scan_limit = "100M" |
| + |
| + small_box = can(regex("^t4g\\.(nano|micro|small)$", var.instance_type)) |
| + |
| + init_script = templatefile("${path.module}/scanner-init.sh.tftpl", { |
| + region = var.region |
| + stream_max_bytes = var.stream_max_bytes |
| + scan_limit = local.scan_limit |
| + memory_max = var.memory_max |
| + memory_high = var.memory_high |
| + concurrent_reload = var.concurrent_database_reload ? "yes" : "no" |
| + selftest_b64 = base64encode(file("${path.module}/files/scanner-selftest")) |
| + selftest_unit_b64 = base64encode(file("${path.module}/files/scanner-selftest.service")) |
| + tcp_socket_b64 = base64encode(file("${path.module}/files/scanner-tcp.socket")) |
| + tcp_service_b64 = base64encode(file("${path.module}/files/scanner-tcp.service")) |
| + }) |
| +} |
| + |
| +resource "aws_security_group" "scanner" { |
| + name = var.name |
| + description = "clamd, reachable from the console box only" |
| + vpc_id = data.aws_security_group.client.vpc_id |
| + |
| + # The whole fence. clamd authenticates nobody, so nothing but the console's |
| + # own group may reach it, and never a CIDR. |
| + ingress { |
| + description = "clamd INSTREAM from the console" |
| + from_port = 3310 |
| + to_port = 3310 |
| + protocol = "tcp" |
| + security_groups = [var.client_security_group_id] |
| + } |
| + |
| + # Signatures (freshclam) and packages (apt). No SSH ingress exists: admin |
| + # access is SSM Session Manager. |
| + egress { |
| + from_port = 0 |
| + to_port = 0 |
| + protocol = "-1" |
| + cidr_blocks = ["0.0.0.0/0"] |
| + } |
| + |
| + tags = { Name = var.name } |
| +} |
| + |
| +resource "aws_iam_role" "scanner" { |
| + name = var.name |
| + assume_role_policy = jsonencode({ |
| + Version = "2012-10-17" |
| + Statement = [{ |
| + Effect = "Allow" |
| + Principal = { Service = "ec2.amazonaws.com" } |
| + Action = "sts:AssumeRole" |
| + }] |
| + }) |
| +} |
| + |
| +resource "aws_iam_role_policy_attachment" "scanner_ssm" { |
| + role = aws_iam_role.scanner.name |
| + policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore" |
| +} |
| + |
| +# AmazonSSMManagedInstanceCore grants ssm:GetParameter* on "*". Left alone, |
| +# this box could read the deployment's scan tag key and its release key. An |
| +# explicit Deny beats the managed Allow and names only the parameter-read |
| +# actions, so Session Manager is untouched. Same shape as region-host's |
| +# host_ssm_parameter_scope, with no parameter allowed at all. |
| +resource "aws_iam_role_policy" "no_parameters" { |
| + name = "${var.name}-no-parameters" |
| + role = aws_iam_role.scanner.id |
| + policy = jsonencode({ |
| + Version = "2012-10-17" |
| + Statement = [ |
| + { |
| + Effect = "Deny" |
| + Action = [ |
| + "ssm:GetParameter", |
| + "ssm:GetParameters", |
| + "ssm:GetParameterHistory", |
| + "ssm:GetParametersByPath", |
| + ] |
| + Resource = "*" |
| + }, |
| + ] |
| + }) |
| +} |
| + |
| +resource "aws_iam_instance_profile" "scanner" { |
| + name = var.name |
| + role = aws_iam_role.scanner.name |
| +} |
| + |
| +resource "aws_instance" "scanner" { |
| + ami = var.ami_id |
| + instance_type = var.instance_type |
| + subnet_id = var.subnet_id |
| + vpc_security_group_ids = [aws_security_group.scanner.id] |
| + iam_instance_profile = aws_iam_instance_profile.scanner.name |
| + |
| + # Gzipped: the script carries the self-test and three units, and EC2 caps |
| + # user_data at 16 KiB. cloud-init unpacks it. |
| + user_data_base64 = base64gzip(local.init_script) |
| + user_data_replace_on_change = false |
| + |
| + metadata_options { |
| + http_endpoint = "enabled" |
| + http_tokens = "required" |
| + http_put_response_hop_limit = 1 |
| + } |
| + |
| + root_block_device { |
| + volume_size = 20 |
| + volume_type = "gp3" |
| + encrypted = true |
| + } |
| + |
| + tags = { Name = var.name } |
| + |
| + # A newer image or an edited script never replaces the box by itself. |
| + # Changing either, including stream_max_bytes, is an explicit |
| + # `tofu apply -replace=module.<name>.aws_instance.scanner`. A replaced box |
| + # has a new private IP: update the console's CARLOS_CLAMD_ADDR. |
| + lifecycle { |
| + ignore_changes = [ami, user_data, user_data_base64] |
| + |
| + precondition { |
| + condition = var.subnet_id != null || data.aws_vpc.client.default |
| + error_message = "The client security group is not in the default VPC, so the scanner cannot use the default subnet: its security group and its instance would be in different VPCs. Pass subnet_id naming a subnet in the client group's VPC." |
| + } |
| + |
| + precondition { |
| + condition = !(local.small_box && var.concurrent_database_reload) |
| + error_message = "A t4g.small or smaller cannot hold two signature databases at once. Either use t4g.medium, or set concurrent_database_reload = false (scanning pauses during each reload)." |
| + } |
| + } |
| +} |
diff --git a/infra/modules/scanner-host/outputs.tf b/infra/modules/scanner-host/outputs.tf| new file mode 100644 |
| index 000000000..d734de6a1 |
| --- /dev/null |
| +++ b/infra/modules/scanner-host/outputs.tf |
| @@ -0,0 +1,19 @@ |
| +output "clamd_addr" { |
| + description = "Where the console reaches clamd: set it as CARLOS_CLAMD_ADDR. A private address, reachable only from the client security group. It changes if the box is replaced." |
| + value = "${aws_instance.scanner.private_ip}:3310" |
| +} |
| + |
| +output "stream_max_bytes" { |
| + description = "clamd's StreamMaxLength in bytes: set it as CARLOS_CLAMD_MAX_BYTES on the console." |
| + value = var.stream_max_bytes |
| +} |
| + |
| +output "instance_id" { |
| + description = "EC2 instance id of the scanner box, for SSM." |
| + value = aws_instance.scanner.id |
| +} |
| + |
| +output "security_group_id" { |
| + description = "Security group id of the scanner box." |
| + value = aws_security_group.scanner.id |
| +} |
diff --git a/infra/modules/scanner-host/scanner-init.sh.tftpl b/infra/modules/scanner-host/scanner-init.sh.tftpl| new file mode 100644 |
| index 000000000..16f8406b7 |
| --- /dev/null |
| +++ b/infra/modules/scanner-host/scanner-init.sh.tftpl |
| @@ -0,0 +1,128 @@ |
| +#!/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 |
| + |
| +say() { echo "scanner-init: $*" | tee /dev/console; } |
| +fail() { 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 |
| +# 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" |
| +} |
| +setkey StreamMaxLength ${stream_max_bytes} |
| +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" |
| + |
| +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 <<EOF |
| +# Every start of clamd, an automatic restart included, asks for the gate again. |
| +[Unit] |
| +Wants=scanner-tcp.socket |
| + |
| +# The kernel kills clamd under memory pressure, never the SSM agent. |
| +[Service] |
| +MemoryMax=${memory_max} |
| +MemoryHigh=${memory_high} |
| +Restart=on-failure |
| +EOF |
| +systemctl daemon-reload |
| + |
| +# ---- 4. first signatures --------------------------------------------------- |
| +say "4/6 first signatures" |
| +# NotifyClamd would complain that no clamd is running yet. It is not. |
| +sed '/^NotifyClamd/d' /etc/clamav/freshclam.conf > /run/freshclam-first.conf |
| +freshclam --config-file=/run/freshclam-first.conf |
| +rm -f /run/freshclam-first.conf |
| +for db in main daily bytecode; do |
| + ls /var/lib/clamav/$db.c[vl]d >/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" |
diff --git a/infra/modules/scanner-host/tests/scanner_host.tftest.hcl b/infra/modules/scanner-host/tests/scanner_host.tftest.hcl| new file mode 100644 |
| index 000000000..9941565fd |
| --- /dev/null |
| +++ b/infra/modules/scanner-host/tests/scanner_host.tftest.hcl |
| @@ -0,0 +1,148 @@ |
| +# Plan-time tests. mock_provider: no account, no credentials. |
| +mock_provider "aws" {} |
| + |
| +override_data { |
| + target = data.aws_security_group.client |
| + values = { vpc_id = "vpc-0aaaaaaaaaaaaaaaa" } |
| +} |
| + |
| +override_data { |
| + target = data.aws_vpc.client |
| + values = { default = true } |
| +} |
| + |
| +variables { |
| + name = "carlos-test-scanner" |
| + ami_id = "ami-0123456789abcdef0" |
| + client_security_group_id = "sg-0123456789abcdef0" |
| + region = "eu-west-1" |
| +} |
| + |
| +run "the_fence" { |
| + command = plan |
| + assert { |
| + condition = length(aws_security_group.scanner.ingress) == 1 |
| + error_message = "exactly one way in" |
| + } |
| + assert { |
| + condition = alltrue([ |
| + for i in aws_security_group.scanner.ingress : |
| + i.from_port == 3310 && i.to_port == 3310 && i.protocol == "tcp" |
| + && i.security_groups == toset(["sg-0123456789abcdef0"]) |
| + && try(length(i.cidr_blocks), 0) == 0 && try(length(i.ipv6_cidr_blocks), 0) == 0 |
| + ]) |
| + error_message = "3310 from the client security group only, never a CIDR" |
| + } |
| + assert { |
| + condition = aws_security_group.scanner.vpc_id == "vpc-0aaaaaaaaaaaaaaaa" |
| + error_message = "the group lands in the client group's VPC" |
| + } |
| +} |
| + |
| +run "the_box" { |
| + command = plan |
| + assert { |
| + condition = aws_instance.scanner.metadata_options[0].http_tokens == "required" |
| + error_message = "IMDSv2 only" |
| + } |
| + assert { |
| + condition = aws_instance.scanner.root_block_device[0].encrypted |
| + error_message = "encrypted root volume" |
| + } |
| + assert { |
| + condition = aws_instance.scanner.instance_type == "t4g.medium" |
| + error_message = "t4g.medium by default" |
| + } |
| + assert { |
| + # base64 is 4 characters per 3 bytes, so 21800 characters is under 16 KiB. |
| + condition = length(base64gzip(local.init_script)) < 21800 |
| + error_message = "EC2 refuses user_data over 16 KiB, and the script is sent gzipped to stay well under it" |
| + } |
| +} |
| + |
| +run "the_role_cannot_read_parameters" { |
| + command = plan |
| + assert { |
| + condition = jsondecode(aws_iam_role_policy.no_parameters.policy).Statement[0].Effect == "Deny" |
| + error_message = "an explicit Deny, because the managed SSM policy allows GetParameter* on *" |
| + } |
| + assert { |
| + condition = toset(jsondecode(aws_iam_role_policy.no_parameters.policy).Statement[0].Action) == toset([ |
| + "ssm:GetParameter", "ssm:GetParameters", "ssm:GetParameterHistory", "ssm:GetParametersByPath", |
| + ]) |
| + error_message = "all four parameter-read actions" |
| + } |
| + assert { |
| + condition = jsondecode(aws_iam_role_policy.no_parameters.policy).Statement[0].Resource == "*" |
| + error_message = "on every parameter: this box is allowed none" |
| + } |
| +} |
| + |
| +run "the_engine_config" { |
| + command = plan |
| + assert { |
| + condition = strcontains(local.init_script, "setkey MaxFileSize 100M") && strcontains(local.init_script, "setkey MaxScanSize 100M") |
| + error_message = "both limits 100M: equal is what makes a trimmed member trip the scan budget" |
| + } |
| + assert { |
| + condition = strcontains(local.init_script, "setkey AlertExceedsMax yes") |
| + error_message = "limits must alert" |
| + } |
| + assert { |
| + condition = strcontains(local.init_script, "setkey StreamMaxLength 26214400") |
| + error_message = "StreamMaxLength is stream_max_bytes" |
| + } |
| + assert { |
| + condition = !strcontains(local.init_script, "setkey TCPSocket") && !strcontains(local.init_script, "setkey TCPAddr") |
| + error_message = "clamd itself never listens on TCP; the gated proxy does" |
| + } |
| + assert { |
| + condition = output.stream_max_bytes == 26214400 |
| + error_message = "the console's CARLOS_CLAMD_MAX_BYTES comes from this output" |
| + } |
| +} |
| + |
| +run "small_box_with_concurrent_reload_is_refused" { |
| + command = plan |
| + variables { |
| + instance_type = "t4g.small" |
| + } |
| + expect_failures = [aws_instance.scanner] |
| +} |
| + |
| +run "small_box_without_concurrent_reload_is_fine" { |
| + command = plan |
| + variables { |
| + instance_type = "t4g.small" |
| + concurrent_database_reload = false |
| + } |
| + assert { |
| + condition = strcontains(local.init_script, "setkey ConcurrentDatabaseReload no") |
| + error_message = "the reload setting reaches clamd.conf" |
| + } |
| +} |
| + |
| +run "stream_limit_too_small_for_the_fixtures" { |
| + command = plan |
| + variables { |
| + stream_max_bytes = 1048576 |
| + } |
| + expect_failures = [var.stream_max_bytes] |
| +} |
| + |
| +run "stream_limit_above_the_scan_budget" { |
| + command = plan |
| + variables { |
| + stream_max_bytes = 209715200 |
| + } |
| + expect_failures = [var.stream_max_bytes] |
| +} |
| + |
| +run "another_vpc_needs_a_subnet" { |
| + command = plan |
| + override_data { |
| + target = data.aws_vpc.client |
| + values = { default = false } |
| + } |
| + expect_failures = [aws_instance.scanner] |
| +} |
diff --git a/infra/modules/scanner-host/variables.tf b/infra/modules/scanner-host/variables.tf| new file mode 100644 |
| index 000000000..3b890f635 |
| --- /dev/null |
| +++ b/infra/modules/scanner-host/variables.tf |
| @@ -0,0 +1,59 @@ |
| +variable "name" { |
| + description = "Name for the instance, its security group, its IAM role and its instance profile (e.g. \"carlos-flagship-scanner\")." |
| + type = string |
| +} |
| + |
| +variable "ami_id" { |
| + description = "Debian arm64 AMI. Pass the console box's own image, module.<host>.ami_id, so the two are built from the same base. Ignored after creation: a newer image never replaces the box by itself." |
| + type = string |
| +} |
| + |
| +variable "client_security_group_id" { |
| + description = "Security group of the ONE box allowed to reach clamd: the console's. clamd has no authentication at all, so this group is the whole fence. The scanner's own group is created in this group's VPC." |
| + type = string |
| +} |
| + |
| +variable "region" { |
| + description = "AWS region the box runs in (e.g. \"eu-west-1\"). The SSM agent package is served per region and the box must fetch its own." |
| + type = string |
| +} |
| + |
| +variable "instance_type" { |
| + description = "EC2 instance type (arm64 Graviton). clamd holds about 1.5 GB of signatures resident and briefly about twice that while reloading them, so t4g.medium is the default. A t4g.small works only with concurrent_database_reload = false." |
| + type = string |
| + default = "t4g.medium" |
| +} |
| + |
| +variable "stream_max_bytes" { |
| + description = "Largest object clamd accepts on a stream (StreamMaxLength), in bytes. Set the console's CARLOS_CLAMD_MAX_BYTES to this module's stream_max_bytes output: the two must be equal. Between 8 MiB and 100 MiB: the box's self-test streams fixtures of about 5 MB, and a whole stream must fit the 100 MiB scan budget." |
| + type = number |
| + default = 26214400 |
| + validation { |
| + condition = var.stream_max_bytes >= 8388608 && var.stream_max_bytes <= 104857600 |
| + error_message = "stream_max_bytes must be between 8388608 (8 MiB) and 104857600 (100 MiB)." |
| + } |
| +} |
| + |
| +variable "memory_max" { |
| + description = "clamd's cgroup ceiling (systemd MemoryMax). Under memory pressure the kernel kills clamd, never the SSM agent that is the only way onto the box." |
| + type = string |
| + default = "3G" |
| +} |
| + |
| +variable "memory_high" { |
| + description = "clamd's throttle point (systemd MemoryHigh), about 85% of memory_max." |
| + type = string |
| + default = "2560M" |
| +} |
| + |
| +variable "concurrent_database_reload" { |
| + description = "Keep scanning while freshclam's new signatures load, at the cost of holding both databases in memory at once. False pauses scanning during each reload and fits a smaller box." |
| + type = bool |
| + default = true |
| +} |
| + |
| +variable "subnet_id" { |
| + description = "Subnet to launch in. Null (the default) is the default subnet of the default VPC, and is refused unless the client security group lives in the default VPC. The subnet needs a route to the internet: the box fetches signatures and packages." |
| + type = string |
| + default = null |
| +} |
diff --git a/infra/modules/scanner-host/versions.tf b/infra/modules/scanner-host/versions.tf| new file mode 100644 |
| index 000000000..c028066c2 |
| --- /dev/null |
| +++ b/infra/modules/scanner-host/versions.tf |
| @@ -0,0 +1,9 @@ |
| +terraform { |
| + required_version = ">= 1.10" |
| + required_providers { |
| + aws = { |
| + source = "hashicorp/aws" |
| + version = ">= 6.0" |
| + } |
| + } |
| +} |