diff --git a/deno.json b/deno.json index 97aa81b..657dc27 100644 --- a/deno.json +++ b/deno.json @@ -25,7 +25,8 @@ "files": [], "compilerOptions": { "types": [ - "./src/types/express.ts" + "./src/types/express.ts", + "./src/types/http.ts" ] } } diff --git a/src/apiutils.ts b/src/apiutils.ts index c874d14..1906564 100644 --- a/src/apiutils.ts +++ b/src/apiutils.ts @@ -4,8 +4,9 @@ import Logging from "@proxnet/undead-logging"; import { decode } from "@gz/jwt"; import { Config } from "./config.ts"; import { AuthType, User, UserTokenFormat } from "./data/users.ts"; -import Profile, { ProfileTokenFormat } from "./data/profiles.ts"; +import UnifiedProfile, { ProfileTokenFormat } from "./data/profiles.ts"; import z from "zod"; +import { IncomingMessage } from "node:http"; const config = Config.getConfig(); @@ -129,8 +130,16 @@ export function getSrcIpDefault(rq: express.Request): string { const xrIp = rq.header("x-real-ip"); if (xrIp !== undefined) return xrIp; - const ip = typeof rq.ip === "undefined" ? "(unknown source)" : rq.ip; - return ip; + return typeof rq.ip === "undefined" ? "(unknown source)" : rq.ip; +} +export function getSrcIpDefaultRaw(rq: IncomingMessage) { + const cfIp = rq.headers['cf-connecting-ip']; + if (cfIp) return cfIp; + + const xrIp = rq.headers['x-real-ip']; + if (xrIp) return xrIp; + + return rq.socket.remoteAddress ? rq.socket.remoteAddress : "(unknown source)"; } export function statusResponse(code: number) { @@ -272,7 +281,7 @@ export async function Authentication( ].includes(false); if (valid) { if (decodedToken.typ == AuthType.Web) rs.locals.user = new User(decodedToken.sub); - else if (decodedToken.typ == AuthType.Game) rs.locals.profile = new Profile(decodedToken.sub); + else if (decodedToken.typ == AuthType.Game) rs.locals.profile = UnifiedProfile.get(decodedToken.sub); nxt(); } else { diff --git a/src/data/live/base.ts b/src/data/live/base.ts index 2c5b264..76550e5 100644 --- a/src/data/live/base.ts +++ b/src/data/live/base.ts @@ -1,4 +1,4 @@ -import Profile from "../profiles.ts"; +import { Profile } from "../profiles.ts"; const loginLocks: Map = new Map(); diff --git a/src/data/live/instances.ts b/src/data/live/instances.ts index ba9c911..baab969 100644 --- a/src/data/live/instances.ts +++ b/src/data/live/instances.ts @@ -1,5 +1,5 @@ import Logging from "@proxnet/undead-logging"; -import Profile from "../profiles.ts"; +import { Profile } from "../profiles.ts"; import { RoomInstance, InstanceOptions } from "./types.ts"; import { Config } from "../../config.ts"; diff --git a/src/data/live/presence.ts b/src/data/live/presence.ts index 7e01f57..b19f826 100644 --- a/src/data/live/presence.ts +++ b/src/data/live/presence.ts @@ -1,6 +1,6 @@ import { z } from "zod"; import { SettingKey } from "../content/settings.ts"; -import Profile from "../profiles.ts"; +import { Profile } from "../profiles.ts"; import { DeviceClass, PlayerStatusVisibility, RoomInstance, VRMovementMode } from "./types.ts"; import Logging from "@proxnet/undead-logging"; @@ -151,7 +151,7 @@ const Presence = new PresenceBase(); const id = setInterval(() => { Presence.deleteDeadPresences(); }, 480000); // delete dead presences every 8 minutes -Deno.addSignalListener("SIGINT", async () => { +Deno.addSignalListener("SIGINT", () => { clearInterval(id); const presArray = Presence.getAllRawPresences(); for (const pres of presArray.values()) clearInterval(pres.intervalId); diff --git a/src/data/live/types.ts b/src/data/live/types.ts index aeb80e0..3864641 100644 --- a/src/data/live/types.ts +++ b/src/data/live/types.ts @@ -1,5 +1,5 @@ import { IntegratedRoomScene, RoomDetails } from "../content/roomtypes.ts"; -import Profile from "../profiles.ts"; +import { Profile } from "../profiles.ts"; export enum PhotonRegionCodeString { Europe = "eu", diff --git a/src/data/profiles.ts b/src/data/profiles.ts index 8ff137f..76ab6aa 100644 --- a/src/data/profiles.ts +++ b/src/data/profiles.ts @@ -242,4 +242,23 @@ class Profile { } } -export default Profile; +const profiles: Map = new Map() + +class UnifiedProfileBase { + + get(id: number) { + let profile = profiles.get(id); + if (!profile) { + const inst = new Profile(id); + profiles.set(id, inst); + profile = inst; + } + return profile; + } + +} + +const UnifiedProfile = new UnifiedProfileBase(); + +export { Profile }; +export default UnifiedProfile; diff --git a/src/data/users.ts b/src/data/users.ts index 49e4c84..77d8b12 100644 --- a/src/data/users.ts +++ b/src/data/users.ts @@ -1,7 +1,7 @@ import { Redis } from "../db.ts"; import * as JsonWebToken from "@gz/jwt"; import { Config } from "../config.ts"; -import Profile from "./profiles.ts"; +import { Profile } from "./profiles.ts"; import { TokenBaseFormat } from "../apiutils.ts"; type UserInitOptions = { diff --git a/src/main.ts b/src/main.ts index 7148db6..97fa0a1 100644 --- a/src/main.ts +++ b/src/main.ts @@ -9,10 +9,8 @@ import express from "express"; import WebSocket, { WebSocketServer } from "ws"; import { IncomingMessage } from "../../AppData/Local/deno/npm/registry.npmjs.org/@types/connect/3.4.38/index.d.ts"; import { decode } from "@gz/jwt"; -import Profile, { ProfileTokenFormat } from "./data/profiles.ts"; +import UnifiedProfile, { ProfileTokenFormat } from "./data/profiles.ts"; import { SocketHandoff } from "./socket/handoff.ts"; -import internal from "node:stream"; -import { Buffer } from "node:buffer"; import { SignalRSocketHandler } from "./socket/socket.ts"; const instanceId = generateRandomString(64); @@ -100,6 +98,47 @@ app.use((rq: express.Request, rs: express.Response) => { try { + /** + * Galvanic WebSocket Server + */ + + type AuthResult = { + token?: ProfileTokenFormat, + valid: boolean + } + const authenticate = async (req: IncomingMessage) => { + const authHeader = req.headers.authorization; + if (!authHeader) return { valid: false } as AuthResult; + + const token = authHeader.split(" ")[1]; + if (!token) return { valid: false } as AuthResult; + + const decodedToken = await decode(token, config.auth.secret, {algorithm: 'HS512'}); + const schemaResult = ProfileTokenSchema.safeParse(decodedToken); + if (!schemaResult.success) return { valid: false } as AuthResult; + else return { token: decodedToken, valid: true } as AuthResult; + } + + const wss = new WebSocketServer({ noServer: true, path: "/notify/hub/v1" }); + wss.on('connection', (socket: WebSocket, req: IncomingMessage) => { + if (!req.token) { + socket.close(); + return; + } + + // ID is given as "/notify/hub/v1?&id=pprhdSzJn" by the client. + let handoff: SocketHandoff | undefined; + if (req.url) { + const pathParts = req.url.replace('v1', '').split('/'); + const query = new URLSearchParams(pathParts[pathParts.length - 1]); + const connectionId = query.get('id'); + if (connectionId) handoff = SocketHandoff.find(connectionId); + } + + if (handoff) handoff.complete(); + new SignalRSocketHandler(socket, UnifiedProfile.get(req.token.sub)); + }); + const http = app.listen(config.web.port, config.web.host, () => { log.n(`Listening on http://${config.web.host}:${config.web.port}`); @@ -110,60 +149,39 @@ try { log.i(`Shutting down`); http.close(); + http.closeAllConnections(); + }); + Deno.addSignalListener("SIGINT", () => { + for (const handoff of SocketHandoff.all()) handoff.complete(); }); }); - const wss = new WebSocketServer({ - server: http, - path: "/notify/hub/v1" - }); - wss.on('connection', (ws: WebSocket, rq: IncomingMessage, profile: Profile, connectionId: string) => { - const handoff = SocketHandoff.find(connectionId); - if (handoff) handoff.complete(); - log.d(typeof profile); - new SignalRSocketHandler(ws, profile); - }); - http.on('upgrade', async (rq: IncomingMessage, socket: internal.Duplex, head: Buffer) => { - const errorHandler = (err: Error | undefined) => { log.e(`Socket error: ${err?.stack}`); }; - socket.on('error', errorHandler); + // Currently not working in Deno. Socket problem? + /*http.on('upgrade', async (req, socket, head) => { + log.d('Handling upgrade'); + try { + const authResult = await authenticate(req); - function writeUnauthorized() { - socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n'); + if (authResult.valid) { + req.token = authResult.token; + + log.d('Auth result was valid.'); + wss.handleUpgrade(req, socket, head, (ws) => { + wss.emit('connection', ws, req); + }); + } else { + // Reject the upgrade + log.e(`Socket authentication error (401) from ${APIUtils.getSrcIpDefaultRaw(req)}`); + socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n'); + socket.destroy(); + } + } catch (err) { + // Handle authentication error + log.e(`Socket authentication error (500): ${err}\n from ${APIUtils.getSrcIpDefaultRaw(req)}`); + socket.write('HTTP/1.1 500 Internal Server Error\r\n\r\n'); socket.destroy(); } - - let wrong = false; - const unparsedToken = rq.headers['Authorization']; - const connectionId = new URL(`http://${rq.headers.host ? rq.headers.host : 'localhost'}${rq.url}`).searchParams.get('connectionId'); - if (connectionId == null) wrong = true; - else { - if (typeof unparsedToken == 'string') { - const splitToken = unparsedToken.split(' ')[1] - if (splitToken) { - try { - - const decodedToken = await decode(splitToken, config.auth.secret, {algorithm: 'HS512'}); - const schemaResult = ProfileTokenSchema.safeParse(decodedToken); - if (!schemaResult.success) wrong = true; - else { - wss.handleUpgrade(rq, socket, head, (ws) => { - wss.emit('connection', ws, rq, new Profile(decodedToken.sub), connectionId); - }); - } - - } catch { - wrong = true; - } - } else wrong = true; - } else wrong = true; - } - - if (wrong) { - writeUnauthorized(); - return; - } - socket.removeListener('error', errorHandler); - }); + });*/ } catch (err) { log.e(`Cannot start: Network could not be initalized. ${err}`); diff --git a/src/routes/account/account.ts b/src/routes/account/account.ts index bd040e5..5d249ea 100644 --- a/src/routes/account/account.ts +++ b/src/routes/account/account.ts @@ -1,6 +1,6 @@ import { APIUtils } from "../../apiutils.ts"; import express from "express"; -import Profile from "../../data/profiles.ts"; +import { Profile } from "../../data/profiles.ts"; import { z } from "zod"; export const route = APIUtils.createRouter("/account"); diff --git a/src/routes/auth/connect.ts b/src/routes/auth/connect.ts index 33daa09..613c388 100644 --- a/src/routes/auth/connect.ts +++ b/src/routes/auth/connect.ts @@ -1,6 +1,6 @@ import { APIUtils, NoBody } from "../../apiutils.ts"; import express from "express"; -import Profile from "../../data/profiles.ts"; +import UnifiedProfile, { Profile } from "../../data/profiles.ts"; import { decode } from "@gz/jwt"; import { Config } from "../../config.ts"; import Logging from "@proxnet/undead-logging"; @@ -143,7 +143,7 @@ route.router.post("/token", rs.locals.user.addAssociatedDeviceId(rq.body.device_id); rs.locals.user.addAssociatedPlatformId(rq.body.platform_id); - const profile = new Profile(targetAccount); + const profile = UnifiedProfile.get(targetAccount); if (!(await Profile.exists(profile.getId()))) { requestFailed(); return; diff --git a/src/socket/handoff.ts b/src/socket/handoff.ts index fafd9d0..9092a69 100644 --- a/src/socket/handoff.ts +++ b/src/socket/handoff.ts @@ -11,10 +11,14 @@ function randomId(length: number) { return result; } -// Lots of this is redundant. The WebSocket request already contains an access token for the profile, but I'd -// like to make sure that connectionIds are freed automatically. +/** + * Reserve `connectionId`s for each connected client, hand off to socket handler when complete or timed out + */ export class SocketHandoff { + static all() { + return Array.from(handoffs.values()); + } static generateId() { let id = randomId(48); while (handoffs.values().find(handoff => handoff.id == id)) id = randomId(48); @@ -32,19 +36,15 @@ export class SocketHandoff { this.id = SocketHandoff.generateId(); this.#timeout = setTimeout(() => { - handoffs.delete(this); + this.complete(); }); handoffs.add(this); } - delete() { + complete() { clearTimeout(this.#timeout); handoffs.delete(this); } - complete() { - this.delete(); - } - } \ No newline at end of file diff --git a/src/socket/socket.ts b/src/socket/socket.ts index 444edcf..b8efdfb 100644 --- a/src/socket/socket.ts +++ b/src/socket/socket.ts @@ -1,6 +1,5 @@ import WebSocket from "ws"; -import Profile from "../data/profiles.ts"; -import { IncomingMessage } from "node:http"; +import { Profile } from "../data/profiles.ts"; import Logging from "@proxnet/undead-logging"; export class SignalRSocketHandler { @@ -11,15 +10,13 @@ export class SignalRSocketHandler { #profile: Profile; constructor(socket: WebSocket, player: Profile) { - + this.#socket = socket; + this.#initLogSource(); + this.#profile = player; - throw new Error("This will fail due to undefined access attempt. Debug this. Also, please unify profiles."); player.setSocketHandler(this); - this.log.source += player.getId().toString(); - - // log: we connected!! Deno.addSignalListener('SIGINT', this.destroy); @@ -30,4 +27,17 @@ export class SignalRSocketHandler { Deno.removeSignalListener('SIGINT', this.destroy); } + async #initLogSource() { + this.log.source += this.#profile.getId().toString(); + + this.log.i(`Player '${(await this.#profile.export())?.username}' (${this.#profile.getId()}) created hub socket`); + + this.#socket.on('open', () => { + this.log.d(`hello world`) + }); + this.#socket.on('message', data => { + this.log.d(data.toString()); + }); + } + } \ No newline at end of file diff --git a/src/types/express.ts b/src/types/express.ts index 3d497d9..748c429 100644 --- a/src/types/express.ts +++ b/src/types/express.ts @@ -1,4 +1,4 @@ -import Profile from "../data/profiles.ts"; +import { Profile } from "../data/profiles.ts"; import { User } from "../data/users.ts"; declare global { diff --git a/src/types/http.ts b/src/types/http.ts new file mode 100644 index 0000000..45983ab --- /dev/null +++ b/src/types/http.ts @@ -0,0 +1,8 @@ +import { ProfileTokenFormat } from "../data/profiles.ts"; + +// Extend IncomingMessage interface to include custom properties +declare module 'node:http' { + interface IncomingMessage { + token?: ProfileTokenFormat; + } +} \ No newline at end of file