carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1# đŸ¤– A CARLOS scanner host: one box running clamd for a deployment's console.
2#
3# The console reads each object from S3 and streams it here. This box reads
4# nothing for itself: its role has Session Manager and nothing else, so a
5# compromised clamd cannot fetch a tenant object or a key.
6#
7# What makes it more than "install clamav" is in scanner-init.sh.tftpl and
8# files/: TCP 3310 is open only while a self-test has passed against the clamd
9# process running now. See README.md.
10
11data "aws_security_group" "client" {
12 id = var.client_security_group_id
13}
14
15data "aws_vpc" "client" {
16 id = data.aws_security_group.client.vpc_id
17}
18
19locals {
20 # Fixed in this release. The self-test's fixtures and expected answers are
21 # sized for it; making it a variable means deriving both.
22 scan_limit = "100M"
23
24 small_box = can(regex("^t4g\\.(nano|micro|small)$", var.instance_type))
25
26 init_script = templatefile("${path.module}/scanner-init.sh.tftpl", {
27 region = var.region
28 stream_max_mib = var.stream_max_bytes / 1048576
29 scan_limit = local.scan_limit
30 memory_max = var.memory_max
31 memory_high = var.memory_high
32 concurrent_reload = var.concurrent_database_reload ? "yes" : "no"
33 selftest_b64 = base64encode(file("${path.module}/files/scanner-selftest"))
34 selftest_unit_b64 = base64encode(file("${path.module}/files/scanner-selftest.service"))
35 tcp_socket_b64 = base64encode(file("${path.module}/files/scanner-tcp.socket"))
36 tcp_service_b64 = base64encode(file("${path.module}/files/scanner-tcp.service"))
37 })
38}
39
40resource "aws_security_group" "scanner" {
41 name = var.name
42 description = "clamd, reachable from the console box only"
43 vpc_id = data.aws_security_group.client.vpc_id
44
45 # The whole fence. clamd authenticates nobody, so nothing but the console's
46 # own group may reach it, and never a CIDR.
47 ingress {
48 description = "clamd INSTREAM from the console"
49 from_port = 3310
50 to_port = 3310
51 protocol = "tcp"
52 security_groups = [var.client_security_group_id]
53 }
54
55 # Signatures (freshclam) and packages (apt). No SSH ingress exists: admin
56 # access is SSM Session Manager.
57 egress {
58 from_port = 0
59 to_port = 0
60 protocol = "-1"
61 cidr_blocks = ["0.0.0.0/0"]
62 }
63
64 tags = { Name = var.name }
65}
66
67resource "aws_iam_role" "scanner" {
68 name = var.name
69 assume_role_policy = jsonencode({
70 Version = "2012-10-17"
71 Statement = [{
72 Effect = "Allow"
73 Principal = { Service = "ec2.amazonaws.com" }
74 Action = "sts:AssumeRole"
75 }]
76 })
77}
78
79resource "aws_iam_role_policy_attachment" "scanner_ssm" {
80 role = aws_iam_role.scanner.name
81 policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
82}
83
84# AmazonSSMManagedInstanceCore grants ssm:GetParameter* on "*". Left alone,
85# this box could read the deployment's scan tag key and its release key. An
86# explicit Deny beats the managed Allow and names only the parameter-read
87# actions, so Session Manager is untouched. Same shape as region-host's
88# host_ssm_parameter_scope, with no parameter allowed at all.
89resource "aws_iam_role_policy" "no_parameters" {
90 name = "${var.name}-no-parameters"
91 role = aws_iam_role.scanner.id
92 policy = jsonencode({
93 Version = "2012-10-17"
94 Statement = [
95 {
96 Effect = "Deny"
97 Action = [
98 "ssm:GetParameter",
99 "ssm:GetParameters",
100 "ssm:GetParameterHistory",
101 "ssm:GetParametersByPath",
102 ]
103 Resource = "*"
104 },
105 ]
106 })
107}
108
109resource "aws_iam_instance_profile" "scanner" {
110 name = var.name
111 role = aws_iam_role.scanner.name
112}
113
114resource "aws_instance" "scanner" {
115 ami = var.ami_id
116 instance_type = var.instance_type
117 subnet_id = var.subnet_id
118 vpc_security_group_ids = [aws_security_group.scanner.id]
119 iam_instance_profile = aws_iam_instance_profile.scanner.name
120
121 # Gzipped: the script carries the self-test and three units, and EC2 caps
122 # user_data at 16 KiB. cloud-init unpacks it.
123 user_data_base64 = base64gzip(local.init_script)
124 user_data_replace_on_change = false
125
126 metadata_options {
127 http_endpoint = "enabled"
128 http_tokens = "required"
129 http_put_response_hop_limit = 1
130 }
131
132 root_block_device {
133 volume_size = 20
134 volume_type = "gp3"
135 encrypted = true
136 }
137
138 tags = { Name = var.name }
139
140 # A newer image or an edited script never replaces the box by itself.
141 # Changing either, including stream_max_bytes, is an explicit
142 # `tofu apply -replace=module.<name>.aws_instance.scanner`. A replaced box
143 # has a new private IP: update the console's CARLOS_CLAMD_ADDR.
144 lifecycle {
145 ignore_changes = [ami, user_data, user_data_base64]
146
147 precondition {
148 condition = var.subnet_id != null || data.aws_vpc.client.default
149 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."
150 }
151
152 precondition {
153 condition = !(local.small_box && var.concurrent_database_reload)
154 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)."
155 }
156 }
157}
158