/* * The Peacock Project - a HITMAN server replacement. * Copyright (C) 2021-2023 The Peacock Project Team * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ /* eslint-disable no-inner-declarations */ // noinspection RequiredAttributes // load as soon as possible to prevent dependency issues import "./generatedPeacockRequireTable" // load flags as soon as possible import { getFlag, loadFlags } from "./flags" loadFlags() import { setFlagsFromString } from "v8" import { program } from "commander" import express, { Request, Router } from "express" import http from "http" import { checkForUpdates, extractToken, handleAxiosError, IS_LAUNCHER, jokes, PEACOCKVER, PEACOCKVERSTRING, ServerVer, } from "./utils" import { getConfig, getSwizzleable, swizzle } from "./configSwizzleManager" import { handleOauthToken } from "./oauthToken" import type { RequestWithJwt, S2CEventWithTimestamp, ServerConnectionConfig, } from "./types/types" import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs" import { join } from "path" import { errorLoggingMiddleware, log, loggingMiddleware, LogLevel, requestLoggingMiddleware, } from "./loggingInterop" import { eventRouter } from "./eventHandler" import { contractRoutingRouter } from "./contracts/contractRouting" import { profileRouter } from "./profileHandler" import { menuDataRouter, preMenuDataRouter } from "./menuData" import { menuSystemPreRouter, menuSystemRouter } from "./menus/menuSystem" import { legacyEventRouter } from "./2016/legacyEventRouter" import { legacyMenuSystemRouter } from "./2016/legacyMenuSystem" import { _theLastYardbirdScpc, controller } from "./controller" import { STEAM_NAMESPACE_2016, STEAM_NAMESPACE_2018, STEAM_NAMESPACE_2021, STEAM_NAMESPACE_SCPC, } from "./platformEntitlements" import { legacyProfileRouter } from "./2016/legacyProfileRouter" import { legacyMenuDataRouter } from "./2016/legacyMenuData" import { legacyContractRouter } from "./2016/legacyContractHandler" import { initRp } from "./discordRp" import random from "random" import { generateUserCentric } from "./contracts/dataGen" import { json as jsonMiddleware, urlencoded } from "body-parser" import { loadoutRouter, loadouts } from "./loadouts" import { setupHotListener } from "./hotReloadService" import type { AxiosError } from "axios" import serveStatic from "serve-static" import { webFeaturesRouter } from "./webFeatures" import { toolsMenu } from "./tools" import picocolors from "picocolors" import { multiplayerRouter } from "./multiplayer/multiplayerService" import { multiplayerMenuDataRouter } from "./multiplayer/multiplayerMenuData" import { pack, unpack } from "msgpackr" import { liveSplitManager } from "./livesplit/liveSplitManager" import { cheapLoadUserData } from "./databaseHandler" import { reportRouter } from "./contracts/reportRouting" // welcome to the bleeding edge setFlagsFromString("--harmony") const host = process.env.HOST || "0.0.0.0" const port = process.env.PORT || 80 function uncaught(error: Error): void { if ( (error.message || "").includes("EADDRINUSE") || (error.message || "").includes("EACCES") || (error.stack || "").includes("EADDRINUSE") ) { log(LogLevel.ERROR, `Failed to use the server on ${host}:${port}!`) log( LogLevel.ERROR, "This is likely due to one of the following reasons:", ) log(LogLevel.ERROR, ` - Peacock is already running on this port`) log( LogLevel.ERROR, ` - Another app is already using this port (like IIS server)`, ) log( LogLevel.ERROR, ` - Your user account doesn't have permission (firewall can block it)`, ) process.exit(1) } if ((error as AxiosError).isAxiosError) { handleAxiosError(error as AxiosError) } log(LogLevel.ERROR, error.message) error.stack && log(LogLevel.ERROR, error.stack) } process.on("uncaughtException", uncaught) const app = express() app.use(loggingMiddleware) if (getFlag("developmentLogRequests")) { app.use(requestLoggingMiddleware) } app.use("/_wf", webFeaturesRouter) app.get("/", (req: Request, res) => { if (PEACOCK_DEV) { res.contentType("text/html") res.send( 'PEACOCK_DEV active, please run "yarn webui start" to start the web UI on port 3000 and access it there.', ) } else { const data = readFileSync("webui/dist/index.html").toString() res.contentType("text/html") res.send(data) } }) serveStatic.mime.define({ "application/javascript": ["js"] }) app.use("/assets", serveStatic("webui/dist/assets")) // make sure all responses have a default content-type set app.use(function (_req, res, next) { res.contentType("text/plain") next() }) if (getFlag("loadoutSaving") === "PROFILES") { app.use("/loadouts", loadoutRouter) } app.get( "/config/:audience/:serverVersion(\\d+_\\d+_\\d+)", (req: RequestWithJwt<{ issuer: string }>, res) => { const proto = req.protocol const config = getConfig("config", true) as ServerConnectionConfig const serverhost = req.get("Host") config.Versions[0].GAME_VER = req.params.serverVersion.startsWith("8") ? `${ServerVer._Major}.${ServerVer._Minor}.${ServerVer._Build}` : req.params.serverVersion.startsWith("7") ? "7.17.0" : "6.74.0" if (req.params.serverVersion.startsWith("8")) { config.Versions[0].SERVER_VER.GlobalAuthentication.RequestedAudience = "pc-prod_8" } if (req.params.serverVersion.startsWith("7")) { config.Versions[0].SERVER_VER.GlobalAuthentication.RequestedAudience = "pc-prod_7" } if (req.params.serverVersion.startsWith("6")) { config.Versions[0].SERVER_VER.GlobalAuthentication.RequestedAudience = "pc-prod_6" } if (req.query.issuer === STEAM_NAMESPACE_2021) { config.Versions[0].SERVER_VER.GlobalAuthentication.RequestedAudience = "steam-prod_8" } if (req.params.audience === "scpc-prod") { log(LogLevel.DEBUG, "Entering special mode.") // sniper challenge is a different game/audience config.Versions[0].Name = "scpc-prod" config.Versions[0].GAME_VER = "7.3.0" config.Versions[0].SERVER_VER.GlobalAuthentication.RequestedAudience = "scpc-prod" } config.Versions[0].ISSUER_ID = req.query.issuer || "*" config.Versions[0].SERVER_VER.Metrics.MetricsServerHost = `${proto}://${serverhost}` config.Versions[0].SERVER_VER.Authentication.AuthenticationHost = `${proto}://${serverhost}` config.Versions[0].SERVER_VER.Configuration.Url = `${proto}://${serverhost}/files/onlineconfig.json` config.Versions[0].SERVER_VER.Configuration.AgreementUrl = `${proto}://${serverhost}/files/privacypolicy/hm3/privacypolicy.json` config.Versions[0].SERVER_VER.Resources.ResourcesServicePath = `${proto}://${serverhost}/files` config.Versions[0].SERVER_VER.GlobalAuthentication.AuthenticationHost = `${proto}://${serverhost}` res.json(config) }, ) app.get("/files/privacypolicy/hm3/privacypolicy_*.json", (req, res) => { res.set("Content-Type", "application/octet-stream") res.set("x-ms-meta-version", "20181001") res.send(getConfig("privacypolicy", false)) }) app.post( "/api/metrics/*", jsonMiddleware({ limit: "10Mb" }), (req: RequestWithJwt, res) => { req.body.forEach((event) => { controller.hooks.newMetricsEvent.call(event, req) }) res.send() }, ) app.use("/oauth/token", urlencoded()) app.post("/oauth/token", (req: RequestWithJwt, res) => handleOauthToken(req, res), ) app.get("/files/onlineconfig.json", (req, res) => { res.set("Content-Type", "application/octet-stream") res.send(getConfig("onlineconfig", false)) }) app.get( "/profiles/page//dashboard//Dashboard_Category_Sniper_Singleplayer/00000000-0000-0000-0000-000000000015/Contract/ff9f46cf-00bd-4c12-b887-eac491c3a96d", (req: RequestWithJwt, res) => { res.json({ template: getConfig("FrankensteinMmSpTemplate", false), data: { Item: { Id: "ff9f46cf-00bd-4c12-b887-eac491c3a96d", Type: "Contract", Title: "UI_CONTRACT_HAWK_TITLE", Date: new Date().toISOString(), Data: generateUserCentric( _theLastYardbirdScpc, req.jwt.unique_name, "h1", ), }, }, }) }, ) // We handle this for now, but it's not used. For the future though. app.get( "/profiles/page//dashboard//Dashboard_Category_Sniper_Multiplayer/00000000-0000-0000-0000-000000000015/Contract/ff9f46cf-00bd-4c12-b887-eac491c3a96d", (req: RequestWithJwt, res) => { const template = getConfig("FrankensteinMmMpTemplate", false) /* To enable multiplayer: * Change MultiplayerNotSupported to false * NOTE: REMOVING THIS FULLY WILL BREAK THE EDITED TEMPLATE! */ res.json({ template: template, data: { Item: { Id: "ff9f46cf-00bd-4c12-b887-eac491c3a96d", Type: "Contract", Title: "UI_CONTRACT_HAWK_TITLE", Date: new Date().toISOString(), Disabled: true, Data: { ...generateUserCentric( _theLastYardbirdScpc, req.jwt.unique_name, "h1", ), ...{ MultiplayerNotSupported: true }, }, }, }, }) }, ) // NOTE! All routes attached after this point will be checked for a JWT or blob signature. // If you are adding a route that does NOT require authentication, put it ABOVE this message! app.use( Router() .use( "/resources-:serverVersion(\\d+-\\d+)/", (req: RequestWithJwt, res, next) => { req.serverVersion = req.params.serverVersion req.gameVersion = req.serverVersion.startsWith("8") ? "h3" : req.serverVersion.startsWith("7") && // prettier-ignore req.serverVersion !== "7.3.0" ? // prettier-ignore "h2" : // prettier-ignore "h1" if (req.serverVersion === "7.3.0") { req.gameVersion = "scpc" } next("router") }, ) // we're fine with skipping to the next router if we don't have auth .use(extractToken, (req: RequestWithJwt, res, next) => { switch (req.jwt?.pis) { case "egp_io_interactive_hitman_the_complete_first_season": case STEAM_NAMESPACE_2016: case STEAM_NAMESPACE_SCPC: req.serverVersion = "6-74" break case STEAM_NAMESPACE_2018: req.serverVersion = "7-17" break case "fghi4567xQOCheZIin0pazB47qGUvZw4": case STEAM_NAMESPACE_2021: req.serverVersion = "8-11" break default: res.status(400).json({ message: "no game data" }) return } req.gameVersion = req.serverVersion.startsWith("8") ? "h3" : req.serverVersion.startsWith("7") ? "h2" : "h1" if (req.jwt?.aud === "scpc-prod") { req.gameVersion = "scpc" } next() }), ) if (getFlag("developmentAllowRuntimeRestart")) { app.use(async (req: RequestWithJwt, _res, next): Promise => { if (!req.jwt) { next() return } //Make sure the userdata is always loaded if a proper JWT token is available await cheapLoadUserData(req.jwt.unique_name, req.gameVersion) next() }) } function generateBlobConfig(req: RequestWithJwt) { return { bloburl: `${req.protocol}://${req.get("Host")}/resources-${ req.serverVersion }/`, blobsig: `?sv=2018-03-28&ver=${req.gameVersion}`, blobsigduration: 7200000.0, } } app.get( "/authentication/api/configuration/Init?*", extractToken, (req: RequestWithJwt, res) => { // configName=pc-prod&lockedContentDisabled=false&isFreePrologueUser=false&isIntroPackUser=false&isFullExperienceUser=false res.json({ token: `${req.jwt.exp}-${req.jwt.nbf}-${req.jwt.platform}-${req.jwt.userid}`, blobconfig: generateBlobConfig(req), profileid: req.jwt.unique_name, serverversion: `${ServerVer._Major}.${ServerVer._Minor}.${ServerVer._Build}.${ServerVer._Revision}`, servertimeutc: new Date().toISOString(), ias: 2, }) }, ) app.post( "/authentication/api/userchannel/AuthenticationService/RenewBlobSignature", (req: RequestWithJwt, res) => { res.json(generateBlobConfig(req)) }, ) const legacyRouter = Router() const primaryRouter = Router() legacyRouter.use( "/authentication/api/userchannel/EventsService/", legacyEventRouter, ) legacyRouter.use("/resources-(\\d+-\\d+)/", legacyMenuSystemRouter) legacyRouter.use("/authentication/api/userchannel/", legacyProfileRouter) legacyRouter.use("/profiles/page/", legacyMenuDataRouter) legacyRouter.use( "/authentication/api/userchannel/ContractsService/", legacyContractRouter, ) legacyRouter.use( "/authentication/api/userchannel/ContractSessionsService/", legacyContractRouter, ) primaryRouter.use( "/authentication/api/userchannel/MultiplayerService/", multiplayerRouter, ) primaryRouter.use("/authentication/api/userchannel/EventsService/", eventRouter) primaryRouter.use( "/authentication/api/userchannel/ContractsService/", contractRoutingRouter, ) primaryRouter.use( "/authentication/api/userchannel/ReportingService/", reportRouter, ) primaryRouter.use("/authentication/api/userchannel/", profileRouter) primaryRouter.use("/profiles/page/", preMenuDataRouter) primaryRouter.use("/profiles/page", multiplayerMenuDataRouter) primaryRouter.use("/profiles/page/", menuDataRouter) primaryRouter.use("/resources-(\\d+-\\d+)/", menuSystemPreRouter) primaryRouter.use("/resources-(\\d+-\\d+)/", menuSystemRouter) app.use( Router() .use((req: RequestWithJwt, res, next) => { if (req.shouldCease) { return next("router") } if (req.serverVersion === "6-74" || req.serverVersion === "7-3") { return next() // continue along h1router } next("router") }) .use(legacyRouter), Router() .use((req: RequestWithJwt, res, next) => { if (req.shouldCease) { return next("router") } if ( ["6-74", "7-3", "7-17", "8-11"].includes( req.serverVersion, ) ) { return next() // continue along h3 router } next("router") }) .use(primaryRouter), ) app.all("*", (req, res) => { log(LogLevel.WARN, `Unhandled URL: ${req.url}`) res.status(404).send("Not found!") }) app.use(errorLoggingMiddleware) program.description( "The Peacock Project is a HITMAN™ World of Assassination Trilogy server built for general use.", ) const PEECOCK_ART = ` ███████████ ██████████ ██████████ █████████ ███████ █████████ █████ ████ ░░███░░░░░███░░███░░░░░█░░███░░░░░█ ███░░░░░███ ███░░░░░███ ███░░░░░███░░███ ███░ ░███ ░███ ░███ █ ░ ░███ █ ░ ███ ░░░ ███ ░░███ ███ ░░░ ░███ ███ ░██████████ ░██████ ░██████ ░███ ░███ ░███░███ ░███████ ░███░░░░░░ ░███░░█ ░███░░█ ░███ ░███ ░███░███ ░███░░███ ░███ ░███ ░ █ ░███ ░ █░░███ ███░░███ ███ ░░███ ███ ░███ ░░███ █████ ██████████ ██████████ ░░█████████ ░░░███████░ ░░█████████ █████ ░░████ ░░░░░ ░░░░░░░░░░ ░░░░░░░░░░ ░░░░░░░░░ ░░░░░░░ ░░░░░░░░░ ░░░░░ ░░░░ ` function startServer(options: { hmr: boolean; pluginDevHost: boolean }): void { checkForUpdates() if (!IS_LAUNCHER) { console.log( Math.random() < 0.001 ? PEECOCK_ART : picocolors.greenBright(` ███████████ ██████████ █████████ █████████ ███████ █████████ █████ ████ ░░███░░░░░███░░███░░░░░█ ███░░░░░███ ███░░░░░███ ███░░░░░███ ███░░░░░███░░███ ███░ ░███ ░███ ░███ █ ░ ░███ ░███ ███ ░░░ ███ ░░███ ███ ░░░ ░███ ███ ░██████████ ░██████ ░███████████ ░███ ░███ ░███░███ ░███████ ░███░░░░░░ ░███░░█ ░███░░░░░███ ░███ ░███ ░███░███ ░███░░███ ░███ ░███ ░ █ ░███ ░███ ░░███ ███░░███ ███ ░░███ ███ ░███ ░░███ █████ ██████████ █████ █████ ░░█████████ ░░░███████░ ░░█████████ █████ ░░████ ░░░░░ ░░░░░░░░░░ ░░░░░ ░░░░░ ░░░░░░░░░ ░░░░░░░ ░░░░░░░░░ ░░░░░ ░░░░ `), ) } log( LogLevel.INFO, `This is Peacock v${PEACOCKVERSTRING} (rev ${PEACOCKVER}), with Node v${process.versions.node}.`, ) // jokes lol if (getFlag("jokes") === true) { log( LogLevel.INFO, picocolors.yellowBright( `${jokes[random.int(0, jokes.length - 1)]}`, ), ) } // make sure required folder structure is in place for (const dir of [ "contractSessions", "plugins", "userdata", "contracts", join("userdata", "epicids"), join("userdata", "steamids"), join("userdata", "users"), join("userdata", "h1", "steamids"), join("userdata", "h1", "epicids"), join("userdata", "h1", "users"), join("userdata", "h2", "steamids"), join("userdata", "h2", "users"), join("userdata", "scpc", "users"), join("userdata", "scpc", "steamids"), join("images", "actors"), join("images", "contracts"), join("images", "contracts", "elusive"), join("images", "contracts", "escalation"), join("images", "contracts", "featured"), join("images", "unlockables_override"), ]) { if (existsSync(dir)) { continue } log(LogLevel.DEBUG, `Creating missing directory ${dir}`) mkdirSync(dir, { recursive: true }) } if (options.hmr) { log(LogLevel.DEBUG, "Experimental HMR enabled.") // eslint-disable-next-line @typescript-eslint/require-await setupHotListener("contracts", () => { log(LogLevel.INFO, "Detected a change in contracts! Re-indexing...") controller.index() }) } // once contracts directory is present, we are clear to boot loadouts.init() controller.boot(options.pluginDevHost) const httpServer = http.createServer(app) // @ts-expect-error Non-matching method sig httpServer.listen(port, host) log(LogLevel.INFO, "Server started.") if (getFlag("discordRp") === true) { initRp() } // initialize livesplit liveSplitManager.init() } program.option( "--hmr", "enable experimental hot reloading of contracts", getFlag("experimentalHMR") as boolean, ) program.option( "--plugin-dev-host", "activate plugin development features - requires plugin dev workspace setup", getFlag("developmentPluginDevHost") as boolean, ) program.action(startServer) program .command("swizzle") .option( "-c, --config ", "the config file to generate an override for", "", ) .option("--list", "get a list of config files that can be overridden") .description( "generates a file that overrides its internal config counterpart", ) .action((args: { list: boolean; config: string }) => { if (args.list) { log(LogLevel.INFO, "The following configurations can be swizzled:") getSwizzleable().forEach((swizzleable) => { log(LogLevel.INFO, ` - ${swizzleable}`) }) return } // doesn't want list, but hasn't specified a swizzleable if (!args.list && args.config === "") { log(LogLevel.ERROR, "No config specified! - Aborting.") return process.exit(1) } return swizzle(args.config) }) program .command("tools") .description("open the tools UI") .action(() => { toolsMenu() }) program .command("pack") .argument("", "input file to pack") .option("-o, --output ", "where to output the packed file to", "") .description("packs an input file into a Challenge Resource Package") .action((input, options: { output: string }) => { const outputPath = options.output !== "" ? options.output : input.replace(/\.[^/\\.]+$/, ".crp") writeFileSync( outputPath, pack(JSON.parse(readFileSync(input).toString())), ) log(LogLevel.INFO, `Packed "${input}" to "${outputPath}" successfully.`) }) program .command("unpack") .argument("", "input file to unpack") .option("-o, --output ", "where to output the unpacked file to", "") .description("unpacks a Challenge Resource Package") .action((input, options: { output: string }) => { const outputPath = options.output !== "" ? options.output : input.replace(/\.[^/\\.]+$/, ".json") writeFileSync(outputPath, JSON.stringify(unpack(readFileSync(input)))) log( LogLevel.INFO, `Unpacked "${input}" to "${outputPath}" successfully.`, ) }) program.parse(process.argv)