/* * The Peacock Project - a HITMAN server replacement. * Copyright (C) 2021-2024 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 . */ // load as soon as possible to prevent dependency issues import "./generatedPeacockRequireTable" // load flags as soon as possible import { getFlag, loadFlags } from "./flags" loadFlags() import { program } from "commander" import express, { Request, Router } from "express" import http from "http" import { checkForUpdates, extractToken, handleAxiosError, IS_LAUNCHER, jokes, PEACOCKVERSTRING, ServerVer, } from "./utils" import { getConfig } from "./configSwizzleManager" import { error400, error406, handleOAuthToken, OAuthTokenBody, } from "./oauthToken" import type { RequestWithJwt, S2CEventWithTimestamp, ServerConnectionConfig, } from "./types/types" import { readFileSync, writeFileSync } from "fs" import { errorLoggingMiddleware, log, loggingMiddleware, LogLevel, requestLoggingMiddleware, } from "./loggingInterop" import { eventRouter } from "./eventHandler" import { contractRoutingRouter } from "./contracts/contractRouting" import { profileRouter } from "./profileHandler" import { menuDataRouter } from "./menuData" import { menuSystemPreRouter, menuSystemRouter } from "./menus/menuSystem" 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 "./discord/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, setupFileStructure } from "./databaseHandler" 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("/", (_: 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.', ) return } 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+)", // @ts-expect-error Has jwt props. (req: RequestWithJwt<{ issuer: string }>, res) => { const proto = req.protocol const config = getConfig( "ServerVersionConfig", true, ) as ServerConnectionConfig const serverhost = req.get("Host") config.Versions[0].GAME_VER = "6.74.0" if (req.params.serverVersion.startsWith("8")) { config.Versions[0].GAME_VER = `${ServerVer._Major}.${ServerVer._Minor}.${ServerVer._Build}` } else if (req.params.serverVersion.startsWith("7")) { config.Versions[0].GAME_VER = "7.17.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") { // 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", (_, 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" }), // @ts-expect-error jwt props. (req: RequestWithJwt, res) => { for (const event of req.body) { controller.hooks.newMetricsEvent.call(event, req) } res.send() }, ) app.post( "/oauth/token", urlencoded(), // @ts-expect-error jwt props. (req: RequestWithJwt, res) => { handleOAuthToken(req) .then((token) => { if (token === error400) { return res.status(400).send() } else if (token === error406) { return res.status(406).send() } else { return res.json(token) } }) .catch((err) => { log(LogLevel.ERROR, err.message) res.status(500).send() }) }, ) app.get("/files/onlineconfig.json", (_, res) => { res.set("Content-Type", "application/octet-stream") res.send(getConfig("OnlineConfig", false)) }) // 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+)/", // @ts-expect-error Has jwt props. (req: RequestWithJwt, _, next) => { req.serverVersion = req.params.serverVersion req.gameVersion = "h1" if (req.serverVersion.startsWith("8")) { req.gameVersion = "h3" } else if (req.serverVersion.startsWith("7")) { req.gameVersion = "h2" } 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 // @ts-expect-error Has jwt props. .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-15" break default: res.status(400).json({ message: "no game data" }) return } req.gameVersion = "h1" if (req.serverVersion.startsWith("8")) { req.gameVersion = "h3" } else if (req.serverVersion.startsWith("7")) { req.gameVersion = "h2" } if (req.jwt?.aud === "scpc-prod") { req.gameVersion = "scpc" } next() }), ) app.get( "/profiles/page//dashboard//Dashboard_Category_Sniper_Singleplayer/00000000-0000-0000-0000-000000000015/Contract/ff9f46cf-00bd-4c12-b887-eac491c3a96d", // @ts-expect-error jwt props. (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, "scpc", ), }, }, }) }, ) // 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", // @ts-expect-error jwt props. (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, "scpc", ), ...{ MultiplayerNotSupported: true }, }, }, }, }) }, ) if (PEACOCK_DEV) { // @ts-expect-error Has jwt props. 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?*", // @ts-expect-error jwt props. 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", // @ts-expect-error jwt props. (req: RequestWithJwt, res) => { res.json(generateBlobConfig(req)) }, ) const legacyRouter = Router() const primaryRouter = Router() 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.get( "/authentication/api/userchannel/ReportingService/ReportContract", (_, res) => { // TODO res.json({}) }, ) primaryRouter.use("/authentication/api/userchannel/", profileRouter) 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() // @ts-expect-error Has jwt props. .use((req: RequestWithJwt, _, 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() // @ts-expect-error Has jwt props. .use((req: RequestWithJwt, _, next) => { if (req.shouldCease) { return next("router") } if ( ["6-74", "7-3", "7-17", "8-15"].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 replacement.", ) async function startServer(options: { hmr: boolean pluginDevHost: boolean }): Promise { void checkForUpdates() if (!IS_LAUNCHER) { console.log( picocolors.greenBright(` ███████████ ██████████ █████████ █████████ ███████ █████████ █████ ████ ░░███░░░░░███░░███░░░░░█ ███░░░░░███ ███░░░░░███ ███░░░░░███ ███░░░░░███░░███ ███░ ░███ ░███ ░███ █ ░ ░███ ░███ ███ ░░░ ███ ░░███ ███ ░░░ ░███ ███ ░██████████ ░██████ ░███████████ ░███ ░███ ░███░███ ░███████ ░███░░░░░░ ░███░░█ ░███░░░░░███ ░███ ░███ ░███░███ ░███░░███ ░███ ░███ ░ █ ░███ ░███ ░░███ ███░░███ ███ ░░███ ███ ░███ ░░███ █████ ██████████ █████ █████ ░░█████████ ░░░███████░ ░░█████████ █████ ░░████ ░░░░░ ░░░░░░░░░░ ░░░░░ ░░░░░ ░░░░░░░░░ ░░░░░░░ ░░░░░░░░░ ░░░░░ ░░░░ `), ) } log( LogLevel.INFO, `This is Peacock v${PEACOCKVERSTRING} with Node v${process.versions.node}.`, ) if (getFlag("jokes") === true) { log( LogLevel.INFO, picocolors.yellowBright( `${jokes[random.int(0, jokes.length - 1)]}`, ), ) } try { // make sure required folder structure is in place await setupFileStructure() if (options.hmr) { void setupHotListener("contracts", () => { log( LogLevel.INFO, "Detected a change in contracts! Re-indexing...", ) controller.index() }) } // once contracts directory is present, we are clear to boot await loadouts.init() await 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 await liveSplitManager.init() return } catch (e) { log(LogLevel.ERROR, "Critical error during bootstrap!") log(LogLevel.ERROR, e) } } 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("tools").description("open the tools UI").action(toolsMenu) // noinspection RequiredAttributes 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 || input.replace(/\.[^/\\.]+$/, ".crp") writeFileSync( outputPath, pack(JSON.parse(readFileSync(input).toString())), ) log(LogLevel.INFO, `Packed "${input}" to "${outputPath}" successfully.`) }) // noinspection RequiredAttributes 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 || input.replace(/\.[^/\\.]+$/, ".json") writeFileSync(outputPath, JSON.stringify(unpack(readFileSync(input)))) log( LogLevel.INFO, `Unpacked "${input}" to "${outputPath}" successfully.`, ) }) program.parse(process.argv)