Files
hytale-f2p-mirror/local-dev/debug-wrapper.js
sanasol 66493d35ca Fix community link order: TG Group > TG Channel > Chat
Consistent order across all files.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 01:11:17 +01:00

801 lines
24 KiB
JavaScript

/**
* Debug Wrapper Entry Point
* Wraps the existing hytale-auth-server with debug capabilities
*/
const http = require('http');
const https = require('https');
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
// Only load debug functionality if DEBUG_MODE is enabled
const DEBUG_MODE = process.env.DEBUG_MODE === 'true';
const DATA_DIR = process.env.DATA_DIR || '/app/data';
const LOG_FILE = path.join(DATA_DIR, 'debug-requests.jsonl');
const SSL_DIR = path.join(DATA_DIR, 'ssl');
const SSL_KEY = path.join(SSL_DIR, 'server.key');
const SSL_CERT = path.join(SSL_DIR, 'server.crt');
const HTTPS_PORT = parseInt(process.env.HTTPS_PORT || '3443');
/**
* Generate self-signed SSL certificates if they don't exist
*/
function ensureSSLCertificates() {
if (!fs.existsSync(SSL_DIR)) {
fs.mkdirSync(SSL_DIR, { recursive: true });
}
if (!fs.existsSync(SSL_KEY) || !fs.existsSync(SSL_CERT)) {
console.log('Generating self-signed SSL certificates...');
try {
execSync(`openssl req -x509 -newkey rsa:2048 -keyout "${SSL_KEY}" -out "${SSL_CERT}" -days 365 -nodes -subj "/CN=localhost" 2>/dev/null`);
console.log('SSL certificates generated successfully');
} catch (err) {
console.error('Failed to generate SSL certificates:', err.message);
console.error('HTTPS will not be available. Install openssl or provide certificates manually.');
return null;
}
}
try {
return {
key: fs.readFileSync(SSL_KEY),
cert: fs.readFileSync(SSL_CERT)
};
} catch (err) {
console.error('Failed to read SSL certificates:', err.message);
return null;
}
}
// In-memory request storage for debug dashboard
const capturedRequests = [];
const MAX_CAPTURED = 500;
// Color codes for terminal output
const colors = {
reset: '\x1b[0m',
green: '\x1b[32m',
blue: '\x1b[34m',
yellow: '\x1b[33m',
magenta: '\x1b[35m',
cyan: '\x1b[36m',
red: '\x1b[31m'
};
function extractSubdomain(host) {
if (!host) return null;
const hostname = host.split(':')[0];
const match = hostname.match(/^(sessions|account-data|telemetry|tools|oauth\.accounts|accounts)\./);
return match ? match[1] : null;
}
function getSubdomainColor(subdomain) {
const subColors = {
'sessions': colors.green,
'account-data': colors.blue,
'telemetry': colors.yellow,
'tools': colors.magenta
};
return subColors[subdomain] || colors.cyan;
}
/**
* Serve the debug dashboard HTML
*/
function serveDebugDashboard(res) {
const html = `<!DOCTYPE html>
<html><head><title>Debug Dashboard - Local Dev</title>
<style>
body{font-family:monospace;background:#1a1a2e;color:#eee;padding:20px;margin:0}
h1{color:#00d4ff;margin-bottom:10px}
.info{color:#888;font-size:12px;margin-bottom:20px}
.section{background:#16213e;padding:15px;margin:10px 0;border-radius:8px}
.req{background:#0f3460;padding:10px;margin:5px 0;border-radius:4px;font-size:12px;border-left:3px solid #00d4ff}
.req.error{border-left-color:#f87171}
.GET{color:#4ade80}.POST{color:#60a5fa}.DELETE{color:#f87171}.PUT{color:#fbbf24}
.sub{color:#c084fc;font-weight:bold}
.status{padding:2px 6px;border-radius:3px;font-size:11px}
.status.ok{background:#166534;color:#4ade80}
.status.error{background:#7f1d1d;color:#f87171}
pre{background:#0a0a1a;padding:10px;overflow-x:auto;font-size:11px;border-radius:4px;max-height:300px;overflow-y:auto}
button{background:#00d4ff;color:#000;border:none;padding:8px 16px;cursor:pointer;margin:4px;border-radius:4px;font-weight:bold}
button:hover{background:#00b8e6}
button.danger{background:#dc2626}
button.danger:hover{background:#b91c1c}
button.paused{background:#fbbf24;color:#000}
select{padding:8px;background:#0f3460;border:1px solid #00d4ff;color:#fff;border-radius:4px}
details summary{cursor:pointer;color:#60a5fa;padding:5px 0}
.timestamp{color:#666;font-size:10px}
.duration{color:#fbbf24}
.path{color:#fff}
#count{margin-left:10px;color:#4ade80}
#pauseStatus{margin-left:10px;color:#fbbf24;font-weight:bold}
.controls{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
</style></head><body>
<h1>Debug Dashboard - Local Development</h1>
<div class="info">Capturing all requests to auth server for research purposes</div>
<div class="section">
<div class="controls">
<button onclick="refresh()">Refresh</button>
<button id="pauseBtn" onclick="togglePause()">Pause</button>
<button class="danger" onclick="clearReqs()">Clear All</button>
<select id="filter" onchange="refresh()">
<option value="">All Subdomains</option>
<option value="sessions">sessions</option>
<option value="account-data">account-data</option>
<option value="telemetry">telemetry</option>
<option value="tools">tools</option>
<option value="main">main (no subdomain)</option>
</select>
<span id="count"></span>
<span id="pauseStatus"></span>
</div>
</div>
<div class="section">
<h2>Subdomain Summary</h2>
<div id="subs"></div>
</div>
<div class="section">
<h2>Recent Requests</h2>
<div id="reqs" style="max-height:600px;overflow-y:auto"></div>
</div>
<script>
let isPaused = false;
let refreshInterval = null;
async function refresh() {
const f = document.getElementById('filter').value;
const r = await fetch('/debug/requests' + (f ? '?subdomain=' + f : ''));
const d = await r.json();
document.getElementById('count').textContent = d.total + ' total, ' + d.filtered + ' shown';
document.getElementById('reqs').innerHTML = d.requests.map((r, i) => {
const statusClass = r.response?.statusCode >= 400 ? 'error' : 'ok';
const reqClass = r.response?.statusCode >= 400 ? 'error' : '';
return \`<div class="req \${reqClass}">
<div>
<span class="timestamp">\${r.timestamp}</span>
<span class="\${r.method}">\${r.method}</span>
<span class="sub">\${r.subdomain || 'main'}</span>
<span class="path">\${r.path}</span>
<span class="status \${statusClass}">\${r.response?.statusCode || '?'}</span>
<span class="duration">\${r.response?.duration || 0}ms</span>
</div>
<details ontoggle="handleDetailsToggle(this)">
<summary>Request Details</summary>
<pre>\${JSON.stringify({
host: r.host,
query: r.query,
body: r.body,
headers: r.headers
}, null, 2)}</pre>
</details>
<details ontoggle="handleDetailsToggle(this)">
<summary>Response</summary>
<pre>\${JSON.stringify(r.response, null, 2)}</pre>
</details>
</div>\`;
}).join('');
const s = await fetch('/debug/subdomains');
const sd = await s.json();
document.getElementById('subs').innerHTML = Object.entries(sd)
.map(([k, v]) => '<span class="sub">' + k + '</span>: ' + v.count)
.join(' | ') || '<em>No requests yet</em>';
}
function handleDetailsToggle(el) {
// Auto-pause when any details block is opened
if (el.open && !isPaused) {
togglePause();
}
}
function togglePause() {
isPaused = !isPaused;
const btn = document.getElementById('pauseBtn');
const status = document.getElementById('pauseStatus');
if (isPaused) {
btn.textContent = 'Resume';
btn.classList.add('paused');
status.textContent = '(auto-refresh paused)';
if (refreshInterval) {
clearInterval(refreshInterval);
refreshInterval = null;
}
} else {
btn.textContent = 'Pause';
btn.classList.remove('paused');
status.textContent = '';
refreshInterval = setInterval(autoRefresh, 3000);
refresh();
}
}
function autoRefresh() {
if (!isPaused) {
refresh();
}
}
async function clearReqs() {
if (confirm('Clear all captured requests?')) {
await fetch('/debug/requests', { method: 'DELETE' });
refresh();
}
}
refreshInterval = setInterval(autoRefresh, 3000);
refresh();
</script>
</body></html>`;
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end(html);
}
/**
* Handle debug API endpoints
*/
function handleDebugApi(req, res, urlPath, url) {
if (urlPath === '/debug' || urlPath === '/debug/') {
serveDebugDashboard(res);
return true;
}
if (urlPath === '/debug/requests') {
if (req.method === 'DELETE') {
capturedRequests.length = 0;
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ cleared: true }));
return true;
}
let filtered = capturedRequests;
const subdomain = url.searchParams.get('subdomain');
if (subdomain) {
if (subdomain === 'main') {
filtered = filtered.filter(r => !r.subdomain);
} else {
filtered = filtered.filter(r => r.subdomain === subdomain);
}
}
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
total: capturedRequests.length,
filtered: filtered.length,
requests: filtered.slice(0, 100)
}));
return true;
}
if (urlPath === '/debug/subdomains') {
const summary = {};
for (const r of capturedRequests) {
const sub = r.subdomain || 'main';
if (!summary[sub]) summary[sub] = { count: 0 };
summary[sub].count++;
}
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(summary));
return true;
}
return false;
}
/**
* Capture request/response for debugging
*/
function captureRequest(req, res, url, startTime) {
const subdomain = extractSubdomain(req.headers.host);
const originalEnd = res.end.bind(res);
let responseBody = null;
// Intercept res.end to capture response
res.end = function(chunk, encoding, callback) {
if (chunk) {
try {
const str = chunk.toString();
if (str.length > 2000) {
responseBody = { _truncated: true, _length: str.length, _preview: str.substring(0, 500) };
} else {
try {
responseBody = JSON.parse(str);
} catch {
responseBody = str;
}
}
} catch (e) {
responseBody = { _error: 'Could not capture response' };
}
}
const duration = Date.now() - startTime;
// Log with colors
const subColor = getSubdomainColor(subdomain);
const statusColor = res.statusCode >= 400 ? colors.red : colors.green;
console.log(`${subColor}[${subdomain || 'main'}]${colors.reset} ${req.method} ${url.pathname} ${statusColor}${res.statusCode}${colors.reset} (${duration}ms)`);
// Store in memory
const entry = {
timestamp: new Date().toISOString(),
method: req.method,
path: url.pathname,
host: req.headers.host,
subdomain,
query: Object.fromEntries(url.searchParams),
headers: sanitizeHeaders(req.headers),
response: {
statusCode: res.statusCode,
duration,
body: responseBody
}
};
capturedRequests.unshift(entry);
if (capturedRequests.length > MAX_CAPTURED) {
capturedRequests.pop();
}
// Also write to file
try {
fs.appendFileSync(LOG_FILE, JSON.stringify(entry) + '\n');
} catch (e) {
// Ignore file write errors
}
return originalEnd(chunk, encoding, callback);
};
}
/**
* Sanitize headers for logging (remove sensitive data)
*/
function sanitizeHeaders(headers) {
const sanitized = { ...headers };
// Keep Authorization header but might want to truncate in future
return sanitized;
}
/**
* Main entry point - wraps the existing server
*/
async function main() {
if (DEBUG_MODE) {
console.log('\x1b[33m=== DEBUG MODE ENABLED ===\x1b[0m');
console.log(`Debug dashboard: http://localhost:${process.env.PORT || 3000}/debug`);
console.log(`Request log: ${LOG_FILE}`);
console.log('');
}
// Load the original app module
const app = require('./app');
// If not debug mode, just run normally
if (!DEBUG_MODE) {
app.run();
return;
}
// In debug mode, we need to wrap the request handler
// Re-implement the server startup with debug wrapper
const config = require('./config');
const { connect: connectRedis, isConnected } = require('./services/redis');
const assets = require('./services/assets');
console.log('=== Hytale Auth Server (Debug Mode) ===');
console.log(`Domain: ${config.domain}`);
console.log(`Data directory: ${config.dataDir}`);
// Pre-load cosmetics
assets.preloadCosmetics();
// Connect to Redis
await connectRedis();
// Create the original request handler reference
const originalHandler = require('./app').handleRequest || null;
// Since handleRequest is not exported, we need a different approach
// Load all the modules manually
const middleware = require('./middleware');
const routes = require('./routes');
const { sendJson } = require('./utils/response');
const auth = require('./services/auth');
const crypto = require('crypto');
// Recreate the request handler with debug wrapping
async function debugWrappedHandler(req, res) {
const startTime = Date.now();
const url = new URL(req.url, `http://${req.headers.host}`);
const urlPath = url.pathname;
// Skip debug endpoints from capture (but still handle them)
if (urlPath.startsWith('/debug')) {
if (handleDebugApi(req, res, urlPath, url)) {
return;
}
}
// Skip favicon and health from debug capture
const skipCapture = urlPath === '/favicon.ico' || urlPath === '/health';
// Capture the request/response if not skipped
if (!skipCapture) {
captureRequest(req, res, url, startTime);
}
// Now delegate to original handling logic
// CORS headers
middleware.corsHeaders(res);
if (req.method === 'OPTIONS') {
middleware.handleOptions(req, res);
return;
}
// Handle binary uploads
const headCacheMatch = urlPath.match(/^\/avatar\/([^/]+)\/head-cache$/);
if (headCacheMatch && req.method === 'POST') {
routes.avatar.handleAvatarRoutes(req, res, urlPath, {});
return;
}
// Parse JSON body
const body = await middleware.parseBody(req);
// Store body for debug capture
if (!skipCapture && capturedRequests.length > 0 && !capturedRequests[0].body) {
capturedRequests[0].body = body;
}
// Extract user context
const { uuid, name, tokenScope } = middleware.extractUserContext(body, req.headers);
// Route the request (copied from app.js to avoid modification)
await routeRequestDebug(req, res, url, urlPath, body, uuid, name, tokenScope, middleware, routes, sendJson, auth, crypto);
}
// Create HTTP server with debug wrapper
const server = http.createServer(debugWrappedHandler);
server.listen(config.port, '0.0.0.0', () => {
console.log(`HTTP Server running on port ${config.port}`);
console.log(`Redis: ${isConnected() ? 'connected' : 'NOT CONNECTED'}`);
console.log(`\n\x1b[36mDebug Dashboard: http://localhost:${config.port}/debug\x1b[0m`);
});
// Create HTTPS server if certificates are available
const sslOptions = ensureSSLCertificates();
if (sslOptions) {
const httpsServer = https.createServer(sslOptions, debugWrappedHandler);
httpsServer.listen(HTTPS_PORT, '0.0.0.0', () => {
console.log(`\x1b[32mHTTPS Server running on port ${HTTPS_PORT}\x1b[0m`);
console.log(`\x1b[33mNote: Self-signed cert - add to keychain or use NODE_TLS_REJECT_UNAUTHORIZED=0\x1b[0m\n`);
});
}
}
/**
* Route request - copied from app.js to avoid modifying original
*/
async function routeRequestDebug(req, res, url, urlPath, body, uuid, name, tokenScope, middleware, routes, sendJson, auth, crypto) {
const headers = req.headers;
// Avatar viewer routes
if (urlPath.startsWith('/avatar/')) {
await routes.avatar.handleAvatarRoutes(req, res, urlPath, body);
return;
}
// Customizer route
if (urlPath.startsWith('/customizer')) {
routes.avatar.handleCustomizerRoute(req, res, urlPath);
return;
}
// Cosmetics list API
if (urlPath === '/cosmetics/list') {
routes.assets.handleCosmeticsList(req, res);
return;
}
// Single cosmetic item data API
if (urlPath.startsWith('/cosmetics/item/')) {
routes.assets.handleCosmeticItem(req, res, urlPath);
return;
}
// Static assets route
if (urlPath.startsWith('/assets/')) {
routes.assets.handleStaticAssets(req, res, urlPath);
return;
}
// Asset extraction route
if (urlPath.startsWith('/asset/')) {
routes.assets.handleAssetRoute(req, res, urlPath);
return;
}
// Download route
if (urlPath.startsWith('/download/')) {
routes.assets.handleDownload(req, res, urlPath);
return;
}
// Health check
if (urlPath === '/health' || urlPath === '/') {
routes.health.handleHealth(req, res);
return;
}
// Favicon
if (urlPath === '/favicon.ico') {
res.writeHead(204);
res.end();
return;
}
// JWKS endpoint
if (urlPath === '/.well-known/jwks.json' || urlPath === '/jwks.json') {
routes.health.handleJwks(req, res);
return;
}
// Server auto-auth
if (urlPath === '/server/auto-auth') {
routes.server.handleServerAutoAuth(req, res, body);
return;
}
// Server game profiles
if (urlPath === '/server/game-profiles' || urlPath === '/game-profiles') {
routes.server.handleServerGameProfiles(req, res, headers);
return;
}
// OAuth device authorization
if (urlPath === '/oauth2/device/auth') {
routes.server.handleOAuthDeviceAuth(req, res, body);
return;
}
// OAuth device verification
if (urlPath === '/oauth2/device/verify') {
const query = Object.fromEntries(url.searchParams);
routes.server.handleOAuthDeviceVerify(req, res, query);
return;
}
// OAuth token endpoint
if (urlPath === '/oauth2/token') {
routes.server.handleOAuthToken(req, res, body);
return;
}
// Game session endpoints
if (urlPath === '/game-session/new') {
routes.session.handleGameSessionNew(req, res, body, uuid, name);
return;
}
if (urlPath === '/game-session/refresh') {
await routes.session.handleGameSessionRefresh(req, res, body, uuid, name, headers);
return;
}
if (urlPath === '/game-session/child' || urlPath.includes('/game-session/child')) {
routes.session.handleGameSessionChild(req, res, body, uuid, name);
return;
}
// Authorization grant
if (urlPath === '/game-session/authorize' || urlPath.includes('/authorize') || urlPath.includes('/auth-grant')) {
routes.session.handleAuthorizationGrant(req, res, body, uuid, name, headers);
return;
}
// Token exchange
if (urlPath === '/server-join/auth-token' || urlPath === '/game-session/exchange' || urlPath.includes('/auth-token')) {
routes.session.handleTokenExchange(req, res, body, uuid, name, headers);
return;
}
// Session/Auth endpoints
if ((urlPath.includes('/session') || urlPath.includes('/child')) && !urlPath.startsWith('/admin')) {
routes.session.handleSession(req, res, body, uuid, name);
return;
}
if (urlPath.includes('/auth')) {
routes.session.handleAuth(req, res, body, uuid, name);
return;
}
if (urlPath.includes('/token')) {
routes.session.handleToken(req, res, body, uuid, name);
return;
}
if (urlPath.includes('/validate') || urlPath.includes('/verify')) {
routes.session.handleValidate(req, res, body, uuid, name);
return;
}
if (urlPath.includes('/refresh')) {
routes.session.handleRefresh(req, res, body, uuid, name);
return;
}
// Account data endpoints
if (urlPath === '/my-account/game-profile' || urlPath.includes('/game-profile')) {
await routes.account.handleGameProfile(req, res, body, uuid, name);
return;
}
if (urlPath === '/my-account/skin') {
await routes.account.handleSkin(req, res, body, uuid, name, routes.avatar.invalidateHeadCache);
return;
}
// Account-data skin endpoint
if (urlPath.startsWith('/account-data/skin/')) {
const skinUuid = urlPath.replace('/account-data/skin/', '');
await routes.account.handleSkin(req, res, body, skinUuid, name, routes.avatar.invalidateHeadCache);
return;
}
if (urlPath === '/my-account/cosmetics' || urlPath.includes('/my-account/cosmetics')) {
routes.account.handleCosmetics(req, res, body, uuid, name);
return;
}
if (urlPath === '/my-account/get-launcher-data') {
routes.account.handleLauncherData(req, res, body, uuid, name);
return;
}
if (urlPath === '/my-account/get-profiles') {
routes.account.handleGetProfiles(req, res, body, uuid, name);
return;
}
// Bug reports and feedback
if (urlPath === '/bugs/create' || urlPath === '/feedback/create') {
res.writeHead(204);
res.end();
return;
}
// Game session delete
if (urlPath === '/game-session' && req.method === 'DELETE') {
await routes.session.handleGameSessionDelete(req, res, headers);
return;
}
// Admin login
if (urlPath === '/admin/login' && req.method === 'POST') {
await routes.admin.handleAdminLogin(req, res, body);
return;
}
// Admin verify
if (urlPath === '/admin/verify') {
const token = headers['x-admin-token'] || url.searchParams.get('token');
await routes.admin.handleAdminVerify(req, res, token);
return;
}
// Admin dashboard
if (urlPath === '/admin' || urlPath === '/admin/') {
routes.admin.handleAdminDashboard(req, res);
return;
}
// Test page for head embed
if (urlPath === '/test/head') {
routes.avatar.handleTestHeadPage(req, res);
return;
}
// Protected admin routes
if (urlPath.startsWith('/admin/')) {
const validToken = await middleware.verifyAdminAuth(headers);
if (!validToken) {
sendJson(res, 401, { error: 'Unauthorized. Please login at /admin' });
return;
}
}
// Admin API endpoints
if (urlPath === '/admin/sessions' || urlPath === '/sessions/active') {
await routes.admin.handleActiveSessions(req, res);
return;
}
if (urlPath === '/admin/stats') {
await routes.admin.handleAdminStats(req, res);
return;
}
if (urlPath.startsWith('/admin/servers')) {
await routes.admin.handleAdminServers(req, res, url);
return;
}
if (urlPath === '/admin/search') {
await routes.admin.handlePlayerSearch(req, res, url);
return;
}
if (urlPath === '/admin/prerender-queue') {
await routes.admin.handlePrerenderQueue(req, res);
return;
}
// Profile lookup by UUID
if (urlPath.startsWith('/profile/uuid/')) {
const lookupUuid = urlPath.replace('/profile/uuid/', '');
await routes.account.handleProfileLookupByUuid(req, res, lookupUuid, headers);
return;
}
// Profile lookup by username
if (urlPath.startsWith('/profile/username/')) {
const lookupUsername = decodeURIComponent(urlPath.replace('/profile/username/', ''));
await routes.account.handleProfileLookupByUsername(req, res, lookupUsername, headers);
return;
}
// Profile endpoint
if (urlPath.includes('/profile') || urlPath.includes('/user') || urlPath.includes('/me')) {
routes.account.handleProfile(req, res, body, uuid, name);
return;
}
// Cosmetics endpoint
if (urlPath.includes('/cosmetic') || urlPath.includes('/unlocked') || urlPath.includes('/inventory')) {
routes.account.handleCosmetics(req, res, body, uuid, name);
return;
}
// Telemetry endpoint
if (urlPath.includes('/telemetry') || urlPath.includes('/analytics') || urlPath.includes('/event')) {
sendJson(res, 200, { success: true, received: true });
return;
}
// Catch-all for unknown endpoints - important for research!
console.log(`\x1b[31m[UNKNOWN ENDPOINT]\x1b[0m ${req.method} ${urlPath}`);
const requestHost = req.headers.host;
const authGrant = auth.generateAuthorizationGrant(uuid, name, crypto.randomUUID(), null, requestHost);
const accessToken = auth.generateIdentityToken(uuid, name, null, ['game.base'], requestHost);
sendJson(res, 200, {
debug: true,
message: 'Unknown endpoint captured for research',
endpoint: urlPath,
method: req.method,
identityToken: accessToken,
sessionToken: auth.generateSessionToken(uuid, requestHost),
authorizationGrant: authGrant,
accessToken: accessToken,
tokenType: 'Bearer',
user: { uuid, name, premium: true }
});
}
main().catch(err => {
console.error('Failed to start debug server:', err);
process.exit(1);
});