2026-06-28 16:47:32 -04:00
|
|
|
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";
|
|
|
|
|
|
2026-06-28 17:21:25 -04:00
|
|
|
// 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";
|
|
|
|
|
|
2026-06-28 16:47:32 -04:00
|
|
|
// 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 <ami key> && 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),
|
|
|
|
|
});
|
|
|
|
|
|
2026-06-28 17:21:25 -04:00
|
|
|
// 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),
|
|
|
|
|
});
|
|
|
|
|
|
2026-06-28 16:47:32 -04:00
|
|
|
// ── 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 });
|
2026-06-28 17:21:25 -04:00
|
|
|
new cdk.CfnOutput(this, "AdminConsole", {
|
|
|
|
|
value: `http://${ADMIN_RECORD_NAME}:21114`,
|
|
|
|
|
});
|
2026-06-28 16:47:32 -04:00
|
|
|
}
|
|
|
|
|
}
|