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