Skip to content

Commit

Permalink
feat: improve pairing (#316)
Browse files Browse the repository at this point in the history
 - generate random pin and run pairing command on host via ssm
 - adjust Desktop application to match the client's screen resolution
 - default app when connecting via Moonlight is now 'Desktop'
 - improved pairing instructions
 - improve detection of distribution
 - improve connect-moonlight and stop
 - optionally manage DNS record for EC2 instances via Route53
 - set AWS region in /etc/profile.d/restic.sh using cloud-init query
 - add unit tests for lambda functions
 - run unit tests via GitHub Action
 - improve restic profile.d setup
 - allow passing EC2 instance IDs to CLI tools
 - extracted common helper methods
 - updated pre-commit config
 - updated README.md
 - set Ubuntu 24.04 noble as new default
  • Loading branch information
stefanjenkner authored Jan 1, 2025
1 parent 0bd3423 commit e28da77
Show file tree
Hide file tree
Showing 20 changed files with 570 additions and 140 deletions.
14 changes: 10 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ permissions:
checks: write

jobs:
lint:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
Expand All @@ -22,17 +22,23 @@ jobs:
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
- name: lint python code
- name: lint Python code
run: |
ruff check .
- name: validate AWS SAM Template
- name: run tests with coverage
run: |
coverage run -m unittest discover tests
- name: generate coverage report
run: |
coverage report -m
- name: validate AWS SAM template
run: |
sam validate --lint
- name: lint CloudFormation templates
run: |
cfn-lint cloudformation/*
release:
needs: [lint]
needs: [test]
if: ${{ github.event_name != 'pull_request' }}
concurrency: release
permissions:
Expand Down
15 changes: 14 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,17 @@
NOTES.md

# virtual environment
venv/

# packaging
dist/
ec2-gaming-sunshine.template

# cached compiled bytecode
__pycache__/

# SAM artifacts and files used for local testing
.aws-sam/

# unit test coverage report and linter cache
.coverage
.ruff_cache/
3 changes: 2 additions & 1 deletion .markdownlint.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
{
"MD013": {
"line_length": 120
"line_length": 120,
"code_block_line_length": 200
},
"MD033": {
"allowed_elements": [
Expand Down
8 changes: 8 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,14 @@ repos:
hooks:
- id: ruff
- id: ruff-format
- repo: local
hooks:
- id: coverage
name: Python test coverage check
entry: bash -c "coverage run -m unittest discover tests && coverage report -m"
language: system
pass_filenames: false
always_run: true
- repo: https://github.com/python-jsonschema/check-jsonschema
rev: 0.30.0
hooks:
Expand Down
56 changes: 40 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,13 @@ Cloud Gaming powered by [Sunshine] on EC2 Spot Instances, tested with:
Stable features:

* Launch templates for both Spot and On-Demand EC2 instances
* Minimalistic Ubuntu Linux 22.04 with [Sunshine], [Steam] and [Lutris] preinstalled
* Minimalistic Ubuntu Linux with [Sunshine], [Steam] and [Lutris] preinstalled
* VPC with public subnet and security groups to restrict access by IP
* S3 bucket for fast backup/restore of the Steam Library to/from instance storage using [restic]

Experimental features:

* Debian Bookworm (without gamepad support at this time)
* Ubuntu Linux 24.04 Noble (using ubuntu-drivers setup)

## Prerequisites

Expand All @@ -37,20 +36,20 @@ When updating CloudFormation stack, passing parameters is not required and exist

Launch spot instance:

aws ec2 run-instances --launch-template LaunchTemplateName=ec2-gaming-sunshine-jammy-spot,Version=\$Latest
aws ec2 run-instances --launch-template LaunchTemplateName=ec2-gaming-sunshine-noble-spot,Version=\$Latest

Launch on-demand instance:

# ubuntu jammy (22.04)
aws ec2 run-instances --launch-template LaunchTemplateName=ec2-gaming-sunshine-jammy-on-demand,Version=\$Latest
# or: ubuntu noble (24.04)
# ubuntu noble (24.04)
aws ec2 run-instances --launch-template LaunchTemplateName=ec2-gaming-sunshine-noble-on-demand,Version=\$Latest
# or: ubuntu jammy (22.04)
aws ec2 run-instances --launch-template LaunchTemplateName=ec2-gaming-sunshine-jammy-on-demand,Version=\$Latest
# or: debian bookworm (12)
aws ec2 run-instances --launch-template LaunchTemplateName=ec2-gaming-sunshine-bookworm-on-demand,Version=\$Latest

Launch on-demand instance with custom instance type:

aws ec2 run-instances --launch-template LaunchTemplateName=ec2-gaming-sunshine-jammy-on-demand,Version=\$Latest \
aws ec2 run-instances --launch-template LaunchTemplateName=ec2-gaming-sunshine-noble-on-demand,Version=\$Latest \
--instance-type g5.4xlarge

By default, access to the EC2 instance is restriced. To update the whitelisted IP address to the IP address of the
Expand All @@ -72,7 +71,7 @@ Login to the EC2 instance:

./connect-ssh.py --stack-name ec2-gaming-sunshine

# or: manually connect to Ubuntu (jammy) instances
# or: manually connect to Ubuntu (noble or jammy) instances
ssh ubuntu@<IP>
# or: manually connect to Debian (bookworm) instances
ssh admin@<IP>
Expand All @@ -87,33 +86,56 @@ Install NVIDIA gaming driver and reboot:

sudo reboot

### Setup Sunshine
### Set up Sunshine

Login to the EC2 instance:

./connect-ssh.py --stack-name ec2-gaming-sunshine

Configure username and password for sunshine API user:

https --verify=no :47990/api/password newUsername="sunshine" newPassword="sunshine" confirmNewPassword="sunshine"

Add apps for different screen resolutions:
Adjust pre-defined applications to match the client's screen resolution:

# Desktop
https --verify=no -a sunshine:sunshine :47990/api/apps name="Desktop" \
prep-cmd:='[{"do":"bash -c \"xrandr --output DVI-D-0 --mode \\\"${SUNSHINE_CLIENT_WIDTH}x${SUNSHINE_CLIENT_HEIGHT}\\\" --rate 60\"","undo":""},{"do":"loginctl unlock-session","undo":""}]' \
output="" cmd:=[] index:=0 detached:=[] image-path="desktop-alt.png"

Optional: add applications for different screen resolutions:

# 1280x720
https --verify=no -a sunshine:sunshine :47990/api/apps \
name="1280x720" prep-cmd:='[{"do":"xrandr --output DVI-D-0 --mode 1280x720","undo":""}]' \
https --verify=no -a sunshine:sunshine :47990/api/apps name="1280x720" \
prep-cmd:='[{"do":"xrandr --output DVI-D-0 --mode 1280x720","undo":""}]' \
output="" cmd:=[] index=-1 detached:=[] image-path="desktop-alt.png"

# 1280x800
https --verify=no -a sunshine:sunshine :47990/api/apps \
name="1280x800" prep-cmd:='[{"do":"xrandr --output DVI-D-0 --mode 1280x800","undo":""}]' \
https --verify=no -a sunshine:sunshine :47990/api/apps name="1280x800" \
prep-cmd:='[{"do":"xrandr --output DVI-D-0 --mode 1280x800","undo":""}]' \
output="" cmd:=[] index=-1 detached:=[] image-path="desktop-alt.png"

# 1920x1080
https --verify=no -a sunshine:sunshine :47990/api/apps \
name="1920x1080" prep-cmd:='[{"do":"xrandr --output DVI-D-0 --mode 1920x1080","undo":""}]' \
https --verify=no -a sunshine:sunshine :47990/api/apps name="1920x1080" \
prep-cmd:='[{"do":"xrandr --output DVI-D-0 --mode 1920x1080","undo":""}]' \
output="" cmd:=[] index=-1 detached:=[] image-path="desktop-alt.png"

Set a password for `sunshine` Linux user:

sudo passwd sunshine

## Connect Moonlight client automatically (macOS and Linux clients only)

Connect and pair [Moonlight] automatically:

./connect-moonlight.py --stack-name ec2-gaming-sunshine

## Connect Moonlight client manually

Login to the EC2 instance:

./connect-ssh.py --stack-name ec2-gaming-sunshine

Determine the public IPv4 address and connect via the [Moonlight] client:

cloud-init query ds.meta_data.public_ipv4
Expand All @@ -122,6 +144,8 @@ Allow connection by entering the PIN:

https --verify=no -a sunshine:sunshine :47990/api/pin pin="0000"

## Launch Steam

Launch Steam, Login for the first time and:

* Move the Steam Library to `/mnt/sunshine/SteamLibrary` (Setting/Downloads/Steam Library Folder)
Expand Down
112 changes: 108 additions & 4 deletions cloudformation/ec2-gaming-sunshine.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,17 @@ Parameters:
KeyPair:
Description: EC2 Key Pair
Type: "AWS::EC2::KeyPair::KeyName"
HostedZoneName:
Description: "Optional hosted zone name (e.g.: play.example.com). Leave empty to skip creation."
Type: String
Default: ""

Conditions:
ShouldCreateHostedZone:
Fn::Not:
- Fn::Equals:
- !Ref HostedZoneName
- ""

Resources:
InstanceRole:
Expand All @@ -29,6 +40,7 @@ Resources:
Path: /
ManagedPolicyArns:
- arn:aws:iam::aws:policy/AmazonS3ReadOnlyAccess
- arn:aws:iam::aws:policy/AmazonSSMManagedEC2InstanceDefaultPolicy
Policies:
- PolicyName: !Sub "${AWS::StackName}-library-access"
PolicyDocument:
Expand Down Expand Up @@ -117,10 +129,6 @@ Resources:
FromPort: 22
ToPort: 22
CidrIp: !Ref MyIp
- IpProtocol: tcp
FromPort: 47990
ToPort: 47990
CidrIp: !Ref MyIp
GamingAccess:
Type: AWS::EC2::SecurityGroup
Properties:
Expand Down Expand Up @@ -221,6 +229,102 @@ Resources:
KeyPair: !Ref KeyPair
InstanceProfileArn: !GetAtt InstanceProfile.Arn

HostedZone:
Type: AWS::Route53::HostedZone
Condition: ShouldCreateHostedZone
Properties:
Name: !Ref HostedZoneName
OnInstanceStartStopLambdaRole:
Type: AWS::IAM::Role
Condition: ShouldCreateHostedZone
Properties:
RoleName: !Sub "${AWS::StackName}-on-start-stop-lambda-role"
AssumeRolePolicyDocument:
Version: 2012-10-17
Statement:
- Effect: Allow
Principal:
Service:
- lambda.amazonaws.com
Action:
- sts:AssumeRole
Path: /
ManagedPolicyArns:
- arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
Policies:
- PolicyName: !Sub "${AWS::StackName}-update-hosted-zone"
PolicyDocument:
Version: "2012-10-17"
Statement:
- Effect: Allow
Action:
- ec2:DescribeInstances
- ec2:DescribeTags
- route53:ListHostedZones
Resource: "*"
- Effect: Allow
Action:
- route53:ChangeResourceRecordSets
- route53:GetHostedZone
- route53:ListResourceRecordSets
Resource: !Join ["/", ["arn:aws:route53:::hostedzone", !Ref HostedZone]]
OnInstanceStartStopLogGroup:
Type: AWS::Logs::LogGroup
Condition: ShouldCreateHostedZone
Properties:
LogGroupName: !Sub "/aws/lambda/${AWS::StackName}-on-instance-start-stop"
RetentionInDays: 1
OnEc2InstanceStartFunction:
Type: AWS::Serverless::Function
Condition: ShouldCreateHostedZone
Properties:
FunctionName: !Sub "${AWS::StackName}-on-instance-start"
CodeUri: ../src/
Handler: on_ec2_start_stop_function.on_start_lambda_handler
Runtime: python3.13
Architectures: [arm64]
Timeout: 15
Events:
EC2InstanceStartEvent:
Type: EventBridgeRule
Properties:
Pattern:
source: [aws.ec2]
detail-type: [EC2 Instance State-change Notification]
detail:
state: [running]
Environment:
Variables:
HOSTED_ZONE_ID: !Ref HostedZone
Role: !GetAtt OnInstanceStartStopLambdaRole.Arn
LoggingConfig:
LogGroup: !Ref OnInstanceStartStopLogGroup
OnEc2InstanceStopFunction:
Type: AWS::Serverless::Function
Condition: ShouldCreateHostedZone
Properties:
FunctionName: !Sub "${AWS::StackName}-on-instance-stop"
CodeUri: ../src/
Handler: on_ec2_start_stop_function.on_stop_lambda_handler
Runtime: python3.13
Architectures: [arm64]
Timeout: 15
Events:
EC2InstanceStopEvent:
Type: EventBridgeRule
Properties:
Pattern:
source: [aws.ec2]
detail-type: [EC2 Instance State-change Notification]
detail:
state: [stopped]
Environment:
Variables:
HOSTED_ZONE_ID: !Ref HostedZone
Role: !GetAtt OnInstanceStartStopLambdaRole.Arn
LoggingConfig:
LogGroup: !Ref OnInstanceStartStopLogGroup

Outputs:
OnDemandLaunchTemplateBookworm:
Description: On-demand instance launch template (Bookworm)
Expand Down
5 changes: 3 additions & 2 deletions cloudformation/launch-templates-noble-userdata.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -147,8 +147,9 @@ UserData:
- path: /etc/profile.d/restic.sh
content: |
#!/bin/sh
REGION=$(curl -s http://169.254.169.254/latest/dynamic/instance-identity/document | grep -oP '(?<="region" : ")[^"]*')
export RESTIC_REPOSITORY=s3:https://s3.${REGION}.amazonaws.com/ec2-gaming-sunshine-library
REGION=$(cloud-init query region)
STACK_NAME=ec2-gaming-sunshine
export RESTIC_REPOSITORY=s3:https://s3.${REGION}.amazonaws.com/${STACK_NAME}-library
export RESTIC_PASSWORD=sunshine
permissions: '0644'
- path: /usr/local/bin/backup
Expand Down
Loading

0 comments on commit e28da77

Please sign in to comment.