The public rustdesk.seahaven.com name resolves to the EIP, which the security group blocks on the admin port (21114). Add an internal-only rustdesk-admin.int.seahaven.com record pointed at the instance private IP so the console is reachable over the VPN without using the raw IP.
247 lines
11 KiB
TypeScript
247 lines
11 KiB
TypeScript
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 <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),
|
|
});
|
|
|
|
// 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`,
|
|
});
|
|
}
|
|
}
|