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:
2026-03-06 10:49:53 +00:00
co-authored by Claude Opus 4.6
parent a0d9ea63f9
commit daf5580c39
12 changed files with 467 additions and 190 deletions
+10
View File
@@ -1,5 +1,6 @@
import { createRouter } from '../../create-router';
import { getVncPassword } from './vnc-config';
import * as sidecar from '@@/sidecar-registry';
export const desktopRouter = createRouter();
@@ -11,3 +12,12 @@ desktopRouter.get('/vnc-password', async (ctx) => {
}
return ctx.json({ password });
});
desktopRouter.get('/vnc-status', async (ctx) => {
const user = ctx.get('user');
if (!sidecar.isVncConnected()) {
return ctx.json({ connected: false, session: null });
}
const session = await sidecar.getVncStatus(user.email);
return ctx.json({ connected: true, session });
});
+2 -12
View File
@@ -1,11 +1,8 @@
import { join } from 'node:path';
import { getHomeDir } from '@@/data-path';
import { getHomeDirForRole } from '@@/data-path';
function getVncDir(email: string, role: string | null): string {
const home = role === 'Super Admin' && process.env.HOME_DIR
? process.env.HOME_DIR
: getHomeDir(email);
return join(home, '.vnc');
return join(getHomeDirForRole(email, role), '.vnc');
}
export async function getVncPassword(email: string, role: string | null): Promise<string | null> {
@@ -13,10 +10,3 @@ export async function getVncPassword(email: string, role: string | null): Promis
if (!(await file.exists())) return null;
return (await file.text()).trim();
}
export async function getVncPort(email: string, role: string | null): Promise<number> {
const file = Bun.file(join(getVncDir(email, role), 'port'));
if (!(await file.exists())) return 5901;
const port = parseInt((await file.text()).trim(), 10);
return isNaN(port) ? 5901 : port;
}
+32 -11
View File
@@ -1,6 +1,6 @@
import type { ServerWebSocket } from 'bun';
import type { Socket } from 'bun';
import { getVncPort } from './vnc-config';
import * as sidecar from '@@/sidecar-registry';
type WSData = { userId: number; email: string; username: string; role: string; sandboxed: boolean; sessionId?: string };
@@ -13,15 +13,25 @@ const sessions = new Map<ServerWebSocket<WSData>, VncSession>();
export const desktopWebsocket = {
async open(ws: ServerWebSocket<WSData>) {
if (ws.data.role !== 'Super Admin') {
ws.close(4003, 'Forbidden');
return;
}
const port = await getVncPort(ws.data.email, ws.data.role);
const session: VncSession = { tcpSocket: null, pendingMessages: [] };
sessions.set(ws, session);
let port: number;
try {
const result = await sidecar.startVnc({
email: ws.data.email,
username: ws.data.username,
role: ws.data.role,
});
port = result.port;
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to start VNC session';
console.error('[desktop] VNC start failed:', message);
sessions.delete(ws);
ws.close(4004, 'VNC server unavailable');
return;
}
try {
const tcpSocket = await Bun.connect({
hostname: '127.0.0.1',
@@ -36,20 +46,31 @@ export const desktopWebsocket = {
},
close() {
sessions.delete(ws);
try { ws.close(); } catch { /* ignore */ }
try {
ws.close();
} catch {
/* ignore */
}
},
error(_socket, err) {
console.error('[desktop] TCP error:', err.message);
sessions.delete(ws);
try { ws.close(); } catch { /* ignore */ }
try {
ws.close();
} catch {
/* ignore */
}
},
connectError(_socket, err) {
console.error('[desktop] TCP connect error:', err.message);
sessions.delete(ws);
try { ws.close(4004, 'VNC server unavailable'); } catch { /* ignore */ }
try {
ws.close(4004, 'VNC server unavailable');
} catch {
/* ignore */
}
},
open(socket) {
// Flush any pending messages
for (const msg of session.pendingMessages) {
socket.write(msg);
}