Skip to content

Commit

Permalink
Add SpawningPool: Gateway-managed GameApis
Browse files Browse the repository at this point in the history
  • Loading branch information
Nebual committed Dec 31, 2022
1 parent da98d41 commit 02b9201
Show file tree
Hide file tree
Showing 32 changed files with 974 additions and 97 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -79,5 +79,6 @@ package-lock.json


gateway/admins.json
gateway/spawningPool.json
/game-api/serviceaccount.json
modpack-base/
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,12 @@

- auto save/backup
- chat
- sort Cards by most recently running
- EditCard.js can probably derive the list of games that support SpawningPool from Gateway, who can learn it based on gameApis reporting the feature

# Dev Guide

## Adding support for a new Game

1. Create a new `./game-api/src/games/` manager, extending either `GenericDockerManager` or `BaseGameManager`
2. Add to `./ui/src/components/EditCard.js`'s `gameApiOptions`
7 changes: 0 additions & 7 deletions game-api/gman-nginx.conf
Original file line number Diff line number Diff line change
Expand Up @@ -116,13 +116,6 @@ server {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_pass http://127.0.0.1:6737;
}
location /valheim/ {
rewrite ^/(?:[a-z\-2]+)/?(.*)$ /$1 break;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_pass http://127.0.0.1:6756;
}
location /valheim-loki/ {
rewrite ^/(?:[a-z\-\d]+)/?(.*)$ /$1 break;
proxy_set_header Host $host;
Expand Down
Empty file added game-api/jsconfig.json
Empty file.
2 changes: 2 additions & 0 deletions game-api/src/cliArgs.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,10 @@ module.exports = {
listenPort: argv.port || 6726,
gatewayUrl: argv.gatewayUrl || "https://gmanman.nebtown.info/gateway/",
connectUrl: argv.connectUrl,
gamePort: argv.gamePort,
rconPort: argv.rconPort,
steamApiKey: argv.steamApiKey,
saveName: argv.saveName || gameId.replace(new RegExp(`^${game}[\-_]?`), ""),
gamePassword: argv.gamePassword,
serviceAccount: argv.serviceAccount || __dirname + "/../serviceaccount.json",
};
4 changes: 2 additions & 2 deletions game-api/src/games/ark.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ const {
dockerIsProcessRunning,
dockerLogRead,
readEnvFileCsv,
writeEnvFileCsv,
writeEnvFile,
steamWorkshopGetModSearch,
} = require("./common-helpers");

Expand Down Expand Up @@ -81,7 +81,7 @@ module.exports = class ArkManager {
.filter(({ enabled }) => enabled)
.map(({ id }) => id)
.join(",");
await writeEnvFileCsv("ARK_MODS", modsString);
await writeEnvFile({ ARK_MODS: modsString });
return true;
}
async getModSearch(query) {
Expand Down
33 changes: 20 additions & 13 deletions game-api/src/games/common-helpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -137,24 +137,31 @@ async function rconSRCDSConnect(port) {
}

async function readEnvFileCsv(envName) {
const env = dotenv.parse(
await fsPromises.readFile(path.join(gameDir, ".env"))
);
if (!env[envName]) {
try {
const env = dotenv.parse(
await fsPromises.readFile(path.join(gameDir, ".env"))
);
if (!env[envName]) {
return [];
}
return env[envName].trim().split(",");
} catch (e) {
console.warn("Unable to read .env", e);
return [];
}
return env[envName].trim().split(",");
}

async function writeEnvFileCsv(envName, newEnvValue) {
async function writeEnvFile(changes) {
const envFilePath = path.join(gameDir, ".env");
const envFileContents = (await fsPromises.readFile(envFilePath)) || "";
const newEnvFile =
envFileContents
.toString()
.replace(new RegExp(`^${envName}=".*"\n?`, "ms"), "")
.replace(new RegExp(`^${envName}=.*$\n?`, "m"), "")
.trim() + `\n${envName}="${newEnvValue}"\n`;
let newEnvFile = envFileContents.toString();
for (let envName in changes) {
newEnvFile =
newEnvFile
.replace(new RegExp(`^${envName}=".*?"\n?`, "ms"), "")
.replace(new RegExp(`^${envName}=.*?$\n?`, "m"), "")
.trim() + `\n${envName}="${changes[envName]}"\n`;
}
await fsPromises.writeFile(envFilePath, newEnvFile);
}

Expand Down Expand Up @@ -296,7 +303,7 @@ module.exports = {
rconConnect,
rconSRCDSConnect,
readEnvFileCsv,
writeEnvFileCsv,
writeEnvFile,
steamWorkshopGetModSearch,
BaseGameManager,
};
6 changes: 5 additions & 1 deletion game-api/src/games/docker.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,15 @@ module.exports = class GenericDockerManager extends BaseGameManager {
};
}

getRconPort() {
return rconPort;
}

async rcon(command) {
debugLog(`Running rcon: ${command}`);
try {
const response = await (
await rconSRCDSConnect(rconPort)
await rconSRCDSConnect(this.getRconPort())
).command(command, 500);
debugLog(`Rcon response: ${response}`);
return true;
Expand Down
51 changes: 44 additions & 7 deletions game-api/src/games/valheim.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,24 @@ const {
gameId,
debugLog,
connectUrl,
rconPort,
gameDir,
gameName,
saveName,
gamePassword,
} = require("../cliArgs");
let { gamePort, rconPort } = require("../cliArgs");

if (!gamePort) gamePort = 2456;
if (!rconPort) rconPort = gamePort + 1;

const {
dockerComposePull,
gamedigQueryPlayers,
readEnvFileCsv,
writeEnvFileCsv,
writeEnvFile,
} = require("./common-helpers");
const GenericDockerManager = require("./docker");
const { spawnProcess } = require("../libjunkdrawer/fsPromises");
const fs = require("../libjunkdrawer/fsPromises");
const fse = require("fs-extra");

const reDownloadUrl1 = /package\/download\/([^\/]+)\/([^\/]+)\/([^\/]+)\//;
Expand All @@ -28,11 +35,15 @@ module.exports = class ValheimManager extends GenericDockerManager {
getConnectUrl() {
return connectUrl;
}
getRconPort() {
return rconPort;
}
oldGetPlayersResult = false;
async getPlayers() {
const lookupPromise = gamedigQueryPlayers({
type: "valheim",
socketTimeout: 4000,
port: rconPort,
}).then((result) => {
this.oldGetPlayersResult = result;
return result;
Expand Down Expand Up @@ -84,13 +95,15 @@ module.exports = class ValheimManager extends GenericDockerManager {
.filter(({ enabled }) => enabled)
.map(({ id }) => allModsById[id].downloadUrl)
.join(",\n");
await writeEnvFileCsv("MODS", modsString);

const modsStringDisabled = modsList
.filter(({ enabled }) => !enabled)
.map(({ id }) => allModsById[id].downloadUrl)
.join(",\n");
await writeEnvFileCsv("MODS_OFF", modsStringDisabled);

await writeEnvFile({
MODS: modsString,
MODS_OFF: modsStringDisabled,
});
return true;
}

Expand Down Expand Up @@ -156,10 +169,34 @@ module.exports = class ValheimManager extends GenericDockerManager {
}
async getModPackHash() {
return (
await spawnProcess("bash", [
await fs.spawnProcess("bash", [
"-c",
`cd ${gameDir} && find server/BepInEx/{config,plugins} -type f -exec md5sum {} \\; | sort -k 2 | md5sum | cut -d ' ' -f1`,
])
).trim();
}

async setupInstanceFiles() {
if (!(await fs.exists(`${gameDir}docker-compose.yml`))) {
await fse.copy(
path.join(__dirname, `../../../game-setups/${game}`),
gameDir,
{
overwrite: false,
}
);
}
if (!(await fs.exists(`${gameDir}.env`))) {
await fs.writeFile(`${gameDir}.env`, "");
}
await writeEnvFile({
API_ID: gameId,
API_NAME: gameName,
GAMEPASSWORD: gamePassword,
SAVENAME: saveName,
GAMEPORT: gamePort,
RCONPORT: rconPort,
EXTRAPORT: gamePort + 2,
});
}
};
5 changes: 5 additions & 0 deletions game-api/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,10 @@ app.post("/rcon", async (request, response) => {
app.listen(listenPort);
console.log(`Listening on port ${listenPort}`);

if (gameManager.setupInstanceFiles) {
gameManager.setupInstanceFiles();
}

async function registerWithGateway() {
try {
return await axios.post(`${gatewayUrl}register/`, {
Expand All @@ -277,6 +281,7 @@ async function registerWithGateway() {
gameManager.updateOnStart && "updateOnStart",
gameManager.filesToBackup && "backup",
gameManager.rcon && "rcon",
gameManager.setupInstanceFiles && "spawningPool",
].filter(Boolean),
});
} catch (err) {
Expand Down
1 change: 1 addition & 0 deletions gateway/.nvmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
16
Empty file added gateway/jsconfig.json
Empty file.
2 changes: 2 additions & 0 deletions gateway/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,15 @@
"discord.js": "^13.6.0",
"express": "^4.17.3",
"express-prettify": "^0.1.2",
"fs-extra": "^11.1.0",
"jsonwebtoken": "^8.5.1",
"jwks-rsa": "^2.0.5",
"md5": "^2.3.0",
"minimist": "^1.2.6",
"moment": "^2.29.3",
"moment-timezone": "^0.5.34",
"otplib": "^12.0.1",
"ps-node": "^0.1.6",
"sodium": "^3.0.2",
"ws": "^7.4.2",
"ytdl-core": "^4.11.0"
Expand Down
45 changes: 30 additions & 15 deletions gateway/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,12 @@ const {
} = require("./routes/messages");
app.use("/messages", messagesRouter);

const {
spawningPoolRouter,
initSpawningPool,
} = require("./routes/spawningPool");
app.use("/spawningPool", spawningPoolRouter);

app.post("/auth", async (request, response) => {
const token = request.body.id_token;
try {
Expand All @@ -70,9 +76,10 @@ app.post("/auth", async (request, response) => {
app.get("/register", (request, response) => {
response.json({
games: Object.values(knownGameApis).map(
({ game, id, name, connectUrl, features }) => ({
({ game, gameId, name, connectUrl, features }) => ({
game,
id,
id: gameId, // todo: remove once new UI deployed
gameId,
name,
connectUrl,
features,
Expand All @@ -83,7 +90,7 @@ app.get("/register", (request, response) => {
/**
* {
game: "factorio",
id: "factorio-angelbob",
gameId: "factorio-angelbob",
name: "Factorio Angelbob",
connectUrl: "steam://connect/gman.nebtown.info:27015",
features: [
Expand All @@ -94,8 +101,9 @@ app.get("/register", (request, response) => {
}
*/
app.post("/register", (request, response) => {
knownGameApis[request.body.id] = { ...request.body, timeoutStartTime: 0 };
debugLog(`Registered ${request.body.id}`, request.body);
const id = request.body.gameId || request.body.id;
knownGameApis[id] = { gameId: id, ...request.body, timeoutStartTime: 0 };
debugLog(`Registered ${id}`, request.body);
response.json({});
});

Expand All @@ -107,7 +115,10 @@ app.all("/:gameId/*", async (request, response) => {
}
const headers = request.headers || {};
const queryParams = request.query || {};
const bodyParams = request.body || {};
const bodyParams =
request.body && Object.values(request.body).length
? request.body
: undefined;
let requestURL = `${gameApi.url}${endpoint}`;
const extraAxiosOptions = {};
if (endpoint.startsWith("mods/pack")) {
Expand Down Expand Up @@ -143,7 +154,7 @@ app.all("/:gameId/*", async (request, response) => {
gameApi.timeoutStartTime = 0;
} catch (err) {
if (err.code === "ECONNREFUSED") {
debugLog("Game API", gameApi.url, err.message);
debugLog("Game API ECONNREFUSED", gameApi.url, err.message);
response.status(504).json({ message: "Game API offline" });
if (!gameApi.timeoutStartTime) {
gameApi.timeoutStartTime = Date.now();
Expand All @@ -168,17 +179,20 @@ app.all("/:gameId/*", async (request, response) => {
if (extraAxiosOptions.responseType === "stream") {
responseData = await streamToString(responseData);
}
debugLog(
"Game API",
gameApi.url,
err.message,
`Status: ${status} ${statusText}`,
responseData
);
if (status !== 304) {
debugLog(
"Game API",
requestURL,
err.message,
`Status: ${status} ${statusText}`,
responseData,
responseHeaders
);
}
response.status(status).json(responseData);
}
} else {
console.warn("Game API", err);
console.warn("Game API 500", err);
response.status(500).json({});
}
}
Expand All @@ -197,4 +211,5 @@ const httpServer = http.createServer(app);
httpServer.listen(listenPort);
initWebsocketListener(httpServer);
initPlayerStatusPoller(knownGameApis);
initSpawningPool();
console.log(`Gateway listening on port ${listenPort}`);
Loading

0 comments on commit 02b9201

Please sign in to comment.