#!/usr/bin/env node
/**
* DOSBox Door Bridge - Multiplexing Server v4
*
* Architecture:
* - PHP creates session record in database (with ws_token, session_path, etc.)
* - Browser connects to WebSocket with auth token
* - Bridge authenticates, launches emulator (DOSBox or DOSEMU), generates config
* - Emulator connects to bridge (TCP for DOSBox, PTY for DOSEMU)
* - Bridge multiplexes data between WebSocket and emulator
*
* Supports multiple emulators via adapter pattern for optimal memory usage.
*/
const net = require('net');
const WebSocket = require('ws');
const iconv = require('iconv-lite');
const url = require('url');
const fs = require('fs');
const path = require('path');
const { spawn } = require('child_process');
const { Client } = require('pg');
const { createEmulatorAdapter } = require('./emulator-adapters');
require('dotenv').config({ path: __dirname + '/../../.env' });
// Prepend ISO timestamp to every console.log / .error / .warn line
['log', 'error', 'warn'].forEach(method => {
const original = console[method].bind(console);
console[method] = (...args) => {
const d = new Date();
const ts = `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')} ${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}:${String(d.getSeconds()).padStart(2,'0')}`;
original(`[${ts}]`, ...args);
};
});
/**
* Create a per-session logger that prefixes every line with
* [sessionId|username|clientIp] so log lines can be correlated
* across a full session lifetime.
*
* @param {string} sessionId - Session ID from the database
* @param {string} username - Player alias / real name
* @param {string} clientIp - Originating IP address
* @returns {{ log, warn, error }}
*/
function makeSessionLogger(sessionId, username, clientIp) {
const shortId = sessionId ? sessionId.replace(/_\d+$/, '') : '?';
const label = `[${shortId}|${username || 'guest'}|${clientIp || '?'}]`;
return {
log: (...args) => console.log(label, ...args),
warn: (...args) => console.warn(label, ...args),
error: (...args) => console.error(label, ...args),
};
}
// Daemon support
const IS_DAEMON = process.argv.includes('--daemon');
const IS_DAEMON_CHILD = process.env.DOSBOX_BRIDGE_DAEMON_CHILD === '1';
const PID_FILE = path.resolve(__dirname, '../../data/run/multiplexing-server.pid');
const LOG_FILE = path.resolve(__dirname, '../../data/logs/multiplexing-server.log');
if (IS_DAEMON && !IS_DAEMON_CHILD) {
// Parent: spawn a detached child then exit immediately so the shell returns.
// The child inherits the log file as stdout/stderr (proper Unix daemon pattern).
// Ensure directories exist
fs.mkdirSync(path.dirname(PID_FILE), { recursive: true });
fs.mkdirSync(path.dirname(LOG_FILE), { recursive: true });
// Check if already running
if (fs.existsSync(PID_FILE)) {
const oldPid = parseInt(fs.readFileSync(PID_FILE, 'utf8'));
try {
process.kill(oldPid, 0);
console.error(`Error: Multiplexing server already running with PID ${oldPid}`);
console.error(`PID file: ${PID_FILE}`);
console.error('To force start, remove the PID file first.');
process.exit(1);
} catch (err) {
// Stale PID file - process is gone
console.log(`Removing stale PID file (process ${oldPid} not running)`);
fs.unlinkSync(PID_FILE);
}
}
// Open log file so the child can inherit it as stdout/stderr
const logFd = fs.openSync(LOG_FILE, 'a');
const child = spawn(process.execPath, process.argv.slice(1), {
detached: true,
stdio: ['ignore', logFd, logFd],
env: { ...process.env, DOSBOX_BRIDGE_DAEMON_CHILD: '1' }
});
fs.closeSync(logFd);
child.unref(); // Allow parent to exit without waiting for child
console.log(`Starting in daemon mode (PID: ${child.pid})`);
console.log(`PID file: ${PID_FILE}`);
console.log(`Log file: ${LOG_FILE}`);
process.exit(0);
} else if (IS_DAEMON && IS_DAEMON_CHILD) {
// Child (daemon): write PID file, set up signal handlers, then continue running.
// stdout/stderr are already pointing at the log file via inherited file descriptors.
fs.writeFileSync(PID_FILE, process.pid.toString());
const cleanupPidFile = () => {
try {
if (fs.existsSync(PID_FILE)) {
const currentPid = parseInt(fs.readFileSync(PID_FILE, 'utf8'));
if (currentPid === process.pid) {
fs.unlinkSync(PID_FILE);
}
}
} catch (err) { /* best effort */ }
};
process.on('exit', cleanupPidFile);
process.on('SIGINT', () => {
console.log('\nReceived SIGINT, shutting down...');
process.exit(0);
});
process.on('SIGTERM', () => {
console.log('\nReceived SIGTERM, shutting down...');
process.exit(0);
});
process.on('SIGHUP', () => {
reloadEnv();
});
} else {
// Interactive mode
process.on('SIGINT', () => {
console.log('\nReceived SIGINT, shutting down...');
process.exit(0);
});
process.on('SIGTERM', () => {
console.log('\nReceived SIGTERM, shutting down...');
process.exit(0);
});
process.on('SIGHUP', () => {
reloadEnv();
});
}
// Configuration from environment
const WS_PORT = parseInt(process.env.DOSDOOR_WS_PORT) || 6001;
const WS_BIND_HOST = process.env.DOSDOOR_WS_BIND_HOST || '127.0.0.1';
// These three are safe to reload via SIGHUP ? they take effect per-session/per-event.
let DISCONNECT_TIMEOUT = parseInt(process.env.DOSDOOR_DISCONNECT_TIMEOUT) || 0;
let DEBUG_KEEP_FILES = process.env.DOSDOOR_DEBUG_KEEP_FILES === 'true'; // Set to 'true' to disable cleanup
let CARRIER_LOSS_TIMEOUT = parseInt(process.env.DOSDOOR_CARRIER_LOSS_TIMEOUT) || 5000; // ms to wait after carrier loss
// Comma-separated list of proxy IPs whose X-Forwarded-For header is trusted.
// Only connections originating from one of these addresses will have their
// remote IP replaced by the forwarded value. Default: 127.0.0.1 only.
const TRUSTED_PROXIES = new Set(
(process.env.DOSDOOR_TRUSTED_PROXIES || '127.0.0.1')
.split(',')
.map(s => s.trim())
.filter(Boolean)
);
/**
* Resolve the real client IP for a WebSocket request.
* If the socket's remote address is a trusted proxy and an X-Forwarded-For
* header is present, the leftmost (originating) address is returned.
* Otherwise the raw socket address is used.
*
* @param {import('http').IncomingMessage} req
* @returns {string}
*/
function getClientIp(req) {
const socketIp = req.socket.remoteAddress;
if (TRUSTED_PROXIES.has(socketIp)) {
const xff = req.headers['x-forwarded-for'];
if (xff) {
const forwarded = xff.split(',')[0].trim();
if (forwarded) return forwarded;
}
}
return socketIp;
}
/**
* Re-read .env and update runtime-reloadable configuration values.
* Called on SIGHUP. Values that require a restart (WS_PORT, WS_BIND_HOST,
* DOSDOOR_TRUSTED_PROXIES, DB_* credentials) are logged but not applied.
*/
function reloadEnv() {
console.log('[Config] Reloading .env...');
require('dotenv').config({ path: __dirname + '/../../.env', override: true });
const newDisconnectTimeout = parseInt(process.env.DOSDOOR_DISCONNECT_TIMEOUT) || 0;
const newDebugKeepFiles = process.env.DOSDOOR_DEBUG_KEEP_FILES === 'true';
const newCarrierLossTimeout = parseInt(process.env.DOSDOOR_CARRIER_LOSS_TIMEOUT) || 5000;
if (newDisconnectTimeout !== DISCONNECT_TIMEOUT) {
console.log(`[Config] DOSDOOR_DISCONNECT_TIMEOUT: ${DISCONNECT_TIMEOUT} -> ${newDisconnectTimeout}`);
DISCONNECT_TIMEOUT = newDisconnectTimeout;
}
if (newDebugKeepFiles !== DEBUG_KEEP_FILES) {
console.log(`[Config] DOSDOOR_DEBUG_KEEP_FILES: ${DEBUG_KEEP_FILES} -> ${newDebugKeepFiles}`);
DEBUG_KEEP_FILES = newDebugKeepFiles;
}
if (newCarrierLossTimeout !== CARRIER_LOSS_TIMEOUT) {
console.log(`[Config] DOSDOOR_CARRIER_LOSS_TIMEOUT: ${CARRIER_LOSS_TIMEOUT} -> ${newCarrierLossTimeout}`);
CARRIER_LOSS_TIMEOUT = newCarrierLossTimeout;
}
// DOSDOOR_HEADLESS and DOSBOX_EXECUTABLE are already read inline per session ? no action needed.
// WS_PORT, WS_BIND_HOST, DOSDOOR_TRUSTED_PROXIES, and DB_* require a full restart to take effect.
console.log('[Config] Reload complete. Note: WS_PORT, WS_BIND_HOST, DOSDOOR_TRUSTED_PROXIES, and DB_* require a restart.');
}
const TCP_PORT_BASE = 5000;
const TCP_PORT_MAX = 5100;
const BASE_PATH = path.resolve(__dirname, '../..');
// Database configuration
const DB_CONFIG = {
host: process.env.DB_HOST || 'localhost',
port: parseInt(process.env.DB_PORT) || 5432,
database: process.env.DB_NAME || 'binktest',
user: process.env.DB_USER || 'binktest',
password: process.env.DB_PASS || 'binktest',
ssl: process.env.DB_SSL === 'true' ? { rejectUnauthorized: false } : false
};
console.log('=== DOSBox Door Bridge - Multiplexing Server v3 ===');
console.log(`WebSocket Port: ${WS_PORT}`);
console.log(`Bind Address: ${WS_BIND_HOST}`);
console.log(`Trusted Proxies: ${[...TRUSTED_PROXIES].join(', ')}`);
console.log(`TCP Port Range: ${TCP_PORT_BASE}-${TCP_PORT_MAX}`);
console.log(`Disconnect Timeout: ${DISCONNECT_TIMEOUT} minutes`);
console.log(`Carrier Loss Timeout: ${CARRIER_LOSS_TIMEOUT}ms`);
console.log(`Debug Keep Files: ${DEBUG_KEEP_FILES ? 'YES (cleanup disabled)' : 'NO (cleanup enabled)'}`);
console.log(`Base Path: ${BASE_PATH}`);
console.log(`Database: ${DB_CONFIG.user}@${DB_CONFIG.host}:${DB_CONFIG.port}/${DB_CONFIG.database}`);
console.log('');
/**
* Port Pool Manager
* Tracks available ports and allocates them dynamically
*/
class PortPool {
constructor(basePort, maxPort) {
this.basePort = basePort;
this.maxPort = maxPort;
this.usedPorts = new Set();
}
allocate() {
for (let port = this.basePort; port <= this.maxPort; port++) {
if (!this.usedPorts.has(port)) {
this.usedPorts.add(port);
console.log(`[PORT] Allocated port ${port}`);
return port;
}
}
throw new Error('No available ports in pool');
}
release(port) {
if (this.usedPorts.delete(port)) {
console.log(`[PORT] Released port ${port}`);
}
}
isAvailable(port) {
return !this.usedPorts.has(port);
}
}
/**
* Session Manager
* Handles door sessions and multiplexing
*/
class SessionManager {
constructor(portPool) {
this.portPool = portPool;
this.sessionsByToken = new Map(); // ws_token -> session
this.sessionsByPort = new Map(); // tcp_port -> session
this.pendingDosBoxConnections = new Map(); // tcp_port -> session (waiting for DOSBox)
}
async findSessionByToken(token) {
const client = new Client(DB_CONFIG);
try {
await client.connect();
const result = await client.query(
`SELECT session_id, user_id, door_id, node_number, ws_port, ws_token, session_path, user_data, door_type
FROM door_sessions
WHERE ws_token = $1 AND ended_at IS NULL
LIMIT 1`,
[token]
);
if (result.rows.length === 0) {
console.log('[AUTH] Invalid or expired token');
return null;
}
const session = result.rows[0];
console.log(`[AUTH] Token valid - Session: ${session.session_id}, Door: ${session.door_id}, Node: ${session.node_number}`);
return session;
} catch (err) {
console.error('[AUTH] Database error:', err.message);
return null;
} finally {
await client.end();
}
}
async updateSessionPorts(sessionId, tcpPort, dosboxPid, slog = null) {
const log = slog ? slog.log.bind(slog) : (...a) => console.log(...a);
const client = new Client(DB_CONFIG);
try {
await client.connect();
await client.query(
`UPDATE door_sessions
SET tcp_port = $1, dosbox_pid = $2
WHERE session_id = $3`,
[tcpPort, dosboxPid, sessionId]
);
log(`[DB] Updated session ${sessionId} with tcp_port=${tcpPort}, dosbox_pid=${dosboxPid}`);
} catch (err) {
console.error('[DB] Update error:', err.message);
throw err;
} finally {
await client.end();
}
}
async updateSessionPath(sessionId, sessionPath, slog = null) {
const log = slog ? slog.log.bind(slog) : (...a) => console.log(...a);
const client = new Client(DB_CONFIG);
try {
await client.connect();
await client.query(
`UPDATE door_sessions
SET session_path = $1
WHERE session_id = $2`,
[sessionPath, sessionId]
);
log(`[DB] Updated session ${sessionId} with session_path=${sessionPath}`);
} catch (err) {
console.error('[DB] Update error:', err.message);
throw err;
} finally {
await client.end();
}
}
async deleteSession(sessionId, slog = null) {
const log = slog ? slog.log.bind(slog) : (...a) => console.log(...a);
const client = new Client(DB_CONFIG);
try {
await client.connect();
await client.query(
`DELETE FROM door_sessions
WHERE session_id = $1`,
[sessionId]
);
log(`[DB] Deleted session record ${sessionId}`);
} catch (err) {
console.error('[DB] Delete error:', err.message);
// Don't throw - cleanup should continue even if DB delete fails
} finally {
await client.end();
}
}
async handleWebSocketConnection(ws, sessionData, clientIp) {
const sessionId = sessionData.session_id;
// Build a per-session logger for log correlation
let rawUserData = sessionData.user_data;
if (typeof rawUserData === 'string') { try { rawUserData = JSON.parse(rawUserData); } catch (_) {} }
const username = (rawUserData && (rawUserData.alias || rawUserData.real_name)) || 'guest';
const slog = makeSessionLogger(sessionId, username, clientIp);
slog.log(`[WS] Connection`);
// Check if session already exists (reconnection)
let session = this.sessionsByToken.get(sessionData.ws_token);
if (session) {
slog.log(`[WS] Reconnection to existing session`);
// Close old WebSocket
if (session.ws && session.ws.readyState === WebSocket.OPEN) {
session.ws.close(1000, 'Client reconnected');
}
session.ws = ws;
// Refresh logger with latest IP in case of reconnect from different address
session.slog = slog;
this.setupWebSocketHandlers(session);
return;
}
// Create new session
slog.log(`[WS] Creating new session`);
session = {
sessionId,
sessionData,
slog, // Per-session logger (use instead of console.*)
clientIp,
ws,
emulator: null, // Emulator adapter instance
emulatorSocket: null, // Connection to emulator (TCP socket or PTY)
tcpPort: null, // TCP port (DOSBox only)
tcpServer: null, // TCP server (DOSBox only)
emulatorProcess: null, // Emulator process
emulatorPid: null, // Emulator PID
bytesFromEmulator: 0,
bytesToEmulator: 0,
startTime: Date.now(),
disconnectTimer: null,
isRemoving: false
};
this.sessionsByToken.set(sessionData.ws_token, session);
// Set up WebSocket handlers
this.setupWebSocketHandlers(session);
// Launch emulator (DOSBox or DOSEMU)
try {
await this.launchEmulator(session);
} catch (err) {
session.slog.error(`[SESSION] Failed to launch emulator:`, err.message);
ws.close(1011, 'Failed to start door session');
this.removeSession(session);
}
}
/**
* Launch emulator (DOSBox or DOSEMU) using adapter pattern
*/
async launchEmulator(session) {
const { sessionId, sessionData } = session;
// Create emulator adapter based on door_type
const doorType = sessionData.door_type || 'dos';
session.emulator = createEmulatorAdapter(BASE_PATH, doorType);
session.emulator.slog = session.slog; // propagate session logger into adapter
const emulatorName = session.emulator.getName();
session.slog.log(`[SESSION] Using ${emulatorName} (door_type=${doorType})`);
// Set up exit handler for emulator process
session.emulator.onExit((code, signal) => {
session.slog.log(`[${emulatorName}] Process exited: code=${code}, signal=${signal}`);
this.handleDosBoxExit(session);
});
// Create session directory
const sessionPath = path.join(BASE_PATH, 'data', 'run', 'door_sessions', sessionId);
if (!fs.existsSync(sessionPath)) {
fs.mkdirSync(sessionPath, { recursive: true });
session.slog.log(`[SESSION] Created session directory: ${sessionPath}`);
}
// Update session with path
session.sessionData.session_path = sessionPath;
// Generate DOOR.SYS drop file
if (sessionData.user_data) {
let userData = sessionData.user_data;
if (typeof userData === 'string') {
userData = JSON.parse(userData);
}
let dropPath;
if (emulatorName === 'Native') {
// Native doors: drop file goes in native-doors/drops/NODE{n}/
dropPath = path.join(BASE_PATH, 'native-doors', 'drops', `NODE${sessionData.node_number}`);
session.slog.log(`[DROPFILE] Native door drop path: ${dropPath}`);
} else {
// DOS doors: check manifest for custom dropfile_path
// DOORS directory is uppercase - Linux filesystem is case-sensitive
const manifestPath = path.join(BASE_PATH, 'dosbox-bridge', 'dos', 'DOORS', sessionData.door_id.toUpperCase(), 'dosdoor.jsn');
if (fs.existsSync(manifestPath)) {
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
if (manifest.door && manifest.door.dropfile_path) {
// Sanitize custom dropfile_path to prevent directory traversal
// Replace backslashes, strip leading slashes, normalize
const rawPath = manifest.door.dropfile_path.replace(/\\/g, '/').replace(/^\/+/, '');
const normalized = path.normalize(rawPath);
const safeBase = path.resolve(BASE_PATH, 'dosbox-bridge', 'dos');
const segments = normalized.split(path.sep);
const resolved = path.resolve(safeBase, normalized);
if (!path.isAbsolute(normalized) &&
segments.every(seg => seg !== '..') &&
(resolved === safeBase || resolved.startsWith(safeBase + path.sep))) {
dropPath = resolved;
session.slog.log(`[DROPFILE] Using custom dropfile_path from manifest: ${dropPath}`);
} else {
session.slog.warn(`[DROPFILE] Custom dropfile_path '${rawPath}' rejected (traversal or absolute), using default`);
dropPath = path.join(BASE_PATH, 'dosbox-bridge', 'dos', 'DROPS', `NODE${sessionData.node_number}`);
}
} else {
dropPath = path.join(BASE_PATH, 'dosbox-bridge', 'dos', 'DROPS', `NODE${sessionData.node_number}`);
session.slog.log(`[DROPFILE] Using default node-based dropfile path: ${dropPath}`);
}
} else {
dropPath = path.join(BASE_PATH, 'dosbox-bridge', 'dos', 'DROPS', `NODE${sessionData.node_number}`);
session.slog.log(`[DROPFILE] Manifest not found, using default path: ${dropPath}`);
}
}
if (!fs.existsSync(dropPath)) {
fs.mkdirSync(dropPath, { recursive: true });
session.slog.log(`[DROPFILE] Created drop directory: ${dropPath}`);
}
// Store so cleanupSessionFiles can remove the right drop file
session.dropPath = dropPath;
// Determine dropfile format (native doors can specify DOOR32.SYS)
let dropfileFormat = 'DOOR.SYS';
if (emulatorName === 'Native') {
const nativeManifestPath = path.join(BASE_PATH, 'native-doors', 'doors', sessionData.door_id, 'nativedoor.json');
if (fs.existsSync(nativeManifestPath)) {
const nativeManifest = JSON.parse(fs.readFileSync(nativeManifestPath, 'utf8'));
dropfileFormat = (nativeManifest.door && nativeManifest.door.dropfile_format) || 'DOOR.SYS';
}
}
session.slog.log(`[DROPFILE] Writing ${dropfileFormat} to: ${dropPath}`);
session.slog.log(`[DROPFILE] User data:`, JSON.stringify(userData).substring(0, 200));
if (dropfileFormat === 'DOOR32.SYS') {
this.generateDoor32Sys(dropPath, userData, sessionData.node_number);
session.slog.log(`[DROPFILE] Generated DOOR32.SYS in ${dropPath}`);
} else {
this.generateDoorSys(dropPath, userData, sessionData.node_number);
session.slog.log(`[DROPFILE] Generated DOOR.SYS in ${dropPath}`);
}
} else {
session.slog.warn(`[DROPFILE] No user_data found`);
}
// Update database with session_path
await this.updateSessionPath(sessionId, sessionPath, session.slog);
// For DOS emulators (DOSBox/DOSEMU): allocate TCP port and create listener
// Native doors use PTY directly - no TCP port needed
if (emulatorName === 'DOSBox' || emulatorName === 'DOSEMU') {
const tcpPort = this.portPool.allocate();
session.tcpPort = tcpPort;
this.sessionsByPort.set(tcpPort, session);
session.slog.log(`[${emulatorName}] Allocated port ${tcpPort}`);
// Create TCP listener
await session.emulator.createTCPListener(tcpPort, (socket) => {
this.handleEmulatorConnection(socket, session);
});
// Mark as pending connection
this.pendingDosBoxConnections.set(tcpPort, session);
}
// Launch emulator
const result = await session.emulator.launch(session, sessionData);
session.emulatorProcess = result.process;
session.emulatorPid = result.pid;
session.slog.log(`[${emulatorName}] Launched PID ${result.pid}`);
if (emulatorName === 'Native') {
// Native doors connect immediately via PTY - set up handlers now
this.setupEmulatorHandlers(session);
}
// For DOSBox/DOSEMU: setupEmulatorHandlers will be called in handleEmulatorConnection
// Update database
await this.updateSessionPorts(sessionId, session.tcpPort || 0, result.pid, session.slog);
}
/**
* Generate DOOR.SYS drop file from user_data
* Format: https://en.wikipedia.org/wiki/Doorway_(BBS_door)
*/
generateDoorSys(dropPath, userData, nodeNumber) {
// Always write as DOOR.SYS (node separation is done via directory structure)
const doorSysPath = path.join(dropPath, 'DOOR.SYS');
// Build DOOR.SYS content (standard 52-line format)
const lines = [
userData.com_port || 'COM1:', // 1: Comm port (COM0: = local, COM1-8: = serial)
userData.baud_rate || '115200', // 2: Baud rate
'8', // 3: Parity (8 = no parity)
userData.node || '1', // 4: Node number
'115200', // 5: DTE rate (locked port rate)
'Y', // 6: Screen display (Y/N)
'Y', // 7: Printer toggle (Y/N)
'Y', // 8: Page bell (Y/N)
'Y', // 9: Caller alarm (Y/N)
userData.real_name || 'Guest', // 10: User's name
userData.location || 'Unknown', // 11: User's location
userData.phone || '000-000-0000', // 12: User's phone
userData.phone || '000-000-0000', // 13: User's phone (again)
userData.password || 'PASSWORD', // 14: Password (usually blanked)
userData.security_level || '10', // 15: Security level
userData.times_on || '1', // 16: Times on system
userData.last_date || '01/01/2025', // 17: Last date on (MM/DD/YYYY)
userData.seconds_remaining || '7200', // 18: Seconds remaining (this session)
userData.minutes_remaining || '120', // 19: Minutes remaining (this session)
'GR', // 20: Graphics mode (GR/NG/7E)
userData.page_length || '23', // 21: Page length
'N', // 22: User mode (Y = expert, N = novice)
'1,2,3,4,5,6,7', // 23: Conferences registered
'1', // 24: Conference in
userData.upload_kb || '0', // 25: Total KB uploaded
userData.download_kb || '0', // 26: Total KB downloaded
userData.daily_dl_limit || '0', // 27: Daily download limit (KB)
userData.daily_dl_total || '0', // 28: Daily KB downloaded today
userData.birthdate || '00/00/00', // 29: Birthdate (MM/DD/YY)
userData.registration_path || '', // 30: Path to user registration file
userData.door_path || '', // 31: Path to door info file
userData.sysop_name || 'Sysop', // 32: Sysop name
userData.alias || userData.real_name || 'Guest', // 33: User's alias/handle
'00:05', // 34: Event time (HH:MM)
'Y', // 35: Error-free connection (Y/N)
'N', // 36: ANSI supported (Y/N) - We always use ANSI
'Y', // 37: Use record locking (Y/N)
'14', // 38: Default text color
userData.time_limit || '120', // 39: Time limit (minutes)
userData.time_remaining || '7200', // 40: Time remaining (seconds)
'0', // 41: Fossil port
'0', // 42: Fossil IRQ
'0', // 43: Fossil base address
userData.bbs_name || 'BinktermPHP BBS', // 44: BBS name
userData.sysop_first || 'System', // 45: Sysop first name
userData.sysop_last || 'Operator', // 46: Sysop last name
'0', // 47: Fossil port (again)
'0', // 48: Fossil IRQ (again)
'0', // 49: Fossil base address (again)
'DOOR.SYS', // 50: Drop file type
userData.node || '1', // 51: Node number (again)
'115200' // 52: DTE rate (again)
];
// Write DOOR.SYS file (DOS CRLF line endings)
const content = lines.join('\r\n') + '\r\n';
try {
fs.writeFileSync(doorSysPath, content, 'ascii');
console.log(`[DROPFILE] Wrote ${content.length} bytes to ${doorSysPath}`);
// Verify the write by reading back immediately
const stats = fs.statSync(doorSysPath);
console.log(`[DROPFILE] Verification: file size is ${stats.size} bytes`);
if (stats.size !== content.length) {
console.error(`[DROPFILE] WARNING: Size mismatch! Expected ${content.length}, got ${stats.size}`);
}
} catch (err) {
console.error(`[DROPFILE] Failed to write DOOR.SYS: ${err.message}`);
throw err;
}
}
/**
* Generate DOOR32.SYS drop file from user_data
* Format: https://wiki.bbsdev.net/index.php/DOOR32.SYS
* 11-line format used by modern door games
*/
generateDoor32Sys(dropPath, userData, nodeNumber) {
const door32Path = path.join(dropPath, 'DOOR32.SYS');
// DOOR32.SYS 11-line format
const lines = [
'2', // 1: Comm type (0=local, 1=serial, 2=telnet/socket)
'0', // 2: Comm handle/socket (0 for telnet/PTY)
'0', // 3: Baud rate (0 for telnet/socket)
userData.bbs_name || 'BinktermPHP BBS', // 4: BBS software name
userData.user_id || '1', // 5: User record number
userData.real_name || 'Guest', // 6: User's real name
userData.alias || userData.real_name || 'Guest', // 7: User's handle/alias
userData.security_level || '10', // 8: Security level
userData.minutes_remaining || '120', // 9: Time left (minutes)
'1', // 10: ANSI (1=yes, 0=no)
String(nodeNumber || 1), // 11: Node number
];
const content = lines.join('\r\n') + '\r\n';
try {
fs.writeFileSync(door32Path, content, 'ascii');
console.log(`[DROPFILE] Wrote ${content.length} bytes to ${door32Path}`);
const stats = fs.statSync(door32Path);
console.log(`[DROPFILE] Verification: file size is ${stats.size} bytes`);
if (stats.size !== content.length) {
console.error(`[DROPFILE] WARNING: Size mismatch! Expected ${content.length}, got ${stats.size}`);
}
} catch (err) {
console.error(`[DROPFILE] Failed to write DOOR32.SYS: ${err.message}`);
throw err;
}
}
generateDosBoxConfig(session, tcpPort) {
const { sessionData } = session;
// session_path is now set by bridge
const sessionPath = sessionData.session_path;
// Read base config template
const headless = process.env.DOSDOOR_HEADLESS !== 'false';
const configTemplate = headless
? path.join(BASE_PATH, 'dosbox-bridge', 'dosbox-bridge-production.conf')
: path.join(BASE_PATH, 'dosbox-bridge', 'dosbox-bridge-test.conf');
let config = fs.readFileSync(configTemplate, 'utf8');
// Replace port placeholder
config = config.replace(/port:5000/g, `port:${tcpPort}`);
// Get door manifest to build launch command
// DOORS directory is uppercase - Linux filesystem is case-sensitive
const manifestPath = path.join(BASE_PATH, 'dosbox-bridge', 'dos', 'DOORS', sessionData.door_id.toUpperCase(), 'dosdoor.jsn');
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
// Build door launch command
const doorDir = manifest.door.directory.replace('dosbox-bridge/dos', '').replace(/\//g, '\\');
const dropDir = `\\DROPS\\NODE${sessionData.node_number}`;
let launchCmd = manifest.door.launch_command || `call ${manifest.door.executable}`;
launchCmd = launchCmd.replace('{node}', sessionData.node_number);
launchCmd = launchCmd.replace('{dropfile}', 'DOOR.SYS');
// Build autoexec commands
// Copy DOOR.SYS to door directory, CD there, then launch
const autoexecCommands = `copy ${dropDir}\\DOOR.SYS ${doorDir}\\DOOR.SYS\ncd ${doorDir}\n${launchCmd}`;
// Replace autoexec placeholder
config = config.replace('# Door-specific commands will be appended here', autoexecCommands);
// Write session-specific config
const configPath = path.join(sessionPath, 'dosbox.conf');
fs.writeFileSync(configPath, config);
return configPath;
}
async launchDosBox(session, configPath) {
const dosboxExe = this.findDosBoxExecutable();
if (!dosboxExe) {
throw new Error('DOSBox executable not found');
}
const headless = process.env.DOSDOOR_HEADLESS !== 'false';
const isLinux = process.platform !== 'win32';
// Build arguments based on platform and headless mode
let args;
if (headless) {
if (isLinux) {
// Linux: Use -noconsole for true headless
args = ['-noconsole', '-conf', configPath, '-exit'];
} else {
// Windows: Use -nogui
args = ['-nogui', '-conf', configPath, '-exit'];
}
} else {
args = ['-conf', configPath, '-exit'];
}
// Set up spawn options
const spawnOptions = {
cwd: BASE_PATH,
detached: false,
stdio: 'ignore'
};
// Linux: Set SDL_VIDEODRIVER=dummy for true headless operation
if (isLinux && headless) {
spawnOptions.env = {
...process.env,
SDL_VIDEODRIVER: 'dummy'
};
console.log(`[DOSBOX] Linux headless mode: SDL_VIDEODRIVER=dummy`);
}
console.log(`[DOSBOX] Spawning: ${dosboxExe} ${args.join(' ')}`);
const dosboxProcess = spawn(dosboxExe, args, spawnOptions);
session.dosboxProcess = dosboxProcess;
dosboxProcess.on('error', (err) => {
session.slog.error(`[DOSBOX] Process error:`, err.message);
});
dosboxProcess.on('exit', (code, signal) => {
session.slog.log(`[DOSBOX] Process exited: code=${code}, signal=${signal}`);
this.handleDosBoxExit(session);
});
return dosboxProcess.pid;
}
findDosBoxExecutable() {
const envPath = process.env.DOSBOX_EXECUTABLE;
if (envPath && fs.existsSync(envPath)) {
console.log(`[DOSBOX] Using configured executable: ${envPath}`);
return envPath;
}
// Prefer vanilla DOSBox (much lighter: ~30MB vs ~256MB RSS)
// Try dosbox first, fall back to dosbox-x
const candidates = process.platform === 'win32'
? ['dosbox.exe', 'dosbox-x.exe', 'c:\\dosbox\\dosbox.exe', 'c:\\dosbox-x\\dosbox-x.exe']
: ['dosbox', 'dosbox-x'];
// Check which executables exist in PATH
for (const candidate of candidates) {
try {
const result = require('child_process').spawnSync(
process.platform === 'win32' ? 'where' : 'which',
[candidate],
{ encoding: 'utf8' }
);
if (result.status === 0 && result.stdout.trim()) {
const exePath = result.stdout.trim().split('\n')[0];
console.log(`[DOSBOX] Found executable: ${exePath}`);
return candidate;
}
} catch (e) {
// Command not found, try next
}
}
// Fallback: assume dosbox is in PATH
console.log('[DOSBOX] No DOSBox found in PATH, trying "dosbox"');
return 'dosbox';
}
handleDosBoxConnection(socket, session) {
session.slog.log(`[DOSBOX] Connection received`);
// Remove from pending
this.pendingDosBoxConnections.delete(session.tcpPort);
// Store DOSBox socket
session.dosboxSocket = socket;
// Set up DOSBox handlers
this.setupDosBoxHandlers(session);
// Start multiplexing
session.slog.log(`[SESSION] Multiplexing started`);
}
/**
* Handle emulator connection (adapter-based, supports DOSBox and DOSEMU)
*/
handleEmulatorConnection(socket, session) {
const emulatorName = session.emulator.getName();
session.slog.log(`[${emulatorName}] Connection received`);
// Remove from pending (DOSBox only)
if (session.tcpPort) {
this.pendingDosBoxConnections.delete(session.tcpPort);
}
session.emulatorSocket = socket;
this.setupEmulatorHandlers(session);
session.slog.log(`[SESSION] Multiplexing started`);
}
/**
* Set up emulator data handlers (adapter-based)
*/
setupEmulatorHandlers(session) {
const { sessionId } = session;
const emulatorName = session.emulator.getName();
// Set up data flow: Emulator -> WebSocket
session.emulator.onData((data) => {
session.bytesFromEmulator += data.length;
try {
// DOSBox: CP437 bytes via TCP socket -> decode to UTF-8
// Native (cp437): binary string from PTY -> decode to UTF-8
// DOSEMU / Native (utf8): already UTF-8
let utf8Data;
if (emulatorName === 'DOSBox') {
utf8Data = iconv.decode(data, 'cp437');
} else if (emulatorName === 'Native' && session.emulator.outputEncoding === 'cp437') {
utf8Data = iconv.decode(Buffer.from(data, 'binary'), 'cp437');
} else {
utf8Data = typeof data === 'string' ? data : data.toString('utf8');
}
if (session.ws && session.ws.readyState === WebSocket.OPEN) {
session.ws.send(utf8Data);
} else {
console.warn(`[${emulatorName}->WS] WebSocket not ready, dropping ${data.length} bytes`);
}
} catch (err) {
session.slog.error(`[${emulatorName}] Encoding error:`, err.message);
}
});
}
setupDosBoxHandlers(session) {
const { dosboxSocket, sessionId } = session;
dosboxSocket.setNoDelay(true);
dosboxSocket.on('data', (data) => {
session.bytesFromDosbox += data.length;
// Convert CP437 (DOS) to UTF-8
try {
const utf8Data = iconv.decode(data, 'cp437');
// Forward to WebSocket client if connected
if (session.ws && session.ws.readyState === WebSocket.OPEN) {
session.ws.send(utf8Data);
} else {
console.warn(`[DOSBOX->WS] WebSocket not ready, dropping ${data.length} bytes`);
}
} catch (err) {
session.slog.error(`[DOSBOX] Encoding error:`, err.message);
}
});
dosboxSocket.on('close', () => {
session.slog.log(`[DOSBOX] Connection closed`);
this.handleDosBoxDisconnect(session);
});
dosboxSocket.on('error', (err) => {
session.slog.error(`[DOSBOX] Socket error:`, err.message);
});
}
setupWebSocketHandlers(session) {
const { ws, sessionId } = session;
ws.on('message', (data) => {
session.bytesToEmulator += data.length;
try {
const dataStr = data.toString('utf8');
// Intercept JSON control messages (e.g. terminal resize) before forwarding bytes
if (dataStr.charCodeAt(0) === 0x7B) {
try {
const msg = JSON.parse(dataStr);
if (msg.type === 'resize') {
if (session.emulator) {
const cols = Math.max(20, Math.min(500, parseInt(msg.cols) || 80));
const rows = Math.max(5, Math.min(200, parseInt(msg.rows) || 25));
session.slog.log(`[RESIZE] Received resize: ${cols}x${rows}`);
session.emulator.resize(cols, rows);
} else {
session.slog.log(`[RESIZE] Received resize but emulator not ready`);
}
return;
}
} catch (_) {}
}
// DOSBox: encode UTF-8 input to CP437 for the emulator
// Native (cp437): encode UTF-8 input to CP437 for the door
// DOSEMU / Native (utf8): pass through as UTF-8
const emulatorName = session.emulator ? session.emulator.getName() : 'Unknown';
let emulatorData;
if (emulatorName === 'DOSBox') {
emulatorData = iconv.encode(dataStr, 'cp437');
} else if (emulatorName === 'Native' && session.emulator.outputEncoding === 'cp437') {
emulatorData = iconv.encode(dataStr, 'cp437');
} else {
emulatorData = Buffer.from(dataStr, 'utf8');
}
// Forward to emulator
if (session.emulator) {
session.emulator.write(emulatorData);
} else {
console.warn(`[WS->EMULATOR] Emulator not ready, dropping ${data.length} bytes`);
}
} catch (err) {
session.slog.error(`[WS] Encoding error:`, err.message);
}
});
ws.on('close', (code, reason) => {
session.slog.log(`[WS] Client disconnected: ${code} ${reason}`);
this.handleWebSocketDisconnect(session);
});
ws.on('error', (err) => {
session.slog.error(`[WS] WebSocket error:`, err.message);
});
}
handleDosBoxExit(session) {
session.slog.log(`[SESSION] DOSBox exited`);
// Clear kill timeout since process exited gracefully
if (session.killTimeout) {
clearTimeout(session.killTimeout);
session.killTimeout = null;
}
// Mark that DOSBox has exited (so WebSocket handler knows to clean up immediately)
session.dosboxExited = true;
// Close WebSocket - this will trigger handleWebSocketDisconnect which does cleanup
if (session.ws && session.ws.readyState === WebSocket.OPEN) {
session.ws.close(1000, 'Door session ended');
} else {
// WebSocket already closed, do cleanup directly
this.removeSession(session);
}
}
handleDosBoxDisconnect(session) {
session.slog.log(`[SESSION] DOSBox disconnected`);
// DOSBox TCP connection closed (usually means 'exit' command ran)
// Give DOSBox a few seconds to exit gracefully, then force kill if needed
if (session.dosboxProcess && session.dosboxPid) {
session.slog.log(`[SESSION] Waiting 3 seconds for DOSBox PID ${session.dosboxPid} to exit gracefully...`);
session.killTimeout = setTimeout(() => {
session.slog.log(`[SESSION] DOSBox didn't exit gracefully, force killing PID ${session.dosboxPid}`);
try {
if (process.platform === 'win32') {
require('child_process').execSync(`taskkill /F /PID ${session.dosboxPid}`, { stdio: 'ignore' });
} else {
process.kill(session.dosboxPid, 'SIGKILL');
}
} catch (err) {
session.slog.error(`[SESSION] Failed to kill DOSBox PID ${session.dosboxPid}:`, err.message);
}
// handleDosBoxExit will be called when process actually dies
}, 3000); // 3 second grace period
}
}
handleWebSocketDisconnect(session) {
// If DOSBox already exited, clean up immediately (no grace period)
if (session.dosboxExited) {
session.slog.log(`[WS] DOSBox exited, cleaning up`);
this.removeSession(session);
return;
}
// User closed browser but DOSBox still running
if (DISCONNECT_TIMEOUT === 0) {
// Immediate disconnect mode - kill DOSBox and clean up
session.slog.log(`[WS] Immediate disconnect mode - removing session`);
this.removeSession(session);
} else {
// Grace period mode - keep DOSBox running, allow reconnect
session.slog.log(`[WS] Disconnect grace period: ${DISCONNECT_TIMEOUT} minutes`);
session.disconnectTimer = setTimeout(() => {
session.slog.log(`[WS] Grace period expired - removing session`);
this.removeSession(session);
}, DISCONNECT_TIMEOUT * 60 * 1000);
}
}
removeSession(session) {
// Prevent double cleanup
if (session.isRemoving) {
session.slog.log(`[SESSION] Already removing, skipping`);
return;
}
session.isRemoving = true;
session.slog.log(`[SESSION] Removing`);
// Clear disconnect timer
if (session.disconnectTimer) {
clearTimeout(session.disconnectTimer);
}
// Close emulator TCP connection (simulates carrier loss)
if (session.emulator) {
session.emulator.close();
}
// Give emulator process time to detect carrier loss and exit gracefully
// Then force kill if still running
if (session.emulatorProcess && session.emulatorPid) {
const timeoutSec = (CARRIER_LOSS_TIMEOUT / 1000).toFixed(1);
session.slog.log(`[SESSION] Waiting ${timeoutSec} seconds for emulator PID ${session.emulatorPid} to exit after carrier loss...`);
setTimeout(() => {
// Check if process still running
try {
process.kill(session.emulatorPid, 0); // Signal 0 checks if process exists
session.slog.log(`[SESSION] Emulator PID ${session.emulatorPid} still running, force killing`);
if (process.platform === 'win32') {
require('child_process').execSync(`taskkill /F /PID ${session.emulatorPid}`, { stdio: 'ignore' });
} else {
process.kill(session.emulatorPid, 'SIGKILL');
}
} catch (err) {
// Process already exited, good
session.slog.log(`[SESSION] Emulator PID ${session.emulatorPid} already exited`);
}
}, CARRIER_LOSS_TIMEOUT);
}
// Close WebSocket
if (session.ws && session.ws.readyState === WebSocket.OPEN) {
session.ws.close();
}
// Release port (DOSBox only)
if (session.tcpPort) {
this.portPool.release(session.tcpPort);
this.sessionsByPort.delete(session.tcpPort);
this.pendingDosBoxConnections.delete(session.tcpPort);
}
// Remove from maps
if (session.sessionData && session.sessionData.ws_token) {
this.sessionsByToken.delete(session.sessionData.ws_token);
}
// Clean up session files and directory
this.cleanupSessionFiles(session);
// Delete session from database
this.deleteSession(session.sessionId, session.slog);
// Log statistics
const uptime = Math.floor((Date.now() - session.startTime) / 1000);
session.slog.log(`[SESSION] Stats - Uptime ${uptime}s, From Emulator: ${session.bytesFromEmulator}, To Emulator: ${session.bytesToEmulator}`);
}
cleanupSessionFiles(session) {
const { sessionData, sessionId } = session;
if (!sessionData || !sessionData.session_path) {
return;
}
const sessionPath = sessionData.session_path;
try {
// Skip cleanup if debug mode is enabled
if (DEBUG_KEEP_FILES) {
session.slog.log(`[CLEANUP] Debug mode - keeping files`);
return;
}
// Remove DOOR.SYS file from drop directory
const dropPath = session.dropPath ||
path.join(BASE_PATH, 'dosbox-bridge', 'dos', 'DROPS', `NODE${sessionData.node_number}`);
const doorSysPath = path.join(dropPath, 'DOOR.SYS');
if (fs.existsSync(doorSysPath)) {
fs.unlinkSync(doorSysPath);
session.slog.log(`[CLEANUP] Removed DOOR.SYS from drop directory`);
}
// Check if session directory exists
if (!fs.existsSync(sessionPath)) {
session.slog.log(`[CLEANUP] Session directory does not exist: ${sessionPath}`);
return;
}
// Remove DOSBox config
const configPath = path.join(sessionPath, 'dosbox.conf');
if (fs.existsSync(configPath)) {
fs.unlinkSync(configPath);
session.slog.log(`[CLEANUP] Removed dosbox.conf`);
}
// Remove session directory (if empty or force remove all contents)
const files = fs.readdirSync(sessionPath);
if (files.length === 0) {
fs.rmdirSync(sessionPath);
session.slog.log(`[CLEANUP] Removed session directory`);
} else {
// Directory has other files - remove them all and the directory
for (const file of files) {
const filePath = path.join(sessionPath, file);
fs.unlinkSync(filePath);
}
fs.rmdirSync(sessionPath);
session.slog.log(`[CLEANUP] Removed session directory and ${files.length} remaining files`);
}
} catch (err) {
session.slog.error(`[CLEANUP] Error cleaning up:`, err.message);
// Don't throw - cleanup should be best-effort
}
}
getStats() {
return {
activeSessions: this.sessionsByToken.size,
availablePorts: TCP_PORT_MAX - TCP_PORT_BASE - this.portPool.usedPorts.size,
sessions: Array.from(this.sessionsByToken.values()).map(s => ({
sessionId: s.sessionId,
emulator: s.emulator ? s.emulator.getName() : 'Unknown',
tcpPort: s.tcpPort,
uptime: Math.floor((Date.now() - s.startTime) / 1000),
bytesFromEmulator: s.bytesFromEmulator,
bytesToEmulator: s.bytesToEmulator,
wsConnected: s.ws && s.ws.readyState === WebSocket.OPEN,
emulatorConnected: s.emulatorSocket != null,
emulatorPid: s.emulatorPid
}))
};
}
}
// Create port pool and session manager
const portPool = new PortPool(TCP_PORT_BASE, TCP_PORT_MAX);
const sessionManager = new SessionManager(portPool);
// Create WebSocket server
const wsServer = new WebSocket.Server({
host: WS_BIND_HOST,
port: WS_PORT,
clientTracking: true
});
console.log(`[WS] Server listening on ${WS_BIND_HOST}:${WS_PORT}`);
// Clean up stale sessions from database on startup
// This handles sessions that weren't cleaned up if bridge crashed/was killed
(async () => {
const client = new Client(DB_CONFIG);
try {
await client.connect();
const result = await client.query(`
UPDATE door_sessions
SET ended_at = NOW(), exit_status = 'bridge_restart'
WHERE ended_at IS NULL
`);
if (result.rowCount > 0) {
console.log(`[STARTUP] Cleaned up ${result.rowCount} stale session(s) from database`);
}
} catch (err) {
console.error('[STARTUP] Failed to clean up stale sessions:', err.message);
} finally {
await client.end();
}
})();
console.log('[WS] Waiting for connections...');
console.log('');
wsServer.on('connection', async (ws, req) => {
const clientIp = getClientIp(req);
console.log('[WS] New connection from', clientIp);
// Parse token from query string
const queryParams = url.parse(req.url, true).query;
const token = queryParams.token;
if (!token) {
console.warn('[WS] No token provided - rejecting connection');
ws.close(1008, 'Authentication token required');
return;
}
// Authenticate and get session from database
const sessionData = await sessionManager.findSessionByToken(token);
if (!sessionData) {
console.warn('[WS] Authentication failed - rejecting connection');
ws.close(1008, 'Invalid or expired token');
return;
}
console.log('[WS] Authentication successful');
// Handle WebSocket connection (this will allocate port and launch DOSBox)
try {
await sessionManager.handleWebSocketConnection(ws, sessionData, clientIp);
} catch (err) {
console.error('[WS] Failed to handle connection:', err.message);
ws.close(1011, 'Internal server error');
}
});
wsServer.on('error', (err) => {
console.error('[WS] Server error:', err.message);
});
// Status reporting (every 60 seconds) ? only log when sessions are active
setInterval(() => {
const stats = sessionManager.getStats();
if (stats.activeSessions > 0) {
console.log('[STATUS] Active sessions:', stats.activeSessions, '| Available ports:', stats.availablePorts);
stats.sessions.forEach(s => {
console.log(` - ${s.sessionId} (port ${s.tcpPort}, PID ${s.emulatorPid}): ${s.uptime}s, WS:${s.wsConnected}, Emulator:${s.emulatorConnected}`);
});
}
}, 60000);
// Graceful shutdown
process.on('SIGINT', () => {
console.log('\n[SHUTDOWN] Received SIGINT, closing all connections...');
// Close all sessions
for (const session of sessionManager.sessionsByToken.values()) {
sessionManager.removeSession(session);
}
// Close WebSocket server
wsServer.close(() => {
console.log('[SHUTDOWN] Server closed');
process.exit(0);
});
});
process.on('SIGTERM', () => {
console.log('\n[SHUTDOWN] Received SIGTERM, closing all connections...');
// Close all sessions
for (const session of sessionManager.sessionsByToken.values()) {
sessionManager.removeSession(session);
}
// Close WebSocket server
wsServer.close(() => {
console.log('[SHUTDOWN] Server closed');
process.exit(0);
});
});
// Handle uncaught errors
process.on('uncaughtException', (err) => {
console.error('[ERROR] Uncaught exception:', err);
});
process.on('unhandledRejection', (reason, promise) => {
console.error('[ERROR] Unhandled rejection:', reason);
});
console.log('Bridge server started successfully!');
console.log('Press Ctrl+C to stop.');
|