On Demand Windows Machine in AWS

May 03, 2020

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:

  1. Push power button (IoT or iOS shortcut) -> trigger a lambda that starts the EC2 instance
  2. Wait 30 seconds
  3. Start RDP client into Windows machine (connectivity is restricted by security groups)
  4. 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:

iOS Shortcut

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 :)

© Terry Heath 2020