mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-04-25 14:57:37 +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 { TerminalManager } from './terminal-manager.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']>>;
|
||||
|
||||
|
|
@ -14,7 +16,60 @@ const app = express();
|
|||
const server = createServer(app);
|
||||
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
|
||||
const possibleTtyFwdPaths = [
|
||||
|
|
@ -53,6 +108,23 @@ const terminalManager = new TerminalManager(TTY_FWD_CONTROL_DIR);
|
|||
// Initialize Stream Watcher for efficient file streaming
|
||||
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
|
||||
if (!fs.existsSync(TTY_FWD_CONTROL_DIR)) {
|
||||
fs.mkdirSync(TTY_FWD_CONTROL_DIR, { recursive: true });
|
||||
|
|
@ -83,6 +155,35 @@ function resolvePath(inputPath: string, fallback?: string): string {
|
|||
// Middleware
|
||||
app.use(express.json());
|
||||
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')));
|
||||
|
||||
// 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 ===
|
||||
|
||||
// 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
|
||||
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) {
|
||||
console.error('Error getting session buffer stats:', error);
|
||||
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
|
||||
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 isHotReload = url.searchParams.get('hotReload') === 'true';
|
||||
|
||||
|
|
@ -911,6 +1149,23 @@ if (process.env.NODE_ENV !== 'test') {
|
|||
server.listen(PORT, () => {
|
||||
console.log(`VibeTunnel New Server running on http://localhost:${PORT}`);
|
||||
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
|
||||
|
|
@ -920,6 +1175,23 @@ if (process.env.NODE_ENV !== 'test') {
|
|||
},
|
||||
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
|
||||
|
|
|
|||
Loading…
Reference in a new issue