import chalk from 'chalk'; import type { Response as ExpressResponse } from 'express'; import express from 'express'; import * as fs from 'fs'; import type * as http from 'http'; import { createServer } from 'http'; import * as os from 'os'; import * as path from 'path'; import { v4 as uuidv4 } from 'uuid'; import { WebSocketServer } from 'ws'; import type { AuthenticatedRequest } from './middleware/auth.js'; import { createAuthMiddleware } from './middleware/auth.js'; import { PtyManager } from './pty/index.js'; import { createAuthRoutes } from './routes/auth.js'; import { createFileRoutes } from './routes/files.js'; import { createFilesystemRoutes } from './routes/filesystem.js'; import { createLogRoutes } from './routes/logs.js'; import { createPushRoutes } from './routes/push.js'; import { createRemoteRoutes } from './routes/remotes.js'; import { createScreencapRoutes, initializeScreencap } from './routes/screencap.js'; import { createSessionRoutes } from './routes/sessions.js'; import { createWebRTCConfigRouter } from './routes/webrtc-config.js'; import { WebSocketInputHandler } from './routes/websocket-input.js'; import { ActivityMonitor } from './services/activity-monitor.js'; import { AuthService } from './services/auth-service.js'; import { BellEventHandler } from './services/bell-event-handler.js'; import { BufferAggregator } from './services/buffer-aggregator.js'; import { ControlDirWatcher } from './services/control-dir-watcher.js'; import { HQClient } from './services/hq-client.js'; import { mdnsService } from './services/mdns-service.js'; import { PushNotificationService } from './services/push-notification-service.js'; import { RemoteRegistry } from './services/remote-registry.js'; import { StreamWatcher } from './services/stream-watcher.js'; import { TerminalManager } from './services/terminal-manager.js'; import { closeLogger, createLogger, initLogger, setDebugMode } from './utils/logger.js'; import { VapidManager } from './utils/vapid-manager.js'; import { getVersionInfo, printVersionBanner } from './version.js'; import { screencapUnixHandler } from './websocket/screencap-unix-handler.js'; // Extended WebSocket request with authentication and routing info interface WebSocketRequest extends http.IncomingMessage { pathname?: string; searchParams?: URLSearchParams; userId?: string; authMethod?: string; } const logger = createLogger('server'); // Global shutdown state management let shuttingDown = false; export function isShuttingDown(): boolean { return shuttingDown; } export function setShuttingDown(value: boolean): void { shuttingDown = value; } interface Config { port: number | null; bind: string | null; enableSSHKeys: boolean; disallowUserPassword: boolean; noAuth: boolean; isHQMode: boolean; hqUrl: string | null; hqUsername: string | null; hqPassword: string | null; remoteName: string | null; allowInsecureHQ: boolean; showHelp: boolean; showVersion: boolean; debug: boolean; // Push notification configuration pushEnabled: boolean; vapidEmail: string | null; generateVapidKeys: boolean; bellNotificationsEnabled: boolean; // Local bypass configuration allowLocalBypass: boolean; localAuthToken: string | null; // HQ auth bypass for testing noHqAuth: boolean; // mDNS advertisement enableMDNS: boolean; } // Show help message function showHelp() { console.log(` VibeTunnel Server - Terminal Multiplexer Usage: vibetunnel-server [options] Options: --help Show this help message --version Show version information --port Server port (default: 4020 or PORT env var) --bind
Bind address (default: 0.0.0.0, all interfaces) --enable-ssh-keys Enable SSH key authentication UI and functionality --disallow-user-password Disable password auth, SSH keys only (auto-enables --enable-ssh-keys) --no-auth Disable authentication (auto-login as current user) --allow-local-bypass Allow localhost connections to bypass authentication --local-auth-token Token for localhost authentication bypass --debug Enable debug logging Push Notification Options: --push-enabled Enable push notifications (default: enabled) --push-disabled Disable push notifications --vapid-email Contact email for VAPID (or PUSH_CONTACT_EMAIL env var) --generate-vapid-keys Generate new VAPID keys if none exist Network Discovery Options: --no-mdns Disable mDNS/Bonjour advertisement (enabled by default) HQ Mode Options: --hq Run as HQ (headquarters) server Remote Server Options: --hq-url HQ server URL to register with --hq-username Username for HQ authentication --hq-password Password for HQ authentication --name Unique name for this remote server --allow-insecure-hq Allow HTTP URLs for HQ (default: HTTPS only) --no-hq-auth Disable HQ authentication (for testing only) Environment Variables: PORT Default port if --port not specified VIBETUNNEL_USERNAME Default username if --username not specified VIBETUNNEL_PASSWORD Default password if --password not specified VIBETUNNEL_CONTROL_DIR Control directory for session data PUSH_CONTACT_EMAIL Contact email for VAPID configuration Examples: # Run a simple server with authentication vibetunnel-server --username admin --password secret # Run as HQ server vibetunnel-server --hq --username hq-admin --password hq-secret # Run as remote server registering with HQ vibetunnel-server --username local --password local123 \\ --hq-url https://hq.example.com \\ --hq-username hq-admin --hq-password hq-secret \\ --name remote-1 `); } // Parse command line arguments function parseArgs(): Config { const args = process.argv.slice(2); const config = { port: null as number | null, bind: null as string | null, enableSSHKeys: false, disallowUserPassword: false, noAuth: false, isHQMode: false, hqUrl: null as string | null, hqUsername: null as string | null, hqPassword: null as string | null, remoteName: null as string | null, allowInsecureHQ: false, showHelp: false, showVersion: false, debug: false, // Push notification configuration pushEnabled: true, // Enable by default with auto-generation vapidEmail: null as string | null, generateVapidKeys: true, // Generate keys automatically bellNotificationsEnabled: true, // Enable bell notifications by default // Local bypass configuration allowLocalBypass: false, localAuthToken: null as string | null, // HQ auth bypass for testing noHqAuth: false, // mDNS advertisement enableMDNS: true, // Enable mDNS by default }; // Check for help flag first if (args.includes('--help') || args.includes('-h')) { config.showHelp = true; return config; } // Check for version flag if (args.includes('--version') || args.includes('-v')) { config.showVersion = true; return config; } // Check for command line arguments for (let i = 0; i < args.length; i++) { if (args[i] === '--port' && i + 1 < args.length) { config.port = Number.parseInt(args[i + 1], 10); i++; // Skip the port value in next iteration } else if (args[i] === '--bind' && i + 1 < args.length) { config.bind = args[i + 1]; i++; // Skip the bind value in next iteration } else if (args[i] === '--enable-ssh-keys') { config.enableSSHKeys = true; } else if (args[i] === '--disallow-user-password') { config.disallowUserPassword = true; config.enableSSHKeys = true; // Auto-enable SSH keys } else if (args[i] === '--no-auth') { config.noAuth = true; } else if (args[i] === '--hq') { config.isHQMode = true; } else if (args[i] === '--hq-url' && i + 1 < args.length) { config.hqUrl = args[i + 1]; i++; // Skip the URL value in next iteration } else if (args[i] === '--hq-username' && i + 1 < args.length) { config.hqUsername = args[i + 1]; i++; // Skip the username value in next iteration } else if (args[i] === '--hq-password' && i + 1 < args.length) { config.hqPassword = args[i + 1]; i++; // Skip the password value in next iteration } else if (args[i] === '--name' && i + 1 < args.length) { config.remoteName = args[i + 1]; i++; // Skip the name value in next iteration } else if (args[i] === '--allow-insecure-hq') { config.allowInsecureHQ = true; } else if (args[i] === '--debug') { config.debug = true; } else if (args[i] === '--push-enabled') { config.pushEnabled = true; } else if (args[i] === '--push-disabled') { config.pushEnabled = false; } else if (args[i] === '--vapid-email' && i + 1 < args.length) { config.vapidEmail = args[i + 1]; i++; // Skip the email value in next iteration } else if (args[i] === '--generate-vapid-keys') { config.generateVapidKeys = true; } else if (args[i] === '--allow-local-bypass') { config.allowLocalBypass = true; } else if (args[i] === '--local-auth-token' && i + 1 < args.length) { config.localAuthToken = args[i + 1]; i++; // Skip the token value in next iteration } else if (args[i] === '--no-hq-auth') { config.noHqAuth = true; } else if (args[i] === '--no-mdns') { config.enableMDNS = false; } else if (args[i].startsWith('--')) { // Unknown argument logger.error(`Unknown argument: ${args[i]}`); logger.error('Use --help to see available options'); process.exit(1); } } // Check environment variables for push notifications if (!config.vapidEmail && process.env.PUSH_CONTACT_EMAIL) { config.vapidEmail = process.env.PUSH_CONTACT_EMAIL; } return config; } // Validate configuration function validateConfig(config: ReturnType) { // Validate auth configuration if (config.noAuth && (config.enableSSHKeys || config.disallowUserPassword)) { logger.warn( '--no-auth overrides all other authentication settings (authentication is disabled)' ); } if (config.disallowUserPassword && !config.enableSSHKeys) { logger.warn('--disallow-user-password requires SSH keys, auto-enabling --enable-ssh-keys'); config.enableSSHKeys = true; } // Validate HQ registration configuration if (config.hqUrl && (!config.hqUsername || !config.hqPassword) && !config.noHqAuth) { logger.error('HQ username and password required when --hq-url is specified'); logger.error('Use --hq-username and --hq-password with --hq-url'); logger.error('Or use --no-hq-auth for testing without authentication'); process.exit(1); } // Validate remote name is provided when registering with HQ if (config.hqUrl && !config.remoteName) { logger.error('Remote name required when --hq-url is specified'); logger.error('Use --name to specify a unique name for this remote server'); process.exit(1); } // Validate HQ URL is HTTPS unless explicitly allowed if (config.hqUrl && !config.hqUrl.startsWith('https://') && !config.allowInsecureHQ) { logger.error('HQ URL must use HTTPS protocol'); logger.error('Use --allow-insecure-hq to allow HTTP for testing'); process.exit(1); } // Validate HQ registration configuration if ( (config.hqUrl || config.hqUsername || config.hqPassword) && (!config.hqUrl || !config.hqUsername || !config.hqPassword) && !config.noHqAuth ) { logger.error('All HQ parameters required: --hq-url, --hq-username, --hq-password'); logger.error('Or use --no-hq-auth for testing without authentication'); process.exit(1); } // Can't be both HQ mode and register with HQ if (config.isHQMode && config.hqUrl) { logger.error('Cannot use --hq and --hq-url together'); logger.error('Use --hq to run as HQ server, or --hq-url to register with an HQ'); process.exit(1); } // Warn about no-hq-auth if (config.noHqAuth && config.hqUrl) { logger.warn('--no-hq-auth is enabled: Remote servers can register without authentication'); logger.warn('This should only be used for testing!'); } } interface AppInstance { app: express.Application; server: ReturnType; wss: WebSocketServer; startServer: () => void; config: Config; ptyManager: PtyManager; terminalManager: TerminalManager; streamWatcher: StreamWatcher; remoteRegistry: RemoteRegistry | null; hqClient: HQClient | null; controlDirWatcher: ControlDirWatcher | null; bufferAggregator: BufferAggregator | null; activityMonitor: ActivityMonitor; pushNotificationService: PushNotificationService | null; } // Track if app has been created let appCreated = false; export async function createApp(): Promise { // Prevent multiple app instances if (appCreated) { logger.error('App already created, preventing duplicate instance'); throw new Error('Duplicate app creation detected'); } appCreated = true; const config = parseArgs(); // Check if help was requested if (config.showHelp) { showHelp(); process.exit(0); } // Check if version was requested if (config.showVersion) { const versionInfo = getVersionInfo(); console.log(`VibeTunnel Server v${versionInfo.version}`); console.log(`Built: ${versionInfo.buildDate}`); console.log(`Platform: ${versionInfo.platform}/${versionInfo.arch}`); console.log(`Node: ${versionInfo.nodeVersion}`); process.exit(0); } // Print version banner on startup printVersionBanner(); validateConfig(config); logger.log('Initializing VibeTunnel server components'); const app = express(); const server = createServer(app); const wss = new WebSocketServer({ noServer: true }); // Add JSON body parser middleware with size limit app.use(express.json({ limit: '10mb' })); logger.debug('Configured express middleware'); // Control directory for session data const CONTROL_DIR = process.env.VIBETUNNEL_CONTROL_DIR || path.join(os.homedir(), '.vibetunnel/control'); // Ensure control directory exists if (!fs.existsSync(CONTROL_DIR)) { fs.mkdirSync(CONTROL_DIR, { recursive: true }); logger.log(chalk.green(`Created control directory: ${CONTROL_DIR}`)); } else { logger.debug(`Using existing control directory: ${CONTROL_DIR}`); } // Initialize PTY manager const ptyManager = new PtyManager(CONTROL_DIR); logger.debug('Initialized PTY manager'); // Clean up sessions from old VibeTunnel versions const sessionManager = ptyManager.getSessionManager(); const cleanupResult = sessionManager.cleanupOldVersionSessions(); if (cleanupResult.versionChanged) { logger.log( chalk.yellow( `Version change detected - cleaned up ${cleanupResult.cleanedCount} sessions from previous version` ) ); } else if (cleanupResult.cleanedCount > 0) { logger.log( chalk.yellow( `Cleaned up ${cleanupResult.cleanedCount} legacy sessions without version information` ) ); } // Initialize Terminal Manager for server-side terminal state const terminalManager = new TerminalManager(CONTROL_DIR); logger.debug('Initialized terminal manager'); // Initialize stream watcher for file-based streaming const streamWatcher = new StreamWatcher(); logger.debug('Initialized stream watcher'); // Initialize activity monitor const activityMonitor = new ActivityMonitor(CONTROL_DIR); logger.debug('Initialized activity monitor'); // Initialize push notification services let vapidManager: VapidManager | null = null; let pushNotificationService: PushNotificationService | null = null; let bellEventHandler: BellEventHandler | null = null; if (config.pushEnabled) { try { logger.log('Initializing push notification services'); // Initialize VAPID manager with auto-generation vapidManager = new VapidManager(); await vapidManager.initialize({ contactEmail: config.vapidEmail || 'noreply@vibetunnel.local', generateIfMissing: true, // Auto-generate keys if none exist }); logger.log('VAPID keys initialized successfully'); // Initialize push notification service pushNotificationService = new PushNotificationService(vapidManager); await pushNotificationService.initialize(); // Initialize bell event handler bellEventHandler = new BellEventHandler(); bellEventHandler.setPushNotificationService(pushNotificationService); logger.log(chalk.green('Push notification services initialized')); } catch (error) { logger.error('Failed to initialize push notification services:', error); logger.warn('Continuing without push notifications'); vapidManager = null; pushNotificationService = null; bellEventHandler = null; } } else { logger.debug('Push notifications disabled'); } // Initialize HQ components let remoteRegistry: RemoteRegistry | null = null; let hqClient: HQClient | null = null; let controlDirWatcher: ControlDirWatcher | null = null; let bufferAggregator: BufferAggregator | null = null; let remoteBearerToken: string | null = null; if (config.isHQMode) { remoteRegistry = new RemoteRegistry(); logger.log(chalk.green('Running in HQ mode')); logger.debug('Initialized remote registry for HQ mode'); } else if ( config.hqUrl && config.remoteName && (config.noHqAuth || (config.hqUsername && config.hqPassword)) ) { // Generate bearer token for this remote server remoteBearerToken = uuidv4(); logger.debug(`Generated bearer token for remote server: ${config.remoteName}`); } // Initialize authentication service const authService = new AuthService(); logger.debug('Initialized authentication service'); // Initialize buffer aggregator bufferAggregator = new BufferAggregator({ terminalManager, remoteRegistry, isHQMode: config.isHQMode, }); logger.debug('Initialized buffer aggregator'); // Initialize WebSocket input handler const websocketInputHandler = new WebSocketInputHandler({ ptyManager, terminalManager, activityMonitor, remoteRegistry, authService, isHQMode: config.isHQMode, }); logger.debug('Initialized WebSocket input handler'); // Set up authentication const authMiddleware = createAuthMiddleware({ enableSSHKeys: config.enableSSHKeys, disallowUserPassword: config.disallowUserPassword, noAuth: config.noAuth, isHQMode: config.isHQMode, bearerToken: remoteBearerToken || undefined, // Token that HQ must use to auth with us authService, // Add enhanced auth service for JWT tokens allowLocalBypass: config.allowLocalBypass, localAuthToken: config.localAuthToken || undefined, }); // Serve static files with .html extension handling const publicPath = path.join(process.cwd(), 'public'); app.use( express.static(publicPath, { extensions: ['html'], // This allows /logs to resolve to /logs.html }) ); logger.debug(`Serving static files from: ${publicPath}`); // Health check endpoint (no auth required) app.get('/api/health', (_req, res) => { const versionInfo = getVersionInfo(); res.json({ status: 'ok', timestamp: new Date().toISOString(), mode: config.isHQMode ? 'hq' : 'remote', version: versionInfo.version, buildDate: versionInfo.buildDate, uptime: versionInfo.uptime, pid: versionInfo.pid, }); }); // Connect bell event handler to PTY manager if push notifications are enabled if (bellEventHandler) { ptyManager.on('bell', (bellContext) => { bellEventHandler.processBellEvent(bellContext).catch((error) => { logger.error('Failed to process bell event:', error); }); }); logger.debug('Connected bell event handler to PTY manager'); } // Mount authentication routes (no auth required) app.use( '/api/auth', createAuthRoutes({ authService, enableSSHKeys: config.enableSSHKeys, disallowUserPassword: config.disallowUserPassword, noAuth: config.noAuth, }) ); logger.debug('Mounted authentication routes'); // Apply auth middleware to all API routes (except auth routes which are handled above) app.use('/api', authMiddleware); logger.debug('Applied authentication middleware to /api routes'); // Mount routes app.use( '/api', createSessionRoutes({ ptyManager, terminalManager, streamWatcher, remoteRegistry, isHQMode: config.isHQMode, activityMonitor, }) ); logger.debug('Mounted session routes'); app.use( '/api', createRemoteRoutes({ remoteRegistry, isHQMode: config.isHQMode, }) ); logger.debug('Mounted remote routes'); // Mount filesystem routes app.use('/api', createFilesystemRoutes()); logger.debug('Mounted filesystem routes'); // Mount log routes app.use('/api', createLogRoutes()); logger.debug('Mounted log routes'); // Mount file routes app.use('/api', createFileRoutes()); logger.debug('Mounted file routes'); // Mount push notification routes if (vapidManager) { app.use( '/api', createPushRoutes({ vapidManager, pushNotificationService, bellEventHandler: bellEventHandler ?? undefined, }) ); logger.debug('Mounted push notification routes'); } // Mount screencap routes app.use('/', createScreencapRoutes()); logger.debug('Mounted screencap routes'); // WebRTC configuration route app.use('/api', createWebRTCConfigRouter()); logger.debug('Mounted WebRTC config routes'); // Initialize screencap service in background initializeScreencap().catch((error) => { logger.error('Failed to initialize screencap service:', error); logger.warn('Continuing without screencap service'); }); // Start UNIX socket server for Mac app communication screencapUnixHandler .start() .then(() => { logger.log(chalk.green('Screen Capture UNIX socket: READY')); }) .catch((error) => { logger.error('Failed to start UNIX socket server:', error); logger.warn('Screen capture Mac app communication will not work'); }); // Handle WebSocket upgrade with authentication server.on('upgrade', async (request, socket, head) => { // Parse the URL to extract path and query parameters const parsedUrl = new URL(request.url || '', `http://${request.headers.host || 'localhost'}`); // Handle WebSocket paths if ( parsedUrl.pathname !== '/buffers' && parsedUrl.pathname !== '/ws/input' && parsedUrl.pathname !== '/ws/screencap-signal' ) { socket.write('HTTP/1.1 404 Not Found\r\n\r\n'); socket.destroy(); return; } // Check authentication and capture user info const authResult = await new Promise<{ authenticated: boolean; userId?: string; authMethod?: string; }>((resolve) => { // Track if promise has been resolved to prevent multiple resolutions let resolved = false; const safeResolve = (value: { authenticated: boolean; userId?: string; authMethod?: string; }) => { if (!resolved) { resolved = true; resolve(value); } }; // Convert URLSearchParams to plain object for query parameters const query: Record = {}; parsedUrl.searchParams.forEach((value, key) => { query[key] = value; }); // Create a mock Express request/response to use auth middleware const req = { ...request, url: request.url, path: parsedUrl.pathname, userId: undefined as string | undefined, authMethod: undefined as string | undefined, query, // Include parsed query parameters for token-based auth headers: request.headers, ip: (request.socket as unknown as { remoteAddress?: string }).remoteAddress || '', socket: request.socket, hostname: request.headers.host?.split(':')[0] || 'localhost', // Add minimal Express-like methods needed by auth middleware get: (header: string) => request.headers[header.toLowerCase()], header: (header: string) => request.headers[header.toLowerCase()], accepts: () => false, acceptsCharsets: () => false, acceptsEncodings: () => false, acceptsLanguages: () => false, } as unknown as AuthenticatedRequest; let authFailed = false; const res = { status: (code: number) => { // Only consider it a failure if it's an error status code if (code >= 400) { authFailed = true; safeResolve({ authenticated: false }); } return { json: () => {}, send: () => {}, end: () => {}, }; }, setHeader: () => {}, send: () => {}, json: () => {}, end: () => {}, } as unknown as ExpressResponse; const next = (error?: unknown) => { // Authentication succeeds if next() is called without error and no auth failure was recorded const authenticated = !error && !authFailed; safeResolve({ authenticated, userId: req.userId, authMethod: req.authMethod, }); }; // Add a timeout to prevent indefinite hanging const timeoutId = setTimeout(() => { logger.error('WebSocket auth timeout - auth middleware did not complete in time'); safeResolve({ authenticated: false }); }, 5000); // 5 second timeout // Call authMiddleware and handle potential async errors Promise.resolve(authMiddleware(req, res, next)) .then(() => { clearTimeout(timeoutId); }) .catch((error) => { clearTimeout(timeoutId); logger.error('Auth middleware error:', error); safeResolve({ authenticated: false }); }); }); if (!authResult.authenticated) { logger.debug('WebSocket connection rejected: unauthorized'); socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n'); socket.destroy(); return; } // Handle the upgrade wss.handleUpgrade(request, socket, head, (ws) => { // Add path and auth information to the request for routing const wsRequest = request as WebSocketRequest; wsRequest.pathname = parsedUrl.pathname; wsRequest.searchParams = parsedUrl.searchParams; wsRequest.userId = authResult.userId; wsRequest.authMethod = authResult.authMethod; wss.emit('connection', ws, wsRequest); }); }); // WebSocket connection router wss.on('connection', (ws, req) => { const wsReq = req as WebSocketRequest; const pathname = wsReq.pathname; const searchParams = wsReq.searchParams; if (pathname === '/buffers') { // Handle buffer updates WebSocket if (bufferAggregator) { bufferAggregator.handleClientConnection(ws); } else { logger.error('BufferAggregator not initialized for WebSocket connection'); ws.close(); } } else if (pathname === '/ws/input') { // Handle input WebSocket const sessionId = searchParams?.get('sessionId'); if (!sessionId) { logger.error('WebSocket input connection missing sessionId parameter'); ws.close(); return; } // Extract user ID from the authenticated request const userId = wsReq.userId || 'unknown'; websocketInputHandler.handleConnection(ws, sessionId, userId); } else if (pathname === '/ws/screencap-signal') { // Handle screencap WebRTC signaling from browser const _userId = wsReq.userId || 'unknown'; screencapUnixHandler.handleBrowserConnection(ws); } else { logger.error(`Unknown WebSocket path: ${pathname}`); ws.close(); } }); // Serve index.html for client-side routes (but not API routes) app.get('/', (_req, res) => { res.sendFile(path.join(publicPath, 'index.html')); }); // 404 handler for all other routes app.use((req, res) => { if (req.path.startsWith('/api/')) { res.status(404).json({ error: 'API endpoint not found' }); } else { res.status(404).sendFile(path.join(publicPath, '404.html'), (err) => { if (err) { res.status(404).send('404 - Page not found'); } }); } }); // Start server function const startServer = () => { const requestedPort = config.port !== null ? config.port : Number(process.env.PORT) || 4020; logger.log(`Starting server on port ${requestedPort}`); // Remove all existing error listeners first to prevent duplicates server.removeAllListeners('error'); // Add error handler for port already in use server.on('error', (error: NodeJS.ErrnoException) => { if (error.code === 'EADDRINUSE') { logger.error(`Port ${requestedPort} is already in use`); // Provide more helpful error message in development mode const isDevelopment = !process.env.BUILD_DATE || process.env.NODE_ENV === 'development'; if (isDevelopment) { logger.error(chalk.yellow('\nDevelopment mode options:')); logger.error( ` 1. Run server on different port: ${chalk.cyan('pnpm run dev:server --port 4021')}` ); logger.error(` 2. Use environment variable: ${chalk.cyan('PORT=4021 pnpm run dev')}`); logger.error( ' 3. Stop the existing server (check Activity Monitor for vibetunnel processes)' ); } else { logger.error( 'Please use a different port with --port or stop the existing server' ); } process.exit(9); // Exit with code 9 to indicate port conflict } else { logger.error('Server error:', error); process.exit(1); } }); const bindAddress = config.bind || '0.0.0.0'; server.listen(requestedPort, bindAddress, () => { const address = server.address(); const actualPort = typeof address === 'string' ? requestedPort : address?.port || requestedPort; const displayAddress = bindAddress === '0.0.0.0' ? 'localhost' : bindAddress; logger.log( chalk.green(`VibeTunnel Server running on http://${displayAddress}:${actualPort}`) ); if (config.noAuth) { logger.warn(chalk.yellow('Authentication: DISABLED (--no-auth)')); logger.warn('Anyone can access this server without authentication'); } else if (config.disallowUserPassword) { logger.log(chalk.green('Authentication: SSH KEYS ONLY (--disallow-user-password)')); logger.log(chalk.gray('Password authentication is disabled')); } else { logger.log(chalk.green('Authentication: SYSTEM USER PASSWORD')); if (config.enableSSHKeys) { logger.log(chalk.green('SSH Key Authentication: ENABLED')); } else { logger.log( chalk.gray('SSH Key Authentication: DISABLED (use --enable-ssh-keys to enable)') ); } } // Initialize HQ client now that we know the actual port if ( config.hqUrl && config.remoteName && (config.noHqAuth || (config.hqUsername && config.hqPassword)) ) { // Use the actual bind address for HQ registration // If bind is 0.0.0.0, we need to determine the actual network interface IP let remoteHost = bindAddress; if (bindAddress === '0.0.0.0') { // When binding to all interfaces, use the machine's hostname // This allows HQ to connect from the network remoteHost = os.hostname(); } const remoteUrl = `http://${remoteHost}:${actualPort}`; hqClient = new HQClient( config.hqUrl, config.hqUsername || 'no-auth', config.hqPassword || 'no-auth', config.remoteName, remoteUrl, remoteBearerToken || '' ); if (config.noHqAuth) { logger.log( chalk.yellow( `Remote mode: ${config.remoteName} registering WITHOUT HQ authentication (--no-hq-auth)` ) ); } else { logger.log( chalk.green(`Remote mode: ${config.remoteName} will accept Bearer token for HQ access`) ); logger.debug(`Bearer token: ${hqClient.getToken()}`); } } // Send message to parent process if running as child (for testing) // Skip in vitest environment to avoid channel conflicts if (process.send && !process.env.VITEST) { process.send({ type: 'server-started', port: actualPort }); } // Register with HQ if configured if (hqClient) { logger.log(`Registering with HQ at ${config.hqUrl}`); hqClient.register().catch((err) => { logger.error('Failed to register with HQ:', err); }); } // Start control directory watcher controlDirWatcher = new ControlDirWatcher({ controlDir: CONTROL_DIR, remoteRegistry, isHQMode: config.isHQMode, hqClient, ptyManager, }); controlDirWatcher.start(); logger.debug('Started control directory watcher'); // Start activity monitor activityMonitor.start(); logger.debug('Started activity monitor'); // Start mDNS advertisement if enabled if (config.enableMDNS) { mdnsService.startAdvertising(actualPort).catch((err) => { logger.error('Failed to start mDNS advertisement:', err); }); } else { logger.debug('mDNS advertisement disabled'); } }); }; return { app, server, wss, startServer, config, ptyManager, terminalManager, streamWatcher, remoteRegistry, hqClient, controlDirWatcher, bufferAggregator, activityMonitor, pushNotificationService, }; } // Track if server has been started let serverStarted = false; // Export a function to start the server export async function startVibeTunnelServer() { // Initialize logger if not already initialized (preserves debug mode from CLI) initLogger(); // Prevent multiple server instances if (serverStarted) { logger.error('Server already started, preventing duplicate instance'); logger.error('This should not happen - duplicate server startup detected'); process.exit(1); } serverStarted = true; logger.debug('Creating VibeTunnel application instance'); // Create and configure the app const appInstance = await createApp(); const { startServer, server, terminalManager, remoteRegistry, hqClient, controlDirWatcher, activityMonitor, config, } = appInstance; // Update debug mode based on config or environment variable if (config.debug || process.env.DEBUG === 'true') { setDebugMode(true); logger.log(chalk.gray('Debug logging enabled')); } startServer(); // Cleanup old terminals every 5 minutes const _terminalCleanupInterval = setInterval( () => { terminalManager.cleanup(5 * 60 * 1000); // 5 minutes }, 5 * 60 * 1000 ); logger.debug('Started terminal cleanup interval (5 minutes)'); // Cleanup inactive push subscriptions every 30 minutes let _subscriptionCleanupInterval: NodeJS.Timeout | null = null; if (appInstance.pushNotificationService) { _subscriptionCleanupInterval = setInterval( () => { appInstance.pushNotificationService?.cleanupInactiveSubscriptions().catch((error) => { logger.error('Failed to cleanup inactive subscriptions:', error); }); }, 30 * 60 * 1000 // 30 minutes ); logger.debug('Started subscription cleanup interval (30 minutes)'); } // Graceful shutdown let localShuttingDown = false; const shutdown = async () => { if (localShuttingDown) { logger.warn('Force exit...'); process.exit(1); } localShuttingDown = true; setShuttingDown(true); logger.log(chalk.yellow('\nShutting down...')); try { // Clear cleanup intervals clearInterval(_terminalCleanupInterval); if (_subscriptionCleanupInterval) { clearInterval(_subscriptionCleanupInterval); } logger.debug('Cleared cleanup intervals'); // Stop activity monitor activityMonitor.stop(); logger.debug('Stopped activity monitor'); // Stop mDNS advertisement if it was started if (mdnsService.isActive()) { await mdnsService.stopAdvertising(); logger.debug('Stopped mDNS advertisement'); } // Stop control directory watcher if (controlDirWatcher) { controlDirWatcher.stop(); logger.debug('Stopped control directory watcher'); } // Stop UNIX socket server try { const { screencapUnixHandler } = await import('./websocket/screencap-unix-handler.js'); screencapUnixHandler.stop(); logger.debug('Stopped UNIX socket server'); } catch (_error) { // Ignore if module not loaded } if (hqClient) { logger.debug('Destroying HQ client connection'); await hqClient.destroy(); } if (remoteRegistry) { logger.debug('Destroying remote registry'); remoteRegistry.destroy(); } server.close(() => { logger.log(chalk.green('Server closed successfully')); closeLogger(); process.exit(0); }); // Force exit after 5 seconds if graceful shutdown fails setTimeout(() => { logger.warn('Graceful shutdown timeout, forcing exit...'); closeLogger(); process.exit(1); }, 5000); } catch (error) { logger.error('Error during shutdown:', error); closeLogger(); process.exit(1); } }; process.on('SIGINT', shutdown); process.on('SIGTERM', shutdown); logger.debug('Registered signal handlers for graceful shutdown'); } // Export for testing export * from './version.js';