From 89f358285de77b9fa14cf73a04bac6f94e29243e Mon Sep 17 00:00:00 2001 From: yurisasc Date: Tue, 30 Jan 2024 14:30:27 +0700 Subject: [PATCH] feat(bot): graceful shutdown --- discord-bot/.env.example | 4 +- discord-bot/lang/lang.en-GB.json | 6 +- discord-bot/lang/lang.en-US.json | 6 +- discord-bot/package-lock.json | 13 +++- discord-bot/package.json | 3 +- .../src/commands/chat/stop-server-command.ts | 13 +++- discord-bot/src/services/aws-service.ts | 41 +++++++++++ .../src/services/multi-servers-service.ts | 36 +++++++++- discord-bot/src/services/rcon-service.ts | 69 +++++++++++++++++++ discord-bot/src/start-bot.ts | 4 +- 10 files changed, 187 insertions(+), 8 deletions(-) create mode 100644 discord-bot/src/services/rcon-service.ts diff --git a/discord-bot/.env.example b/discord-bot/.env.example index 45f0526..933eefc 100644 --- a/discord-bot/.env.example +++ b/discord-bot/.env.example @@ -2,4 +2,6 @@ CLIENT_ID='YOUR_BOT_CLIENT_ID_HERE' CLIENT_TOKEN='YOUR_BOT_TOKEN_HERE' SERVERS_CONFIG=[{"name": "My Private Server", "awsAccount": {"profileName": "default", "accessKeyId": "", "secretAccessKey": "", "region": "us-east-1"}}] CLUSTER_NAME=palworld -SERVICE_NAME=palworld-server \ No newline at end of file +SERVICE_NAME=palworld-server +RCON_PORT=25575 +RCON_PASSWORD='YOUR_RCON_PASSWORD_HERE' \ No newline at end of file diff --git a/discord-bot/lang/lang.en-GB.json b/discord-bot/lang/lang.en-GB.json index 1744f18..c89d987 100644 --- a/discord-bot/lang/lang.en-GB.json +++ b/discord-bot/lang/lang.en-GB.json @@ -5,10 +5,14 @@ "title": "🟡 Starting {{SERVER_NAME}}...", "description": "Please wait while the server starts. It may take up to 10 minutes." }, - "stopServer": { + "stoppingServer": { "title": "🟡 Stopping {{SERVER_NAME}}...", "description": "We will save your progress before stopping the server." }, + "stoppedServer": { + "title": "🔴 Server stopped", + "description": "Hope you had fun and don't forget to touch real grass 🌿" + }, "invalidServerName": { "title": "❌ Invalid server", "description": "{{SERVER_NAME}} does not exist. Please choose the available server." diff --git a/discord-bot/lang/lang.en-US.json b/discord-bot/lang/lang.en-US.json index 1744f18..c89d987 100644 --- a/discord-bot/lang/lang.en-US.json +++ b/discord-bot/lang/lang.en-US.json @@ -5,10 +5,14 @@ "title": "🟡 Starting {{SERVER_NAME}}...", "description": "Please wait while the server starts. It may take up to 10 minutes." }, - "stopServer": { + "stoppingServer": { "title": "🟡 Stopping {{SERVER_NAME}}...", "description": "We will save your progress before stopping the server." }, + "stoppedServer": { + "title": "🔴 Server stopped", + "description": "Hope you had fun and don't forget to touch real grass 🌿" + }, "invalidServerName": { "title": "❌ Invalid server", "description": "{{SERVER_NAME}} does not exist. Please choose the available server." diff --git a/discord-bot/package-lock.json b/discord-bot/package-lock.json index 7407300..bb4c60d 100644 --- a/discord-bot/package-lock.json +++ b/discord-bot/package-lock.json @@ -16,7 +16,8 @@ "linguini": "^1.3.1", "node-fetch": "^3.3.0", "pino": "^8.11.0", - "pino-pretty": "^9.4.0" + "pino-pretty": "^9.4.0", + "rcon-srcds": "^2.1.0" }, "devDependencies": { "@types/node": "^16.4.10", @@ -1294,6 +1295,11 @@ "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==" }, + "node_modules/rcon-srcds": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/rcon-srcds/-/rcon-srcds-2.1.0.tgz", + "integrity": "sha512-kkF32wt+xsX19b6wrerU8qTOHa9AX81eJTffgZD0FfaVV2U+sL1gFr5W2VX4m+ILh/l4ZEAYBHx653l2Ci+h+Q==" + }, "node_modules/readable-stream": { "version": "3.6.1", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.1.tgz", @@ -2727,6 +2733,11 @@ "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==" }, + "rcon-srcds": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/rcon-srcds/-/rcon-srcds-2.1.0.tgz", + "integrity": "sha512-kkF32wt+xsX19b6wrerU8qTOHa9AX81eJTffgZD0FfaVV2U+sL1gFr5W2VX4m+ILh/l4ZEAYBHx653l2Ci+h+Q==" + }, "readable-stream": { "version": "3.6.1", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.1.tgz", diff --git a/discord-bot/package.json b/discord-bot/package.json index 2d01e20..7eca534 100644 --- a/discord-bot/package.json +++ b/discord-bot/package.json @@ -32,7 +32,8 @@ "linguini": "^1.3.1", "node-fetch": "^3.3.0", "pino": "^8.11.0", - "pino-pretty": "^9.4.0" + "pino-pretty": "^9.4.0", + "rcon-srcds": "^2.1.0" }, "devDependencies": { "@types/node": "^16.4.10", diff --git a/discord-bot/src/commands/chat/stop-server-command.ts b/discord-bot/src/commands/chat/stop-server-command.ts index a309fb6..89ebaab 100644 --- a/discord-bot/src/commands/chat/stop-server-command.ts +++ b/discord-bot/src/commands/chat/stop-server-command.ts @@ -32,11 +32,22 @@ export class StopServerCommand implements Command { if (args.option) { try { // TODO: Check that the server is running before stopping it + // Saving the game and shutting down the server gracefully. + embed = Lang.getEmbed("displayEmbeds.stoppingServer", data.lang, { + SERVER_NAME: args.option, + }); + await InteractionUtils.send(intr, embed); + await this.multiServersService.saveGameAndShutdownByName(args.option); + + // Stopping the server instance await this.multiServersService.stopServerByName(args.option); - embed = Lang.getEmbed("displayEmbeds.stopServer", data.lang, { + embed = Lang.getEmbed("displayEmbeds.stoppedServer", data.lang, { SERVER_NAME: args.option, }); + await InteractionUtils.send(intr, embed); + return; } catch (error) { + console.log(error); embed = Lang.getEmbed("displayEmbeds.invalidServerName", data.lang, { SERVER_NAME: args.option, }); diff --git a/discord-bot/src/services/aws-service.ts b/discord-bot/src/services/aws-service.ts index 2050bec..2774733 100644 --- a/discord-bot/src/services/aws-service.ts +++ b/discord-bot/src/services/aws-service.ts @@ -12,6 +12,47 @@ export class AWSService { this.setServiceDesiredCount(0); } + public async getServerPublicIP(profile: AWSProfile): Promise { + this.setProfile(profile); + try { + const service = new AWS.ECS({ region: profile.region }); + const network = new AWS.EC2({ region: profile.region }); + + const tasks = await service + .listTasks({ + cluster: process.env.CLUSTER_NAME, + serviceName: process.env.SERVICE_NAME, + }) + .promise(); + + if (tasks.taskArns.length === 0) { + console.log("No tasks found for this service"); + return; + } + + const describeTasksResponse = await service + .describeTasks({ + cluster: process.env.CLUSTER_NAME, + tasks: tasks.taskArns, + }) + .promise(); + const task = describeTasksResponse.tasks[0]; + const eni = task.attachments[0].details.find( + (detail) => detail.name === "networkInterfaceId" + ).value; + + // Get the public IP address from the network interface + const networkInterface = await network + .describeNetworkInterfaces({ + NetworkInterfaceIds: [eni], + }) + .promise(); + return networkInterface.NetworkInterfaces[0].Association.PublicIp; + } catch (error) { + console.error("Error finding public IP:", error); + } + } + private setServiceDesiredCount(desiredCount: number): void { const ecs = new AWS.ECS(); diff --git a/discord-bot/src/services/multi-servers-service.ts b/discord-bot/src/services/multi-servers-service.ts index 26a638b..fb3816d 100644 --- a/discord-bot/src/services/multi-servers-service.ts +++ b/discord-bot/src/services/multi-servers-service.ts @@ -1,10 +1,14 @@ import { AWSProfile, PalworldServer } from "../models/internal-models"; import { AWSService } from "./aws-service"; +import { RconService } from "./rcon-service"; export class MultiServersService { private servers: PalworldServer[]; - constructor(private readonly awsService: AWSService) { + constructor( + private readonly awsService: AWSService, + private readonly rconService: RconService + ) { this.servers = readServerConfigs(); } @@ -18,6 +22,36 @@ export class MultiServersService { await this.awsService.stopServer(awsAccount); } + public async saveGameAndShutdownByName(name: string): Promise { + const awsAccount = this.awsAccountByName(name); + const publicIp = await this.awsService.getServerPublicIP(awsAccount); + + try { + this.rconService.connect({ + host: publicIp, + port: 25575, + password: process.env.RCON_PASSWORD!, + }); + console.log("Authenticated"); + + const saveResult = await this.rconService.sendCommand("Save"); + + // Delay to allow the server to save + await new Promise((resolve) => setTimeout(resolve, 15000)); + console.log("SaveResult:", saveResult); + + const shutdownResult = await this.rconService.sendCommand( + "Shutdown 10 Shutdown executed by Discord bot..." + ); + + console.log("ShutdownResult:", shutdownResult); + // Delay to allow the server to shutdown + await new Promise((resolve) => setTimeout(resolve, 15000)); + } catch (error) { + console.error("Error in saveGameAndShutdownByName:", error); + } + } + private awsAccountByName(name: string): AWSProfile { const awsAccount = this.servers.find( (server) => server.name === name diff --git a/discord-bot/src/services/rcon-service.ts b/discord-bot/src/services/rcon-service.ts new file mode 100644 index 0000000..2e96565 --- /dev/null +++ b/discord-bot/src/services/rcon-service.ts @@ -0,0 +1,69 @@ +import * as net from "net"; + +export class RconService { + private client: net.Socket; + private requestId: number = 1; + private host: string; + private port: number; + private password: string; + + connect({ + host, + port, + password, + }: { + host: string; + port: number; + password: string; + }): Promise { + this.host = host; + this.port = port; + this.password = password; + this.client = new net.Socket(); + + return new Promise((resolve, reject) => { + this.client.connect(this.port, this.host, () => { + this.sendPacket(3, this.password) + .then(() => resolve()) + .catch(reject); + }); + + this.client.on("error", (error) => { + reject(error); + }); + }); + } + + sendCommand(command: string): Promise { + return this.sendPacket(2, command); + } + + private sendPacket(type: number, body: string): Promise { + return new Promise((resolve, reject) => { + const buffer = Buffer.alloc(14 + body.length); + buffer.writeInt32LE(10 + body.length, 0); + buffer.writeInt32LE(this.requestId, 4); + buffer.writeInt32LE(type, 8); + buffer.write(body, 12); + buffer.writeInt16LE(0, 12 + body.length); + + this.client.write(buffer); + + this.client.once("data", (data) => { + if (data.readInt32LE(8) === -1) { + reject(new Error("Authentication failed")); + return; + } + resolve(data.toString("utf-8", 12, data.length - 2)); + }); + + this.client.once("error", (error) => { + reject(error); + }); + }); + } + + disconnect(): void { + this.client.destroy(); + } +} diff --git a/discord-bot/src/start-bot.ts b/discord-bot/src/start-bot.ts index f638288..07563c0 100644 --- a/discord-bot/src/start-bot.ts +++ b/discord-bot/src/start-bot.ts @@ -27,6 +27,7 @@ import { MultiServersService, getServerNames, } from "./services/multi-servers-service.js"; +import { RconService } from "./services/rcon-service.js"; const require = createRequire(import.meta.url); let Config = require("../config/config.json"); @@ -36,7 +37,8 @@ async function start(): Promise { // Services let eventDataService = new EventDataService(); let awsService = new AWSService(); - let multiServersService = new MultiServersService(awsService); + let rconService = new RconService(); + let multiServersService = new MultiServersService(awsService, rconService); // Client let client = new CustomClient({