mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-03-29 10:05:53 +00:00
1130 lines
37 KiB
TypeScript
1130 lines
37 KiB
TypeScript
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 <number> Server port (default: 4020 or PORT env var)
|
|
--bind <address> 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> 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 <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 <url> HQ server URL to register with
|
|
--hq-username <user> Username for HQ authentication
|
|
--hq-password <pass> Password for HQ authentication
|
|
--name <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<typeof parseArgs>) {
|
|
// 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<typeof createServer>;
|
|
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<AppInstance> {
|
|
// 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<string, string> = {};
|
|
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 <number> 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';
|