Skip to content

Instantly share code, notes, and snippets.

@DaveB93
Forked from rclark/readme.md
Last active January 9, 2025 19:18
Show Gist options
  • Save DaveB93/913df743af6763594c7559b7aa10d0f8 to your computer and use it in GitHub Desktop.
Save DaveB93/913df743af6763594c7559b7aa10d0f8 to your computer and use it in GitHub Desktop.
satisfactory dedicated server on aws

Satisfactory dedicated server on AWS ECS

A CloudFormation stack that you can run in your AWS account to host up a dedicated Satisfactory server.

Thanks to https://github.com/wolveix/satisfactory-server for the Docker image!

Thanks to https://gist.github.com/rclark/057059bfbd869743d1742a95b456bcff for the original. I've cloned theirs and gotten it working with Satisfactory 1.0 in September of 2024 ( mostly some small aws changes ). But I've added more things.

What's New/ Different from the original scripts.

This script adds a few AWS lambda functions that make this better for sharing with friends.

  • server start / stop
    • a hook that can be called with a password to start and stop the server.
  • server status
    • is the container running, what is it's IP, is the server running, how long has it been up, how many players
    • This is mostly useful for checking if the container is stuck in a loop, or checking if your friends are on without starting the game.
  • server auto shutdown
    • this gets started after the server is booted, and pings the server every X minutes to see if there's someone still on the server. If no one is on the server it will stop it. ( prevents your friend from accidentally leaving the server running overnight. )

Runs on AWS ECS

The dedicated server application runs on ECS Fargate, so you get a more-or-less "serverless" setup. It uses Fargate Spot, which allows you to get the cheapest possible setup, though AWS may choose to stop and restart your server. FWIW I've never actually observed that happening.

Files and backups

The game files and saves are stored on EFS, a network-attached storage system that allows these files to persist when/if ECS tasks stop and restart. On a daily basis, the save files are copied up from EFS to an S3 bucket in your account, named satistfactory-backups-{aws account id number}. This makes for a cheap daily backup + easier access to those files.

Networking and connecting to the dedicated server

When an ECS task launches, it gets a public IP address, with the exposed ports required to access the dedicated server application from the Satisfactory game client. However, if the task/container ever stops, a new one will launch to replace it, and it will have a new IP address. Because of this, we need to work with DNS records that we can update.

You must bring your own domain name that you own, provided as a stack parameter. For example, I might own a domain rclark.life. The stack builds a Route53 hosted zone for a subdomain of your domain, for example satisfactory.rclark.life. That hosted zone's name servers are a stack output. After launching the stack, you are responsible for making an NS record under the owned domain that references these name servers. That (in a sense) forwards traffic through your domain registrar to AWS Route53.

The stack also creates a Lambda function. Every time a new ECS task starts, the Lambda function runs. It finds out the new container's IP address, and updates an A record in the Route53 hosted zone, for example www.satisfactory.rclark.life.

That means in the Satisfactory game client, you connect to the server at a domain name like, for example, www.satisfactory.rclark.life.

If the server application crashes (and it will), or if AWS stops your task (I haven't noticed), you will have to exit your Satisfactory game client all the way to your desktop. Wait a few minutes before launching it again. In that time a new ECS task launches, and the DNS A record gets updated by the Lambda function. It appears that the game client will only do the DNS lookup when the client launches, so you do have to exit the client and start it again after the record has been updated.

Costs

Roughly, it seems to cost about $50-60 USD per month to run this setup 24/7. Almost all of that cost is from running the ECS Fargate Spot task constantly. My AWS bill last month was $60.08, and $46.03 of that was ECS. <-- Note from previous guy

You can turn the dedicated server off and back on again by making adjustments to the ECS service's desired task count. The ECS service can be found in the ECS console by browsing to the games cluster. That cluster should host just 1 service called satisfactory-server.

Set the number of desired tasks to 0 to tell ECS to run nothing. When you want to play again, set it back to 1. If you do this, you'll reduces the monthly cost dramatically... unless you actually play for most of the day on most days, in which case you're just gonna have to pay up.

Dave note: With my script changes, the server will auto shut off if there's no one playing. MY aws bill was $8 last month with someone playing quite a bit for most of a week, but not as much other times.

Note: Never set the desired task count > 1. There'd be 2 dedicated servers trying to access the same gamefiles and save files at that point, and things would definitely get weird.

Parameters To change

  • StartStopPassword - you'll be sharing this with your friends so they can hit your endpoint to start and stop your server
  • ServerApiKey - To get this, after starting your server, you will need to get onto the in game server console and run server.GenerateAPIToken

Some troubleshooting

Here are some aws-cli commands you can use to try and troubleshoot anything going wrong. Make sure you set the region properly for whatever AWS region you launched the stack into. Either add --region flags, or setup a default region in your ~/.aws/config file.

Turn the dedicated server off and on

# OFF
aws ecs update-service \
    --cluster games \
    --service satisfactory-server \
    --desired-count 0
    
## ON
aws ecs update-service \
    --cluster games \
    --service satisfactory-server \
    --desired-count 1

Make an SSH connection to the running container

aws ecs execute-command  \
    --cluster games \
    --task $(aws ecs list-tasks \
                --service-name satisfactory-server \
                --cluster games \
                --query "taskArns[0]" \
                --output text) \
    --container satisfactory-server \
    --command "/bin/bash" \
    --interactive

Find the IP address of the currently running container

aws ec2 describe-network-interfaces \
    --network-interface-ids $(aws ecs describe-tasks \
            --cluster games \
            --tasks $(aws ecs list-tasks \
                --service-name satisfactory-server \
                --cluster games \
                --query "taskArns[0]" \
                --output text) \
            --query "tasks[0].attachments[0].details[1].value" \
            --output text) \
    --query "NetworkInterfaces[0].Association.PublicIp" \
    --output text

If server gets stuck in a boot loop!!

Important. this seems to have been happening to me a lot with the wolviex container. If there's an update to satisfactory, something goes wrong with the update process and it corrupts the download. This happened a lot over december, to the point where I almost want to submit a pull request to wolviex with an env var to clean it up.

Anyway. if you look for 'steam update exited with -6' ? i think ? and your server is just boot looping, then you'll need to do this.

in the aws console, open the shell. go to the fargate page and find the id of the running task, just as it's coming up. run

aws ecs execute-command --cluster games --task <task id> --container satisfactory-server --command "/bin/bash" --interactive

e.g.

aws ecs execute-command --cluster games --task arn:aws:ecs:us-west-2:016545324165:task/games/b7a2515ce27c4fbabc3dc0985f25285e --container satisfactory-server --command "/bin/bash" --interactive to get a shell in your container. quickly move or delete the... game data dir..

if you do it fast enough the next time the server boots it will re-create it and work.

Tell Lambda to update the DNS record

aws lambda invoke \
    --function-name satisfactory-dns-refresher \
    --invocation-type EVENT \
    --payload '{}'
Parameters:
TopLevelDomainName:
Type: String
ServerStatusPath:
Type: String
Default: serverstatus
ServerStartStopPath:
Type: String
Default: startstop
StartStopPassword:
Type: String
Default: changeme
ServerApiKey:
Type: String
Default: changeme
ShutdownCheckMinutes:
Type: String
Default: 15
ShutdownCheckDelayMinutes:
Type: String
Default: 15
DisableSeasonalEvents:
Type: String
Default: "false"
AutoPause:
Type: String
Default: "true"
Resources:
VPC:
Type: AWS::EC2::VPC
Properties:
CidrBlock: 10.0.0.0/16
EnableDnsHostnames: true
EnableDnsSupport: true
Tags:
- Key: Name
Value: !Ref AWS::StackName
DHCP:
Type: AWS::EC2::DHCPOptions
Properties:
DomainName: !Sub ${AWS::Region}.compute.internal
DomainNameServers:
- AmazonProvidedDNS
DHCPAssociation:
Type: AWS::EC2::VPCDHCPOptionsAssociation
Properties:
VpcId: !Ref VPC
DhcpOptionsId: !Ref DHCP
Gateway:
Type: AWS::EC2::InternetGateway
GatewayAttachment:
Type: AWS::EC2::VPCGatewayAttachment
Properties:
VpcId: !Ref VPC
InternetGatewayId: !Ref Gateway
RouteTable:
Type: AWS::EC2::RouteTable
Properties:
VpcId: !Ref VPC
RouteTableAssociation:
Type: AWS::EC2::SubnetRouteTableAssociation
Properties:
SubnetId: !Ref Subnet
RouteTableId: !Ref RouteTable
InternetRoute:
Type: AWS::EC2::Route
Properties:
RouteTableId: !Ref RouteTable
DestinationCidrBlock: 0.0.0.0/0
GatewayId: !Ref Gateway
Subnet:
Type: AWS::EC2::Subnet
Properties:
VpcId: !Ref VPC
CidrBlock: 10.0.128.0/20
AvailabilityZone: !Sub "${AWS::Region}a"
Disk:
Type: AWS::EFS::FileSystem
Properties:
Encrypted: true
LifecyclePolicies:
- TransitionToIA: AFTER_14_DAYS
PerformanceMode: generalPurpose
ThroughputMode: bursting
Mount:
Type: AWS::EFS::MountTarget
Properties:
FileSystemId: !Ref Disk
SecurityGroups:
- !GetAtt DiskAccess.GroupId
SubnetId: !Ref Subnet
AccessPoint:
Type: AWS::EFS::AccessPoint
Properties:
FileSystemId: !Ref Disk
PosixUser:
Uid: "1000"
Gid: "1000"
RootDirectory:
Path: /home/satisfactory-server
CreationInfo:
OwnerUid: "1000"
OwnerGid: "1000"
Permissions: "755"
DiskAccess:
Type: AWS::EC2::SecurityGroup
Properties:
VpcId: !Ref VPC
GroupDescription: Access to satisfactory EFS disk
SecurityGroupIngress:
- IpProtocol: tcp
FromPort: 2049
ToPort: 2049
CidrIp: 0.0.0.0/0
Task:
Type: AWS::ECS::TaskDefinition
Properties:
Cpu: "4096"
Memory: "12288"
ExecutionRoleArn: !GetAtt ExecutionRole.Arn
TaskRoleArn: !GetAtt TaskRole.Arn
NetworkMode: awsvpc
RequiresCompatibilities:
- FARGATE
ContainerDefinitions:
- Name: satisfactory-server
Image: wolveix/satisfactory-server
PortMappings:
- ContainerPort: 7777
Protocol: udp
- ContainerPort: 7777
Protocol: tcp
MountPoints:
- ContainerPath: /config
SourceVolume: disk
LogConfiguration:
LogDriver: awslogs
Options:
awslogs-group: !Ref Logs
awslogs-region: !Ref AWS::Region
awslogs-stream-prefix: !Ref AWS::StackName
Command:
- --disable-telemetry
Environment:
- Name: PGID
Value: "1000"
- Name: PUID
Value: "1000"
- Name: DISABLESEASONALEVENTS
Value: !Ref DisableSeasonalEvents
- Name: AUTOPAUSE
Value: !Ref AutoPause
Volumes:
- Name: disk
EFSVolumeConfiguration:
FilesystemId: !Ref Disk
TransitEncryption: ENABLED
TransitEncryptionPort: 2050
AuthorizationConfig:
AccessPointId: !Ref AccessPoint
IAM: ENABLED
TaskRole:
Type: AWS::IAM::Role
Properties:
AssumeRolePolicyDocument:
Statement:
- Effect: Allow
Action: sts:AssumeRole
Principal:
Service: ecs-tasks.amazonaws.com
Policies:
- PolicyName: main
PolicyDocument:
Statement:
- Effect: Allow
Action:
- elasticfilesystem:ClientMount
- elasticfilesystem:ClientWrite
Resource: !GetAtt Disk.Arn
Condition:
StringEquals:
elasticfilesystem:AccessPointArn: !GetAtt AccessPoint.Arn
- PolicyName: exec
PolicyDocument:
Statement:
- Effect: Allow
Action:
- ssmmessages:CreateControlChannel
- ssmmessages:CreateDataChannel
- ssmmessages:OpenControlChannel
- ssmmessages:OpenDataChannel
Resource: "*"
- Effect: Allow
Action: kms:Decrypt
Resource: !GetAtt ExecKey.Arn
ExecutionRole:
Type: AWS::IAM::Role
Properties:
AssumeRolePolicyDocument:
Statement:
- Effect: Allow
Action: sts:AssumeRole
Principal:
Service: ecs-tasks.amazonaws.com
Policies:
- PolicyName: main
PolicyDocument:
Statement:
- Effect: Allow
Action: logs:*
Resource: !GetAtt Logs.Arn
- PolicyName: monitoring
PolicyDocument:
Statement:
- Effect: Allow
Action: ecs:StartTelemetrySession
Resource: "*"
TaskAccess:
Type: AWS::EC2::SecurityGroup
Properties:
VpcId: !Ref VPC
GroupDescription: Ingress for satisfactory-server
SecurityGroupIngress:
- IpProtocol: udp
FromPort: 7777
ToPort: 7777
CidrIp: 0.0.0.0/0
- IpProtocol: tcp
FromPort: 7777
ToPort: 7777
CidrIp: 0.0.0.0/0
Logs:
Type: AWS::Logs::LogGroup
Properties:
LogGroupName: !Ref AWS::StackName
RetentionInDays: 14
Cluster:
Type: AWS::ECS::Cluster
Properties:
ClusterName: games
Service:
Type: AWS::ECS::Service
Properties:
CapacityProviderStrategy:
- CapacityProvider: FARGATE_SPOT
Weight: 1
Cluster: !Ref Cluster
DesiredCount: 1
EnableECSManagedTags: true
EnableExecuteCommand: true
NetworkConfiguration:
AwsvpcConfiguration:
AssignPublicIp: ENABLED
SecurityGroups:
- !GetAtt TaskAccess.GroupId
Subnets:
- !Ref Subnet
ServiceName: satisfactory-server
TaskDefinition: !Ref Task
ExecKey:
Type: AWS::KMS::Key
Properties:
KeyPolicy:
Version: 2012-10-17
Id: key-default-1
Statement:
- Sid: Default
Effect: Allow
Principal:
AWS: !Sub arn:aws:iam::${AWS::AccountId}:root
Action: kms:*
Resource: "*"
Domain:
Type: AWS::Route53::HostedZone
Properties:
Name: !Sub "satisfactory.${TopLevelDomainName}"
ServerAutoShutdown:
Type: AWS::Lambda::Function
Properties:
FunctionName: satisfactory-server-shutdown-if-empty
Role: !GetAtt ServerAutoShutdownRole.Arn
Handler: index.handler
Runtime: nodejs18.x
Environment:
Variables:
ServerApiKey: !Ref ServerApiKey
PASSWORD: !Ref StartStopPassword
Code:
ZipFile: !Sub |
"use strict";
const { SFNClient, StartExecutionCommand } = require('@aws-sdk/client-sfn');
const http = require('node:https');
exports.handler = async (event, context, callback) => {
var statusResults = await shutdownIfEmptyFunction();
console.log("status results: " + JSON.stringify(statusResults));
let response = {
statusCode: 200,
headers: {
},
body: JSON.stringify(statusResults)
};
callback(null, response);
};
async function shutdownIfEmptyFunction() {
// Define the object that will hold the data values returned
let statusResults = {
isEmpty: false,
triggeredShutdown: false,
};
const postData = JSON.stringify({
"function":"QueryServerState","data":{"clientCustomData":""}
});
var serverStatusOptions = {
host: 'www.satisfactory.beleznay.ca',
port: 7777,
path: '/api/v1',
method: 'POST',
timeout: 2000,
headers: {
'Authorization': 'Bearer ' + process.env.ServerApiKey
, 'Content-Type': 'application/json'
, 'Content-Length': Buffer.byteLength(postData),
},
rejectUnauthorized: false
};
const req = http.request(serverStatusOptions, onHttpReq);
req.on('error', (e) => {
console.error(`problem with request: ` + e.message);
statusResults.triggeredShutdown = 'true';
triggerStopServerStepFunction();
});
req.on('timeout', () => {
console.error(`timeout request: `);
statusResults.triggeredShutdown = 'true';
triggerStopServerStepFunction();
});
// Write data to request body
req.write(postData);
await req.end();
return statusResults;
}
async function onHttpReq(res) {
console.log('STATUS: ' + res.statusCode);
console.log('HEADERS: ' + JSON.stringify(res.headers));
res.setEncoding('utf8');
res.on('data', function (chunk) {
console.log('BODY: ' + chunk);
const obj = JSON.parse(chunk);
const isEmpty = obj.data.serverGameState.numConnectedPlayers == 0;
if (isEmpty) {
triggerStopServerStepFunction().then(
(data) => {console.log(data);},
(err) => { console.log(err);}
);
}
});
}
async function triggerStopServerStepFunction() {
const stepFunctionClient = new SFNClient();
const params = {
stateMachineArn: '${StartStopStateMachine.Arn}',
input: JSON.stringify({'queryStringParameters':{'desiredCount': 0, 'key': process.env.PASSWORD}})
};
const command = new StartExecutionCommand(params);
const startExecution = await stepFunctionClient.send(command);
console.log(startExecution);
}
ServerAutoShutdownLogs:
Type: AWS::Logs::LogGroup
Properties:
LogGroupName: /aws/lambda/satisfactory-server-shutdown-if-empty
RetentionInDays: 14
ServerAutoShutdownRole:
Type: AWS::IAM::Role
Properties:
AssumeRolePolicyDocument:
Statement:
- Effect: Allow
Action: sts:AssumeRole
Principal:
Service: lambda.amazonaws.com
Policies:
- PolicyName: main
PolicyDocument:
Statement:
- Effect: Allow
Action: logs:*
Resource: !GetAtt ServerAutoShutdownLogs.Arn
- Effect: Allow
Action:
- states:StartExecution
Resource: "*"
ServerAutoShutdownSchedule:
Type: AWS::Scheduler::Schedule
Properties:
Name: satisfactory-server-auto-shutdown-check
FlexibleTimeWindow:
MaximumWindowInMinutes: 5
Mode: "FLEXIBLE"
ScheduleExpression: !Sub "rate(${ShutdownCheckMinutes} minutes)"
Target:
Arn: !GetAtt ServerAutoShutdown.Arn
RoleArn: !GetAtt ServerAutoShutdownExecutionRole.Arn
State: DISABLED
ServerAutoShutdownExecutionRole:
Type: "AWS::IAM::Role"
Properties:
AssumeRolePolicyDocument:
Version: "2012-10-17"
Statement:
- Effect: "Allow"
Principal:
Service:
- scheduler.amazonaws.com
Action: "sts:AssumeRole"
Path: "/"
Policies:
- PolicyName: StatesExecutionPolicy
PolicyDocument:
Version: "2012-10-17"
Statement:
- Effect: Allow
Action:
- "lambda:InvokeFunction"
Resource: !GetAtt ServerAutoShutdown.Arn
- Effect: Allow
Action: logs:*
Resource: !GetAtt ServerAutoShutdownLogs.Arn
ServerAutoShutdownPermission:
Type: AWS::Lambda::Permission
Properties:
Action: lambda:InvokeFunction
FunctionName: !Ref ServerAutoShutdown
Principal: scheduler.amazonaws.com
SourceArn: !GetAtt ServerAutoShutdownSchedule.Arn
ServerStatus:
Type: AWS::Lambda::Function
Properties:
FunctionName: satisfactory-server-status
Role: !GetAtt ServerStatusRole.Arn
Handler: index.handler
Runtime: nodejs18.x
Environment:
Variables:
ServerApiKey: !Ref ServerApiKey
Code:
ZipFile: !Sub |
"use strict";
const { ECSClient, ListTasksCommand, DescribeTasksCommand } = require('@aws-sdk/client-ecs');
const { EC2Client, DescribeNetworkInterfacesCommand } = require('@aws-sdk/client-ec2');
const client = new ECSClient();
const ec2Client = new EC2Client();
const http = require('node:https');
exports.handler = async (event, context, callback) => {
var statusResults = await getIPFunction();
console.log("status results: " + JSON.stringify(statusResults));
let response = {
statusCode: 200,
headers: {
},
body: JSON.stringify(statusResults)
};
callback(null, response);
};
async function getIPFunction() {
// Define the object that will hold the data values returned
let statusResults = {
running: false,
ip: "",
startedAt: null,
serverstate: {},
};
try {
var ListTasksParams = {
servicesName: '${Service.Name}',
cluster: '${Cluster.Arn}',
desiredStatus: "RUNNING"
};
const listTasksCommand = new ListTasksCommand(ListTasksParams);
const listTasks = await client.send(listTasksCommand);
console.log(listTasks);
if (listTasks.taskArns.length > 0) {
var describeTaskParams = {
cluster: '${Cluster.Arn}',
tasks: listTasks.taskArns
};
const describeTaskCommand = new DescribeTasksCommand(describeTaskParams);
const describeTasks = await client.send(describeTaskCommand);
console.log(describeTasks);
var networkInterfaceId = describeTasks.tasks[0].attachments[0].details.find(x => x.name === "networkInterfaceId").value;
console.log("found network interfaceid " + networkInterfaceId);
var describeNetworkInterfacesParams = {
NetworkInterfaceIds: [networkInterfaceId]
};
const describeNetworkInterfacesCommand = new DescribeNetworkInterfacesCommand(describeNetworkInterfacesParams);
const networkInterfaces = await ec2Client.send(describeNetworkInterfacesCommand);
console.log(networkInterfaces);
var publicIp = networkInterfaces.NetworkInterfaces.find(x => x.Association != undefined).Association.PublicIp;
console.log("found public IP " + publicIp);
statusResults.running = true;
statusResults.ip = publicIp + ":7777";
statusResults.startedAt = describeTasks.tasks[0].createdAt;
}
} catch (error) {
console.log(error);
}
if (statusResults.running) {
await satisfactoryServerState().then((data) => {
statusResults.serverstate = data;
});
}
console.log(JSON.stringify(statusResults));
return statusResults;
};
async function satisfactoryServerState() {
return new Promise((resolve,reject) => {
// Define the object that will hold the data values returned
let satisfactoryStatusResults = {
errored: false,
timeout: false,
numplayers: 0,
};
const postData = JSON.stringify({
"function": "QueryServerState", "data": { "clientCustomData": "" }
});
var serverStatusOptions = {
host: 'www.satisfactory.beleznay.ca',
port: 7777,
path: '/api/v1',
method: 'POST',
timeout: 2000,
headers: {
'Authorization': 'Bearer ' + process.env.ServerApiKey
, 'Content-Type': 'application/json'
, 'Content-Length': Buffer.byteLength(postData),
},
rejectUnauthorized: false
};
const req = http.request(serverStatusOptions, (res) => {
console.log('STATUS: ' + res.statusCode);
console.log('HEADERS: ' + JSON.stringify(res.headers));
res.setEncoding('utf8');
const body = []
res.on('data', function (chunk) {
body.push(chunk);
});
res.on('end', () => {
console.log('BODY: ' + body);
const obj = JSON.parse(body);
satisfactoryStatusResults.numplayers = obj.data.serverGameState.numConnectedPlayers;
resolve(satisfactoryStatusResults);
});
});
req.on('error', (e) => {
console.error(`problem with request: ` + e.message);
satisfactoryStatusResults.errored = 'true';
satisfactoryStatusResults.errorMessage = e.message;
resolve(satisfactoryStatusResults);
});
req.on('timeout', () => {
console.error(`timeout request: `);
satisfactoryStatusResults.timeout = 'true';
req.destroy();
resolve(satisfactoryStatusResults)
});
// Write data to request body
req.write(postData);
req.end();
});
}
ServerStatusLogs:
Type: AWS::Logs::LogGroup
Properties:
LogGroupName: /aws/lambda/satisfactory-server-status
RetentionInDays: 14
ServerStatusRole:
Type: AWS::IAM::Role
Properties:
AssumeRolePolicyDocument:
Statement:
- Effect: Allow
Action: sts:AssumeRole
Principal:
Service: lambda.amazonaws.com
Policies:
- PolicyName: main
PolicyDocument:
Statement:
- Effect: Allow
Action: logs:*
Resource: !GetAtt Logs.Arn
- Effect: Allow
Action:
- ecs:DescribeTasks
- ecs:ListTasks
- ec2:DescribeNetworkInterfaces
Resource: "*"
ServerStartStop:
Type: AWS::Lambda::Function
Properties:
FunctionName: satisfactory-server-start-stop
Role: !GetAtt ServerStartStopRole.Arn
Handler: index.handler
Runtime: nodejs18.x
Environment:
Variables:
PASSWORD: !Ref StartStopPassword
Code:
ZipFile: !Sub |
"use strict";
const { ECSClient, UpdateServiceCommand } = require( '@aws-sdk/client-ecs');
const { SchedulerClient, GetScheduleCommand, UpdateScheduleCommand } = require( '@aws-sdk/client-scheduler');
const SERVICE_NAME = '${Service.Name}';
const CLUSTER_ARN = '${Cluster.Arn}';
const PASSWORD = process.env.PASSWORD;
exports.handler = (event, context, callback) => {
console.log("request: " + JSON.stringify(event));
let responseCode = 400;
let message = "authentication failed";
var params = {
desiredCount: 1,
service: SERVICE_NAME,
cluster: CLUSTER_ARN
}
if (event.queryStringParameters && event.queryStringParameters.desiredCount !== undefined) {
let count = Math.min(Math.max(event.queryStringParameters.desiredCount, 0), 1);
params.desiredCount = count;
console.log("changing desiredCount to " + count);
}
if (event.queryStringParameters && event.queryStringParameters.key) {
let key = event.queryStringParameters.key;
if (key == PASSWORD) {
const client = new ECSClient();
console.log("starting service " + JSON.stringify(params));
message = "authentication success";
responseCode = 200;
const updateCommand = new UpdateServiceCommand(params);
client.send(updateCommand).then(
(data) => {console.log(data);},
(err) => { console.log(err);}
);
scheduleAutoShutdownCheck(params.desiredCount).then(
(data) => {console.log(data);},
(err) => { console.log(err);}
);
}
}
let responseBody = {
message: message,
};
let response = {
statusCode: responseCode,
headers: {
},
body: JSON.stringify(responseBody)
};
// Return the JSON result to the caller of the Lambda function
callback(null, response);
};
async function scheduleAutoShutdownCheck(desiredCount) {
console.log("scheduling auto shutdown check with " + desiredCount);
const schedulerClient = new SchedulerClient();
const eventParams = {
Name: 'satisfactory-server-auto-shutdown-check'
}
const getScheduleCommand = new GetScheduleCommand(eventParams);
var checkStopSchedule = await schedulerClient.send(getScheduleCommand);
if (desiredCount == 0) {
checkStopSchedule.State = "DISABLED"
}
else {
checkStopSchedule.State = "ENABLED"
const delayMilliseconds = ${ShutdownCheckDelayMinutes} * 60 * 1000;
checkStopSchedule.StartDate = new Date(Date.now + delayMilliseconds).toUTCString();
}
console.log('updating schedule' + JSON.stringify(checkStopSchedule));
const updateScheduleCommand = new UpdateScheduleCommand(checkStopSchedule);
schedulerClient.send(updateScheduleCommand).then(
(data) => {console.log(data);},
(err) => { console.log(err);}
);
}
ServerStartStopLogs:
Type: AWS::Logs::LogGroup
Properties:
LogGroupName: /aws/lambda/satisfactory-server-start-stop
RetentionInDays: 14
ServerStartStopRole:
Type: AWS::IAM::Role
Properties:
AssumeRolePolicyDocument:
Statement:
- Effect: Allow
Action: sts:AssumeRole
Principal:
Service: lambda.amazonaws.com
Policies:
- PolicyName: main
PolicyDocument:
Statement:
- Effect: Allow
Action: logs:*
Resource: !GetAtt ServerStartStopLogs.Arn
- Effect: Allow
Action:
- ecs:UpdateService
Resource: "*"
- Effect: Allow
Action:
- iam:PassRole
- scheduler:GetSchedule
- scheduler:UpdateSchedule
Resource: "*"
StartStopStateMachineExecutionRole:
Type: "AWS::IAM::Role"
Properties:
AssumeRolePolicyDocument:
Version: "2012-10-17"
Statement:
- Effect: "Allow"
Principal:
Service:
- !Sub states.${AWS::Region}.amazonaws.com
Action: "sts:AssumeRole"
Path: "/"
Policies:
- PolicyName: StatesExecutionPolicy
PolicyDocument:
Version: "2012-10-17"
Statement:
- Effect: Allow
Action:
- "lambda:InvokeFunction"
Resource: "*"
StartStopStateMachine:
Type: "AWS::StepFunctions::StateMachine"
Properties:
DefinitionString:
!Sub
- |-
{
"Comment": "StepFunction State Machine to call Start Stop",
"StartAt": "StartStop",
"States": {
"StartStop": {
"Type": "Task",
"Resource": "${lambdaArn}",
"End": true
}
}
}
- {lambdaArn: !GetAtt [ ServerStartStop, Arn ]}
RoleArn: !GetAtt [ StartStopStateMachineExecutionRole, Arn ]
apiGateway:
Type: AWS::ApiGateway::RestApi
Properties:
Name: satisfactory-startstopserver-api
Description: "This service allows you to start / stop and get the status of an ECS task."
EndpointConfiguration:
Types:
- REGIONAL
serverStatusResource:
Type: AWS::ApiGateway::Resource
Properties:
ParentId: !GetAtt apiGateway.RootResourceId
PathPart: !Ref ServerStatusPath
RestApiId:
Ref: apiGateway
serverStatusApiGatewayMethod:
Type: AWS::ApiGateway::Method
Properties:
AuthorizationType: NONE
HttpMethod: ANY
Integration:
IntegrationHttpMethod: POST
Type: AWS_PROXY
Uri: !Sub
- arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${lambdaArn}/invocations
- lambdaArn: !GetAtt ServerStatus.Arn
ResourceId: !Ref serverStatusResource
RestApiId: !Ref apiGateway
serverStatusLambdaApiGatewayInvoke:
Type: AWS::Lambda::Permission
Properties:
Action: lambda:InvokeFunction
FunctionName: !GetAtt ServerStatus.Arn
Principal: apigateway.amazonaws.com
SourceArn: !Sub
- arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${apiGateway}/${stageName}/*/${ServerStatusPath}
- stageName: !Ref apiGatewayDeploymentStage
serverStatusLambdaApiGatewayInvokeTest:
Type: AWS::Lambda::Permission
Properties:
Action: lambda:InvokeFunction
FunctionName: !GetAtt ServerStatus.Arn
Principal: apigateway.amazonaws.com
SourceArn: !Sub arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${apiGateway}/test-invoke-stage/*/${ServerStatusPath}
serverStartStopResource:
Type: AWS::ApiGateway::Resource
Properties:
ParentId: !GetAtt apiGateway.RootResourceId
PathPart: !Ref ServerStartStopPath
RestApiId:
Ref: apiGateway
serverStartStopApiGatewayMethod:
Type: AWS::ApiGateway::Method
Properties:
AuthorizationType: NONE
HttpMethod: ANY
Integration:
IntegrationHttpMethod: POST
Type: AWS_PROXY
Uri: !Sub
- arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${lambdaArn}/invocations
- lambdaArn: !GetAtt ServerStartStop.Arn
ResourceId: !Ref serverStartStopResource
RestApiId: !Ref apiGateway
serverStartStopLambdaApiGatewayInvoke:
Type: AWS::Lambda::Permission
Properties:
Action: lambda:InvokeFunction
FunctionName: !GetAtt ServerStartStop.Arn
Principal: apigateway.amazonaws.com
SourceArn: !Sub
- arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${apiGateway}/${stageName}/*/${ServerStartStopPath}
- stageName: !Ref apiGatewayDeploymentStage
serverStartStopLambdaApiGatewayInvokeTest:
Type: AWS::Lambda::Permission
Properties:
Action: lambda:InvokeFunction
FunctionName: !GetAtt ServerStartStop.Arn
Principal: apigateway.amazonaws.com
SourceArn: !Sub arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${apiGateway}/test-invoke-stage/*/${ServerStartStopPath}
apiGatewayDeployment:
Type: AWS::ApiGateway::Deployment
DependsOn:
- serverStatusApiGatewayMethod
- serverStartStopApiGatewayMethod
Properties:
RestApiId: !Ref apiGateway
apiGatewayDeploymentStage:
Type: AWS::ApiGateway::Stage
Properties:
RestApiId: !Ref apiGateway
DeploymentId: !Ref apiGatewayDeployment
StageName: prod
Refresher:
Type: AWS::Lambda::Function
Properties:
FunctionName: satisfactory-dns-refresher
Role: !GetAtt RefresherRole.Arn
Handler: index.handler
Runtime: nodejs18.x
Code:
ZipFile: !Sub |
const { ECSClient, ListTasksCommand, DescribeTasksCommand } = require('@aws-sdk/client-ecs');
const { EC2Client, DescribeNetworkInterfacesCommand } = require('@aws-sdk/client-ec2');
const { Route53Client, ChangeResourceRecordSetsCommand } = require('@aws-sdk/client-route-53');
exports.handler = async () => {
const ecs = new ECSClient();
const ec2 = new EC2Client();
const r53 = new Route53Client();
const listTasks = new ListTasksCommand({
cluster: 'games',
serviceName: 'satisfactory-server'
});
const tasks = await ecs.send(listTasks);
if (tasks.taskArns.length === 0) return;
const describeTasks = new DescribeTasksCommand({
cluster: 'games',
tasks: [tasks.taskArns[0]]
});
const desc = await ecs.send(describeTasks);
if (desc.tasks.length === 0) return;
const eni = desc.tasks[0].attachments[0].details.find((a) => a.name === 'networkInterfaceId').value;
const describeNetworkInterfaces = new DescribeNetworkInterfacesCommand({
NetworkInterfaceIds: [eni]
});
const interface = await ec2.send(describeNetworkInterfaces);
if (interface.NetworkInterfaces.length === 0) return;
const changeResourceRecordSets = new ChangeResourceRecordSetsCommand({
HostedZoneId: '${Domain.Id}',
ChangeBatch: {
Changes: [{
Action: 'UPSERT',
ResourceRecordSet: {
Name: 'www.satisfactory.${TopLevelDomainName}',
Type: 'A',
TTL: 60,
ResourceRecords: [{
Value: interface.NetworkInterfaces[0].Association.PublicIp
}]
}
}]
}
});
await r53.send(changeResourceRecordSets);
};
RefresherLogs:
Type: AWS::Logs::LogGroup
Properties:
LogGroupName: /aws/lambda/satisfactory-dns-refresher
RetentionInDays: 14
RefresherRole:
Type: AWS::IAM::Role
Properties:
AssumeRolePolicyDocument:
Statement:
- Effect: Allow
Action: sts:AssumeRole
Principal:
Service: lambda.amazonaws.com
Policies:
- PolicyName: main
PolicyDocument:
Statement:
- Effect: Allow
Action: logs:*
Resource: !GetAtt RefresherLogs.Arn
- Effect: Allow
Action:
- ecs:DescribeTasks
- ecs:ListTasks
- ec2:DescribeNetworkInterfaces
- route53:ChangeResourceRecordSets
Resource: "*"
RefresherEvents:
Type: AWS::Events::Rule
Properties:
Name: satisfactory-server-dns-refresh-termination
EventPattern:
source:
- aws.ecs
detail-type:
- ECS Task State Change
detail:
lastStatus:
- RUNNING
clusterArn:
- !GetAtt Cluster.Arn
Targets:
- Id: satisfactory-server-dns-refresher
Arn: !GetAtt Refresher.Arn
EventsPermission:
Type: AWS::Lambda::Permission
Properties:
Action: lambda:InvokeFunction
FunctionName: !Ref Refresher
Principal: events.amazonaws.com
SourceArn: !GetAtt RefresherEvents.Arn
BackupBucket:
Type: AWS::S3::Bucket
Properties:
BucketName: !Sub satisfactory-backups-${AWS::AccountId}
PublicAccessBlockConfiguration:
BlockPublicAcls: true
BlockPublicPolicy: true
IgnorePublicAcls: true
RestrictPublicBuckets: true
LifecycleConfiguration:
Rules:
- Status: Enabled
AbortIncompleteMultipartUpload:
DaysAfterInitiation: 1
BackupSource:
Type: AWS::DataSync::LocationEFS
Properties:
EfsFilesystemArn: !GetAtt Disk.Arn
Subdirectory: /home/satisfactory-server/saved/server # on ECS task, saves in /config/saved/server
Ec2Config:
SecurityGroupArns:
- !Sub arn:aws:ec2:${AWS::Region}:${AWS::AccountId}:security-group/${DiskAccess.GroupId}
SubnetArn: !Sub arn:aws:ec2:${AWS::Region}:${AWS::AccountId}:subnet/${Subnet}
DependsOn: Mount
BackupDestination:
Type: AWS::DataSync::LocationS3
Properties:
S3BucketArn: !GetAtt BackupBucket.Arn
S3Config:
BucketAccessRoleArn: !GetAtt BackupRole.Arn
S3StorageClass: STANDARD_IA
Subdirectory: saves # in S3, everything ends up under saves/ key
BackupRole:
Type: AWS::IAM::Role
Properties:
AssumeRolePolicyDocument:
Statement:
- Effect: Allow
Action: sts:AssumeRole
Principal:
Service: datasync.amazonaws.com
Policies:
- PolicyName: main
PolicyDocument:
Statement:
- Effect: Allow
Action:
- s3:GetBucketLocation
- s3:ListBucket
- s3:ListBucketMultipartUploads
Resource: !GetAtt BackupBucket.Arn
- Effect: Allow
Action:
- s3:AbortMultipartUpload
- s3:DeleteObject
- s3:GetObject
- s3:ListMultipartUploadParts
- s3:PutObjectTagging
- s3:GetObjectTagging
- s3:PutObject
Resource: !Sub ${BackupBucket.Arn}/*
BackupTask:
Type: AWS::DataSync::Task
Properties:
DestinationLocationArn: !Ref BackupDestination
SourceLocationArn: !Ref BackupSource
Name: satisfactory-backups
Options:
Atime: BEST_EFFORT
Mtime: PRESERVE
Schedule:
ScheduleExpression: rate(1 days)
Outputs:
NameServers:
Value: !Join
- ","
- !GetAtt Domain.NameServers
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment