import * as fs from "fs"; import * as path from "path"; 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 route53 from "aws-cdk-lib/aws-route53"; import * as dlm from "aws-cdk-lib/aws-dlm"; import { Construct } from "constructs"; // Pinned RustDesk Server Pro image tag. Do NOT use "latest" (handbook: pin // container versions). Confirm the current Pro tag before the first deploy and // bump deliberately. https://hub.docker.com/r/rustdesk/rustdesk-server-pro/tags const RUSTDESK_IMAGE_TAG = "1.8.4"; // multi-arch tag; resolves to arm64v8 on t4g // Existing Sea Haven VPC (same account/region as forgejo et al.). const VPC_ID = "vpc-0d3d4b67bd0cf8a68"; // RustDesk clients connect from anywhere, so the host sits in a public subnet // (0.0.0.0/0 -> igw-011688a85a5474db2) with an Elastic IP. The data volume AZ // must match the instance AZ. const PUBLIC_SUBNET_ID = "subnet-0eea820effe1b3ae5"; // seahaven-subnet-public1-us-east-1a const AVAILABILITY_ZONE = "us-east-1a"; // Public DNS name embedded in client configs. const HOSTED_ZONE_NAME = "seahaven.com"; const RECORD_NAME = "rustdesk.seahaven.com"; // Internal-only name for the Pro admin console, pointed at the instance's // private IP so it is reachable over the VPN (the public name above resolves to // the EIP, which the SG blocks on the admin port 21114). const ADMIN_ZONE_NAME = "int.seahaven.com"; const ADMIN_RECORD_NAME = "rustdesk-admin.int.seahaven.com"; // Internal trusted ranges for the admin/management plane. const OFFICE_VPN_CIDR = "10.10.0.0/16"; const VPC_CIDR = "10.20.0.0/16"; // RustDesk Server Pro listening ports. (network_mode: host on the containers, // so the security group is the only access control.) `public: true` opens the // port to the internet (relay/rendezvous ports clients reach from anywhere); // `public: false` restricts it to the office VPN + VPC (the admin console is a // management plane and must not be internet-facing). interface PortSpec { port: number; protocol: "tcp" | "udp"; desc: string; public: boolean; } const RUSTDESK_PORTS: PortSpec[] = [ { port: 21114, protocol: "tcp", desc: "Pro web console / API", public: false }, { port: 21115, protocol: "tcp", desc: "hbbs NAT type test", public: true }, { port: 21116, protocol: "tcp", desc: "hbbs registration / TCP hole punch", public: true }, { port: 21116, protocol: "udp", desc: "hbbs registration / heartbeat", public: true }, { port: 21117, protocol: "tcp", desc: "hbbr relay", public: true }, { port: 21118, protocol: "tcp", desc: "hbbs web client (websocket)", public: true }, { port: 21119, protocol: "tcp", desc: "hbbr web client (websocket)", public: true }, ]; export class RustdeskServerStack extends cdk.Stack { constructor(scope: Construct, id: string, props: cdk.StackProps) { super(scope, id, props); const vpc = ec2.Vpc.fromLookup(this, "SeaHavenVpc", { vpcId: VPC_ID }); const publicSubnet = ec2.Subnet.fromSubnetAttributes(this, "PublicSubnet", { subnetId: PUBLIC_SUBNET_ID, availabilityZone: AVAILABILITY_ZONE, }); // ── Security group ────────────────────────────────────────────── // Relay/rendezvous ports are public (clients connect from anywhere). The // Pro admin console (21114) is restricted to the office VPN + VPC. To take // the relay VPN-only later, flip the `public` flags in RUSTDESK_PORTS. const sg = new ec2.SecurityGroup(this, "SecurityGroup", { vpc, securityGroupName: "rustdesk-server", description: "RustDesk Server Pro - public relay; VPN-only admin console", allowAllOutbound: true, }); for (const p of RUSTDESK_PORTS) { const port = p.protocol === "tcp" ? ec2.Port.tcp(p.port) : ec2.Port.udp(p.port); if (p.public) { sg.addIngressRule(ec2.Peer.anyIpv4(), port, p.desc); } else { sg.addIngressRule(ec2.Peer.ipv4(OFFICE_VPN_CIDR), port, `${p.desc} (office VPN)`); sg.addIngressRule(ec2.Peer.ipv4(VPC_CIDR), port, `${p.desc} (VPC)`); } } // ── Instance role (SSM-managed; no inbound SSH) ───────────────── const role = new iam.Role(this, "InstanceRole", { roleName: "rustdesk-server-instance", assumedBy: new iam.ServicePrincipal("ec2.amazonaws.com"), managedPolicies: [ iam.ManagedPolicy.fromAwsManagedPolicyName( "AmazonSSMManagedInstanceCore", ), ], }); role.addToPolicy( new iam.PolicyStatement({ actions: ["secretsmanager:GetSecretValue"], resources: [ "arn:aws:secretsmanager:us-east-1:328440206208:secret:rustdesk/*", ], }), ); // ── User data ─────────────────────────────────────────────────── const bootstrap = fs .readFileSync(path.join(__dirname, "..", "userdata", "bootstrap.sh"), "utf8") .replace(/__RUSTDESK_IMAGE_TAG__/g, RUSTDESK_IMAGE_TAG); const userData = ec2.UserData.custom(bootstrap); // ── Instance ──────────────────────────────────────────────────── const instance = new ec2.Instance(this, "Instance", { instanceName: "rustdesk-server", vpc, vpcSubnets: { subnets: [publicSubnet] }, instanceType: ec2.InstanceType.of( ec2.InstanceClass.T4G, ec2.InstanceSize.SMALL, ), machineImage: ec2.MachineImage.latestAmazonLinux2023({ cpuType: ec2.AmazonLinuxCpuType.ARM_64, // Cache the resolved AMI in cdk.context.json so deploys don't pick up // new AL2023 releases implicitly (AMI change forces instance // replacement). Refresh deliberately: // cdk context --reset && cdk synth cachedInContext: true, }), securityGroup: sg, role, userData, // Force IMDSv2 (token-required) so the instance role credentials can't be // lifted via a tokenless IMDSv1 request from a host-network container. requireImdsv2: true, // OS-only root volume. ALL durable state lives on the standalone data // volume below, never an inline blockDevice (see RUNBOOK + the EBS // replacement gotcha). blockDevices: [ { deviceName: "/dev/xvda", volume: ec2.BlockDeviceVolume.ebs(20, { volumeType: ec2.EbsDeviceVolumeType.GP3, encrypted: true, }), }, ], }); cdk.Tags.of(instance).add("rustdesk-backup", "true"); // ── Persistent data volume ────────────────────────────────────── // RustDesk key pair (id_ed25519*) + sled DB live here, mounted at // /var/lib/rustdesk. Standalone Volume + RETAIN means the data survives // instance replacement AND stack deletion; userdata mounts the existing // filesystem (blkid guard prevents reformatting). NEVER move this into the // instance's inline blockDevices: an inline data volume is replaced // whenever CFN replaces the instance, destroying the server key and // forcing every client to re-trust the host. const dataVolume = new ec2.Volume(this, "DataVolume", { availabilityZone: AVAILABILITY_ZONE, size: cdk.Size.gibibytes(20), volumeType: ec2.EbsDeviceVolumeType.GP3, encrypted: true, removalPolicy: cdk.RemovalPolicy.RETAIN, }); cdk.Tags.of(dataVolume).add("Name", "rustdesk-data"); cdk.Tags.of(dataVolume).add("rustdesk-backup", "true"); new ec2.CfnVolumeAttachment(this, "DataVolumeAttachment", { instanceId: instance.instanceId, volumeId: dataVolume.volumeId, device: "/dev/xvdf", }); // ── Elastic IP (stable client-facing address) ─────────────────── const eip = new ec2.CfnEIP(this, "Eip", { domain: "vpc", instanceId: instance.instanceId, tags: [{ key: "Name", value: "rustdesk-server" }], }); // ── DNS ───────────────────────────────────────────────────────── const zone = route53.HostedZone.fromLookup(this, "Zone", { domainName: HOSTED_ZONE_NAME, }); new route53.ARecord(this, "ARecord", { zone, recordName: RECORD_NAME, target: route53.RecordTarget.fromIpAddresses(eip.ref), ttl: cdk.Duration.minutes(5), }); // Internal admin-console name -> instance private IP (VPN-reachable only). const adminZone = route53.HostedZone.fromLookup(this, "AdminZone", { domainName: ADMIN_ZONE_NAME, }); new route53.ARecord(this, "AdminARecord", { zone: adminZone, recordName: ADMIN_RECORD_NAME, target: route53.RecordTarget.fromIpAddresses(instance.instancePrivateIp), ttl: cdk.Duration.minutes(5), }); // ── Nightly EBS snapshots ─────────────────────────────────────── const dlmRole = new iam.Role(this, "DlmRole", { roleName: "rustdesk-server-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 RustDesk server", state: "ENABLED", executionRoleArn: dlmRole.roleArn, policyDetails: { resourceTypes: ["INSTANCE"], targetTags: [{ key: "rustdesk-backup", value: "true" }], schedules: [ { name: "rustdesk-nightly", createRule: { interval: 24, intervalUnit: "HOURS", times: ["06:00"], }, retainRule: { count: 30 }, copyTags: true, tagsToAdd: [{ key: "rustdesk-backup", value: "true" }], }, ], }, }); // ── Outputs ───────────────────────────────────────────────────── new cdk.CfnOutput(this, "PublicIp", { value: eip.ref }); new cdk.CfnOutput(this, "Hostname", { value: RECORD_NAME }); new cdk.CfnOutput(this, "AdminConsole", { value: `http://${ADMIN_RECORD_NAME}:21114`, }); } }