From 3aa634c2607cc5926651a66bbe36caae30495df3 Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Thu, 14 May 2026 18:33:44 -0400 Subject: [PATCH] Add shared VpnEc2Instance CDK construct Reference construct for the VPN-accessible EC2 pattern used by file-share and forgejo. Includes VPC/subnet lookup, SG, IAM role, encrypted EBS, and DLM snapshots. Copy into lib/constructs/. --- README.md | 1 + constructs/README.md | 41 ++++++++ constructs/vpn-ec2-instance.ts | 181 +++++++++++++++++++++++++++++++++ 3 files changed, 223 insertions(+) create mode 100644 constructs/README.md create mode 100644 constructs/vpn-ec2-instance.ts diff --git a/README.md b/README.md index d494a2a..fa1e284 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ Engineering conventions and best practices for Sea Haven Industries. - [CI/CD Pipelines](cicd.md) -- every deployable repo gets a pipeline, no manual deploys - [Git Hooks](hooks/) -- recommended pre-push and pre-commit hooks - [Scripts](scripts/) -- repo provisioning, automation tooling +- [CDK Constructs](constructs/) -- shared VPN EC2 instance construct and other reusable patterns ## Contributing diff --git a/constructs/README.md b/constructs/README.md new file mode 100644 index 0000000..602c17d --- /dev/null +++ b/constructs/README.md @@ -0,0 +1,41 @@ +# Shared CDK Constructs + +Reference CDK constructs for common Sea Haven infrastructure patterns. Copy into your project's `lib/constructs/` directory. + +## VpnEc2Instance + +Encapsulates the full EC2-on-VPN pattern: VPC/subnet lookup, security group with VPN + VPC ingress, IAM role (SSM + Secrets Manager), encrypted EBS, and DLM daily snapshots. + +### Usage + +```typescript +import { VpnEc2Instance } from "./constructs/vpn-ec2-instance"; + +const server = new VpnEc2Instance(this, "Server", { + name: "file-share", + ingressPorts: [ + { port: 445, description: "SMB" }, + { port: 8080, description: "FileBrowser" }, + ], + secretsPrefix: "file-share", + dataVolumeSize: 500, + userData: myUserData, +}); + +// Access underlying resources for further configuration: +// server.instance, server.securityGroup, server.role +``` + +### Props + +| Prop | Type | Default | Description | +|---|---|---|---| +| `name` | string | required | Resource name prefix (kebab-case) | +| `ingressPorts` | `IngressPort[]` | required | Ports to open from VPN and VPC CIDRs | +| `secretsPrefix` | string | required | Secrets Manager path prefix for IAM policy | +| `instanceType` | `InstanceType` | t4g.small | EC2 instance type | +| `rootVolumeSize` | number | 20 | Root EBS volume in GiB | +| `dataVolumeSize` | number | — | Optional second EBS volume in GiB (mounted at /dev/xvdf) | +| `userData` | `UserData` | — | EC2 user data script | +| `additionalPolicies` | `PolicyStatement[]` | — | Extra IAM policies for the instance role | +| `snapshotRetentionDays` | number | 30 | DLM snapshot retention count | diff --git a/constructs/vpn-ec2-instance.ts b/constructs/vpn-ec2-instance.ts new file mode 100644 index 0000000..3398c40 --- /dev/null +++ b/constructs/vpn-ec2-instance.ts @@ -0,0 +1,181 @@ +/** + * Shared CDK construct: VPN-accessible EC2 instance on the Sea Haven private subnet. + * + * Encapsulates the repeating pattern from file-share and forgejo stacks: + * - Looks up the Sea Haven VPC and private subnet + * - Creates a security group with VPN (10.10.0.0/16) and VPC (10.20.0.0/16) ingress + * - Creates an IAM role with SSM and Secrets Manager access + * - Launches a t4g ARM64 AL2023 instance with encrypted EBS + * - Sets up DLM daily snapshots with 30-day retention + * - Exports InstanceId and PrivateIp as CloudFormation outputs + * + * Copy this file into your project's lib/constructs/ directory and import it. + */ + +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 dlm from "aws-cdk-lib/aws-dlm"; +import { Construct } from "constructs"; + +export interface IngressPort { + readonly port: number; + readonly description: string; +} + +export interface VpnEc2InstanceProps { + readonly name: string; + readonly instanceType?: ec2.InstanceType; + readonly rootVolumeSize?: number; + readonly dataVolumeSize?: number; + readonly ingressPorts: IngressPort[]; + readonly secretsPrefix: string; + readonly userData?: ec2.UserData; + readonly additionalPolicies?: iam.PolicyStatement[]; + readonly snapshotRetentionDays?: number; +} + +const VPC_ID = "vpc-0d3d4b67bd0cf8a68"; +const PRIVATE_SUBNET_ID = "subnet-04e38c507e96f1926"; +const PRIVATE_SUBNET_AZ = "us-east-1a"; +const ACCOUNT_ID = "328440206208"; +const REGION = "us-east-1"; +const VPN_CIDR = "10.10.0.0/16"; +const VPC_CIDR = "10.20.0.0/16"; + +export class VpnEc2Instance extends Construct { + public readonly instance: ec2.Instance; + public readonly securityGroup: ec2.SecurityGroup; + public readonly role: iam.Role; + + constructor(scope: Construct, id: string, props: VpnEc2InstanceProps) { + super(scope, id); + + const vpc = ec2.Vpc.fromLookup(this, "Vpc", { vpcId: VPC_ID }); + + const subnet = ec2.Subnet.fromSubnetAttributes(this, "PrivateSubnet", { + subnetId: PRIVATE_SUBNET_ID, + availabilityZone: PRIVATE_SUBNET_AZ, + }); + + this.securityGroup = new ec2.SecurityGroup(this, "SecurityGroup", { + vpc, + securityGroupName: props.name, + description: `${props.name} — VPN and VPC access`, + allowAllOutbound: true, + }); + + for (const ingress of props.ingressPorts) { + this.securityGroup.addIngressRule( + ec2.Peer.ipv4(VPN_CIDR), + ec2.Port.tcp(ingress.port), + `${ingress.description} from VPN` + ); + this.securityGroup.addIngressRule( + ec2.Peer.ipv4(VPC_CIDR), + ec2.Port.tcp(ingress.port), + `${ingress.description} from VPC` + ); + } + + this.role = new iam.Role(this, "InstanceRole", { + roleName: `${props.name}-instance`, + assumedBy: new iam.ServicePrincipal("ec2.amazonaws.com"), + managedPolicies: [ + iam.ManagedPolicy.fromAwsManagedPolicyName("AmazonSSMManagedInstanceCore"), + ], + }); + + this.role.addToPolicy( + new iam.PolicyStatement({ + actions: ["secretsmanager:GetSecretValue"], + resources: [ + `arn:aws:secretsmanager:${REGION}:${ACCOUNT_ID}:secret:${props.secretsPrefix}/*`, + ], + }) + ); + + if (props.additionalPolicies) { + for (const policy of props.additionalPolicies) { + this.role.addToPolicy(policy); + } + } + + const blockDevices: ec2.BlockDevice[] = [ + { + deviceName: "/dev/xvda", + volume: ec2.BlockDeviceVolume.ebs(props.rootVolumeSize ?? 20, { + volumeType: ec2.EbsDeviceVolumeType.GP3, + encrypted: true, + }), + }, + ]; + + if (props.dataVolumeSize) { + blockDevices.push({ + deviceName: "/dev/xvdf", + volume: ec2.BlockDeviceVolume.ebs(props.dataVolumeSize, { + volumeType: ec2.EbsDeviceVolumeType.GP3, + encrypted: true, + }), + }); + } + + this.instance = new ec2.Instance(this, "Instance", { + instanceName: props.name, + vpc, + vpcSubnets: { subnets: [subnet] }, + instanceType: + props.instanceType ?? + ec2.InstanceType.of(ec2.InstanceClass.T4G, ec2.InstanceSize.SMALL), + machineImage: ec2.MachineImage.latestAmazonLinux2023({ + cpuType: ec2.AmazonLinuxCpuType.ARM_64, + }), + securityGroup: this.securityGroup, + role: this.role, + userData: props.userData, + blockDevices, + }); + + const backupTag = `${props.name}-backup`; + cdk.Tags.of(this.instance).add(backupTag, "true"); + + const dlmRole = new iam.Role(this, "DlmRole", { + roleName: `${props.name}-dlm`, + assumedBy: new iam.ServicePrincipal("dlm.amazonaws.com"), + managedPolicies: [ + iam.ManagedPolicy.fromAwsManagedPolicyName( + "service-role/AWSDataLifecycleManagerServiceRole" + ), + ], + }); + + new dlm.CfnLifecyclePolicy(this, "SnapshotPolicy", { + description: `Nightly EBS snapshots for ${props.name}`, + state: "ENABLED", + executionRoleArn: dlmRole.roleArn, + policyDetails: { + resourceTypes: ["INSTANCE"], + targetTags: [{ key: backupTag, value: "true" }], + schedules: [ + { + name: `${props.name}-nightly`, + createRule: { interval: 24, intervalUnit: "HOURS", times: ["06:00"] }, + retainRule: { count: props.snapshotRetentionDays ?? 30 }, + copyTags: true, + tagsToAdd: [{ key: backupTag, value: "true" }], + }, + ], + }, + }); + + new cdk.CfnOutput(this, "InstanceId", { + value: this.instance.instanceId, + }); + + new cdk.CfnOutput(this, "PrivateIp", { + value: this.instance.instancePrivateIp, + description: `Private IP for ${props.name}`, + }); + } +}