import * as cdk from "aws-cdk-lib"; import * as ec2 from "aws-cdk-lib/aws-ec2"; import * as iam from "aws-cdk-lib/aws-iam"; import * as cloudwatch from "aws-cdk-lib/aws-cloudwatch"; import * as cwactions from "aws-cdk-lib/aws-cloudwatch-actions"; import * as sns from "aws-cdk-lib/aws-sns"; import { Construct } from "constructs"; /** * syslog-server — EC2 collector that receives remote syslog (UDP/TCP 514) from * the office UniFi fleet over the EIP and ships it to the `unifi-syslog` * CloudWatch Logs group via the CloudWatch agent. * * Brought under IaC for INFRA-12 (AWS audit L-6). Recreated to mirror the * file-share/forgejo CDK pattern; the existing EIP (184.72.154.32) is imported * by allocation ID and re-associated so the forwarding target is unchanged. * * The `unifi-syslog` log group is intentionally NOT a CloudFormation resource: * it holds 90 days of history and is created/retained by the CloudWatch agent * per the user-data config below (log_group_name + retention_in_days). Managing * it as a CFN resource would either collide with the live group on create or * risk deleting the history on a future replacement. The agent owns it; this * stack owns the instance that runs the agent. */ export class SyslogServerStack extends cdk.Stack { constructor(scope: Construct, id: string, props?: cdk.StackProps) { super(scope, id, props); const vpc = ec2.Vpc.fromLookup(this, "SeaHavenVpc", { vpcId: "vpc-0d3d4b67bd0cf8a68", }); // Public subnet (IGW route present) — the instance must be internet-facing // so the office gateways can forward syslog to the EIP. const publicSubnet = ec2.Subnet.fromSubnetAttributes(this, "PublicSubnet", { subnetId: "subnet-0eea820effe1b3ae5", availabilityZone: "us-east-1a", }); // Office public IPs that forward syslog (see reference_office_ips). const OFFICE_1 = "47.21.61.4/32"; const OFFICE_2 = "96.250.164.146/32"; const sg = new ec2.SecurityGroup(this, "SecurityGroup", { vpc, securityGroupName: "syslog-server", description: "Syslog collector - rsyslog 514 from office + VPC", allowAllOutbound: true, }); // Remote syslog (UDP + TCP 514) from the office public IPs and the internal // VPC / VPN CIDRs. for (const proto of [ec2.Port.tcp(514), ec2.Port.udp(514)]) { sg.addIngressRule(ec2.Peer.ipv4(OFFICE_1), proto, "syslog from office-1"); sg.addIngressRule(ec2.Peer.ipv4(OFFICE_2), proto, "syslog from office-2"); sg.addIngressRule(ec2.Peer.ipv4("10.10.0.0/16"), proto, "syslog from office VPN"); sg.addIngressRule(ec2.Peer.ipv4("10.20.0.0/16"), proto, "syslog from VPC"); sg.addIngressRule(ec2.Peer.ipv4("10.30.0.0/16"), proto, "syslog from VPN pool"); } // SSH (SSM is the primary access path; 22 kept for break-glass from office/VPC). sg.addIngressRule(ec2.Peer.ipv4(OFFICE_1), ec2.Port.tcp(22), "SSH from office-1"); sg.addIngressRule(ec2.Peer.ipv4(OFFICE_2), ec2.Port.tcp(22), "SSH from office-2"); sg.addIngressRule(ec2.Peer.ipv4("10.10.0.0/16"), ec2.Port.tcp(22), "SSH from office VPN"); sg.addIngressRule(ec2.Peer.ipv4("10.20.0.0/16"), ec2.Port.tcp(22), "SSH from VPC"); // NetFlow / sFlow ingress reserved from the office IPs. No collector is // configured in user-data yet; kept to preserve the prior capability. for (const port of [ec2.Port.udp(2055), ec2.Port.udp(2056)]) { sg.addIngressRule(ec2.Peer.ipv4(OFFICE_1), port, "netflow/sflow from office-1"); sg.addIngressRule(ec2.Peer.ipv4(OFFICE_2), port, "netflow/sflow from office-2"); } const role = new iam.Role(this, "InstanceRole", { roleName: "syslog-server-role", assumedBy: new iam.ServicePrincipal("ec2.amazonaws.com"), managedPolicies: [ iam.ManagedPolicy.fromAwsManagedPolicyName("AmazonSSMManagedInstanceCore"), iam.ManagedPolicy.fromAwsManagedPolicyName("CloudWatchAgentServerPolicy"), ], }); const userData = ec2.UserData.forLinux(); userData.addCommands( "set -euxo pipefail", "", "# ── 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//.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", "", "# ── 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", ); const instance = new ec2.Instance(this, "Instance", { instanceName: "syslog-server", vpc, vpcSubnets: { subnets: [publicSubnet] }, instanceType: ec2.InstanceType.of(ec2.InstanceClass.T4G, ec2.InstanceSize.NANO), machineImage: ec2.MachineImage.latestAmazonLinux2023({ cpuType: ec2.AmazonLinuxCpuType.ARM_64, // Cache the resolved AMI in cdk.context.json so deploys don't implicitly // pick up new AL2023 releases (AMI change forces instance replacement). // Refresh deliberately: cdk context --reset && cdk synth cachedInContext: true, }), securityGroup: sg, role, userData, blockDevices: [ { deviceName: "/dev/xvda", volume: ec2.BlockDeviceVolume.ebs(30, { volumeType: ec2.EbsDeviceVolumeType.GP3, encrypted: true, }), }, ], }); // Re-associate the existing Elastic IP (184.72.154.32) so the UniFi fleet's // forwarding target is unchanged. The allocation is UNMANAGED (referenced by // ID) — CloudFormation can associate it but never release it. new ec2.CfnEIPAssociation(this, "EipAssociation", { allocationId: "eipalloc-006bdefc9802f3285", instanceId: instance.instanceId, }); // ALARM-only "no incoming logs" alarm to the shared site-alerts topic // (alias/seahaven-alarm-topics CMK). Mirrors the prior standalone alarm: // IncomingLogEvents (Sum) < 1 over two 1-day periods. 2-day window tolerates // quiet weekends; treatMissingData=breaching catches a dead pipeline. const alarmTopic = sns.Topic.fromTopicArn( this, "SiteAlerts", "arn:aws:sns:us-east-1:328440206208:site-alerts", ); const noLogsAlarm = new cloudwatch.Alarm(this, "NoIncomingLogsAlarm", { alarmName: "Syslog-NoIncomingLogs", alarmDescription: "No log events delivered to unifi-syslog for 2 days — syslog pipeline may be down.", metric: new cloudwatch.Metric({ namespace: "AWS/Logs", metricName: "IncomingLogEvents", dimensionsMap: { LogGroupName: "unifi-syslog" }, statistic: "Sum", period: cdk.Duration.days(1), }), threshold: 1, comparisonOperator: cloudwatch.ComparisonOperator.LESS_THAN_THRESHOLD, evaluationPeriods: 2, treatMissingData: cloudwatch.TreatMissingData.BREACHING, }); noLogsAlarm.addAlarmAction(new cwactions.SnsAction(alarmTopic)); new cdk.CfnOutput(this, "InstanceId", { value: instance.instanceId }); new cdk.CfnOutput(this, "PublicIp", { value: "184.72.154.32", description: "Elastic IP — UniFi remote-syslog forwarding target", }); } }