commit 9f18ecab5063cac255688e06e80ed284e6022b51 Author: Adam Moussa Date: Sun Jun 28 16:47:32 2026 -0400 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. diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..4682acc --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,15 @@ +version: 2 +updates: + - package-ecosystem: npm + directory: / + schedule: + interval: weekly + assignees: + - amoussa1229 + + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + assignees: + - amoussa1229 diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..89a077f --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,10 @@ +name: CI +on: + pull_request: + branches: [main] + +jobs: + ci: + uses: Sea-Haven-Industries/.github/.github/workflows/ci-typescript-cdk.yaml@main + with: + node-version: "24" diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml new file mode 100644 index 0000000..2c5d47c --- /dev/null +++ b/.github/workflows/dependency-review.yml @@ -0,0 +1,6 @@ +name: Dependency Review +on: + pull_request: +jobs: + review: + uses: Sea-Haven-Industries/.github/.github/workflows/callable-dependency-review.yaml@main diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml new file mode 100644 index 0000000..90d3497 --- /dev/null +++ b/.github/workflows/deploy.yaml @@ -0,0 +1,20 @@ +name: Deploy +on: + push: + branches: [main] + +permissions: + id-token: write + contents: read + +concurrency: + group: deploy + cancel-in-progress: false + +jobs: + deploy: + uses: Sea-Haven-Industries/.github/.github/workflows/cd-cdk.yaml@main + with: + node-version: "24" + secrets: + deploy-role-arn: ${{ secrets.AWS_DEPLOY_ROLE_ARN }} diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml new file mode 100644 index 0000000..4af423a --- /dev/null +++ b/.github/workflows/labeler.yml @@ -0,0 +1,11 @@ +name: Labeler +on: + pull_request: + branches: [main] +permissions: + contents: read + pull-requests: write + issues: write +jobs: + label: + uses: Sea-Haven-Industries/.github/.github/workflows/callable-labeler.yaml@main diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ba2cdef --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +.env +node_modules/ +cdk.out/ +*.js +*.d.ts +*.js.map +__pycache__/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..42eb813 --- /dev/null +++ b/README.md @@ -0,0 +1,87 @@ +# rustdesk-server + +Self-hosted [RustDesk Server Pro](https://rustdesk.com/docs/en/self-host/) (remote-desktop relay + rendezvous) for Sea Haven Industries, deployed to AWS via CDK. + +> Status: **scaffold** — not yet deployed. See [First deploy](#first-deploy). + +## Architecture + +A single ARM64 EC2 instance runs RustDesk Server Pro (`hbbs` rendezvous/ID + `hbbr` relay) in Docker. Clients connect from anywhere over the public internet to a stable Elastic IP fronted by `rustdesk.seahaven.com`. + +``` +RustDesk clients (anywhere) + │ TCP 21114-21119 / UDP 21116 + ▼ + Elastic IP ──► EC2 t4g.small (AL2023 arm64, SSM-managed) + │ Docker: hbbs + hbbr (network_mode: host) + ├─ /dev/xvda 20 GiB root (OS only, ephemeral) + └─ /var/lib/rustdesk ◄── standalone EBS 20 GiB (RETAIN) + key pair + sled DB + Nightly DLM snapshots (retain 30, tag rustdesk-backup=true) +``` + +All durable state (the `id_ed25519` server key pair and the sled database) lives on a **standalone, RETAINed** EBS data volume, never an inline block device, so it survives instance replacement and stack deletion. See [RUNBOOK.md](RUNBOOK.md). + +## Ports + +| Port | Proto | Purpose | Exposure | +|---|---|---|---| +| 21114 | TCP | Pro web console / API | **VPN/VPC only** (10.10.0.0/16, 10.20.0.0/16) | +| 21115 | TCP | hbbs NAT type test | public | +| 21116 | TCP + UDP | hbbs registration / hole punch / heartbeat | public | +| 21117 | TCP | hbbr relay | public | +| 21118 | TCP | hbbs web client (websocket) | public | +| 21119 | TCP | hbbr web client (websocket) | public | + +Relay/rendezvous ports are public so clients connect from anywhere. The Pro admin console (21114) is restricted to the office VPN + VPC, so you administer the server and activate the Pro license over VPN. To take the relay VPN-only later, flip the `public` flags in `RUSTDESK_PORTS` in the stack. + +## Resources + +- **EC2** `rustdesk-server` — AL2023 arm64, `t4g.small`, SSM-managed (no inbound SSH) +- **EBS data volume** `rustdesk-data` — 20 GiB GP3, encrypted, `RemovalPolicy.RETAIN`, attached at `/dev/xvdf` +- **Elastic IP** — stable public address, associated to the instance +- **Security group** `rustdesk-server` — RustDesk ports above +- **IAM role** `rustdesk-server-instance` — `AmazonSSMManagedInstanceCore` + `secretsmanager:GetSecretValue` on `rustdesk/*` +- **DLM** `rustdesk-server-dlm` — nightly instance snapshots, retain 30 +- **Route 53** A record `rustdesk.seahaven.com` → EIP + +## Configuration + +### Secrets (Secrets Manager) — created out of band + +| Secret | Contents | +|---|---| +| `rustdesk/server-key-pair` | `id_ed25519` private + `.pub` public key generated by `hbbs` on first boot (mirror up post-deploy so a replacement host keeps the same key) | +| `rustdesk/pro-license` | RustDesk Server Pro license key | + +### SSM parameters (non-secret) + +| Parameter | Purpose | +|---|---| +| `/rustdesk-server/relay-host` | Public hostname clients use (`rustdesk.seahaven.com`) | + +The pinned Docker image tag lives in `lib/rustdesk-server-stack.ts` (`RUSTDESK_IMAGE_TAG`). + +## Deployment + +CI/CD runs through the reusable org workflows (`ci-typescript-cdk.yaml`, `cd-cdk.yaml`). Pushes to `main` deploy automatically. + +```bash +npm ci +npx cdk diff +npx cdk deploy +``` + +## First deploy + +1. Confirm a **public** subnet ID in `us-east-1a` and set `PUBLIC_SUBNET_ID` in `lib/rustdesk-server-stack.ts` (replace `subnet-REPLACE_ME`). +2. Verify/pin `RUSTDESK_IMAGE_TAG`. +3. Create the OIDC deploy role `githubdeploy-rustdesk-server` and the repo secret `AWS_DEPLOY_ROLE_ARN`. +4. `cdk deploy` (or push to `main`). The instance boots, pulls the images, and `hbbs` generates the key pair on the empty data volume. +5. SSM in, read `/var/lib/rustdesk/id_ed25519{,.pub}`, store into `rustdesk/server-key-pair`. +6. Over the office VPN, open `http://rustdesk.seahaven.com:21114`, activate the Pro license, create users (the console is not reachable off-VPN). +7. Point a test client at `rustdesk.seahaven.com` + the public key; confirm a session relays. + +## Operations + +See [RUNBOOK.md](RUNBOOK.md) for key rotation, restore-from-snapshot, and instance replacement. diff --git a/RUNBOOK.md b/RUNBOOK.md new file mode 100644 index 0000000..573a245 --- /dev/null +++ b/RUNBOOK.md @@ -0,0 +1,67 @@ +# RustDesk Server — Runbook + +Operational procedures for the `rustdesk-server` stack. Account 328440206208, us-east-1. + +## Access + +The instance is SSM-managed (no inbound SSH): + +```bash +aws ssm start-session --target --region us-east-1 +``` + +Find the instance: `aws ec2 describe-instances --filters Name=tag:Name,Values=rustdesk-server`. + +## The data volume (read this before any redeploy) + +All durable state — the `id_ed25519` server key pair and the sled database — lives on the standalone EBS volume `rustdesk-data`, mounted at `/var/lib/rustdesk`, attached at `/dev/xvdf`. + +- It is declared as a separate `ec2.Volume` with `RemovalPolicy.RETAIN`, **not** an inline `blockDevice`. This is deliberate: an inline data volume gets replaced whenever CloudFormation replaces the instance, which would destroy the server key and force **every** client to re-trust the host. +- `userdata/bootstrap.sh` mounts the existing filesystem and only runs `mkfs` when `blkid` reports the device is blank, so reattaching to a fresh instance preserves data. +- Before any change that may replace the instance (AMI bump, instance-type change), confirm a recent EBS snapshot exists and that the key pair is mirrored to `rustdesk/server-key-pair`. + +## Instance replacement + +A replaced instance reattaches the same RETAINed volume and remounts it without reformatting, so the server key and DB are preserved. After replacement: + +1. Confirm `docker compose ps` shows `hbbs` and `hbbr` healthy (`cd /opt/rustdesk`). +2. Confirm clients still connect without re-accepting a new key. + +## Server key pair + +`hbbs` generates `/var/lib/rustdesk/id_ed25519` (private) and `id_ed25519.pub` (public) on first boot. The public key is the fingerprint clients must trust. + +Mirror after first deploy / after any intentional rotation. Read the key with +`fileb://` so it never lands in process argv (`ps`/`/proc`), shell history, or the +terminal (which SSM session logging would capture): + +```bash +sudo aws secretsmanager put-secret-value \ + --secret-id rustdesk/server-key-pair \ + --secret-string fileb:///var/lib/rustdesk/id_ed25519 \ + --region us-east-1 +``` + +Run this from an environment that holds `secretsmanager:PutSecretValue` — the +instance role only has `GetSecretValue`, so either configure operator credentials +on the box first, or pull the key off the host over SSM (without echoing it) and +push it from your workstation. Never `cat` the private key to the terminal. + +To **rotate** the key (forces all clients to re-trust): stop the containers, delete `id_ed25519*`, restart, re-mirror, redistribute the new public key. + +## Restore from snapshot + +1. Create a volume from the desired snapshot in `us-east-1a`. +2. Detach the current `rustdesk-data` volume (stop the instance first). +3. Attach the restored volume at `/dev/xvdf`, tag `Name=rustdesk-data`, start the instance. +4. Verify the key pair and DB are present and containers come up healthy. + +## Pro license + +Activated in the web console (`http://rustdesk.seahaven.com:21114`). Keep the key in `rustdesk/pro-license`. + +## Logs + +```bash +cd /opt/rustdesk && docker compose logs -f hbbs hbbr +``` diff --git a/bin/app.ts b/bin/app.ts new file mode 100644 index 0000000..c02122f --- /dev/null +++ b/bin/app.ts @@ -0,0 +1,11 @@ +#!/usr/bin/env node +import "source-map-support/register"; +import * as cdk from "aws-cdk-lib"; +import { RustdeskServerStack } from "../lib/rustdesk-server-stack"; + +const app = new cdk.App(); + +new RustdeskServerStack(app, "rustdesk-server", { + stackName: "rustdesk-server", + env: { account: "328440206208", region: "us-east-1" }, +}); diff --git a/cdk.context.json b/cdk.context.json new file mode 100644 index 0000000..d49e851 --- /dev/null +++ b/cdk.context.json @@ -0,0 +1,52 @@ +{ + "vpc-provider:account=328440206208:filter.vpc-id=vpc-0d3d4b67bd0cf8a68:region=us-east-1:returnAsymmetricSubnets=true": { + "vpcId": "vpc-0d3d4b67bd0cf8a68", + "vpcCidrBlock": "10.20.0.0/16", + "ownerAccountId": "328440206208", + "availabilityZones": [], + "vpnGatewayId": "vgw-073737d44762dffc2", + "subnetGroups": [ + { + "name": "Private", + "type": "Private", + "subnets": [ + { + "subnetId": "subnet-04e38c507e96f1926", + "cidr": "10.20.30.0/24", + "availabilityZone": "us-east-1a", + "routeTableId": "rtb-06a2f56f492b9b4de" + }, + { + "subnetId": "subnet-0a0b4fc6f296dfba5", + "cidr": "10.20.40.0/24", + "availabilityZone": "us-east-1b", + "routeTableId": "rtb-01e152fe5cabca7d6" + } + ] + }, + { + "name": "Public", + "type": "Public", + "subnets": [ + { + "subnetId": "subnet-0eea820effe1b3ae5", + "cidr": "10.20.10.0/24", + "availabilityZone": "us-east-1a", + "routeTableId": "rtb-0f2232493a5c43fe8" + }, + { + "subnetId": "subnet-0012f5895182c1580", + "cidr": "10.20.20.0/24", + "availabilityZone": "us-east-1b", + "routeTableId": "rtb-0f2232493a5c43fe8" + } + ] + } + ] + }, + "ssm:account=328440206208:parameterName=/aws/service/ami-amazon-linux-latest/al2023-ami-kernel-6.1-arm64:region=us-east-1": "ami-06c84fbfd615657d3", + "hosted-zone:account=328440206208:domainName=seahaven.com:region=us-east-1": { + "Id": "/hostedzone/Z06652411XKH89KTZD3XA", + "Name": "seahaven.com." + } +} diff --git a/cdk.json b/cdk.json new file mode 100644 index 0000000..4070f75 --- /dev/null +++ b/cdk.json @@ -0,0 +1,21 @@ +{ + "app": "npx ts-node bin/app.ts", + "watch": { + "include": ["**"], + "exclude": [ + "README.md", + "cdk*.json", + "**/*.d.ts", + "**/*.js", + "tsconfig.json", + "package*.json", + "node_modules", + "cdk.out" + ] + }, + "context": { + "@aws-cdk/aws-lambda:recognizeLayerVersion": true, + "@aws-cdk/core:checkSecretUsage": true, + "@aws-cdk/core:target-partitions": ["aws"] + } +} diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml new file mode 100644 index 0000000..1e1719b --- /dev/null +++ b/docker/docker-compose.yml @@ -0,0 +1,23 @@ +# Reference copy only. The authoritative compose file is rendered onto the +# instance by userdata/bootstrap.sh (with the pinned image tag injected from +# lib/rustdesk-server-stack.ts). Keep this in sync for documentation purposes. +# RustDesk Server Pro persists its key pair + DB to /root inside the container. +services: + hbbs: + container_name: hbbs + image: rustdesk/rustdesk-server-pro:1.8.4 + command: hbbs + network_mode: host + volumes: + - /var/lib/rustdesk:/root + depends_on: + - hbbr + restart: unless-stopped + hbbr: + container_name: hbbr + image: rustdesk/rustdesk-server-pro:1.8.4 + command: hbbr + network_mode: host + volumes: + - /var/lib/rustdesk:/root + restart: unless-stopped diff --git a/lib/rustdesk-server-stack.ts b/lib/rustdesk-server-stack.ts new file mode 100644 index 0000000..1db0b12 --- /dev/null +++ b/lib/rustdesk-server-stack.ts @@ -0,0 +1,227 @@ +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 && 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 }); + } +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..541ddec --- /dev/null +++ b/package-lock.json @@ -0,0 +1,521 @@ +{ + "name": "rustdesk-server", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "rustdesk-server", + "version": "1.0.0", + "dependencies": { + "aws-cdk-lib": "2.260.0", + "constructs": "^10.0.0" + }, + "bin": { + "app": "bin/app.js" + }, + "devDependencies": { + "@types/node": "^25.9.3", + "aws-cdk": "^2.1128.1", + "source-map-support": "^0.5.21", + "typescript": "~6.0.3" + } + }, + "node_modules/@aws-cdk/asset-awscli-v1": { + "version": "2.2.282", + "resolved": "https://registry.npmjs.org/@aws-cdk/asset-awscli-v1/-/asset-awscli-v1-2.2.282.tgz", + "integrity": "sha512-7hKMi5tTxDcKGIMIOq14PnY0GBcugW33Uh/2YHDZiEwSxLeFOCYBwhR+BFXONb/EJeVI3RETFgailNZbkcKF6g==", + "license": "Apache-2.0" + }, + "node_modules/@aws-cdk/asset-node-proxy-agent-v6": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@aws-cdk/asset-node-proxy-agent-v6/-/asset-node-proxy-agent-v6-2.1.2.tgz", + "integrity": "sha512-pDiuqH+qY3zM9lhhLjbKJ1tnKOHzQ2V4Wr/3qsxyKeKAkuPMI/BVGvZG1PbrikUw949cGVTfVEt4ETKKYnrj0Q==", + "license": "Apache-2.0" + }, + "node_modules/@aws-cdk/cloud-assembly-schema": { + "version": "54.5.0", + "resolved": "https://registry.npmjs.org/@aws-cdk/cloud-assembly-schema/-/cloud-assembly-schema-54.5.0.tgz", + "integrity": "sha512-X37oRfMQYO/wXBBDotbW8msJ6AgrcMio/W6TpDR/9To9TUWld1KtY/jveHpU58nZyLVPTYEhDgFP548w3JJGsQ==", + "bundleDependencies": [ + "jsonschema", + "semver" + ], + "license": "Apache-2.0", + "dependencies": { + "jsonschema": "^1.5.0", + "semver": "^7.8.4" + }, + "engines": { + "node": ">= 18.0.0" + } + }, + "node_modules/@aws-cdk/cloud-assembly-schema/node_modules/jsonschema": { + "version": "1.5.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/@aws-cdk/cloud-assembly-schema/node_modules/semver": { + "version": "7.8.4", + "inBundle": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@types/node": { + "version": "25.9.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.4.tgz", + "integrity": "sha512-dszCsrKb5U7ZsVZBWiHFklTloVl0mSEnWH/iZXfZUlI4rzCUnsvGmgqfuVRHL54ugE7/wRuxEIXRa2iMZ+BG6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "node_modules/aws-cdk": { + "version": "2.1128.1", + "resolved": "https://registry.npmjs.org/aws-cdk/-/aws-cdk-2.1128.1.tgz", + "integrity": "sha512-y9OHn5/BOcIiq409vPvpypMIr7/8M1ScFe8IkFMSCN1/GI/5c73fQ4pfzNq+VDkj86T5zxs7BQ1qU2lQQytdXA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "cdk": "bin/cdk" + }, + "engines": { + "node": ">= 18.0.0" + } + }, + "node_modules/aws-cdk-lib": { + "version": "2.260.0", + "resolved": "https://registry.npmjs.org/aws-cdk-lib/-/aws-cdk-lib-2.260.0.tgz", + "integrity": "sha512-2PPG+hbPDot8+ibkb5Jl9y3OY5rBE6TFwjzOi+yEyU4ZG6u8bM4DDKhhBi/S20NqqSFDso9rH1txVJAdwXNiuQ==", + "bundleDependencies": [ + "@balena/dockerignore", + "@aws-cdk/cloud-assembly-api", + "case", + "fs-extra", + "ignore", + "jsonschema", + "minimatch", + "punycode", + "semver", + "table", + "yaml", + "mime-types" + ], + "license": "Apache-2.0", + "dependencies": { + "@aws-cdk/asset-awscli-v1": "2.2.282", + "@aws-cdk/asset-node-proxy-agent-v6": "^2.1.2", + "@aws-cdk/cloud-assembly-api": "^2.2.5", + "@aws-cdk/cloud-assembly-schema": "^54.0.0", + "@balena/dockerignore": "^1.0.2", + "case": "1.6.3", + "fs-extra": "^11.3.5", + "ignore": "^5.3.2", + "jsonschema": "^1.5.0", + "mime-types": "^2.1.35", + "minimatch": "^10.2.5", + "punycode": "^2.3.1", + "semver": "^7.8.1", + "table": "^6.9.0", + "yaml": "1.10.3" + }, + "engines": { + "node": ">= 20.0.0" + }, + "peerDependencies": { + "constructs": "^10.5.0" + } + }, + "node_modules/aws-cdk-lib/node_modules/@aws-cdk/cloud-assembly-api": { + "version": "2.2.5", + "inBundle": true, + "license": "Apache-2.0", + "dependencies": { + "jsonschema": "^1.5.0", + "semver": "^7.8.0" + }, + "engines": { + "node": ">= 18.0.0" + }, + "peerDependencies": { + "@aws-cdk/cloud-assembly-schema": ">=53.28.0" + } + }, + "node_modules/aws-cdk-lib/node_modules/@balena/dockerignore": { + "version": "1.0.2", + "inBundle": true, + "license": "Apache-2.0" + }, + "node_modules/aws-cdk-lib/node_modules/ajv": { + "version": "8.20.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/aws-cdk-lib/node_modules/ansi-regex": { + "version": "5.0.1", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/aws-cdk-lib/node_modules/ansi-styles": { + "version": "4.3.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/aws-cdk-lib/node_modules/astral-regex": { + "version": "2.0.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/aws-cdk-lib/node_modules/balanced-match": { + "version": "4.0.4", + "inBundle": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/aws-cdk-lib/node_modules/brace-expansion": { + "version": "5.0.6", + "inBundle": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/aws-cdk-lib/node_modules/case": { + "version": "1.6.3", + "inBundle": true, + "license": "(MIT OR GPL-3.0-or-later)", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/aws-cdk-lib/node_modules/color-convert": { + "version": "2.0.1", + "inBundle": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/aws-cdk-lib/node_modules/color-name": { + "version": "1.1.4", + "inBundle": true, + "license": "MIT" + }, + "node_modules/aws-cdk-lib/node_modules/emoji-regex": { + "version": "8.0.0", + "inBundle": true, + "license": "MIT" + }, + "node_modules/aws-cdk-lib/node_modules/fast-deep-equal": { + "version": "3.1.3", + "inBundle": true, + "license": "MIT" + }, + "node_modules/aws-cdk-lib/node_modules/fast-uri": { + "version": "3.1.2", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "inBundle": true, + "license": "BSD-3-Clause" + }, + "node_modules/aws-cdk-lib/node_modules/fs-extra": { + "version": "11.3.5", + "inBundle": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/aws-cdk-lib/node_modules/graceful-fs": { + "version": "4.2.11", + "inBundle": true, + "license": "ISC" + }, + "node_modules/aws-cdk-lib/node_modules/ignore": { + "version": "5.3.2", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/aws-cdk-lib/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/aws-cdk-lib/node_modules/json-schema-traverse": { + "version": "1.0.0", + "inBundle": true, + "license": "MIT" + }, + "node_modules/aws-cdk-lib/node_modules/jsonfile": { + "version": "6.2.1", + "inBundle": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/aws-cdk-lib/node_modules/jsonschema": { + "version": "1.5.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/aws-cdk-lib/node_modules/lodash.truncate": { + "version": "4.4.2", + "inBundle": true, + "license": "MIT" + }, + "node_modules/aws-cdk-lib/node_modules/mime-db": { + "version": "1.52.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/aws-cdk-lib/node_modules/mime-types": { + "version": "2.1.35", + "inBundle": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/aws-cdk-lib/node_modules/minimatch": { + "version": "10.2.5", + "inBundle": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/aws-cdk-lib/node_modules/punycode": { + "version": "2.3.1", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/aws-cdk-lib/node_modules/require-from-string": { + "version": "2.0.2", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/aws-cdk-lib/node_modules/semver": { + "version": "7.8.1", + "inBundle": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/aws-cdk-lib/node_modules/slice-ansi": { + "version": "4.0.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "astral-regex": "^2.0.0", + "is-fullwidth-code-point": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/aws-cdk-lib/node_modules/string-width": { + "version": "4.2.3", + "inBundle": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/aws-cdk-lib/node_modules/strip-ansi": { + "version": "6.0.1", + "inBundle": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/aws-cdk-lib/node_modules/table": { + "version": "6.9.0", + "inBundle": true, + "license": "BSD-3-Clause", + "dependencies": { + "ajv": "^8.0.1", + "lodash.truncate": "^4.4.2", + "slice-ansi": "^4.0.0", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/aws-cdk-lib/node_modules/universalify": { + "version": "2.0.1", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/aws-cdk-lib/node_modules/yaml": { + "version": "1.10.3", + "inBundle": true, + "license": "ISC", + "engines": { + "node": ">= 6" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/constructs": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/constructs/-/constructs-10.6.0.tgz", + "integrity": "sha512-TxHOnBO5zMo/G76ykzGF/wMpEHu257TbWiIxP9K0Yv/+t70UzgBQiTqjkAsWOPC6jW91DzJI0+ehQV6xDRNBuQ==", + "license": "Apache-2.0" + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..d4907d3 --- /dev/null +++ b/package.json @@ -0,0 +1,24 @@ +{ + "name": "rustdesk-server", + "version": "1.0.0", + "bin": { + "app": "bin/app.js" + }, + "scripts": { + "build": "tsc", + "cdk": "cdk", + "synth": "cdk synth", + "deploy": "cdk deploy", + "diff": "cdk diff" + }, + "devDependencies": { + "@types/node": "^25.9.3", + "aws-cdk": "^2.1128.1", + "source-map-support": "^0.5.21", + "typescript": "~6.0.3" + }, + "dependencies": { + "aws-cdk-lib": "2.260.0", + "constructs": "^10.0.0" + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..2b2e2de --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "commonjs", + "lib": ["ES2022"], + "types": ["node"], + "declaration": true, + "strict": true, + "noImplicitAny": true, + "strictNullChecks": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "inlineSourceMap": true, + "inlineSources": true, + "strictPropertyInitialization": false, + "outDir": "./cdk.out", + "rootDir": ".", + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "esModuleInterop": true + }, + "exclude": ["node_modules", "cdk.out"] +} diff --git a/userdata/bootstrap.sh b/userdata/bootstrap.sh new file mode 100644 index 0000000..0355e87 --- /dev/null +++ b/userdata/bootstrap.sh @@ -0,0 +1,70 @@ +#!/bin/bash +# RustDesk Server Pro bootstrap (Amazon Linux 2023, ARM64). +# Rendered by lib/rustdesk-server-stack.ts; __RUSTDESK_IMAGE_TAG__ is replaced +# at synth time. Idempotent: safe to re-run on instance replacement. +set -euxo pipefail + +RUSTDESK_DATA=/var/lib/rustdesk +RUSTDESK_IMAGE="rustdesk/rustdesk-server-pro:__RUSTDESK_IMAGE_TAG__" + +# ── Persistent data volume (attached at /dev/xvdf via CfnVolumeAttachment; +# surfaces as an nvme device on Nitro). Wait for it, then mount WITHOUT +# reformatting if it already holds a filesystem (preserves the server key). ── +until lsblk -dno NAME | grep -v "$(lsblk -no PKNAME "$(findmnt -n -o SOURCE /)" 2>/dev/null || echo xvda)" | grep -q .; do + echo 'Waiting for data volume...' + sleep 5 +done +DATA_DEVICE=/dev/$(lsblk -dno NAME | grep -v "$(lsblk -no PKNAME "$(findmnt -n -o SOURCE /)" 2>/dev/null || echo xvda)" | head -1) +if ! blkid "$DATA_DEVICE"; then + mkfs.ext4 -L rustdesk-data "$DATA_DEVICE" +fi +mkdir -p "$RUSTDESK_DATA" +grep -q 'LABEL=rustdesk-data' /etc/fstab || \ + echo "LABEL=rustdesk-data $RUSTDESK_DATA ext4 defaults,nofail 0 2" >> /etc/fstab +mount -a +# Restrict the data dir so the server private key (id_ed25519, created inside the +# container at /root) is not readable by any non-root host user even if the +# container writes it world-readable. +chmod 700 "$RUSTDESK_DATA" + +# ── Docker + compose plugin ────────────────────────────────────────── +dnf install -y docker +systemctl enable --now docker +DOCKER_CLI_PLUGINS=/usr/local/lib/docker/cli-plugins +mkdir -p "$DOCKER_CLI_PLUGINS" +curl -fsSL "https://github.com/docker/compose/releases/download/v2.29.7/docker-compose-linux-aarch64" \ + -o "$DOCKER_CLI_PLUGINS/docker-compose" +chmod +x "$DOCKER_CLI_PLUGINS/docker-compose" + +# ── Compose definition (host networking; SG is the access control) ─── +mkdir -p /opt/rustdesk +cat > /opt/rustdesk/docker-compose.yml << COMPOSE +services: + hbbs: + container_name: hbbs + image: ${RUSTDESK_IMAGE} + command: hbbs + network_mode: host + volumes: + - ${RUSTDESK_DATA}:/root + depends_on: + - hbbr + restart: unless-stopped + hbbr: + container_name: hbbr + image: ${RUSTDESK_IMAGE} + command: hbbr + network_mode: host + volumes: + - ${RUSTDESK_DATA}:/root + restart: unless-stopped +COMPOSE + +cd /opt/rustdesk +docker compose pull +docker compose up -d + +# Activate the Pro license and configure users in the web console at +# http://rustdesk.seahaven.com:21114 after first boot. The generated key pair +# lives at $RUSTDESK_DATA/id_ed25519{,.pub} -- mirror it into the +# rustdesk/server-key-pair secret (see RUNBOOK).