fix /desktop: break the VNC password deadlock, drop the vncpasswd dependency

The desktop page has never worked on a fresh install. Two faults, both fatal.

The password could never be created. DesktopView fetches /desktop/vnc-password
before opening the WebSocket, but ensureVncPassword ran only from startSession,
which only the WebSocket triggers — so the endpoint answered "not configured",
the UI stopped, and the socket that would have provisioned it was never opened.
A new vnc:ensure-password sidecar command provisions it directly; the endpoint
asks for it instead of returning 500.

The rfbauth file could never be written either. ensureVncPassword shelled out to
tigervnc's `vncpasswd -f`, which is not installed — and, contrary to the comment
in setup-desktop.sh, is not in tigervnc-common, which ships only tigervncconfig.
The failure was swallowed because only a zero exit wrote the file, so x11vnc got
-rfbauth pointing at nothing. x11vnc writes that format itself with -storepasswd,
so the dependency is gone and a failure now throws.

Verified on the box: the endpoint returns a password, .vnc/{passwd,password} are
written 0600, and the sidecar reports mirroring :0 on 5900 with x11vnc using the
generated rfbauth file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
brunorezio
2026-07-26 05:15:46 +01:00
co-authored by Claude Opus 5
parent 96dd1bfe9e
commit 7556c9ed00
6 changed files with 73 additions and 22 deletions
+11
View File
@@ -1,6 +1,7 @@
import type { SidecarCommand, SidecarEvent } from '../protocol';
import * as vncManager from './vnc-manager';
import { createSidecarConnector } from '../connect';
import { getOwnerHomeDir } from '@@/data-path';
const API_URL = process.env.API_URL ?? `ws://127.0.0.1:${process.env.PORT ?? '5000'}`;
@@ -24,6 +25,16 @@ async function handleCommand(cmd: SidecarCommand, reply: ReplyFn) {
break;
}
case 'vnc:ensure-password': {
try {
const { password } = await vncManager.ensureVncPassword(getOwnerHomeDir(cmd.email));
reply({ type: 'vnc:password', id: cmd.id, password });
} 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 });
+27 -15
View File
@@ -44,36 +44,48 @@ function isProcessAlive(pid: number): boolean {
}
}
async function ensureVncPassword(homeDir: string): Promise<string> {
// Provision the pair of files a VNC session needs: `password` in plaintext, which the API hands to
// the browser, and `passwd` in rfbauth format, which x11vnc authenticates against. Idempotent —
// returns the existing password when both are already present.
//
// x11vnc writes the rfbauth file itself via -storepasswd. This used to shell out to tigervnc's
// `vncpasswd -f`, which is not installed by default (and is not in tigervnc-common, despite what
// setup-desktop.sh claimed), so the rfbauth file silently never appeared — the failure was swallowed
// because only a zero exit code triggered the write.
export async function ensureVncPassword(homeDir: string): Promise<{ password: string; passwdFile: string }> {
const vncDir = join(homeDir, '.vnc');
const passwdFile = join(vncDir, 'passwd');
if (existsSync(passwdFile)) return passwdFile;
const plainFile = join(vncDir, 'password');
if (existsSync(passwdFile) && existsSync(plainFile)) {
const password = (await Bun.file(plainFile).text()).trim();
if (password) return { password, passwdFile };
}
mkdirSync(vncDir, { recursive: true });
// Generate random 8-char password
const password = Array.from(crypto.getRandomValues(new Uint8Array(6)))
// 8 printable ASCII characters. VNC truncates to 8, so there is no point generating more.
const password = Array.from(crypto.getRandomValues(new Uint8Array(8)))
.map((b) => String.fromCharCode(33 + (b % 94)))
.join('');
// Write plaintext password (for API to read)
await Bun.write(join(vncDir, 'password'), password);
await Bun.write(plainFile, password);
// Create encrypted passwd using vncpasswd -f
const proc = Bun.spawnSync({
cmd: ['bash', '-c', `echo '${password.replace(/'/g, "'\\''")}' | vncpasswd -f`],
stdout: 'pipe',
stderr: 'ignore',
cmd: ['x11vnc', '-storepasswd', password, passwdFile],
stdout: 'ignore',
stderr: 'pipe',
});
if (proc.exitCode === 0 && proc.stdout.byteLength > 0) {
await Bun.write(passwdFile, proc.stdout);
if (proc.exitCode !== 0 || !existsSync(passwdFile)) {
const stderr = proc.stderr.toString().trim();
throw new Error(`Could not write the VNC password file: ${stderr || 'x11vnc -storepasswd failed'}`);
}
Bun.spawnSync({ cmd: ['chmod', '600', passwdFile], stdout: 'ignore', stderr: 'ignore' });
Bun.spawnSync({ cmd: ['chmod', '600', join(vncDir, 'password')], stdout: 'ignore', stderr: 'ignore' });
Bun.spawnSync({ cmd: ['chmod', '600', plainFile], stdout: 'ignore', stderr: 'ignore' });
console.log(`[vnc] provisioned VNC password at ${vncDir}`);
return passwdFile;
return { password, passwdFile };
}
async function isPortOpen(port: number): Promise<boolean> {
@@ -107,7 +119,7 @@ export async function startSession(params: VncStartParams): Promise<{ port: numb
mirror = null;
const homeDir = getOwnerHomeDir(params.email);
const passwdFile = await ensureVncPassword(homeDir);
const { passwdFile } = await ensureVncPassword(homeDir);
const xauthority = resolveXauthority();
if (!xauthority) {