vnc sidecar: per-user desktop sessions via sidecar architecture
replaces the single hardcoded systemd VNC service with a dynamic sidecar that manages per-user VNC sessions on demand. any authenticated user can now access their own desktop, not just Super Admin. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
import type { SidecarCommand, SidecarEvent } from '../protocol';
|
||||
import * as vncManager from './vnc-manager';
|
||||
import { createSidecarConnector } from '../connect';
|
||||
|
||||
const API_URL = process.env.API_URL ?? `ws://127.0.0.1:${process.env.PORT ?? '5000'}`;
|
||||
|
||||
// ── Command handlers ──
|
||||
|
||||
type ReplyFn = (msg: SidecarEvent) => void;
|
||||
|
||||
async function handleCommand(cmd: SidecarCommand, reply: ReplyFn) {
|
||||
switch (cmd.type) {
|
||||
case 'ping':
|
||||
reply({ type: 'pong', id: cmd.id });
|
||||
break;
|
||||
|
||||
case 'vnc:start': {
|
||||
try {
|
||||
const { port, display } = await vncManager.startSession(cmd.params);
|
||||
reply({ type: 'vnc:started', id: cmd.id, port, display });
|
||||
} catch (err) {
|
||||
reply({ type: 'vnc:error', id: cmd.id, error: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'vnc:stop':
|
||||
vncManager.stopSession(cmd.email);
|
||||
reply({ type: 'vnc:stopped', id: cmd.id });
|
||||
break;
|
||||
|
||||
case 'vnc:status': {
|
||||
const session = vncManager.getSession(cmd.email);
|
||||
reply({ type: 'vnc:status', id: cmd.id, session });
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
reply({
|
||||
type: 'error',
|
||||
id: (cmd as SidecarCommand).id,
|
||||
error: `Unknown command type: ${(cmd as Record<string, unknown>).type}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Connect to API server ──
|
||||
|
||||
const connection = createSidecarConnector({
|
||||
apiUrl: `${API_URL}/api/sidecar/register`,
|
||||
name: 'vnc',
|
||||
capabilities: ['vnc'],
|
||||
onCommand(cmd, reply) {
|
||||
handleCommand(cmd as SidecarCommand, reply as ReplyFn);
|
||||
},
|
||||
});
|
||||
|
||||
// ── Graceful shutdown ──
|
||||
|
||||
function shutdown(signal: string) {
|
||||
console.log(`[vnc] ${signal} received, stopping all sessions...`);
|
||||
vncManager.stopAll();
|
||||
connection.destroy();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||
@@ -0,0 +1,180 @@
|
||||
import { existsSync, mkdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import type { VncStartParams, VncSessionInfo } from '../protocol';
|
||||
import { getHomeDirForRole, toShellUsername } from '@@/data-path';
|
||||
|
||||
type VncSession = {
|
||||
email: string;
|
||||
username: string;
|
||||
display: number;
|
||||
port: number;
|
||||
pid: number;
|
||||
};
|
||||
|
||||
const sessions = new Map<string, VncSession>();
|
||||
|
||||
function findFreeDisplay(): number {
|
||||
for (let n = 1; n <= 99; n++) {
|
||||
if (existsSync(`/tmp/.X${n}-lock`)) continue;
|
||||
// Also check no existing session uses this display
|
||||
let inUse = false;
|
||||
for (const s of sessions.values()) {
|
||||
if (s.display === n) {
|
||||
inUse = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!inUse) return n;
|
||||
}
|
||||
throw new Error('No free display number available');
|
||||
}
|
||||
|
||||
function isProcessAlive(pid: number): boolean {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureVncEnv(homeDir: string): Promise<void> {
|
||||
const vncDir = join(homeDir, '.vnc');
|
||||
if (existsSync(join(vncDir, 'passwd'))) return;
|
||||
|
||||
mkdirSync(vncDir, { recursive: true });
|
||||
|
||||
// Generate random 8-char password
|
||||
const password = Array.from(crypto.getRandomValues(new Uint8Array(6)))
|
||||
.map((b) => String.fromCharCode(33 + (b % 94)))
|
||||
.join('');
|
||||
|
||||
// Write plaintext password (for API to read)
|
||||
await Bun.write(join(vncDir, 'password'), password);
|
||||
|
||||
// Create encrypted passwd using vncpasswd -f
|
||||
const proc = Bun.spawnSync({
|
||||
cmd: ['bash', '-c', `echo '${password.replace(/'/g, "'\\''")}' | vncpasswd -f`],
|
||||
stdout: 'pipe',
|
||||
stderr: 'ignore',
|
||||
});
|
||||
if (proc.exitCode === 0 && proc.stdout.byteLength > 0) {
|
||||
await Bun.write(join(vncDir, 'passwd'), proc.stdout);
|
||||
}
|
||||
|
||||
// Write xstartup
|
||||
await Bun.write(
|
||||
join(vncDir, 'xstartup'),
|
||||
`#!/bin/sh\nunset SESSION_MANAGER\nunset DBUS_SESSION_BUS_ADDRESS\neval $(dbus-launch --sh-syntax)\nexport DBUS_SESSION_BUS_ADDRESS\nexec startxfce4\n`,
|
||||
);
|
||||
|
||||
Bun.spawnSync({ cmd: ['chmod', '+x', join(vncDir, 'xstartup')], stdout: 'ignore', stderr: 'ignore' });
|
||||
Bun.spawnSync({ cmd: ['chmod', '600', join(vncDir, 'passwd')], stdout: 'ignore', stderr: 'ignore' });
|
||||
Bun.spawnSync({ cmd: ['chmod', '600', join(vncDir, 'password')], stdout: 'ignore', stderr: 'ignore' });
|
||||
|
||||
console.log(`[vnc] lazy-provisioned VNC environment at ${vncDir}`);
|
||||
}
|
||||
|
||||
export async function startSession(params: VncStartParams): Promise<{ port: number; display: number }> {
|
||||
// If session already exists and is alive, return it
|
||||
const existing = sessions.get(params.email);
|
||||
if (existing && isProcessAlive(existing.pid)) {
|
||||
return { port: existing.port, display: existing.display };
|
||||
}
|
||||
|
||||
// Clean up stale session
|
||||
if (existing) {
|
||||
sessions.delete(params.email);
|
||||
}
|
||||
|
||||
const shellUsername = toShellUsername(params.username, params.email);
|
||||
const homeDir = getHomeDirForRole(params.email, params.role);
|
||||
const resolution = params.resolution ?? '1920x1080';
|
||||
const display = findFreeDisplay();
|
||||
const port = 5900 + display;
|
||||
|
||||
// Lazy-provision VNC environment if missing
|
||||
await ensureVncEnv(homeDir);
|
||||
|
||||
// Spawn VNC server as the target user
|
||||
const proc = Bun.spawn({
|
||||
cmd: [
|
||||
'sudo',
|
||||
'-u',
|
||||
shellUsername,
|
||||
'vncserver',
|
||||
`:${display}`,
|
||||
'-geometry',
|
||||
resolution,
|
||||
'-depth',
|
||||
'24',
|
||||
'-localhost',
|
||||
'yes',
|
||||
],
|
||||
env: { ...process.env, HOME: homeDir },
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
});
|
||||
|
||||
const exitCode = await proc.exited;
|
||||
if (exitCode !== 0) {
|
||||
const stderr = await new Response(proc.stderr).text();
|
||||
throw new Error(`vncserver failed (exit ${exitCode}): ${stderr.trim()}`);
|
||||
}
|
||||
|
||||
// Read PID from lock file
|
||||
let pid = 0;
|
||||
const lockFile = `/tmp/.X${display}-lock`;
|
||||
if (existsSync(lockFile)) {
|
||||
const content = await Bun.file(lockFile).text();
|
||||
pid = parseInt(content.trim(), 10) || 0;
|
||||
}
|
||||
|
||||
sessions.set(params.email, { email: params.email, username: params.username, display, port, pid });
|
||||
console.log(`[vnc] started session for ${params.email} on :${display} (port ${port}, pid ${pid})`);
|
||||
|
||||
return { port, display };
|
||||
}
|
||||
|
||||
export function stopSession(email: string): void {
|
||||
const session = sessions.get(email);
|
||||
if (!session) return;
|
||||
|
||||
const shellUsername = toShellUsername(session.username, email);
|
||||
const homeDir = getHomeDirForRole(email, null);
|
||||
|
||||
Bun.spawnSync({
|
||||
cmd: ['sudo', '-u', shellUsername, 'vncserver', '-kill', `:${session.display}`],
|
||||
env: { ...process.env, HOME: homeDir },
|
||||
stdout: 'ignore',
|
||||
stderr: 'ignore',
|
||||
});
|
||||
|
||||
sessions.delete(email);
|
||||
console.log(`[vnc] stopped session for ${email} on :${session.display}`);
|
||||
}
|
||||
|
||||
export function getSession(email: string): VncSessionInfo | null {
|
||||
const session = sessions.get(email);
|
||||
if (!session) return null;
|
||||
|
||||
const alive = session.pid > 0 && isProcessAlive(session.pid);
|
||||
if (!alive) {
|
||||
sessions.delete(email);
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
email: session.email,
|
||||
display: session.display,
|
||||
port: session.port,
|
||||
pid: session.pid,
|
||||
alive,
|
||||
};
|
||||
}
|
||||
|
||||
export function stopAll(): void {
|
||||
for (const email of [...sessions.keys()]) {
|
||||
stopSession(email);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user