On Demand Windows Machine in AWS
A few years back, my wife needed a new Windows machine. I didn’t want to get new hardware, so I convinced her to try out an EC2 instance instead. Instead of an EEEPC with an 8” screen, she got upgraded to a Macbook that would RDP into an EC2 instance.
Using a t3.large and an IoT button for powering on/off, this costs less than $10/month. It takes up no space in the house, and the maintenance is almost zero. The workflow looks like this:
- Push power button (IoT or iOS shortcut) -> trigger a lambda that starts the EC2 instance
- Wait 30 seconds
- Start RDP client into Windows machine (connectivity is restricted by security groups)
- Profit
Here’s how to do it.
All the resources for this post can be found on GitHub.
Overview
We’re going to stand up a CloudFormation stack that uses an Elastic IP to keep connectivity save-able in the RDP client. The EC2 instance will have a security group that only allows ingress traffic from your WAN IP. I maintain that rule with a cronjob that runs hourly:
0 * * * * /usr/local/bin/aws cloudformation deploy --stack-name windows-box --template-file ~user/cloudformation/cf-auto-windows-box.yml --parameter-overrides "ClientIP=$(curl ifconfig.co)" --no-fail-on-empty-changeset
You’ll have to fill in your own VPC and subnet IDs for this stack - the host needs internet access. Using this CloudFormation template, you can create the stack through the AWS console or just command-line it:
ifconfig.co is handy for grabbing your WAN IP
aws cloudformation deploy \
--stack-name windows-box \
--template-file cf-auto-windows-box.yml \
--capabilities CAPABILITY_NAMED_IAM \
--parameter-overrides \
"KeyPair=YOUR-KEY-NAME" \
"VPC=YOUR-VPC-ID" \
"Subnet=YOUR-SUBNET-ID" \
"ClientIP=$(curl ifconfig.co)"
The Lambdas
The template adds two lambdas to your account, power-switch
and power-off
.
The power-switch
lambda toggles the state from stopped to running:
const AWS = require("aws-sdk");
const instanceId = process.env.INSTANCE_ID;
const ec2 = new AWS.EC2();
const checkIfRunning = async () => {
const response = await ec2
.describeInstances({ InstanceIds: [instanceId] })
.promise();
return response.Reservations[0].Instances[0].State.Name !== "stopped";
};
exports.handler = async () => {
const params = {
InstanceIds: [instanceId]
};
const running = await checkIfRunning();
if (running) {
return ec2.stopInstances(params).promise();
}
return ec2.startInstances(params).promise();
};
The power-off
lambda, unsurprisingly, always turns the machine off:
const AWS = require("aws-sdk");
const instanceId = process.env.INSTANCE_ID;
const ec2 = new AWS.EC2();
exports.handler = () => {
const params = {
InstanceIds: [instanceId]
};
return ec2.stopInstances(params).promise();
};
The lambdas will only be allowed to describe your EC2 instances and turn on/off the instance created as part of the stack.
Flipping the switch
Thanks to iOS’s Shortcuts allowing SSH commands, you can actually make a nifty power switch from your phone:
If you end up going the iOS shortcut route, you can skip the cronjob and issue the CloudFormation update with your current IP as needed.
Unfortunately, there isn’t a way to get the IP address of an IoT button when it’s pressed, so I’m still using the cronjob.
If you just want to test this out without using the AWS console, you can invoke the lambda like so:
aws lambda invoke --function-name power-switch /dev/null
Cost Control
The power-off
lambda is used to prevent the machine from running too long. The stack
will schedule it to run once per day. You’ll want to change the cron expression to match
your timezone (in Pacific/Auckland I’ve got it set to 1am-ish).
PowerOffNightlyRule:
Type: AWS::Events::Rule
Properties:
Name: power-off-nightly
Description: powers off the instance daily
ScheduleExpression: cron(0 13 * * ? *) State: ENABLED
Targets:
- Arn: !GetAtt PowerOff.Arn
Id: PowerOffNightly
Wrapping Up
We’ve had this setup running for a few years now. Moving from Austin to Wellington made the latency painful, so I recently recreated the instance in Sydney (it was in us-east-1).
Maintenance has been, as mentioned, light. I changed the instance type from T2 to T3 without any hiccups. We keep EBS snapshots around, and the OSX RDP client is pretty solid - she can mount USB drives and drag files to/from the instance.
Even at her peak usage, this started in 2018, so generously 24 months at $10/month is $240. An actual machine with the same specs would have cost significantly more :)