feat(infra): archive UniFi All Traffic to S3 via Vector (PLAT-206) (#41)

Replace the public rsyslog-to-CloudWatch collector with Vector over a
prod 10.40 IPsec VGW, Firehose, 90-day S3, Glue, and Athena.
This commit is contained in:
Adam Moussa 2026-09-17 18:48:25 +00:00 • committed by GitHub
parent dd61d34cd7
commit 2b12aba50e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 1027 additions and 300 deletions

140
README.md
View file

@ -4,23 +4,34 @@
![AWS](https://img.shields.io/badge/AWS-FF9900?logo=amazonaws&logoColor=white) ![AWS](https://img.shields.io/badge/AWS-FF9900?logo=amazonaws&logoColor=white)
![CI](https://github.com/Sea-Haven-Industries/syslog-server/actions/workflows/ci.yaml/badge.svg) ![CI](https://github.com/Sea-Haven-Industries/syslog-server/actions/workflows/ci.yaml/badge.svg)
EC2 collector that receives remote syslog (UDP/TCP 514) from the office UniFi Vector collector that receives UniFi All Traffic syslog, CEF, and IPFIX over
fleet over an Elastic IP and ships it to the `unifi-syslog` CloudWatch Logs office IPsec, parses to JSON, and writes to S3 through Kinesis Data Firehose
group via the CloudWatch agent. for 90-day Athena search.
Deploy path (PLAT-78): HCP Terraform in seahaven-prod (`011934824531`), Deploy path (PLAT-78 / PLAT-206): HCP Terraform in seahaven-prod
workspace `syslog-server-prod`. CDK CD in mgmt is retired. (`011934824531`), workspace `syslog-server-prod`. CDK CD in mgmt is retired.
## Architecture ## Architecture
``` ```
office UniFi devices ──syslog/514──▶ EIP (prod) ──▶ EC2 (rsyslog) Locust UDM 10.30 ──SD-WAN mesh──▶ Ronkonkoma UDM 10.10
│ │
/var/log/remote/<host>/*.log IPsec UDP 514 + IPFIX 2055/2056
│ │
CloudWatch agent ──▶ unifi-syslog (90d) ▼
│ Vector t4g.small (10.40)
Syslog-NoIncomingLogs alarm ──▶ site-alerts │
▼
Kinesis Data Firehose
│
▼
S3 syslog-server-unifi-logs-* (90d)
│
▼
Glue unifi + Athena
│
Syslog-NoIncomingRecords ──▶ site-alerts
Syslog-FirehoseDeliveryFailed ──▶ site-alerts
``` ```
| Resource | Value | | Resource | Value |
@ -28,55 +39,94 @@ office UniFi devices ──syslog/514──▶ EIP (prod) ──▶ EC2 (rsyslog
| Account / region | seahaven-prod `011934824531` / us-east-1 | | Account / region | seahaven-prod `011934824531` / us-east-1 |
| HCP workspace | `syslog-server-prod` (project `seahaven-prod`; VCS `main`; working dir `terraform`; trigger `terraform/**`) | | HCP workspace | `syslog-server-prod` (project `seahaven-prod`; VCS `main`; working dir `terraform`; trigger `terraform/**`) |
| HCP plan/apply roles | `hcptf-syslog-server-plan` / `hcptf-syslog-server` | | HCP plan/apply roles | `hcptf-syslog-server-plan` / `hcptf-syslog-server` |
| Instance | `syslog-server`, t4g.nano, Amazon Linux 2023 (arm64), 30 GiB encrypted gp3 | | Instance | `syslog-server`, t4g.small, Amazon Linux 2023 (arm64), 20 GiB encrypted gp3, SSM only |
| VPC | dedicated `10.40.0.0/16`, public subnet `10.40.10.0/24` | | VPC | dedicated `10.40.0.0/16`, public subnet `10.40.10.0/24` (egress IP for Vector install / Firehose / SSM; not a syslog target) |
| Elastic IP | `184.193.220.187` (`eipalloc-07d82c1f79a22716a`) — UniFi still points at mgmt until INFRA-11 | | IPsec | VGW + customer gateway on Ronkonkoma WAN `47.21.61.4`; static routes `10.10.0.0/16` and `10.30.0.0/16` |
| Security group | `syslog-server` — 514 tcp/udp + 22 from office IPs + VPC/VPN CIDRs; 2055/2056 udp from office | | UniFi target | instance **private IP**:514 (syslog + CEF) and :2055/:2056 (IPFIX). No public 514. |
| Instance IAM | `/tf-managed/syslog-server-role` with `syslog-server-instance-boundary`; `AmazonSSMManagedInstanceCore` + `CloudWatchAgentServerPolicy` | | Security group | `syslog-server` — UDP/TCP 514 and UDP 2055/2056 from `10.10.0.0/16` and `10.30.0.0/16` only |
| Log group | `unifi-syslog` (90-day retention) | | Instance IAM | `/tf-managed/syslog-server-role` with `syslog-server-instance-boundary`; `AmazonSSMManagedInstanceCore` + `firehose:PutRecordBatch` |
| Alarms | `Syslog-NoIncomingLogs`, `EC2-StatusCheck-syslog-server`, `EC2-StatusCheckSystem-syslog-server-recover` → `site-alerts` | | Store | S3 `syslog-server-unifi-logs-011934824531`, prefixes `format=cef\|iptables\|netflow/dt=YYYY-MM-DD/`, 90-day expire |
| Query | Glue database `unifi` (cef, iptables, netflow) and Athena workgroup `syslog-server` |
| Alarms | `Syslog-NoIncomingRecords`, `Syslog-FirehoseDeliveryFailed`, `EC2-StatusCheck-syslog-server`, `EC2-StatusCheckSystem-syslog-server-recover` → `site-alerts` |
The office IPsec tunnel that already reaches mgmt `10.20.0.0/16` does **not**
land in this VPC. UniFi needs a second site-to-site peer for `10.40.0.0/16`.
Do not re-home this workspace in mgmt.
## Access ## Access
SSM Session Manager (no key pair). SSH 22 is open from office/VPC for SSM Session Manager. SSH 22 is closed. There is no Elastic IP forwarding
break-glass only. target.
## HCP first apply ## IAM bootstrap window
First apply uses the hcptf-bootstrap window (exact `StringEquals` trust, never Instance-boundary document changes and apply-role inline policy changes need
`StringLike`): the hcptf-bootstrap window (`DenySelfMutation` plus deny on
`iam:CreatePolicyVersion`). Sequence:
1. Create the HCP workspace. Auto-apply off. No project-level variable set. 1. From `seahaven-org-baseline`:
Working directory `terraform`. File trigger prefix `terraform/**` only.
Speculative plans on. VCS on `main`.
2. From `seahaven-org-baseline`:
`scripts/create-hcptf-bootstrap-roles.sh --account prod --allow-workspace syslog-server-prod` `scripts/create-hcptf-bootstrap-roles.sh --account prod --allow-workspace syslog-server-prod`
3. Point workspace `TFC_AWS_APPLY_ROLE_ARN` / `TFC_AWS_PLAN_ROLE_ARN` at 2. Point workspace `TFC_AWS_APPLY_ROLE_ARN` / `TFC_AWS_PLAN_ROLE_ARN` at
`hcptf-bootstrap` / `hcptf-bootstrap-plan`. Set `TFC_AWS_PROVIDER_AUTH=true`. `hcptf-bootstrap` / `hcptf-bootstrap-plan`. Keep `TFC_AWS_PROVIDER_AUTH=true`.
Never `TFC_AWS_RUN_ROLE_ARN`. Never `TFC_AWS_RUN_ROLE_ARN`.
4. One manual apply as `hcptf-bootstrap` creates the scoped `hcptf-*` roles, 3. One manual apply as bootstrap creates/updates the scoped `hcptf-*` inline
boundary, and instance role. Bootstrap cannot `ec2:CreateVpc`; the rest of policies and the instance boundary.
the stack applies as `hcptf-syslog-server`. 4. Retarget `TFC_AWS_*` to `hcptf-syslog-server` / `hcptf-syslog-server-plan`.
5. Retarget `TFC_AWS_*` to `hcptf-syslog-server` / `hcptf-syslog-server-plan`.
Re-run the create script with no `--allow-workspace`. Re-run the create script with no `--allow-workspace`.
6. Manual apply as the scoped role. After live-path proof, seal auto-apply on. 5. Manual apply as the scoped role for the rest of the stack (instance,
Firehose, S3, Glue, Athena, VGW). Auto-apply stays off until soak.
`Syslog-NoIncomingLogs` defaults `treat_missing_data` to `notBreaching` so the HCP outputs to copy: `private_ip`, `vpn_connection_id`,
empty prod log group does not page `site-alerts` before UniFi is re-pointed. `vpn_tunnel1_address`, `vpn_tunnel2_address`, `bucket_name`,
After devices deliver to the new EIP, set `no_logs_treat_missing_data=breaching`. `firehose_name`, `athena_workgroup`. Read PSKs with
`terraform output -raw vpn_tunnel1_preshared_key` after apply. Do not commit
them.
HCP outputs to copy: `public_ip`, `instance_id`, `hcptf_apply_role_arn`, AMI is pinned in `var.ami_id`. An AMI or user-data change replaces the
`hcptf_plan_role_arn`. instance. The box is stateless; archives live in S3.
AMI is pinned in `var.ami_id`. An AMI change forces instance replacement. `Syslog-NoIncomingRecords` defaults `treat_missing_data` to `notBreaching`
User-data changes also replace the instance (the box is stateless; logs live until UniFi delivers over IPsec. After Firehose `IncomingRecords` is
in CloudWatch; the EIP re-associates). non-zero, set `no_logs_treat_missing_data=breaching`.
## UniFi cutover
Do this after the HCP apply, not before. Apply drops public 514 and the
CloudWatch `unifi-syslog` log group. Point UniFi immediately.
1. **Ronkonkoma site-to-site VPN** to the AWS tunnel addresses from HCP
outputs. Remote network `10.40.0.0/16`. Local network `10.10.0.0/16`.
IKEv2, AES-256, SHA-256, DH14 matches typical AWS defaults. Use the
Terraform PSK outputs. This is a second child SA alongside the existing
mgmt `10.20` tunnel. Do not replace the mgmt tunnel.
2. **Locust SD-WAN mesh** must already route AWS VPC CIDRs via Ronkonkoma
(same as jumpbox SSH). Add `10.40.0.0/16` if it is missing.
3. Both controllers, **Settings → CyberSecure / System Log**:
- SIEM server = collector **private IP**, port **514**, UDP
- Flow Logging = **All Traffic**
- Activity Logging SIEM contents include firewall
- Control Plane **CEF** to the same IP:514
4. Enable syslog on WAN and inter-VLAN firewall rules, or All Traffic stays
silent.
5. NetFlow/IPFIX: Ronkonkoma → UDP **2055**, Locust → UDP **2056**, same
private IP.
6. Prove the path: send a test syslog from Ronkonkoma; Athena `SELECT` on
`iptables` and `cef`; confirm `format=netflow` objects for 2055/2056;
confirm `site-alerts` does not fire while traffic is present.
7. Flip off any remaining public EIP / mgmt collector **only after**
Firehose `IncomingRecords` is non-zero. PLAT-78 still owns deleting the
mgmt `syslog-server` CloudFormation stack after soak.
Vector treats payloads as untrusted text. It parses fields and does not
shell out. IPFIX datagrams are archived as base64 JSON with a site tag
(Vector has no released IPFIX decoder).
## Documentation ## Documentation
The canonical map of Sea Haven's AWS infrastructure lives in Confluence. The canonical map of Sea Haven's AWS infrastructure lives in Confluence.
- **[AWS Architecture Map](https://seahaven.atlassian.net/wiki/spaces/IT/pages/1540098)** (Confluence, IT space, page 1540098) - **[AWS Architecture Map](https://seahaven.atlassian.net/wiki/spaces/IT/pages/1540098)** (Confluence, IT space, page 1540098)
- **[Syslog Server](https://seahaven.atlassian.net/wiki/spaces/IT/pages/67141633)** (page 67141633)
To widen device coverage of the forwarded syslog feed, see **INFRA-11** Tracked as **PLAT-206**. PLAT-78 remains the HCP move plus mgmt stack delete
(UniFi controller remote-logging config). after this soak.

View file

@ -1,10 +1,10 @@
resource "aws_cloudwatch_metric_alarm" "no_incoming_logs" { resource "aws_cloudwatch_metric_alarm" "no_incoming_records" {
alarm_name = "Syslog-NoIncomingLogs" alarm_name = "Syslog-NoIncomingRecords"
alarm_description = "No log events delivered to unifi-syslog for 2 days — syslog pipeline may be down." alarm_description = "No Firehose IncomingRecords for 2 days. UniFi pipeline may be down."
comparison_operator = "LessThanThreshold" comparison_operator = "LessThanThreshold"
evaluation_periods = 2 evaluation_periods = 2
metric_name = "IncomingLogEvents" metric_name = "IncomingRecords"
namespace = "AWS/Logs" namespace = "AWS/Firehose"
period = 86400 period = 86400
statistic = "Sum" statistic = "Sum"
threshold = 1 threshold = 1
@ -12,13 +12,31 @@ resource "aws_cloudwatch_metric_alarm" "no_incoming_logs" {
alarm_actions = [local.site_alerts_arn] alarm_actions = [local.site_alerts_arn]
dimensions = { dimensions = {
LogGroupName = local.log_group_name DeliveryStreamName = aws_kinesis_firehose_delivery_stream.unifi.name
}
}
resource "aws_cloudwatch_metric_alarm" "firehose_delivery" {
alarm_name = "Syslog-FirehoseDeliveryFailed"
alarm_description = "Firehose DeliveryToS3.Success average below 1 for 10 min. S3 PUTs are failing."
comparison_operator = "LessThanThreshold"
evaluation_periods = 2
metric_name = "DeliveryToS3.Success"
namespace = "AWS/Firehose"
period = 300
statistic = "Average"
threshold = 1
treat_missing_data = "notBreaching"
alarm_actions = [local.site_alerts_arn]
dimensions = {
DeliveryStreamName = aws_kinesis_firehose_delivery_stream.unifi.name
} }
} }
resource "aws_cloudwatch_metric_alarm" "status_check" { resource "aws_cloudwatch_metric_alarm" "status_check" {
alarm_name = "EC2-StatusCheck-syslog-server" alarm_name = "EC2-StatusCheck-syslog-server"
alarm_description = "syslog-server EC2 status check failed (instance and/or system) for 10 min — host may be hung or unreachable." alarm_description = "syslog-server EC2 status check failed (instance and/or system) for 10 min. Host may be hung or unreachable."
comparison_operator = "GreaterThanOrEqualToThreshold" comparison_operator = "GreaterThanOrEqualToThreshold"
evaluation_periods = 2 evaluation_periods = 2
metric_name = "StatusCheckFailed" metric_name = "StatusCheckFailed"
@ -36,7 +54,7 @@ resource "aws_cloudwatch_metric_alarm" "status_check" {
resource "aws_cloudwatch_metric_alarm" "system_recover" { resource "aws_cloudwatch_metric_alarm" "system_recover" {
alarm_name = "EC2-StatusCheckSystem-syslog-server-recover" alarm_name = "EC2-StatusCheckSystem-syslog-server-recover"
alarm_description = "syslog-server EC2 system status check failed — underlying host impaired; auto-recovering onto new hardware." alarm_description = "syslog-server EC2 system status check failed. Underlying host impaired; auto-recovering onto new hardware."
comparison_operator = "GreaterThanOrEqualToThreshold" comparison_operator = "GreaterThanOrEqualToThreshold"
evaluation_periods = 2 evaluation_periods = 2
metric_name = "StatusCheckFailed_System" metric_name = "StatusCheckFailed_System"

63
terraform/athena.tf Normal file
View file

@ -0,0 +1,63 @@
resource "aws_athena_workgroup" "this" {
name = local.athena_workgroup
configuration {
enforce_workgroup_configuration = true
publish_cloudwatch_metrics_enabled = false
result_configuration {
output_location = "s3://${aws_s3_bucket.unifi.bucket}/${local.athena_results_prefix}"
encryption_configuration {
encryption_option = "SSE_S3"
}
}
}
}
resource "aws_athena_named_query" "recent_denies" {
name = "unifi-recent-denies"
workgroup = aws_athena_workgroup.this.id
database = aws_glue_catalog_database.unifi.name
query = <<-SQL
SELECT timestamp, site, hostname, src, dst, proto, action, raw
FROM iptables
WHERE dt >= date_format(current_date - interval '7' day, '%Y-%m-%d')
AND action = 'deny'
ORDER BY timestamp DESC
LIMIT 200
SQL
}
resource "aws_athena_named_query" "src_dst_lookup" {
name = "unifi-src-dst-lookup"
workgroup = aws_athena_workgroup.this.id
database = aws_glue_catalog_database.unifi.name
query = <<-SQL
SELECT timestamp, format, site, hostname, src, dst, proto, action, raw
FROM iptables
WHERE dt >= date_format(current_date - interval '1' day, '%Y-%m-%d')
AND (src = 'x.x.x.x' OR dst = 'x.x.x.x')
ORDER BY timestamp DESC
LIMIT 200
SQL
}
resource "aws_athena_named_query" "cef_security" {
name = "unifi-cef-security"
workgroup = aws_athena_workgroup.this.id
database = aws_glue_catalog_database.unifi.name
query = <<-SQL
SELECT timestamp, site, hostname, src, dst, proto, action, raw
FROM cef
WHERE dt >= date_format(current_date - interval '7' day, '%Y-%m-%d')
AND (
lower(raw) LIKE '%security%'
OR lower(raw) LIKE '%intrusion%'
OR lower(raw) LIKE '%blocked%'
OR lower(raw) LIKE '%threat%'
)
ORDER BY timestamp DESC
LIMIT 200
SQL
}

View file

@ -1,14 +1,15 @@
resource "aws_instance" "this" { resource "aws_instance" "this" {
ami = var.ami_id ami = var.ami_id
instance_type = "t4g.nano" instance_type = "t4g.small"
subnet_id = aws_subnet.public.id subnet_id = aws_subnet.public.id
vpc_security_group_ids = [aws_security_group.this.id] vpc_security_group_ids = [aws_security_group.this.id]
iam_instance_profile = aws_iam_instance_profile.this.name iam_instance_profile = aws_iam_instance_profile.this.name
user_data = file("${path.module}/user_data.sh") associate_public_ip_address = true
user_data = local.user_data
user_data_replace_on_change = true user_data_replace_on_change = true
root_block_device { root_block_device {
volume_size = 30 volume_size = 20
volume_type = "gp3" volume_type = "gp3"
encrypted = true encrypted = true
} }
@ -23,17 +24,11 @@ resource "aws_instance" "this" {
} }
} }
resource "aws_eip" "this" { locals {
domain = "vpc" user_data = templatefile("${path.module}/user_data.sh.tftpl", {
vector_yaml = templatefile("${path.module}/vector.yaml.tftpl", {
tags = { aws_region = var.aws_region
Name = "syslog-server" firehose_stream = aws_kinesis_firehose_delivery_stream.unifi.name
} })
})
depends_on = [aws_internet_gateway.this]
}
resource "aws_eip_association" "this" {
instance_id = aws_instance.this.id
allocation_id = aws_eip.this.id
} }

105
terraform/firehose.tf Normal file
View file

@ -0,0 +1,105 @@
data "aws_iam_policy_document" "firehose_assume" {
statement {
sid = "FirehoseAssume"
effect = "Allow"
actions = ["sts:AssumeRole"]
principals {
type = "Service"
identifiers = ["firehose.amazonaws.com"]
}
}
}
data "aws_iam_policy_document" "firehose" {
statement {
sid = "S3Delivery"
effect = "Allow"
actions = [
"s3:AbortMultipartUpload",
"s3:GetBucketLocation",
"s3:GetObject",
"s3:ListBucket",
"s3:ListBucketMultipartUploads",
"s3:PutObject",
]
resources = [
aws_s3_bucket.unifi.arn,
"${aws_s3_bucket.unifi.arn}/*",
]
}
}
resource "aws_iam_role" "firehose" {
name = local.firehose_role_name
path = "/tf-managed/"
assume_role_policy = data.aws_iam_policy_document.firehose_assume.json
permissions_boundary = aws_iam_policy.instance_boundary.arn
tags = {
Name = local.firehose_role_name
}
}
resource "aws_iam_role_policy" "firehose" {
name = "s3-delivery"
role = aws_iam_role.firehose.id
policy = data.aws_iam_policy_document.firehose.json
}
resource "aws_kinesis_firehose_delivery_stream" "unifi" {
name = local.firehose_name
destination = "extended_s3"
extended_s3_configuration {
role_arn = aws_iam_role.firehose.arn
bucket_arn = aws_s3_bucket.unifi.arn
prefix = "format=!{partitionKeyFromQuery:format}/dt=!{timestamp:yyyy-MM-dd}/"
error_output_prefix = "errors/!{firehose:error-output-type}/dt=!{timestamp:yyyy-MM-dd}/"
buffering_size = 64
buffering_interval = 300
compression_format = "GZIP"
file_extension = ".json.gz"
processing_configuration {
enabled = true
processors {
type = "MetadataExtraction"
parameters {
parameter_name = "JsonParsingEngine"
parameter_value = "JQ-1.6"
}
parameters {
parameter_name = "MetadataExtractionQuery"
parameter_value = "{format:.format}"
}
}
processors {
type = "AppendDelimiterToRecord"
parameters {
parameter_name = "Delimiter"
parameter_value = "\\n"
}
}
}
dynamic_partitioning_configuration {
enabled = true
}
cloudwatch_logging_options {
enabled = false
}
}
tags = {
Name = local.firehose_name
}
depends_on = [aws_iam_role_policy.firehose]
}

59
terraform/glue.tf Normal file
View file

@ -0,0 +1,59 @@
resource "aws_glue_catalog_database" "unifi" {
name = local.glue_database_name
}
locals {
glue_columns = [
{ name = "timestamp", type = "string" },
{ name = "site", type = "string" },
{ name = "format", type = "string" },
{ name = "hostname", type = "string" },
{ name = "src", type = "string" },
{ name = "dst", type = "string" },
{ name = "proto", type = "string" },
{ name = "action", type = "string" },
{ name = "raw", type = "string" },
]
}
resource "aws_glue_catalog_table" "formats" {
for_each = toset(["cef", "iptables", "netflow"])
name = each.value
database_name = aws_glue_catalog_database.unifi.name
table_type = "EXTERNAL_TABLE"
parameters = {
classification = "json"
compressionType = "gzip"
"projection.enabled" = "true"
"projection.dt.type" = "date"
"projection.dt.format" = "yyyy-MM-dd"
"projection.dt.range" = "2026-01-01,NOW"
"storage.location.template" = "s3://${aws_s3_bucket.unifi.bucket}/format=${each.value}/dt=$${dt}/"
}
partition_keys {
name = "dt"
type = "string"
}
storage_descriptor {
location = "s3://${aws_s3_bucket.unifi.bucket}/format=${each.value}/"
input_format = "org.apache.hadoop.mapred.TextInputFormat"
output_format = "org.apache.hadoop.hive.ql.io.HiveIgnoreKeyTextOutputFormat"
ser_de_info {
name = "json"
serialization_library = "org.openx.data.jsonserde.JsonSerDe"
}
dynamic "columns" {
for_each = local.glue_columns
content {
name = columns.value.name
type = columns.value.type
}
}
}
}

View file

@ -1,4 +1,4 @@
# HCP plan/apply roles for syslog-server-prod (PLAT-78 / PLAT-144). # HCP plan/apply roles for syslog-server-prod (PLAT-78 / PLAT-206).
# Copy of seahaven-org-baseline/examples/hcptf-workspace-iam/hcp_iam.tf.example # Copy of seahaven-org-baseline/examples/hcptf-workspace-iam/hcp_iam.tf.example
# with the syslog-server EC2 service set. Create, do not import. # with the syslog-server EC2 service set. Create, do not import.
# #
@ -157,6 +157,19 @@ data "aws_iam_policy_document" "hcptf_scoped_iam" {
} }
} }
statement {
sid = "PassFirehoseRole"
effect = "Allow"
actions = ["iam:PassRole"]
resources = ["arn:aws:iam::${local.account_id}:role/tf-managed/${local.firehose_role_name}"]
condition {
test = "StringEquals"
variable = "iam:PassedToService"
values = ["firehose.amazonaws.com"]
}
}
statement { statement {
sid = "InstanceProfiles" sid = "InstanceProfiles"
effect = "Allow" effect = "Allow"
@ -248,34 +261,151 @@ data "aws_iam_policy_document" "hcptf_scoped_iam" {
} }
data "aws_iam_policy_document" "hcptf_apply_services" { data "aws_iam_policy_document" "hcptf_apply_services" {
# checkov:skip=CKV_AWS_111: EC2 VPC/instance lifecycle and describe APIs require Resource=*. Log group, alarm, and SNS writes are ARN-prefixed. # checkov:skip=CKV_AWS_111: EC2 describe APIs and Glue catalog ARNs require Resource=*. S3, Firehose, Athena, and SNS writes are ARN-prefixed.
statement { statement {
sid = "CloudWatchLogs" sid = "DescribeLogGroups"
effect = "Allow"
actions = ["logs:DescribeLogGroups"]
resources = ["*"]
}
statement {
sid = "DeleteLegacyCloudWatchLogGroup"
effect = "Allow" effect = "Allow"
actions = [ actions = [
"logs:CreateLogGroup",
"logs:DeleteLogGroup", "logs:DeleteLogGroup",
"logs:PutRetentionPolicy",
"logs:DeleteRetentionPolicy",
"logs:TagResource",
"logs:UntagResource",
"logs:ListTagsForResource", "logs:ListTagsForResource",
"logs:AssociateKmsKey", "logs:DeleteRetentionPolicy",
"logs:DisassociateKmsKey",
] ]
resources = [ resources = [
"arn:aws:logs:${var.aws_region}:${local.account_id}:log-group:${local.log_group_name}", "arn:aws:logs:${var.aws_region}:${local.account_id}:log-group:unifi-syslog",
"arn:aws:logs:${var.aws_region}:${local.account_id}:log-group:${local.log_group_name}:*", "arn:aws:logs:${var.aws_region}:${local.account_id}:log-group:unifi-syslog:*",
] ]
} }
statement { statement {
sid = "CloudWatchLogsDescribe" sid = "S3ArchiveBucket"
effect = "Allow"
actions = [
"s3:CreateBucket",
"s3:DeleteBucket",
"s3:DeleteBucketPolicy",
"s3:GetAccelerateConfiguration",
"s3:GetBucketAcl",
"s3:GetBucketCORS",
"s3:GetBucketLocation",
"s3:GetBucketLogging",
"s3:GetBucketNotification",
"s3:GetBucketObjectLockConfiguration",
"s3:GetBucketOwnershipControls",
"s3:GetBucketPolicy",
"s3:GetBucketPolicyStatus",
"s3:GetBucketPublicAccessBlock",
"s3:GetBucketRequestPayment",
"s3:GetBucketTagging",
"s3:GetBucketVersioning",
"s3:GetBucketWebsite",
"s3:GetEncryptionConfiguration",
"s3:GetLifecycleConfiguration",
"s3:GetReplicationConfiguration",
"s3:ListBucket",
"s3:PutBucketOwnershipControls",
"s3:PutBucketPolicy",
"s3:PutBucketPublicAccessBlock",
"s3:PutBucketTagging",
"s3:PutEncryptionConfiguration",
"s3:PutLifecycleConfiguration",
]
resources = ["arn:aws:s3:::${local.bucket_name}"]
}
statement {
sid = "S3ArchiveObjects"
effect = "Allow"
actions = [
"s3:AbortMultipartUpload",
"s3:DeleteObject",
"s3:GetObject",
"s3:PutObject",
]
resources = ["arn:aws:s3:::${local.bucket_name}/*"]
}
statement {
sid = "FirehoseList"
effect = "Allow" effect = "Allow"
actions = ["logs:DescribeLogGroups"] actions = ["firehose:ListDeliveryStreams"]
resources = ["*"] resources = ["*"]
} }
statement {
sid = "FirehoseStream"
effect = "Allow"
actions = [
"firehose:CreateDeliveryStream",
"firehose:DeleteDeliveryStream",
"firehose:DescribeDeliveryStream",
"firehose:ListTagsForDeliveryStream",
"firehose:StartDeliveryStreamEncryption",
"firehose:StopDeliveryStreamEncryption",
"firehose:TagDeliveryStream",
"firehose:UntagDeliveryStream",
"firehose:UpdateDestination",
]
resources = [
"arn:aws:firehose:${var.aws_region}:${local.account_id}:deliverystream/${local.firehose_name}",
]
}
statement {
sid = "GlueCatalog"
effect = "Allow"
actions = [
"glue:CreateDatabase",
"glue:DeleteDatabase",
"glue:GetDatabase",
"glue:GetDatabases",
"glue:UpdateDatabase",
"glue:CreateTable",
"glue:DeleteTable",
"glue:GetTable",
"glue:GetTables",
"glue:UpdateTable",
"glue:GetPartition",
"glue:GetPartitions",
"glue:BatchCreatePartition",
"glue:TagResource",
"glue:UntagResource",
"glue:GetTags",
]
resources = [
"arn:aws:glue:${var.aws_region}:${local.account_id}:catalog",
"arn:aws:glue:${var.aws_region}:${local.account_id}:database/${local.glue_database_name}",
"arn:aws:glue:${var.aws_region}:${local.account_id}:table/${local.glue_database_name}/*",
]
}
statement {
sid = "AthenaWorkgroup"
effect = "Allow"
actions = [
"athena:CreateWorkGroup",
"athena:DeleteWorkGroup",
"athena:GetWorkGroup",
"athena:UpdateWorkGroup",
"athena:CreateNamedQuery",
"athena:DeleteNamedQuery",
"athena:GetNamedQuery",
"athena:ListNamedQueries",
"athena:ListTagsForResource",
"athena:TagResource",
"athena:UntagResource",
]
resources = [
"arn:aws:athena:${var.aws_region}:${local.account_id}:workgroup/${local.athena_workgroup}",
]
}
statement { statement {
sid = "CloudWatchAlarms" sid = "CloudWatchAlarms"
effect = "Allow" effect = "Allow"
@ -365,6 +495,22 @@ data "aws_iam_policy_document" "hcptf_apply_services" {
"ec2:DescribeVpcAttribute", "ec2:DescribeVpcAttribute",
"ec2:DescribeVpcs", "ec2:DescribeVpcs",
"ec2:DescribePrefixLists", "ec2:DescribePrefixLists",
"ec2:DescribeVpnConnections",
"ec2:DescribeVpnGateways",
"ec2:DescribeCustomerGateways",
"ec2:CreateVpnGateway",
"ec2:DeleteVpnGateway",
"ec2:AttachVpnGateway",
"ec2:DetachVpnGateway",
"ec2:CreateCustomerGateway",
"ec2:DeleteCustomerGateway",
"ec2:CreateVpnConnection",
"ec2:DeleteVpnConnection",
"ec2:CreateVpnConnectionRoute",
"ec2:DeleteVpnConnectionRoute",
"ec2:ModifyVpnConnection",
"ec2:ModifyVpnConnectionOptions",
"ec2:ModifyVpnTunnelOptions",
"ec2:DetachInternetGateway", "ec2:DetachInternetGateway",
"ec2:DisassociateAddress", "ec2:DisassociateAddress",
"ec2:DisassociateRouteTable", "ec2:DisassociateRouteTable",
@ -466,15 +612,82 @@ data "aws_iam_policy_document" "hcptf_plan_refresh" {
} }
statement { statement {
sid = "RefreshLogs" sid = "RefreshS3"
effect = "Allow" effect = "Allow"
actions = [ actions = [
"logs:DescribeLogGroups", "s3:GetAccelerateConfiguration",
"logs:ListTagsForResource", "s3:GetBucketAcl",
"s3:GetBucketCORS",
"s3:GetBucketLocation",
"s3:GetBucketLogging",
"s3:GetBucketNotification",
"s3:GetBucketObjectLockConfiguration",
"s3:GetBucketOwnershipControls",
"s3:GetBucketPolicy",
"s3:GetBucketPolicyStatus",
"s3:GetBucketPublicAccessBlock",
"s3:GetBucketRequestPayment",
"s3:GetBucketTagging",
"s3:GetBucketVersioning",
"s3:GetBucketWebsite",
"s3:GetEncryptionConfiguration",
"s3:GetLifecycleConfiguration",
"s3:GetReplicationConfiguration",
"s3:ListBucket",
] ]
resources = ["arn:aws:s3:::${local.bucket_name}"]
}
statement {
sid = "RefreshFirehoseList"
effect = "Allow"
actions = ["firehose:ListDeliveryStreams"]
resources = ["*"] resources = ["*"]
} }
statement {
sid = "RefreshFirehose"
effect = "Allow"
actions = [
"firehose:DescribeDeliveryStream",
"firehose:ListTagsForDeliveryStream",
]
resources = [
"arn:aws:firehose:${var.aws_region}:${local.account_id}:deliverystream/${local.firehose_name}",
]
}
statement {
sid = "RefreshGlue"
effect = "Allow"
actions = [
"glue:GetDatabase",
"glue:GetDatabases",
"glue:GetTable",
"glue:GetTables",
"glue:GetTags",
]
resources = [
"arn:aws:glue:${var.aws_region}:${local.account_id}:catalog",
"arn:aws:glue:${var.aws_region}:${local.account_id}:database/${local.glue_database_name}",
"arn:aws:glue:${var.aws_region}:${local.account_id}:table/${local.glue_database_name}/*",
]
}
statement {
sid = "RefreshAthena"
effect = "Allow"
actions = [
"athena:GetWorkGroup",
"athena:GetNamedQuery",
"athena:ListNamedQueries",
"athena:ListTagsForResource",
]
resources = [
"arn:aws:athena:${var.aws_region}:${local.account_id}:workgroup/${local.athena_workgroup}",
]
}
statement { statement {
sid = "RefreshAlarms" sid = "RefreshAlarms"
effect = "Allow" effect = "Allow"
@ -524,6 +737,9 @@ data "aws_iam_policy_document" "hcptf_plan_refresh" {
"ec2:DescribeVolumes", "ec2:DescribeVolumes",
"ec2:DescribeVpcAttribute", "ec2:DescribeVpcAttribute",
"ec2:DescribeVpcs", "ec2:DescribeVpcs",
"ec2:DescribeVpnConnections",
"ec2:DescribeVpnGateways",
"ec2:DescribeCustomerGateways",
"ec2:GetConsoleOutput", "ec2:GetConsoleOutput",
] ]
resources = ["*"] resources = ["*"]

View file

@ -1,31 +1,37 @@
# Instance permissions boundary. Created on the first (bootstrap) apply. # Instance (and Firehose delivery role) permissions boundary.
# The scoped apply role denies iam:CreatePolicy / CreatePolicyVersion, # The scoped apply role denies iam:CreatePolicy / CreatePolicyVersion,
# so later edits to this document need the hcptf-bootstrap window. # so later edits to this document need the hcptf-bootstrap window.
data "aws_iam_policy_document" "instance_boundary" { data "aws_iam_policy_document" "instance_boundary" {
# checkov:skip=CKV_AWS_111: SSM and CloudWatch agent managed policies require Resource=* for ssmmessages, ec2messages, and describe APIs. Log writes are ARN-prefixed. # checkov:skip=CKV_AWS_111: SSM managed policy requires Resource=* for ssmmessages and describe APIs. Firehose and S3 writes are ARN-prefixed.
statement { statement {
sid = "CloudWatchLogsWrite" sid = "FirehosePut"
effect = "Allow" effect = "Allow"
actions = [ actions = [
"logs:CreateLogGroup", "firehose:PutRecord",
"logs:CreateLogStream", "firehose:PutRecordBatch",
"logs:DescribeLogGroups",
"logs:DescribeLogStreams",
"logs:PutLogEvents",
"logs:PutRetentionPolicy",
] ]
resources = [ resources = [
"arn:aws:logs:${var.aws_region}:${local.account_id}:log-group:${local.log_group_name}", "arn:aws:firehose:${var.aws_region}:${local.account_id}:deliverystream/${local.firehose_name}",
"arn:aws:logs:${var.aws_region}:${local.account_id}:log-group:${local.log_group_name}:*",
] ]
} }
statement { statement {
sid = "CloudWatchLogsDescribe" sid = "S3Archive"
effect = "Allow" effect = "Allow"
actions = ["logs:DescribeLogGroups"] actions = [
resources = ["*"] "s3:AbortMultipartUpload",
"s3:GetBucketLocation",
"s3:GetObject",
"s3:ListBucket",
"s3:ListBucketMultipartUploads",
"s3:PutObject",
"s3:DeleteObject",
]
resources = [
"arn:aws:s3:::${local.bucket_name}",
"arn:aws:s3:::${local.bucket_name}/*",
]
} }
statement { statement {
@ -94,7 +100,6 @@ data "aws_iam_policy_document" "instance_boundary" {
resources = [ resources = [
"arn:aws:ssm:${var.aws_region}::parameter/aws/service/*", "arn:aws:ssm:${var.aws_region}::parameter/aws/service/*",
"arn:aws:ssm:${var.aws_region}:${local.account_id}:parameter/aws/service/*", "arn:aws:ssm:${var.aws_region}:${local.account_id}:parameter/aws/service/*",
"arn:aws:ssm:${var.aws_region}:${local.account_id}:parameter/AmazonCloudWatch-*",
] ]
} }
@ -126,10 +131,10 @@ data "aws_iam_policy_document" "instance_boundary" {
} }
resource "aws_iam_policy" "instance_boundary" { resource "aws_iam_policy" "instance_boundary" {
# checkov:skip=CKV_AWS_111: SSM and CloudWatch agent managed policies require Resource=* for ssmmessages, ec2messages, and describe APIs. Log writes are ARN-prefixed. # checkov:skip=CKV_AWS_111: SSM managed policy requires Resource=* for ssmmessages and describe APIs. Firehose and S3 writes are ARN-prefixed.
name = local.boundary_name name = local.boundary_name
path = "/tf-managed/" path = "/tf-managed/"
description = "Per-workload EC2 permissions boundary for syslog-server (PLAT-78)." description = "Per-workload permissions boundary for syslog-server EC2 and Firehose (PLAT-206)."
policy = data.aws_iam_policy_document.instance_boundary.json policy = data.aws_iam_policy_document.instance_boundary.json
} }
@ -162,9 +167,24 @@ resource "aws_iam_role_policy_attachment" "ssm" {
policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore" policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
} }
resource "aws_iam_role_policy_attachment" "cloudwatch_agent" { data "aws_iam_policy_document" "instance_firehose" {
role = aws_iam_role.instance.name statement {
policy_arn = "arn:aws:iam::aws:policy/CloudWatchAgentServerPolicy" sid = "FirehosePut"
effect = "Allow"
actions = [
"firehose:PutRecord",
"firehose:PutRecordBatch",
]
resources = [
"arn:aws:firehose:${var.aws_region}:${local.account_id}:deliverystream/${local.firehose_name}",
]
}
}
resource "aws_iam_role_policy" "instance_firehose" {
name = "firehose-put"
role = aws_iam_role.instance.id
policy = data.aws_iam_policy_document.instance_firehose.json
} }
resource "aws_iam_instance_profile" "this" { resource "aws_iam_instance_profile" "this" {

View file

@ -12,22 +12,21 @@ locals {
instance_role_name = "syslog-server-role" instance_role_name = "syslog-server-role"
instance_profile_name = "syslog-server-profile" instance_profile_name = "syslog-server-profile"
firehose_role_name = "syslog-server-firehose-role"
boundary_name = "syslog-server-instance-boundary" boundary_name = "syslog-server-instance-boundary"
log_group_name = "unifi-syslog"
site_alerts_arn = "arn:aws:sns:${var.aws_region}:${local.account_id}:site-alerts" site_alerts_arn = "arn:aws:sns:${var.aws_region}:${local.account_id}:site-alerts"
vpc_cidr = "10.40.0.0/16" vpc_cidr = "10.40.0.0/16"
public_subnet_cidr = "10.40.10.0/24" public_subnet_cidr = "10.40.10.0/24"
office_cidrs = ["47.21.61.4/32", "96.250.164.146/32"] office_lan_cidrs = ["10.10.0.0/16", "10.30.0.0/16"]
office_vpn_cidr = "10.10.0.0/16" ronkonkoma_wan_ip = "47.21.61.4"
vpn_pool_cidr = "10.30.0.0/16" customer_gateway_bgp_asn = 65000
syslog_vpc_cidr = local.vpc_cidr
syslog_ingress_cidrs = concat( bucket_name = "syslog-server-unifi-logs-${local.account_id}"
local.office_cidrs, firehose_name = "syslog-server-unifi"
[local.office_vpn_cidr, local.vpn_pool_cidr, local.syslog_vpc_cidr], glue_database_name = "unifi"
) athena_workgroup = "syslog-server"
ssh_ingress_cidrs = concat( athena_results_prefix = "athena-results/"
local.office_cidrs, logs_expire_days = 90
[local.office_vpn_cidr, local.syslog_vpc_cidr], athena_results_expire_days = 30
)
} }

View file

@ -1,4 +0,0 @@
resource "aws_cloudwatch_log_group" "unifi_syslog" {
name = local.log_group_name
retention_in_days = 90
}

View file

@ -3,19 +3,56 @@ output "instance_id" {
value = aws_instance.this.id value = aws_instance.this.id
} }
output "public_ip" { output "private_ip" {
description = "Elastic IP — UniFi remote-syslog forwarding target." description = "Private IP — UniFi SIEM/IPFIX forwarding target over IPsec."
value = aws_eip.this.public_ip value = aws_instance.this.private_ip
} }
output "allocation_id" { output "vpn_connection_id" {
description = "Elastic IP allocation id." description = "Prod office IPsec connection. Configure a UniFi site-to-site VPN to these tunnels with remote network 10.40.0.0/16."
value = aws_eip.this.allocation_id value = aws_vpn_connection.office.id
} }
output "log_group_name" { output "vpn_tunnel1_address" {
description = "CloudWatch Logs group the agent ships remote syslog into." description = "AWS tunnel 1 outside IP for the UniFi IPsec peer."
value = aws_cloudwatch_log_group.unifi_syslog.name value = aws_vpn_connection.office.tunnel1_address
}
output "vpn_tunnel2_address" {
description = "AWS tunnel 2 outside IP for the UniFi IPsec peer."
value = aws_vpn_connection.office.tunnel2_address
}
output "vpn_tunnel1_preshared_key" {
description = "IPsec PSK for tunnel 1. Read with terraform output -raw after apply. Do not commit."
value = aws_vpn_connection.office.tunnel1_preshared_key
sensitive = true
}
output "vpn_tunnel2_preshared_key" {
description = "IPsec PSK for tunnel 2. Read with terraform output -raw after apply. Do not commit."
value = aws_vpn_connection.office.tunnel2_preshared_key
sensitive = true
}
output "bucket_name" {
description = "S3 bucket holding 90-day UniFi JSON archives."
value = aws_s3_bucket.unifi.bucket
}
output "firehose_name" {
description = "Kinesis Data Firehose delivery stream name."
value = aws_kinesis_firehose_delivery_stream.unifi.name
}
output "athena_workgroup" {
description = "Athena workgroup for UniFi log search."
value = aws_athena_workgroup.this.name
}
output "glue_database" {
description = "Glue catalog database with cef, iptables, and netflow tables."
value = aws_glue_catalog_database.unifi.name
} }
output "hcptf_apply_role_arn" { output "hcptf_apply_role_arn" {

106
terraform/s3.tf Normal file
View file

@ -0,0 +1,106 @@
resource "aws_s3_bucket" "unifi" {
bucket = local.bucket_name
tags = {
Name = local.bucket_name
}
}
resource "aws_s3_bucket_public_access_block" "unifi" {
bucket = aws_s3_bucket.unifi.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
resource "aws_s3_bucket_ownership_controls" "unifi" {
bucket = aws_s3_bucket.unifi.id
rule {
object_ownership = "BucketOwnerEnforced"
}
}
resource "aws_s3_bucket_server_side_encryption_configuration" "unifi" {
bucket = aws_s3_bucket.unifi.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "AES256"
}
}
}
resource "aws_s3_bucket_lifecycle_configuration" "unifi" {
bucket = aws_s3_bucket.unifi.id
rule {
id = "expire-logs"
status = "Enabled"
filter {
prefix = "format="
}
expiration {
days = local.logs_expire_days
}
}
rule {
id = "expire-athena-results"
status = "Enabled"
filter {
prefix = local.athena_results_prefix
}
expiration {
days = local.athena_results_expire_days
}
}
rule {
id = "expire-errors"
status = "Enabled"
filter {
prefix = "errors/"
}
expiration {
days = 14
}
}
}
data "aws_iam_policy_document" "bucket" {
statement {
sid = "DenyInsecureTransport"
effect = "Deny"
actions = ["s3:*"]
principals {
type = "*"
identifiers = ["*"]
}
resources = [
aws_s3_bucket.unifi.arn,
"${aws_s3_bucket.unifi.arn}/*",
]
condition {
test = "Bool"
variable = "aws:SecureTransport"
values = ["false"]
}
}
}
resource "aws_s3_bucket_policy" "unifi" {
bucket = aws_s3_bucket.unifi.id
policy = data.aws_iam_policy_document.bucket.json
}

View file

@ -1,143 +0,0 @@
#!/bin/bash
set -euxo pipefail
# ── 1 GiB swap (build headroom + stability on the 512 MiB t4g.nano) ──
if [ ! -f /swapfile ]; then
fallocate -l 1G /swapfile || dd if=/dev/zero of=/swapfile bs=1M count=1024
chmod 600 /swapfile
mkswap /swapfile
echo '/swapfile none swap sw 0 0' >> /etc/fstab
fi
swapon -a || true
# ── rsyslog: listen on UDP/TCP 514 ──
dnf install -y rsyslog
cat > /etc/rsyslog.d/10-listen.conf <<'EOF'
module(load="imudp")
input(type="imudp" port="514")
module(load="imtcp")
input(type="imtcp" port="514")
EOF
# ── Write remote syslog to /var/log/remote/<host>/<program>.log ──
cat > /etc/rsyslog.d/20-remote.conf <<'EOF'
template(name="RemoteHost" type="string" string="/var/log/remote/%HOSTNAME%/%PROGRAMNAME%.log")
if $fromhost-ip != '127.0.0.1' then {
action(type="omfile" dynaFile="RemoteHost" createDirs="on")
stop
}
EOF
mkdir -p /var/log/remote
systemctl enable rsyslog
systemctl restart rsyslog
# ── Rotate /var/log/remote so it can't grow unbounded ──
# CloudWatch (90d) is the system of record; these local files are just a
# spool for the CW agent, so keep only a short window. copytruncate keeps
# rsyslog's open dynaFile handles valid (truncate in place, same inode).
cat > /etc/logrotate.d/remote-syslog <<'EOF'
/var/log/remote/*/*.log {
daily
rotate 7
compress
delaycompress
missingok
notifempty
copytruncate
}
EOF
# ── CloudWatch agent: ship /var/log/remote/**/*.log to unifi-syslog ──
dnf install -y amazon-cloudwatch-agent
cat > /opt/aws/amazon-cloudwatch-agent/etc/amazon-cloudwatch-agent.json <<'EOF'
{
"logs": {
"logs_collected": {
"files": {
"collect_list": [
{
"file_path": "/var/log/remote/**/*.log",
"log_group_name": "unifi-syslog",
"log_stream_name": "{hostname}/{file_name}",
"retention_in_days": 90
}
]
}
}
}
}
EOF
/opt/aws/amazon-cloudwatch-agent/bin/amazon-cloudwatch-agent-ctl \
-a fetch-config -m ec2 \
-c file:/opt/aws/amazon-cloudwatch-agent/etc/amazon-cloudwatch-agent.json -s
systemctl enable amazon-cloudwatch-agent
# ── NetFlow/IPFIX collectors (nfcapd) ──
# nfdump is not packaged for AL2023; build 1.6.23 from source (needs
# rrdtool-devel for librrd). Reconstructed under IaC for INFRA-12 — the
# original instance ran these as hand-installed systemd units. Captures
# are local-only (no consumer/shipping today); 30-day retention enforced.
dnf install -y gcc gcc-c++ make automake autoconf libtool flex bison libpcap-devel zlib-devel bzip2-devel rrdtool-devel tar
NFVER=1.6.23
curl -sfL https://github.com/phaag/nfdump/archive/refs/tags/v${NFVER}.tar.gz | tar xz -C /tmp
( cd /tmp/nfdump-${NFVER} && ./autogen.sh && ./configure && make -j1 && make install )
ldconfig
mkdir -p /var/log/netflow/ronkonkoma /var/log/netflow/locust
chown -R ec2-user:ec2-user /var/log/netflow
# Ronkonkoma gateway -> UDP 2055
cat > /etc/systemd/system/nfcapd.service <<'EOF'
[Unit]
Description=nfcapd NetFlow collector (Ronkonkoma, udp/2055)
After=network.target
[Service]
Type=simple
User=ec2-user
ExecStart=/usr/local/bin/nfcapd -p 2055 -l /var/log/netflow/ronkonkoma
Restart=always
[Install]
WantedBy=multi-user.target
EOF
# Locust Ave gateway -> UDP 2056
cat > /etc/systemd/system/nfcapd-locust.service <<'EOF'
[Unit]
Description=nfcapd NetFlow collector (Locust Ave, udp/2056)
After=network.target
[Service]
Type=simple
User=ec2-user
ExecStart=/usr/local/bin/nfcapd -p 2056 -l /var/log/netflow/locust
Restart=always
[Install]
WantedBy=multi-user.target
EOF
# 30-day retention sweep (daily 03:30 UTC)
cat > /usr/local/sbin/netflow-retention.sh <<'EOF'
#!/bin/bash
find /var/log/netflow -type f -name 'nfcapd.*' -mtime +30 -delete
EOF
chmod +x /usr/local/sbin/netflow-retention.sh
cat > /etc/systemd/system/netflow-retention.service <<'EOF'
[Unit]
Description=Delete NetFlow captures older than 30 days
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/netflow-retention.sh
EOF
cat > /etc/systemd/system/netflow-retention.timer <<'EOF'
[Unit]
Description=Daily NetFlow retention sweep
[Timer]
OnCalendar=*-*-* 03:30:00 UTC
Persistent=true
[Install]
WantedBy=timers.target
EOF
systemctl daemon-reload
systemctl enable --now nfcapd.service nfcapd-locust.service netflow-retention.timer

View file

@ -0,0 +1,25 @@
#!/bin/bash
set -euxo pipefail
# Vector: listen on UDP/TCP 514 and IPFIX 2055/2056, parse, ship to Firehose.
curl -sSL https://setup.vector.dev | bash
dnf install -y vector
install -d -m 0755 /etc/vector /var/lib/vector
cat > /etc/vector/vector.yaml <<'VECTOREOF'
${vector_yaml}
VECTOREOF
chmod 0644 /etc/vector/vector.yaml
vector validate /etc/vector/vector.yaml
install -d -m 0755 /etc/systemd/system/vector.service.d
cat > /etc/systemd/system/vector.service.d/override.conf <<'EOF'
[Service]
AmbientCapabilities=CAP_NET_BIND_SERVICE
CapabilityBoundingSet=CAP_NET_BIND_SERVICE CAP_SETUID CAP_SETGID
NoNewPrivileges=false
EOF
systemctl daemon-reload
systemctl enable --now vector
systemctl restart vector

View file

@ -11,7 +11,7 @@ variable "ami_id" {
} }
variable "no_logs_treat_missing_data" { variable "no_logs_treat_missing_data" {
description = "CloudWatch treat_missing_data for Syslog-NoIncomingLogs. Keep notBreaching until UniFi points at the new EIP, then set breaching." description = "CloudWatch treat_missing_data for Syslog-NoIncomingRecords. Keep notBreaching until UniFi points at the private IP, then set breaching."
type = string type = string
default = "notBreaching" default = "notBreaching"

146
terraform/vector.yaml.tftpl Normal file
View file

@ -0,0 +1,146 @@
data_dir: /var/lib/vector
sources:
syslog_udp:
type: socket
address: 0.0.0.0:514
mode: udp
max_length: 65507
decoding:
codec: bytes
syslog_tcp:
type: socket
address: 0.0.0.0:514
mode: tcp
decoding:
codec: bytes
framing:
method: newline_delimited
# Vector has no released IPFIX decoder. Archive datagrams with a site tag.
netflow_ronkonkoma:
type: socket
address: 0.0.0.0:2055
mode: udp
max_length: 65507
decoding:
codec: bytes
netflow_locust:
type: socket
address: 0.0.0.0:2056
mode: udp
max_length: 65507
decoding:
codec: bytes
transforms:
parse_syslog:
type: remap
inputs: [syslog_udp, syslog_tcp]
source: |-
raw = to_string(.message) ?? encode_json(.)
src_ip = to_string(.host) ?? ""
site = "unknown"
if starts_with(src_ip, "10.10.") {
site = "ronkonkoma"
}
if starts_with(src_ip, "10.30.") {
site = "locust"
}
format = "other"
if contains(raw, "CEF:") {
format = "cef"
} else if contains(raw, "SRC=") && contains(raw, "DST=") {
format = "iptables"
}
src = null
dst = null
proto = null
action = null
hostname = to_string(.hostname) ?? ""
if format == "iptables" {
src_m, err = parse_regex(raw, r'SRC=(?P<v>[0-9.]+)')
if err == null { src = src_m.v }
dst_m, err = parse_regex(raw, r'DST=(?P<v>[0-9.]+)')
if err == null { dst = dst_m.v }
proto_m, err = parse_regex(raw, r'PROTO=(?P<v>[A-Za-z0-9]+)')
if err == null { proto = proto_m.v }
if contains(raw, "DROP") || contains(raw, "REJECT") {
action = "deny"
} else if contains(raw, "ACCEPT") {
action = "allow"
}
}
if format == "cef" {
src_m, err = parse_regex(raw, r'(?:src|sourceAddress)=(?P<v>[0-9.]+)')
if err == null { src = src_m.v }
dst_m, err = parse_regex(raw, r'(?:dst|destinationAddress)=(?P<v>[0-9.]+)')
if err == null { dst = dst_m.v }
proto_m, err = parse_regex(raw, r'proto=(?P<v>[A-Za-z0-9]+)')
if err == null { proto = proto_m.v }
if contains(upcase(raw), "BLOCK") || contains(upcase(raw), "DENY") || contains(upcase(raw), "DROP") {
action = "deny"
}
host_m, err = parse_regex(raw, r'UNIFIhost=(?P<v>[^ ]+)')
if err == null { hostname = host_m.v }
}
. = {
"timestamp": format_timestamp!(now(), "%Y-%m-%dT%H:%M:%SZ"),
"site": site,
"format": format,
"hostname": hostname,
"src": src,
"dst": dst,
"proto": proto,
"action": action,
"raw": raw
}
parse_netflow_ronkonkoma:
type: remap
inputs: [netflow_ronkonkoma]
source: |-
payload = to_string(.message) ?? encode_json(.)
. = {
"timestamp": format_timestamp!(now(), "%Y-%m-%dT%H:%M:%SZ"),
"site": "ronkonkoma",
"format": "netflow",
"hostname": "",
"src": null,
"dst": null,
"proto": "ipfix",
"action": null,
"raw": encode_base64(payload) ?? payload
}
parse_netflow_locust:
type: remap
inputs: [netflow_locust]
source: |-
payload = to_string(.message) ?? encode_json(.)
. = {
"timestamp": format_timestamp!(now(), "%Y-%m-%dT%H:%M:%SZ"),
"site": "locust",
"format": "netflow",
"hostname": "",
"src": null,
"dst": null,
"proto": "ipfix",
"action": null,
"raw": encode_base64(payload) ?? payload
}
sinks:
firehose:
type: aws_kinesis_firehose
inputs: [parse_syslog, parse_netflow_ronkonkoma, parse_netflow_locust]
region: ${aws_region}
stream_name: ${firehose_stream}
encoding:
codec: json
request:
timeout_secs: 30

View file

@ -45,14 +45,60 @@ resource "aws_route" "public_default" {
gateway_id = aws_internet_gateway.this.id gateway_id = aws_internet_gateway.this.id
} }
resource "aws_route" "office_lans" {
for_each = toset(local.office_lan_cidrs)
route_table_id = aws_route_table.public.id
destination_cidr_block = each.value
gateway_id = aws_vpn_gateway.office.id
}
resource "aws_route_table_association" "public" { resource "aws_route_table_association" "public" {
subnet_id = aws_subnet.public.id subnet_id = aws_subnet.public.id
route_table_id = aws_route_table.public.id route_table_id = aws_route_table.public.id
} }
# Prod has no existing IPsec. Mgmt still owns the 10.20 tunnel.
# This VGW is a second child SA so UniFi can reach 10.40.0.0/16 privately.
resource "aws_vpn_gateway" "office" {
vpc_id = aws_vpc.this.id
tags = {
Name = "syslog-server-office"
}
}
resource "aws_customer_gateway" "ronkonkoma" {
bgp_asn = local.customer_gateway_bgp_asn
ip_address = local.ronkonkoma_wan_ip
type = "ipsec.1"
tags = {
Name = "syslog-server-ronkonkoma"
}
}
resource "aws_vpn_connection" "office" {
customer_gateway_id = aws_customer_gateway.ronkonkoma.id
vpn_gateway_id = aws_vpn_gateway.office.id
type = "ipsec.1"
static_routes_only = true
tags = {
Name = "syslog-server-office"
}
}
resource "aws_vpn_connection_route" "office_lans" {
for_each = toset(local.office_lan_cidrs)
destination_cidr_block = each.value
vpn_connection_id = aws_vpn_connection.office.id
}
resource "aws_security_group" "this" { resource "aws_security_group" "this" {
name = "syslog-server" name = "syslog-server"
description = "Syslog collector - rsyslog 514 from office + VPC" description = "UniFi syslog/IPFIX collector over office IPsec"
vpc_id = aws_vpc.this.id vpc_id = aws_vpc.this.id
tags = { tags = {
@ -64,60 +110,49 @@ resource "aws_vpc_security_group_egress_rule" "all" {
security_group_id = aws_security_group.this.id security_group_id = aws_security_group.this.id
ip_protocol = "-1" ip_protocol = "-1"
cidr_ipv4 = "0.0.0.0/0" cidr_ipv4 = "0.0.0.0/0"
description = "All outbound for package installs and CloudWatch" description = "Outbound for Vector install, Firehose, and SSM"
} }
resource "aws_vpc_security_group_ingress_rule" "syslog_tcp" { resource "aws_vpc_security_group_ingress_rule" "syslog_tcp" {
for_each = toset(local.syslog_ingress_cidrs) for_each = toset(local.office_lan_cidrs)
security_group_id = aws_security_group.this.id security_group_id = aws_security_group.this.id
ip_protocol = "tcp" ip_protocol = "tcp"
from_port = 514 from_port = 514
to_port = 514 to_port = 514
cidr_ipv4 = each.value cidr_ipv4 = each.value
description = "syslog TCP 514" description = "syslog TCP 514 from office LAN"
} }
resource "aws_vpc_security_group_ingress_rule" "syslog_udp" { resource "aws_vpc_security_group_ingress_rule" "syslog_udp" {
for_each = toset(local.syslog_ingress_cidrs) for_each = toset(local.office_lan_cidrs)
security_group_id = aws_security_group.this.id security_group_id = aws_security_group.this.id
ip_protocol = "udp" ip_protocol = "udp"
from_port = 514 from_port = 514
to_port = 514 to_port = 514
cidr_ipv4 = each.value cidr_ipv4 = each.value
description = "syslog UDP 514" description = "syslog UDP 514 from office LAN"
}
resource "aws_vpc_security_group_ingress_rule" "ssh" {
for_each = toset(local.ssh_ingress_cidrs)
security_group_id = aws_security_group.this.id
ip_protocol = "tcp"
from_port = 22
to_port = 22
cidr_ipv4 = each.value
description = "SSH break-glass"
} }
resource "aws_vpc_security_group_ingress_rule" "netflow_2055" { resource "aws_vpc_security_group_ingress_rule" "netflow_2055" {
for_each = toset(local.office_cidrs) for_each = toset(local.office_lan_cidrs)
security_group_id = aws_security_group.this.id security_group_id = aws_security_group.this.id
ip_protocol = "udp" ip_protocol = "udp"
from_port = 2055 from_port = 2055
to_port = 2055 to_port = 2055
cidr_ipv4 = each.value cidr_ipv4 = each.value
description = "netflow/sflow UDP 2055" description = "NetFlow/IPFIX UDP 2055 Ronkonkoma"
} }
resource "aws_vpc_security_group_ingress_rule" "netflow_2056" { resource "aws_vpc_security_group_ingress_rule" "netflow_2056" {
for_each = toset(local.office_cidrs) for_each = toset(local.office_lan_cidrs)
security_group_id = aws_security_group.this.id security_group_id = aws_security_group.this.id
ip_protocol = "udp" ip_protocol = "udp"
from_port = 2056 from_port = 2056
to_port = 2056 to_port = 2056
cidr_ipv4 = each.value cidr_ipv4 = each.value
description = "netflow/sflow UDP 2056" description = "NetFlow/IPFIX UDP 2056 Locust"
} }