mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-04-27 15:17:38 +00:00
feat: implement basic HQ mode infrastructure
- Add --hq flag to enable HQ mode for centralized management - Add --join-hq flag for remote servers to register with HQ - Implement RemoteRegistry for managing remote server connections - Add HQClient for remote servers to register and send heartbeats - Add /api/remotes endpoints for registration, heartbeat, and listing - Enforce HTTPS requirement for HQ URLs for security - Add graceful shutdown handling for HQ components 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
85036ba74b
commit
8e9f2485d3
3 changed files with 510 additions and 3 deletions
143
web/src/hq-client.ts
Normal file
143
web/src/hq-client.ts
Normal file
|
|
@ -0,0 +1,143 @@
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
import * as os from 'os';
|
||||||
|
|
||||||
|
export class HQClient {
|
||||||
|
private readonly hqUrl: string;
|
||||||
|
private readonly remoteId: string;
|
||||||
|
private readonly remoteName: string;
|
||||||
|
private token: string;
|
||||||
|
private heartbeatInterval: NodeJS.Timeout | null = null;
|
||||||
|
private registrationRetryTimeout: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
|
constructor(hqUrl: string, password: string) {
|
||||||
|
this.hqUrl = hqUrl;
|
||||||
|
this.remoteId = uuidv4();
|
||||||
|
this.remoteName = `${os.hostname()}-${process.pid}`;
|
||||||
|
this.token = this.generateToken();
|
||||||
|
|
||||||
|
// Store password for future use
|
||||||
|
this.password = password;
|
||||||
|
}
|
||||||
|
|
||||||
|
private password: string;
|
||||||
|
|
||||||
|
private generateToken(): string {
|
||||||
|
return uuidv4();
|
||||||
|
}
|
||||||
|
|
||||||
|
async register(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${this.hqUrl}/api/remotes/register`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
id: this.remoteId,
|
||||||
|
name: this.remoteName,
|
||||||
|
url: `http://localhost:${process.env.PORT || 4020}`,
|
||||||
|
token: this.token,
|
||||||
|
password: this.password,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Registration failed: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Successfully registered with HQ at ${this.hqUrl}`);
|
||||||
|
this.startHeartbeat();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to register with HQ:', error);
|
||||||
|
// Retry registration after 5 seconds
|
||||||
|
this.registrationRetryTimeout = setTimeout(() => this.register(), 5000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async sendHeartbeat(): Promise<void> {
|
||||||
|
try {
|
||||||
|
// Get session count from the session manager
|
||||||
|
const sessionCount = await this.getSessionCount();
|
||||||
|
|
||||||
|
const response = await fetch(`${this.hqUrl}/api/remotes/${this.remoteId}/heartbeat`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${this.token}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
sessionCount,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
console.error('Heartbeat failed:', response.statusText);
|
||||||
|
// Re-register if heartbeat fails
|
||||||
|
this.stopHeartbeat();
|
||||||
|
await this.register();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to send heartbeat:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private startHeartbeat(): void {
|
||||||
|
// Send heartbeat every 15 seconds
|
||||||
|
this.heartbeatInterval = setInterval(() => {
|
||||||
|
this.sendHeartbeat();
|
||||||
|
}, 15000);
|
||||||
|
|
||||||
|
// Send first heartbeat immediately
|
||||||
|
this.sendHeartbeat();
|
||||||
|
}
|
||||||
|
|
||||||
|
private stopHeartbeat(): void {
|
||||||
|
if (this.heartbeatInterval) {
|
||||||
|
clearInterval(this.heartbeatInterval);
|
||||||
|
this.heartbeatInterval = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getSessionCount(): Promise<number> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`http://localhost:${process.env.PORT || 4020}/api/sessions`, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Basic ${Buffer.from(`user:${this.password}`).toString('base64')}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const sessions = await response.json();
|
||||||
|
return Array.isArray(sessions) ? sessions.length : 0;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore errors
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy(): void {
|
||||||
|
this.stopHeartbeat();
|
||||||
|
if (this.registrationRetryTimeout) {
|
||||||
|
clearTimeout(this.registrationRetryTimeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to unregister
|
||||||
|
fetch(`${this.hqUrl}/api/remotes/${this.remoteId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${this.token}`,
|
||||||
|
},
|
||||||
|
}).catch(() => {
|
||||||
|
// Ignore errors during shutdown
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getRemoteId(): string {
|
||||||
|
return this.remoteId;
|
||||||
|
}
|
||||||
|
|
||||||
|
getToken(): string {
|
||||||
|
return this.token;
|
||||||
|
}
|
||||||
|
}
|
||||||
92
web/src/remote-registry.ts
Normal file
92
web/src/remote-registry.ts
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
export interface RemoteServer {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
token: string;
|
||||||
|
registeredAt: Date;
|
||||||
|
lastHeartbeat: Date;
|
||||||
|
sessionCount: number;
|
||||||
|
status: 'online' | 'offline';
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RemoteRegistry {
|
||||||
|
private remotes: Map<string, RemoteServer> = new Map();
|
||||||
|
private heartbeatInterval: NodeJS.Timeout | null = null;
|
||||||
|
private readonly HEARTBEAT_TIMEOUT = 30000; // 30 seconds
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.startHeartbeatChecker();
|
||||||
|
}
|
||||||
|
|
||||||
|
register(remote: Omit<RemoteServer, 'registeredAt' | 'lastHeartbeat' | 'status'>): RemoteServer {
|
||||||
|
const now = new Date();
|
||||||
|
const registeredRemote: RemoteServer = {
|
||||||
|
...remote,
|
||||||
|
registeredAt: now,
|
||||||
|
lastHeartbeat: now,
|
||||||
|
status: 'online',
|
||||||
|
};
|
||||||
|
|
||||||
|
this.remotes.set(remote.id, registeredRemote);
|
||||||
|
console.log(`Remote registered: ${remote.name} (${remote.id}) from ${remote.url}`);
|
||||||
|
|
||||||
|
return registeredRemote;
|
||||||
|
}
|
||||||
|
|
||||||
|
unregister(remoteId: string): boolean {
|
||||||
|
const remote = this.remotes.get(remoteId);
|
||||||
|
if (remote) {
|
||||||
|
console.log(`Remote unregistered: ${remote.name} (${remoteId})`);
|
||||||
|
return this.remotes.delete(remoteId);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateHeartbeat(remoteId: string, sessionCount: number): boolean {
|
||||||
|
const remote = this.remotes.get(remoteId);
|
||||||
|
if (remote) {
|
||||||
|
remote.lastHeartbeat = new Date();
|
||||||
|
remote.sessionCount = sessionCount;
|
||||||
|
remote.status = 'online';
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
getRemote(remoteId: string): RemoteServer | undefined {
|
||||||
|
return this.remotes.get(remoteId);
|
||||||
|
}
|
||||||
|
|
||||||
|
getRemoteByUrl(url: string): RemoteServer | undefined {
|
||||||
|
return Array.from(this.remotes.values()).find((r) => r.url === url);
|
||||||
|
}
|
||||||
|
|
||||||
|
getAllRemotes(): RemoteServer[] {
|
||||||
|
return Array.from(this.remotes.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
getOnlineRemotes(): RemoteServer[] {
|
||||||
|
return this.getAllRemotes().filter((r) => r.status === 'online');
|
||||||
|
}
|
||||||
|
|
||||||
|
private startHeartbeatChecker() {
|
||||||
|
this.heartbeatInterval = setInterval(() => {
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
for (const remote of this.remotes.values()) {
|
||||||
|
const timeSinceLastHeartbeat = now - remote.lastHeartbeat.getTime();
|
||||||
|
|
||||||
|
if (timeSinceLastHeartbeat > this.HEARTBEAT_TIMEOUT && remote.status === 'online') {
|
||||||
|
remote.status = 'offline';
|
||||||
|
console.log(`Remote went offline: ${remote.name} (${remote.id})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 10000); // Check every 10 seconds
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
if (this.heartbeatInterval) {
|
||||||
|
clearInterval(this.heartbeatInterval);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -7,6 +7,8 @@ import * as os from 'os';
|
||||||
import { PtyService, PtyError } from './pty/index.js';
|
import { PtyService, PtyError } from './pty/index.js';
|
||||||
import { TerminalManager } from './terminal-manager.js';
|
import { TerminalManager } from './terminal-manager.js';
|
||||||
import { StreamWatcher } from './stream-watcher.js';
|
import { StreamWatcher } from './stream-watcher.js';
|
||||||
|
import { RemoteRegistry } from './remote-registry.js';
|
||||||
|
import { HQClient } from './hq-client.js';
|
||||||
|
|
||||||
type BufferSnapshot = Awaited<ReturnType<TerminalManager['getBufferSnapshot']>>;
|
type BufferSnapshot = Awaited<ReturnType<TerminalManager['getBufferSnapshot']>>;
|
||||||
|
|
||||||
|
|
@ -14,7 +16,60 @@ const app = express();
|
||||||
const server = createServer(app);
|
const server = createServer(app);
|
||||||
const wss = new WebSocketServer({ server });
|
const wss = new WebSocketServer({ server });
|
||||||
|
|
||||||
const PORT = process.env.PORT || 3000;
|
const PORT = process.env.PORT || 4020;
|
||||||
|
|
||||||
|
// Parse command line arguments
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
let basicAuthPassword: string | null = null;
|
||||||
|
let isHQMode = false;
|
||||||
|
let joinHQUrl: string | null = null;
|
||||||
|
|
||||||
|
// Check for command line arguments
|
||||||
|
for (let i = 0; i < args.length; i++) {
|
||||||
|
if (args[i] === '--password' && i + 1 < args.length) {
|
||||||
|
basicAuthPassword = args[i + 1];
|
||||||
|
i++; // Skip the password value in next iteration
|
||||||
|
} else if (args[i] === '--hq') {
|
||||||
|
isHQMode = true;
|
||||||
|
} else if (args[i] === '--join-hq' && i + 1 < args.length) {
|
||||||
|
joinHQUrl = args[i + 1];
|
||||||
|
i++; // Skip the URL value in next iteration
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to environment variable if no --password argument
|
||||||
|
if (!basicAuthPassword && process.env.VIBETUNNEL_PASSWORD) {
|
||||||
|
basicAuthPassword = process.env.VIBETUNNEL_PASSWORD;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate join-hq URL
|
||||||
|
if (joinHQUrl) {
|
||||||
|
try {
|
||||||
|
const url = new URL(joinHQUrl);
|
||||||
|
if (url.protocol !== 'https:') {
|
||||||
|
console.error(`${RED}ERROR: --join-hq URL must use HTTPS protocol${RESET}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
console.error(`${RED}ERROR: Invalid --join-hq URL: ${joinHQUrl}${RESET}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ANSI color codes
|
||||||
|
const RED = '\x1b[31m';
|
||||||
|
const YELLOW = '\x1b[33m';
|
||||||
|
const GREEN = '\x1b[32m';
|
||||||
|
const RESET = '\x1b[0m';
|
||||||
|
|
||||||
|
if (basicAuthPassword) {
|
||||||
|
console.log(`${GREEN}Basic authentication enabled${RESET}`);
|
||||||
|
} else {
|
||||||
|
console.log(`${RED}WARNING: No authentication configured!${RESET}`);
|
||||||
|
console.log(
|
||||||
|
`${YELLOW}Set VIBETUNNEL_PASSWORD environment variable or use --password flag to enable authentication.${RESET}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// tty-fwd binary path - check multiple possible locations
|
// tty-fwd binary path - check multiple possible locations
|
||||||
const possibleTtyFwdPaths = [
|
const possibleTtyFwdPaths = [
|
||||||
|
|
@ -53,6 +108,23 @@ const terminalManager = new TerminalManager(TTY_FWD_CONTROL_DIR);
|
||||||
// Initialize Stream Watcher for efficient file streaming
|
// Initialize Stream Watcher for efficient file streaming
|
||||||
const streamWatcher = new StreamWatcher();
|
const streamWatcher = new StreamWatcher();
|
||||||
|
|
||||||
|
// Initialize HQ components
|
||||||
|
let remoteRegistry: RemoteRegistry | null = null;
|
||||||
|
let hqClient: HQClient | null = null;
|
||||||
|
|
||||||
|
if (isHQMode) {
|
||||||
|
remoteRegistry = new RemoteRegistry();
|
||||||
|
console.log(`${GREEN}Running in HQ mode${RESET}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (joinHQUrl && basicAuthPassword) {
|
||||||
|
hqClient = new HQClient(joinHQUrl, basicAuthPassword);
|
||||||
|
console.log(`${GREEN}Will register with HQ at: ${joinHQUrl}${RESET}`);
|
||||||
|
} else if (joinHQUrl && !basicAuthPassword) {
|
||||||
|
console.error(`${RED}ERROR: --join-hq requires --password to be set${RESET}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
// Ensure control directory exists
|
// Ensure control directory exists
|
||||||
if (!fs.existsSync(TTY_FWD_CONTROL_DIR)) {
|
if (!fs.existsSync(TTY_FWD_CONTROL_DIR)) {
|
||||||
fs.mkdirSync(TTY_FWD_CONTROL_DIR, { recursive: true });
|
fs.mkdirSync(TTY_FWD_CONTROL_DIR, { recursive: true });
|
||||||
|
|
@ -83,6 +155,35 @@ function resolvePath(inputPath: string, fallback?: string): string {
|
||||||
// Middleware
|
// Middleware
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
app.use(express.urlencoded({ extended: true }));
|
app.use(express.urlencoded({ extended: true }));
|
||||||
|
|
||||||
|
// Basic authentication middleware
|
||||||
|
if (basicAuthPassword) {
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
// Skip auth for WebSocket upgrade requests (handled separately)
|
||||||
|
if (req.headers.upgrade === 'websocket') {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
const authHeader = req.headers.authorization;
|
||||||
|
|
||||||
|
if (!authHeader || !authHeader.startsWith('Basic ')) {
|
||||||
|
res.setHeader('WWW-Authenticate', 'Basic realm="VibeTunnel"');
|
||||||
|
return res.status(401).json({ error: 'Authentication required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const base64Credentials = authHeader.slice(6);
|
||||||
|
const credentials = Buffer.from(base64Credentials, 'base64').toString('utf-8');
|
||||||
|
const [_username, password] = credentials.split(':');
|
||||||
|
|
||||||
|
if (password !== basicAuthPassword) {
|
||||||
|
res.setHeader('WWW-Authenticate', 'Basic realm="VibeTunnel"');
|
||||||
|
return res.status(401).json({ error: 'Invalid credentials' });
|
||||||
|
}
|
||||||
|
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
app.use(express.static(path.join(__dirname, '..', 'public')));
|
app.use(express.static(path.join(__dirname, '..', 'public')));
|
||||||
|
|
||||||
// Hot reload functionality for development
|
// Hot reload functionality for development
|
||||||
|
|
@ -233,6 +334,121 @@ app.post('/api/cleanup-exited', async (req, res) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// === HQ MODE ENDPOINTS ===
|
||||||
|
|
||||||
|
// Register a remote server (HQ mode only)
|
||||||
|
app.post('/api/remotes/register', (req, res) => {
|
||||||
|
if (!isHQMode || !remoteRegistry) {
|
||||||
|
return res.status(404).json({ error: 'HQ mode not enabled' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id, name, url, token, password } = req.body;
|
||||||
|
|
||||||
|
if (!id || !name || !url || !token || !password) {
|
||||||
|
return res.status(400).json({ error: 'Missing required fields' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the password matches
|
||||||
|
if (password !== basicAuthPassword) {
|
||||||
|
return res.status(401).json({ error: 'Invalid password' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const remote = remoteRegistry.register({
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
url,
|
||||||
|
token,
|
||||||
|
sessionCount: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ success: true, remote: { id: remote.id, name: remote.name } });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Heartbeat from remote server
|
||||||
|
app.post('/api/remotes/:remoteId/heartbeat', (req, res) => {
|
||||||
|
if (!isHQMode || !remoteRegistry) {
|
||||||
|
return res.status(404).json({ error: 'HQ mode not enabled' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { remoteId } = req.params;
|
||||||
|
const { sessionCount } = req.body;
|
||||||
|
const authHeader = req.headers.authorization;
|
||||||
|
|
||||||
|
// Verify token
|
||||||
|
const remote = remoteRegistry.getRemote(remoteId);
|
||||||
|
if (!remote) {
|
||||||
|
return res.status(404).json({ error: 'Remote not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||||
|
return res.status(401).json({ error: 'Missing authorization' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = authHeader.slice(7);
|
||||||
|
if (token !== remote.token) {
|
||||||
|
return res.status(401).json({ error: 'Invalid token' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const success = remoteRegistry.updateHeartbeat(remoteId, sessionCount || 0);
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
res.json({ success: true });
|
||||||
|
} else {
|
||||||
|
res.status(404).json({ error: 'Remote not found' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Unregister remote
|
||||||
|
app.delete('/api/remotes/:remoteId', (req, res) => {
|
||||||
|
if (!isHQMode || !remoteRegistry) {
|
||||||
|
return res.status(404).json({ error: 'HQ mode not enabled' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { remoteId } = req.params;
|
||||||
|
const authHeader = req.headers.authorization;
|
||||||
|
|
||||||
|
// Verify token
|
||||||
|
const remote = remoteRegistry.getRemote(remoteId);
|
||||||
|
if (!remote) {
|
||||||
|
return res.status(404).json({ error: 'Remote not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||||
|
return res.status(401).json({ error: 'Missing authorization' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = authHeader.slice(7);
|
||||||
|
if (token !== remote.token) {
|
||||||
|
return res.status(401).json({ error: 'Invalid token' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const success = remoteRegistry.unregister(remoteId);
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
res.json({ success: true });
|
||||||
|
} else {
|
||||||
|
res.status(404).json({ error: 'Remote not found' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// List all remotes (HQ mode only)
|
||||||
|
app.get('/api/remotes', (req, res) => {
|
||||||
|
if (!isHQMode || !remoteRegistry) {
|
||||||
|
return res.status(404).json({ error: 'HQ mode not enabled' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const remotes = remoteRegistry.getAllRemotes().map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
name: r.name,
|
||||||
|
url: r.url,
|
||||||
|
status: r.status,
|
||||||
|
sessionCount: r.sessionCount,
|
||||||
|
lastHeartbeat: r.lastHeartbeat,
|
||||||
|
}));
|
||||||
|
|
||||||
|
res.json(remotes);
|
||||||
|
});
|
||||||
|
|
||||||
// === TERMINAL I/O ===
|
// === TERMINAL I/O ===
|
||||||
|
|
||||||
// Live streaming cast file for XTerm renderer
|
// Live streaming cast file for XTerm renderer
|
||||||
|
|
@ -416,9 +632,12 @@ app.get('/api/sessions/:sessionId/buffer/stats', async (req, res) => {
|
||||||
|
|
||||||
// Add last modified time from stream file
|
// Add last modified time from stream file
|
||||||
const fileStats = fs.statSync(streamOutPath);
|
const fileStats = fs.statSync(streamOutPath);
|
||||||
stats.lastModified = fileStats.mtime.toISOString();
|
const statsWithLastModified = {
|
||||||
|
...stats,
|
||||||
|
lastModified: fileStats.mtime.toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
res.json(stats);
|
res.json(statsWithLastModified);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error getting session buffer stats:', error);
|
console.error('Error getting session buffer stats:', error);
|
||||||
res.status(500).json({ error: 'Failed to get session buffer stats' });
|
res.status(500).json({ error: 'Failed to get session buffer stats' });
|
||||||
|
|
@ -868,6 +1087,25 @@ function sendBinaryBuffer(ws: WebSocket, sessionId: string, snapshot: BufferSnap
|
||||||
|
|
||||||
// WebSocket connections
|
// WebSocket connections
|
||||||
wss.on('connection', (ws, req) => {
|
wss.on('connection', (ws, req) => {
|
||||||
|
// Check basic auth for WebSocket connections if password is set
|
||||||
|
if (basicAuthPassword) {
|
||||||
|
const authHeader = req.headers.authorization;
|
||||||
|
|
||||||
|
if (!authHeader || !authHeader.startsWith('Basic ')) {
|
||||||
|
ws.close(1008, 'Authentication required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const base64Credentials = authHeader.slice(6);
|
||||||
|
const credentials = Buffer.from(base64Credentials, 'base64').toString('utf-8');
|
||||||
|
const [_username, password] = credentials.split(':');
|
||||||
|
|
||||||
|
if (password !== basicAuthPassword) {
|
||||||
|
ws.close(1008, 'Invalid credentials');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const url = new URL(req.url ?? '', `http://${req.headers.host}`);
|
const url = new URL(req.url ?? '', `http://${req.headers.host}`);
|
||||||
const isHotReload = url.searchParams.get('hotReload') === 'true';
|
const isHotReload = url.searchParams.get('hotReload') === 'true';
|
||||||
|
|
||||||
|
|
@ -911,6 +1149,23 @@ if (process.env.NODE_ENV !== 'test') {
|
||||||
server.listen(PORT, () => {
|
server.listen(PORT, () => {
|
||||||
console.log(`VibeTunnel New Server running on http://localhost:${PORT}`);
|
console.log(`VibeTunnel New Server running on http://localhost:${PORT}`);
|
||||||
console.log(`Using tty-fwd: ${TTY_FWD_PATH}`);
|
console.log(`Using tty-fwd: ${TTY_FWD_PATH}`);
|
||||||
|
if (basicAuthPassword) {
|
||||||
|
console.log(`${GREEN}Basic authentication: ENABLED${RESET}`);
|
||||||
|
console.log('Username: <any username>');
|
||||||
|
console.log(`Password: ${basicAuthPassword}`);
|
||||||
|
} else {
|
||||||
|
console.log(`${RED}⚠️ WARNING: Server running without authentication!${RESET}`);
|
||||||
|
console.log(
|
||||||
|
`${YELLOW}Anyone can access this server. Use --password or set VIBETUNNEL_PASSWORD.${RESET}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register with HQ if configured
|
||||||
|
if (hqClient) {
|
||||||
|
hqClient.register().catch((err) => {
|
||||||
|
console.error('Failed to register with HQ:', err);
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Cleanup old terminals every 5 minutes
|
// Cleanup old terminals every 5 minutes
|
||||||
|
|
@ -920,6 +1175,23 @@ if (process.env.NODE_ENV !== 'test') {
|
||||||
},
|
},
|
||||||
5 * 60 * 1000
|
5 * 60 * 1000
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Graceful shutdown
|
||||||
|
process.on('SIGINT', () => {
|
||||||
|
console.log('\nShutting down...');
|
||||||
|
|
||||||
|
if (hqClient) {
|
||||||
|
hqClient.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (remoteRegistry) {
|
||||||
|
remoteRegistry.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
server.close(() => {
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Export for testing
|
// Export for testing
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue