diff --git a/app/authserver/.env.example b/app/authserver/.env.example new file mode 100644 index 000000000..1ac35b915 --- /dev/null +++ b/app/authserver/.env.example @@ -0,0 +1,2 @@ +AUTH_DB_CONNECTION_URL="sqlite://authserver.db" +LOG_LEVEL="debug" diff --git a/app/authserver/AuthServer.ts b/app/authserver/AuthServer.ts new file mode 100644 index 000000000..393d70698 --- /dev/null +++ b/app/authserver/AuthServer.ts @@ -0,0 +1,69 @@ +import { IncomingMessage, ServerResponse, createServer } from "http"; +import { getServerLogger } from "rusty-motors-shared"; +import { AuthServerConfig } from "./config.ts"; +import { handleAuthLogin } from "./handleAuthLogin.ts"; +import { handleShardList } from "./handleShardList.ts"; + +export class AuthServer { + + constructor(private config: AuthServerConfig, private log: ReturnType) { + this.log = log.child({ name: "auth-server" }); + this.config = config; + } + + handleRequest(req: IncomingMessage, res: ServerResponse) { + if (!req.url || !req.method) { + res.writeHead(400, { 'Content-Type': 'text/plain' }); + res.end('Bad Request\n'); + return; + } + + // Handle incoming requests here + this.log.info(`Received request: ${req.method} ${new URL(req.url, `http://${req.headers.host}`).pathname}`); + + if (req.url.startsWith("/AuthLogin")) { + // Handle AuthLogin request + handleAuthLogin.call(this, req, res); + } else if (req.url === "/ShardList/") { + // Handle ShardList request + // Implement shard list retrieval logic here + handleShardList.call(this, req, res); + } + else { + res.writeHead(404, { 'Content-Type': 'text/plain' }); + res.end('Not Found\n'); + return; + } + } + + public start() { + this.log.info("AuthServer started successfully."); + // Initialize server components here (e.g., HTTP server, routes, etc.) + const server = createServer((this.handleRequest).bind(this)); + + const port = parseInt("3000", 10); + server.listen(port, '0.0.0.0', () => { + this.log.info(`AuthServer listening on port ${port}`); + }); + + process.on('SIGINT', () => { + this.log.info('Received SIGINT. Shutting down gracefully...'); + server.close(() => { + this.stop(); + process.exit(0); + }); + }); + + process.on('SIGTERM', () => { + this.log.info('Received SIGTERM. Shutting down gracefully...'); + server.close(() => { + this.stop(); + process.exit(0); + }); + }); + } + public stop() { + this.log.info("AuthServer stopped successfully."); + // Clean up resources here + } +} diff --git a/app/authserver/config.ts b/app/authserver/config.ts new file mode 100644 index 000000000..a45957d0c --- /dev/null +++ b/app/authserver/config.ts @@ -0,0 +1,27 @@ +// detroit is a game server, written from scratch, for an old game +// Copyright (C) <2017> +// +// 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 . + +export interface AuthServerConfig { + dbConnectionUrl: string; + logLevel: string; +} + +export function getConfig() : AuthServerConfig { + return { + dbConnectionUrl: process.env.AUTH_DB_CONNECTION_URL || "sqlite://:memory:", + logLevel: process.env.LOG_LEVEL || "debug", + }; +} \ No newline at end of file diff --git a/app/authserver/databaseConstrants.ts b/app/authserver/databaseConstrants.ts new file mode 100644 index 000000000..7bc5ec465 --- /dev/null +++ b/app/authserver/databaseConstrants.ts @@ -0,0 +1,26 @@ +// Constants +export const DATABASE_PATH = process.env["DATABASE_PATH"] ?? "data/lotus.db"; +// SQL Queries +export const SQL = { + CREATE_USER_TABLE: ` + CREATE TABLE IF NOT EXISTS user( + username TEXT UNIQUE NOT NULL, + password TEXT NOT NULL, + customerId INTEGER PRIMARY KEY NOT NULL + ) STRICT`, + CREATE_SESSION_TABLE: ` + CREATE TABLE IF NOT EXISTS session( + id INTEGER PRIMARY KEY AUTOINCREMENT, + contextId TEXT UNIQUE NOT NULL, + customerId INTEGER NOT NULL, + profileId INTEGER DEFAULT 0 + + ) STRICT`, + INSERT_USER: + "INSERT INTO user (username, password, customerId) VALUES (?, ?, ?)", + FIND_USER: "SELECT * FROM user WHERE username = ? AND password = ?", + GET_ALL_USERS: "SELECT * FROM user", + UPDATE_SESSION: + "INSERT OR REPLACE INTO session (contextId, customerId, profileId) VALUES (?, ?, ?)", + FIND_SESSION_BY_CONTEXT: "SELECT * FROM session WHERE contextId = ?", +} as const; diff --git a/app/authserver/db.ts b/app/authserver/db.ts new file mode 100644 index 000000000..b9897f18a --- /dev/null +++ b/app/authserver/db.ts @@ -0,0 +1,284 @@ +// detroit is a game server, written from scratch, for an old game +// Copyright (C) <2017> +// +// 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 . + +import { compareSync, hashSync } from "bcrypt"; +import { DatabaseSync } from "node:sqlite"; +import { getServerLogger } from "rusty-motors-shared"; +import { getConfig } from "./config.ts"; + +const UserAccounts = [ + { + username: "new", + ticket: "5213dee3a6bcdb133373b2d4f3b9962758", + password: "new", + customerId: "123456", + }, + { + username: "admin", + ticket: "d316cd2dd6bf870893dfbaaf17f965884e", + password: "admin", + customerId: "654321", + }, +]; + +const AuthTickets = [ + { + ticket: "5213dee3a6bcdb133373b2d4f3b9962758", + customerId: "123456", + }, + { + ticket: "d316cd2dd6bf870893dfbaaf17f965884e", + customerId: "654321", + }, +]; + +// SQL Queries +export const SQL = { + CREATE_USER_TABLE: ` + CREATE TABLE IF NOT EXISTS user( + username TEXT UNIQUE NOT NULL, + password TEXT NOT NULL, + customerId INTEGER PRIMARY KEY NOT NULL + ) STRICT`, + CREATE_SESSION_TABLE: ` + CREATE TABLE IF NOT EXISTS session( + id INTEGER PRIMARY KEY AUTOINCREMENT, + contextId TEXT UNIQUE NOT NULL, + customerId INTEGER NOT NULL, + profileId INTEGER DEFAULT 0 + + ) STRICT`, + INSERT_USER: + "INSERT INTO user (username, password, customerId) VALUES (?, ?, ?)", + FIND_USER: "SELECT * FROM user WHERE username = ?", + GET_ALL_USERS: "SELECT * FROM user", + UPDATE_SESSION: + "INSERT OR REPLACE INTO session (contextId, customerId, profileId) VALUES (?, ?, ?)", + FIND_SESSION_BY_CONTEXT: "SELECT * FROM session WHERE contextId = ?", +} as const; + +export interface UserRecordMini { + contextId: string; + customerId: number; + profileId: number; + } + +// Database Service Interface +export interface AuthDatabaseService { + isDatabaseConnected: () => boolean; + registerUser: ( + username: string, + password: string, + customerId: number, + ) => void; + findUser: (username: string, password: string) => UserRecordMini; + updateSession: ( + customerId: number, + contextId: string, + userId: number, + ) => void; + findSessionByContext: (contextId: string) => UserRecordMini | undefined; + } + +// Database Implementation +export const DatabaseImpl = { + /** + * Generates a hashed password using bcrypt + * @param password - The plain text password to hash + * @param saltRounds - Number of salt rounds for bcrypt (default: 10) + * @returns The hashed password string + */ + generatePasswordHash(password: string, saltRounds = 10): string { + const hash = hashSync(password, saltRounds); + return hash; + }, + + /** + * Initializes the database schema by creating necessary tables and indexes + * @param database - The SQLite database instance + */ + initializeDatabase(database: DatabaseSync) { + database.exec(SQL.CREATE_USER_TABLE); + database.exec( + "CREATE INDEX IF NOT EXISTS idx_user_username ON user(username)", + ); + database.exec( + "CREATE INDEX IF NOT EXISTS idx_user_customerId ON user(customerId)", + ); + database.exec(SQL.CREATE_SESSION_TABLE); + database.exec( + "CREATE INDEX IF NOT EXISTS idx_session_customerId ON session(customerId)", + ); + }, + + /** + * Registers a new user in the database + * @param database - The SQLite database instance + * @param username - Unique username for the new user + * @param password - User's password (will be hashed) + * @param customerId - Associated customer ID + * @throws Error if registration fails for reasons other than duplicate username + */ + registerNewUser( + database: DatabaseSync, + username: string, + password: string, + customerId: number, + ) { + const logger = getServerLogger("database"); + const hashedPassword = this.generatePasswordHash(password); + try { + database + .prepare(SQL.INSERT_USER) + .run(username, hashedPassword, customerId); + } catch (error) { + if ( + error instanceof Error && + error.message.includes("UNIQUE constraint failed") + ) { + logger.warn(`User ${username} already exists`); + return; + } + throw error; + } + }, + + /** + * Finds a user by username and password + * @param database - The SQLite database instance + * @param username - Username to search for + * @param password - Password to verify + * @returns UserRecordMini object containing user details + * @throws Error if user is not found + */ + findUser( + database: DatabaseSync, + username: string, + password: string, + ): UserRecordMini | null { + const logger = getServerLogger("database"); + const query = database.prepare(SQL.FIND_USER); + const hashedPassword = this.generatePasswordHash(password); + const user = query.get(username) as UserRecordMini | null; + if (user == null) { + logger.warn(`User ${username} not found`); + return null + } + if (compareSync(password, (user as any).password) === false) { + logger.warn(`Invalid password for user ${username}`); + return null + } + return { + customerId: user.customerId, + profileId: user.profileId, + contextId: user.contextId, + }; + }, + + /** + * Retrieves all users from the database + * @param database - The SQLite database instance + * @returns Array of UserRecordMini objects + */ + getAllUsers(database: DatabaseSync): UserRecordMini[] { + const query = database.prepare(SQL.GET_ALL_USERS); + const users = query.all() as UserRecordMini[]; + return users; + }, + + /** + * Updates or creates a new session for a user + * @param database - The SQLite database instance + * @param customerId - Customer ID associated with the session + * @param contextId - Unique context ID for the session + * @param userId - ID of the user owning the session + */ + updateSession( + database: DatabaseSync, + customerId: number, + contextId: string, + profileId: number, + ) { + const insert = database.prepare(SQL.UPDATE_SESSION); + insert.run(contextId, customerId, profileId); + }, + + findSessionByContext( + database: DatabaseSync, + contextId: string, + ): UserRecordMini | undefined { + const query = database.prepare(SQL.FIND_SESSION_BY_CONTEXT); + const user = query.get(contextId) as UserRecordMini | undefined; + return user; + }, + + /** + * Creates a DatabaseService interface implementation + * @param db - The SQLite database instance + * @returns DatabaseService interface with implemented database operations + */ + createDatabaseService(db: DatabaseSync): AuthDatabaseService { + return { + isDatabaseConnected: () => db !== null, + registerUser: (...args) => this.registerNewUser(db, ...args), + findUser: (...args) => this.findUser(db, ...args), + updateSession: (...args) => this.updateSession(db, ...args), + findSessionByContext: (...args) => this.findSessionByContext(db, ...args), + }; + }, +} as const; + +// Database Instance Management +let databaseInstance: DatabaseSync | null = null; + +/** + * Initializes and returns a database service instance + * @returns DatabaseService interface with database operations + */ +function initializeDatabaseService(): AuthDatabaseService { + if (databaseInstance === null) { + databaseInstance = new DatabaseSync(getConfig().dbConnectionUrl); + DatabaseImpl.initializeDatabase(databaseInstance); + DatabaseImpl.registerNewUser(databaseInstance, "admin", "admin", 5551212); + DatabaseImpl.updateSession( + databaseInstance, + 1212555, + "5213dee3a6bcdb133373b2d4f3b9962758", + 1, + ); + DatabaseImpl.updateSession( + databaseInstance, + 5551212, + "d316cd2dd6bf870893dfbaaf17f965884e", + 2, + ); + getServerLogger("database").info("Database initialized"); + } + + return DatabaseImpl.createDatabaseService(databaseInstance); +} + + +export function findCustomerByContext( + contextId: string, +): UserRecordMini | undefined { + const database = initializeDatabaseService(); + const user = database.findSessionByContext(contextId); + return user; +} + +// Exported Database Service Instance +export const authDB: AuthDatabaseService = initializeDatabaseService(); diff --git a/app/authserver/handleAuthLogin.ts b/app/authserver/handleAuthLogin.ts new file mode 100644 index 000000000..2aaab56da --- /dev/null +++ b/app/authserver/handleAuthLogin.ts @@ -0,0 +1,119 @@ +// detroit is a game server, written from scratch, for an old game +// Copyright (C) <2017> +// +// 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 . + +import { IncomingMessage, ServerResponse } from "node:http"; +import { authDB } from "./db.ts"; +import { randomUUID } from "node:crypto"; + +class AuthLoginResponse { + valid: boolean = false; + ticket: string = ""; + reasonCode: string = ""; + reasonText: string = ""; + reasonUrl: string = ""; + + static createValid(ticket: string) { + return `Valid=TRUE\nTicket=${ticket}`; + } + + static createInvalid( + reasonCode: string, + reasonText: string, + reasonUrl: string, + ) { + return `reasoncode=${reasonCode}\nreasontext=${reasonText}\nreasonurl=${reasonUrl}`; + } + + formatResponse() { + if (this.valid) { + return `Valid=TRUE\nTicket=${this.ticket}`; + } else { + return `reasoncode=${this.reasonCode}\nreasontext=${this.reasonText}\nreasonurl=${this.reasonUrl}`; + } + } +} + + + + +/** +* Generates a ticket for the given customer ID. +* +* @param customerId - The ID of the customer for whom the ticket is being generated. +* @returns The ticket associated with the given customer ID, or an empty string if no ticket is found. +*/ +function generateTicket(customerId: string): string { + console.log(`Generating ticket for customerId: ${customerId}`); + const ticket = randomUUID(); + console.log(`Generated ticket: ${ticket}`); + return ticket; +} + + +/** + * Retrieves a user account based on the provided username and password. + * + * @param username - The username of the account to retrieve. + * @param password - The password of the account to retrieve. + * @returns An object containing the username, ticket, and customerId if the account is found, or null if not. + */ +function retrieveUserAccount( + username: string, + password: string, +): { username: string; ticket: string; customerId: string } | null { + const customer = authDB.findUser(username, password); + if (customer == null) { + console.log(`No user found for username: ${username}`); + return null; + } + console.log(`User found: ${username} with customerId: ${customer.customerId}`); + return { + username, + ticket: generateTicket(customer.customerId.toString()), + customerId: customer.customerId.toString(), + }; + +} + +export function handleAuthLogin(request: IncomingMessage, response: ServerResponse) { + this.log.info("Handling AuthLogin request"); + // Implement authentication logic here + const url = new URL( + `http://${process.env["HOST"] ?? "localhost"}${request.url}`, + ); + const username = url.searchParams.get("username") ?? ""; + const password = url.searchParams.get("password") ?? ""; + + + response.setHeader("Content-Type", "text/plain"); + let authResponse = "Invalid Request"; + const user = retrieveUserAccount(username, password); + + if (user !== null) { + const ticket = generateTicket(user.customerId); + if (ticket !== "") { + authResponse = AuthLoginResponse.createValid(ticket); + } + } else { + + authResponse = AuthLoginResponse.createInvalid( + "INV-100", + "Opps!", + "https://winehq.com", + ); + } + response.end(authResponse); +} diff --git a/app/authserver/handleShardList.ts b/app/authserver/handleShardList.ts new file mode 100644 index 000000000..40583258a --- /dev/null +++ b/app/authserver/handleShardList.ts @@ -0,0 +1,158 @@ +import { IncomingMessage, ServerResponse } from "node:http"; + +export class ShardEntry { + name: string; + description: string; + id: number; + loginServerIp: string; + loginServerPort: number; + lobbyServerIp: string; + lobbyServerPort: number; + mcotsServerIp: string; + statusId: number; + statusReason: string; + serverGroupName: string; + population: number; + maxPersonasPerUser: number; + diagnosticServerHost: string; + diagnosticServerPort: number; + /** + * + * @param {string} name + * @param {string} description + * @param {number} id + * @param {string} loginServerIp + * @param {number} loginServerPort + * @param {string} lobbyServerIp + * @param {number} lobbyServerPort + * @param {string} mcotsServerIp + * @param {number} statusId + * @param {string} statusReason + * @param {string} serverGroupName + * @param {number} population + * @param {number} maxPersonasPerUser + * @param {string} diagnosticServerHost + * @param {number} diagnosticServerPort + */ + constructor( + name: string, + description: string, + id: number, + loginServerIp: string, + loginServerPort: number, + lobbyServerIp: string, + lobbyServerPort: number, + mcotsServerIp: string, + statusId: number, + statusReason: string, + serverGroupName: string, + population: number, + maxPersonasPerUser: number, + diagnosticServerHost: string, + diagnosticServerPort: number, + ) { + this.name = name; + this.description = description; + this.id = id; + this.loginServerIp = loginServerIp; + this.loginServerPort = loginServerPort; + this.lobbyServerIp = lobbyServerIp; + this.lobbyServerPort = lobbyServerPort; + this.mcotsServerIp = mcotsServerIp; + this.statusId = statusId; + this.statusReason = statusReason; + this.serverGroupName = serverGroupName; + this.population = population; + this.maxPersonasPerUser = maxPersonasPerUser; + this.diagnosticServerHost = diagnosticServerHost; + this.diagnosticServerPort = diagnosticServerPort; + } + + /** + * Return the entry in a formatted string + * + * @return {string} + */ + formatForShardList(): string { + return `[${this.name}] + Description=${this.description} + ShardId=${this.id} + LoginServerIP=${this.loginServerIp} + LoginServerPort=${this.loginServerPort} + LobbyServerIP=${this.lobbyServerIp} + LobbyServerPort=${this.lobbyServerPort} + MCOTSServerIP=${this.mcotsServerIp} + StatusId=${this.statusId} + Status_Reason=${this.statusReason} + ServerGroup_Name=${this.serverGroupName} + Population=${this.population} + MaxPersonasPerUser=${this.maxPersonasPerUser} + DiagnosticServerHost=${this.diagnosticServerHost} + DiagnosticServerPort=${this.diagnosticServerPort}`; + } +} + +function generateShardList() { + const SHARD_HOST = "rusty-motors.com" + + const shardClockTower = new ShardEntry( + "The Clocktower", + "The Clocktower", + 44, + SHARD_HOST, + 8226, + SHARD_HOST, + 7003, + SHARD_HOST, + 0, + "", + "Group-1", + 88, + 2, + SHARD_HOST, + 80, + ); + + let _possibleShards: string[] = []; + _possibleShards.push(shardClockTower.formatForShardList()); + + const shardTwinPinesMall = new ShardEntry( + "Twin Pines Mall", + "Twin Pines Mall", + 88, + SHARD_HOST, + 8226, + SHARD_HOST, + 7003, + SHARD_HOST, + 0, + "", + "Group-1", + 88, + 2, + SHARD_HOST, + 80, + ); + + _possibleShards.push(shardTwinPinesMall.formatForShardList()); + + /** @type {string[]} */ + const activeShardList: string[] = []; + + if (_possibleShards.length === 0) { + throw new Error("No shards found"); + } + + activeShardList.push(_possibleShards[0]!); + + return activeShardList.join("\n"); +} + +export function handleShardList(request: IncomingMessage, response: ServerResponse) { + this.log.info("Handling ShardList request"); + // Implement shard list retrieval logic here + const shardList = generateShardList(); + response.setHeader("Content-Type", "text/plain"); + response.writeHead(200); + response.end(shardList); +} diff --git a/app/authserver/instrument.cjs b/app/authserver/instrument.cjs new file mode 100644 index 000000000..14f1be2e3 --- /dev/null +++ b/app/authserver/instrument.cjs @@ -0,0 +1,9 @@ +// Import with `import * as Sentry from "@sentry/node"` if you are using ESM +const Sentry = require('@sentry/node'); + +Sentry.init({ + dsn: 'https://5485e7dd22ee3f9761d7560ca1c7ffbb@o1413557.ingest.us.sentry.io/4509952146472960', + // Setting this option to true will send default PII data to Sentry. + // For example, automatic IP address collection on events + sendDefaultPii: true, +}); diff --git a/app/authserver/package.json b/app/authserver/package.json new file mode 100644 index 000000000..aabf58eaf --- /dev/null +++ b/app/authserver/package.json @@ -0,0 +1,12 @@ +{ + "name": "authserver", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [], + "author": "", +"type": "module" +} diff --git a/app/authserver/server.ts b/app/authserver/server.ts new file mode 100644 index 000000000..e225db5ba --- /dev/null +++ b/app/authserver/server.ts @@ -0,0 +1,47 @@ +// detroit is a game server, written from scratch, for an old game +// Copyright (C) <2017> +// +// 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 . + +import "./instrument.cjs"; + +import * as Sentry from "@sentry/node"; + +import { + getServerLogger, +} from "rusty-motors-shared"; +import { authDB } from "./db.ts"; +import { getConfig } from "./config.ts"; +import { AuthServer } from "./AuthServer.ts"; + +const APP_NAME = "auth-server"; +const coreLogger = getServerLogger(APP_NAME); + +async function main(config = getConfig(), logger = coreLogger) { + coreLogger.info("Starting Auth Server..."); + const authServer = new AuthServer(config, logger); + + console.log("Starting server..."); + authServer.start(); +} + +main().catch((err) => { + const coreLogger = getServerLogger("core"); + coreLogger.fatal(`Unhandled exception in core server: ${String(err)}`); + Sentry.captureException(err); + Sentry.flush(2000).finally(() => { + process.exitCode = 1; + }); +}); +