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:
@@ -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 });
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -83,6 +83,9 @@ export async function provisionLinuxUser(email: string, username: string): Promi
|
||||
const settingsContent = await Bun.file(settingsFile).text();
|
||||
await Bun.write(join(claudeDir, 'settings.json'), settingsContent);
|
||||
|
||||
// Provision VNC environment
|
||||
await provisionVncEnv(homeDir);
|
||||
|
||||
// Service user (pastilhas) owns everything — server can always read/write.
|
||||
// User's personal group gives only that user terminal access. Others get nothing.
|
||||
const serviceUser = process.env.USER ?? 'pastilhas';
|
||||
@@ -139,6 +142,50 @@ async function seedShellConfigs(homeDir: string): Promise<void> {
|
||||
mkdirSync(join(homeDir, '.pi', 'agent', 'sessions'), { recursive: true });
|
||||
}
|
||||
|
||||
async function provisionVncEnv(homeDir: string): Promise<void> {
|
||||
const vncDir = join(homeDir, '.vnc');
|
||||
mkdirSync(vncDir, { recursive: true });
|
||||
|
||||
// Skip if already provisioned
|
||||
if (existsSync(join(vncDir, 'passwd'))) return;
|
||||
|
||||
// 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
|
||||
const xstartup = `#!/bin/sh
|
||||
unset SESSION_MANAGER
|
||||
unset DBUS_SESSION_BUS_ADDRESS
|
||||
eval $(dbus-launch --sh-syntax)
|
||||
export DBUS_SESSION_BUS_ADDRESS
|
||||
exec startxfce4
|
||||
`;
|
||||
await Bun.write(join(vncDir, 'xstartup'), xstartup);
|
||||
|
||||
// Set permissions
|
||||
run(['chmod', '+x', join(vncDir, 'xstartup')]);
|
||||
run(['chmod', '600', join(vncDir, 'passwd')]);
|
||||
run(['chmod', '600', join(vncDir, 'password')]);
|
||||
|
||||
console.log(`[provision] VNC environment provisioned at ${vncDir}`);
|
||||
}
|
||||
|
||||
export function deprovisionLinuxUser(email: string, username: string): boolean {
|
||||
const shellUsername = toShellUsername(username, email);
|
||||
console.log(`[provision] deprovisioning Linux user ${shellUsername}`);
|
||||
|
||||
Reference in New Issue
Block a user