This repository has been archived on 2026-08-04. You can view files and clone it, but cannot push or open issues or pull requests.
rustdesk-server/lib/rustdesk-server-stack.ts
Adam Moussa 9f18ecab50
Add RustDesk Server Pro self-hosted relay stack
Scaffold the CDK stack for a self-hosted RustDesk Server Pro relay so
remote support no longer depends on the public RustDesk rendezvous/relay
infrastructure.

Single ARM64 EC2 (SSM-managed, no SSH) runs hbbs+hbbr in Docker. The
server key pair and DB live on a standalone RETAINed EBS volume so they
survive instance replacement (clients keep trusting the same key). Relay
ports are public; the Pro admin console (21114) is restricted to the
office VPN + VPC. IMDSv2 is enforced and the data dir is locked to root.
EIP + rustdesk.seahaven.com give clients a stable address.
2026-06-28 16:47:32 -04:00

227 lines
9.7 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 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),
});
// ── 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 });
}
}