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:
+20
-2
@@ -7,9 +7,21 @@ module.exports = {
|
|||||||
watch: false,
|
watch: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'officer-sidecar',
|
name: 'officer-claude',
|
||||||
script: 'bun',
|
script: 'bun',
|
||||||
args: 'run src/servers/sidecar/index.ts',
|
args: 'run src/servers/sidecar/claude/index.ts',
|
||||||
|
watch: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'officer-pi',
|
||||||
|
script: 'bun',
|
||||||
|
args: 'run src/servers/sidecar/pi/index.ts',
|
||||||
|
watch: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'officer-email',
|
||||||
|
script: 'bun',
|
||||||
|
args: 'run src/servers/sidecar/email/index.ts',
|
||||||
watch: false,
|
watch: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -18,5 +30,11 @@ module.exports = {
|
|||||||
args: 'src/servers/api/terminal/pty-sidecar.mjs',
|
args: 'src/servers/api/terminal/pty-sidecar.mjs',
|
||||||
watch: false,
|
watch: false,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'officer-vnc',
|
||||||
|
script: 'bun',
|
||||||
|
args: 'run src/servers/sidecar/vnc/index.ts',
|
||||||
|
watch: false,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,20 +3,12 @@ set -euo pipefail
|
|||||||
|
|
||||||
echo "=== Cleaning up Remote Desktop setup ==="
|
echo "=== Cleaning up Remote Desktop setup ==="
|
||||||
|
|
||||||
# Stop and remove VNC service
|
|
||||||
echo "[1/6] Removing VNC service..."
|
|
||||||
sudo systemctl stop officer-vnc 2>/dev/null || true
|
|
||||||
sudo systemctl disable officer-vnc 2>/dev/null || true
|
|
||||||
sudo rm -f /etc/systemd/system/officer-vnc.service
|
|
||||||
sudo systemctl daemon-reload
|
|
||||||
vncserver -kill :1 2>/dev/null || true
|
|
||||||
|
|
||||||
# Remove VNC config
|
# Remove VNC config
|
||||||
echo "[2/6] Removing VNC config..."
|
echo "[1/5] Removing VNC config..."
|
||||||
rm -rf ~/.vnc
|
rm -rf ~/.vnc
|
||||||
|
|
||||||
# Remove Brave
|
# Remove Brave
|
||||||
echo "[3/6] Removing Brave..."
|
echo "[2/5] Removing Brave..."
|
||||||
sudo apt remove -y --purge brave-browser 2>/dev/null || true
|
sudo apt remove -y --purge brave-browser 2>/dev/null || true
|
||||||
sudo rm -f /usr/share/keyrings/brave-browser-archive-keyring.gpg
|
sudo rm -f /usr/share/keyrings/brave-browser-archive-keyring.gpg
|
||||||
sudo rm -f /etc/apt/sources.list.d/brave-browser-release.list
|
sudo rm -f /etc/apt/sources.list.d/brave-browser-release.list
|
||||||
@@ -25,20 +17,20 @@ sudo rm -f /usr/bin/brave-browser-stable
|
|||||||
rm -rf ~/.config/BraveSoftware
|
rm -rf ~/.config/BraveSoftware
|
||||||
|
|
||||||
# Remove Chromium (snap + deb)
|
# Remove Chromium (snap + deb)
|
||||||
echo "[4/6] Removing Chromium..."
|
echo "[3/5] Removing Chromium..."
|
||||||
sudo snap remove chromium 2>/dev/null || true
|
sudo snap remove chromium 2>/dev/null || true
|
||||||
sudo apt remove -y --purge chromium-browser 2>/dev/null || true
|
sudo apt remove -y --purge chromium-browser 2>/dev/null || true
|
||||||
rm -rf ~/.config/chromium
|
rm -rf ~/.config/chromium
|
||||||
|
|
||||||
# Remove XFCE, TigerVNC, dbus-x11
|
# Remove XFCE, TigerVNC, dbus-x11
|
||||||
echo "[5/6] Removing XFCE, TigerVNC, dbus-x11..."
|
echo "[4/5] Removing XFCE, TigerVNC, dbus-x11..."
|
||||||
sudo apt remove -y --purge xfce4 xfce4-goodies tigervnc-standalone-server tigervnc-common dbus-x11 2>/dev/null || true
|
sudo apt remove -y --purge xfce4 xfce4-goodies tigervnc-standalone-server tigervnc-common dbus-x11 2>/dev/null || true
|
||||||
sudo apt autoremove -y 2>/dev/null || true
|
sudo apt autoremove -y 2>/dev/null || true
|
||||||
rm -rf ~/.config/xfce4
|
rm -rf ~/.config/xfce4
|
||||||
rm -rf ~/.cache/xfce4
|
rm -rf ~/.cache/xfce4
|
||||||
|
|
||||||
# Remove keyring data
|
# Remove keyring data
|
||||||
echo "[6/6] Removing keyring data..."
|
echo "[5/5] Removing keyring data..."
|
||||||
rm -rf ~/.local/share/keyrings
|
rm -rf ~/.local/share/keyrings
|
||||||
rm -f ~/.config/autostart/gnome-keyring-*.desktop
|
rm -f ~/.config/autostart/gnome-keyring-*.desktop
|
||||||
sudo apt install -y -qq gnome-keyring > /dev/null 2>&1 || true # Restore if needed by other apps
|
sudo apt install -y -qq gnome-keyring > /dev/null 2>&1 || true # Restore if needed by other apps
|
||||||
|
|||||||
@@ -160,6 +160,29 @@ while IFS='|' read -r email username; do
|
|||||||
sudo mkdir -p "$HOME_DIR/.local/bin"
|
sudo mkdir -p "$HOME_DIR/.local/bin"
|
||||||
sudo mkdir -p "$HOME_DIR/.pi/agent/sessions"
|
sudo mkdir -p "$HOME_DIR/.pi/agent/sessions"
|
||||||
|
|
||||||
|
# VNC environment
|
||||||
|
VNC_DIR="$HOME_DIR/.vnc"
|
||||||
|
sudo mkdir -p "$VNC_DIR"
|
||||||
|
if [ ! -f "$VNC_DIR/passwd" ]; then
|
||||||
|
VNC_PASS=$(head -c 32 /dev/urandom | base64 | tr -dc 'a-zA-Z0-9' | head -c 8)
|
||||||
|
echo -n "$VNC_PASS" | sudo tee "$VNC_DIR/password" > /dev/null
|
||||||
|
echo -n "$VNC_PASS" | vncpasswd -f | sudo tee "$VNC_DIR/passwd" > /dev/null
|
||||||
|
sudo tee "$VNC_DIR/xstartup" > /dev/null << 'XSTARTUP'
|
||||||
|
#!/bin/sh
|
||||||
|
unset SESSION_MANAGER
|
||||||
|
unset DBUS_SESSION_BUS_ADDRESS
|
||||||
|
eval $(dbus-launch --sh-syntax)
|
||||||
|
export DBUS_SESSION_BUS_ADDRESS
|
||||||
|
exec startxfce4
|
||||||
|
XSTARTUP
|
||||||
|
sudo chmod +x "$VNC_DIR/xstartup"
|
||||||
|
sudo chmod 600 "$VNC_DIR/passwd"
|
||||||
|
sudo chmod 600 "$VNC_DIR/password"
|
||||||
|
ok "Provisioned VNC environment"
|
||||||
|
else
|
||||||
|
skip "VNC environment"
|
||||||
|
fi
|
||||||
|
|
||||||
# Set ownership and permissions last
|
# Set ownership and permissions last
|
||||||
# chmod 770 so only owner and group can access (service user is added to group above)
|
# chmod 770 so only owner and group can access (service user is added to group above)
|
||||||
sudo chown -R "$shell_user:$shell_user" "$USER_ROOT"
|
sudo chown -R "$shell_user:$shell_user" "$USER_ROOT"
|
||||||
|
|||||||
+20
-94
@@ -1,22 +1,15 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
# Officer Remote Desktop Setup
|
# Officer Remote Desktop Setup — System packages only.
|
||||||
# Run as the user who will own the VNC session (not root).
|
# Per-user VNC config is handled by user provisioning (provision.ts).
|
||||||
# Usage: ./scripts/setup-desktop.sh <vnc-password> [resolution]
|
# Usage: ./scripts/setup-desktop.sh
|
||||||
|
|
||||||
VNC_PASS="${1:-$(head -c 32 /dev/urandom | base64 | tr -dc 'a-zA-Z0-9' | head -c 8)}"
|
|
||||||
RESOLUTION="${2:-1920x1080}"
|
|
||||||
USER_NAME="$(whoami)"
|
|
||||||
ENV_FILE="$(cd "$(dirname "$0")/.." && pwd)/.env"
|
|
||||||
|
|
||||||
echo "=== Officer Remote Desktop Setup ==="
|
echo "=== Officer Remote Desktop Setup ==="
|
||||||
echo "User: $USER_NAME"
|
|
||||||
echo "Resolution: $RESOLUTION"
|
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
# --- Step 1: Install system packages ---
|
# --- Step 1: Install system packages ---
|
||||||
echo "[1/8] Installing system packages..."
|
echo "[1/4] Installing system packages..."
|
||||||
sudo apt update -qq
|
sudo apt update -qq
|
||||||
sudo apt install -y -qq \
|
sudo apt install -y -qq \
|
||||||
xfce4 xfce4-goodies \
|
xfce4 xfce4-goodies \
|
||||||
@@ -26,7 +19,7 @@ sudo apt install -y -qq \
|
|||||||
echo " Done."
|
echo " Done."
|
||||||
|
|
||||||
# --- Step 2: Install Brave browser (native .deb, not snap) ---
|
# --- Step 2: Install Brave browser (native .deb, not snap) ---
|
||||||
echo "[2/8] Installing Brave browser..."
|
echo "[2/4] Installing Brave browser..."
|
||||||
if ! command -v brave-browser-stable > /dev/null 2>&1; then
|
if ! command -v brave-browser-stable > /dev/null 2>&1; then
|
||||||
sudo curl -fsSLo /usr/share/keyrings/brave-browser-archive-keyring.gpg \
|
sudo curl -fsSLo /usr/share/keyrings/brave-browser-archive-keyring.gpg \
|
||||||
https://brave-browser-apt-release.s3.brave.com/brave-browser-archive-keyring.gpg
|
https://brave-browser-apt-release.s3.brave.com/brave-browser-archive-keyring.gpg
|
||||||
@@ -35,8 +28,7 @@ if ! command -v brave-browser-stable > /dev/null 2>&1; then
|
|||||||
sudo apt update -qq
|
sudo apt update -qq
|
||||||
sudo apt install -y -qq brave-browser > /dev/null 2>&1
|
sudo apt install -y -qq brave-browser > /dev/null 2>&1
|
||||||
fi
|
fi
|
||||||
# Fix launcher symlink (the installed /usr/bin/brave-browser-stable is a copy
|
# Fix launcher symlink
|
||||||
# that breaks because $HERE resolves to /usr/bin instead of /opt/brave.com/brave)
|
|
||||||
if [ -f /opt/brave.com/brave/brave-browser ]; then
|
if [ -f /opt/brave.com/brave/brave-browser ]; then
|
||||||
sudo rm -f /usr/bin/brave-browser-stable
|
sudo rm -f /usr/bin/brave-browser-stable
|
||||||
sudo ln -s /opt/brave.com/brave/brave-browser /usr/bin/brave-browser-stable
|
sudo ln -s /opt/brave.com/brave/brave-browser /usr/bin/brave-browser-stable
|
||||||
@@ -46,97 +38,31 @@ sudo mkdir -p /etc/brave
|
|||||||
echo '--password-store=basic' | sudo tee /etc/brave/brave-flags.conf > /dev/null
|
echo '--password-store=basic' | sudo tee /etc/brave/brave-flags.conf > /dev/null
|
||||||
echo " Done."
|
echo " Done."
|
||||||
|
|
||||||
# --- Step 3: Configure VNC password ---
|
# --- Step 3: Remove GNOME Keyring (prevents password prompts on login) ---
|
||||||
echo "[3/8] Configuring VNC password..."
|
echo "[3/4] Removing GNOME Keyring..."
|
||||||
mkdir -p ~/.vnc
|
|
||||||
echo "$VNC_PASS" | vncpasswd -f > ~/.vnc/passwd
|
|
||||||
chmod 600 ~/.vnc/passwd
|
|
||||||
# Plain-text password + port for the Officer server to read
|
|
||||||
echo -n "$VNC_PASS" > ~/.vnc/password
|
|
||||||
chmod 600 ~/.vnc/password
|
|
||||||
echo "5901" > ~/.vnc/port
|
|
||||||
echo " Done."
|
|
||||||
|
|
||||||
# --- Step 4: Create xstartup ---
|
|
||||||
echo "[4/8] Creating VNC xstartup..."
|
|
||||||
cat > ~/.vnc/xstartup << 'XSTARTUP'
|
|
||||||
#!/bin/sh
|
|
||||||
unset SESSION_MANAGER
|
|
||||||
unset DBUS_SESSION_BUS_ADDRESS
|
|
||||||
eval $(dbus-launch --sh-syntax)
|
|
||||||
export DBUS_SESSION_BUS_ADDRESS
|
|
||||||
exec startxfce4
|
|
||||||
XSTARTUP
|
|
||||||
chmod +x ~/.vnc/xstartup
|
|
||||||
echo " Done."
|
|
||||||
|
|
||||||
# --- Step 5: Remove GNOME Keyring (prevents password prompts on login) ---
|
|
||||||
echo "[5/8] Removing GNOME Keyring..."
|
|
||||||
sudo apt remove -y --purge gnome-keyring > /dev/null 2>&1 || true
|
sudo apt remove -y --purge gnome-keyring > /dev/null 2>&1 || true
|
||||||
rm -rf ~/.local/share/keyrings
|
rm -rf ~/.local/share/keyrings
|
||||||
echo " Done."
|
echo " Done."
|
||||||
|
|
||||||
# --- Step 6: Create systemd service ---
|
# --- Step 4: Set default browser ---
|
||||||
echo "[6/8] Creating systemd service..."
|
echo "[4/4] Setting default browser..."
|
||||||
sudo tee /etc/systemd/system/officer-vnc.service > /dev/null << EOF
|
|
||||||
[Unit]
|
|
||||||
Description=TigerVNC Server for Officer Remote Desktop
|
|
||||||
After=syslog.target network.target
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=simple
|
|
||||||
User=$USER_NAME
|
|
||||||
ExecStartPre=/bin/sh -c '/usr/bin/vncserver -kill :1 > /dev/null 2>&1 || true'
|
|
||||||
ExecStart=/usr/bin/vncserver :1 -geometry $RESOLUTION -depth 24 -localhost yes -fg
|
|
||||||
ExecStop=/usr/bin/vncserver -kill :1
|
|
||||||
Restart=on-failure
|
|
||||||
RestartSec=5
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
EOF
|
|
||||||
sudo systemctl daemon-reload
|
|
||||||
sudo systemctl enable --now officer-vnc
|
|
||||||
echo " Done."
|
|
||||||
|
|
||||||
# --- Step 7: Set default browser ---
|
|
||||||
echo "[7/8] Setting default browser..."
|
|
||||||
sleep 2 # Wait for VNC session to start
|
|
||||||
if command -v brave-browser-stable > /dev/null 2>&1; then
|
if command -v brave-browser-stable > /dev/null 2>&1; then
|
||||||
sudo update-alternatives --set x-www-browser /opt/brave.com/brave/brave 2>/dev/null || true
|
sudo update-alternatives --set x-www-browser /opt/brave.com/brave/brave 2>/dev/null || true
|
||||||
DISPLAY=:1 xdg-settings set default-web-browser brave-browser.desktop 2>/dev/null || true
|
|
||||||
echo " Brave set as default."
|
echo " Brave set as default."
|
||||||
else
|
else
|
||||||
echo " No supported browser found, skipping."
|
echo " No supported browser found, skipping."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# --- Step 8: Add env vars ---
|
# --- Cleanup old systemd service if it exists ---
|
||||||
echo "[8/8] Updating .env..."
|
if systemctl list-unit-files officer-vnc.service &>/dev/null; then
|
||||||
if [ -f "$ENV_FILE" ]; then
|
echo ""
|
||||||
grep -q '^VNC_PASSWORD=' "$ENV_FILE" && sed -i "s/^VNC_PASSWORD=.*/VNC_PASSWORD=$VNC_PASS/" "$ENV_FILE" || echo "VNC_PASSWORD=$VNC_PASS" >> "$ENV_FILE"
|
echo "Removing old officer-vnc systemd service..."
|
||||||
grep -q '^VNC_PORT=' "$ENV_FILE" && sed -i "s/^VNC_PORT=.*/VNC_PORT=5901/" "$ENV_FILE" || echo "VNC_PORT=5901" >> "$ENV_FILE"
|
sudo systemctl stop officer-vnc 2>/dev/null || true
|
||||||
else
|
sudo systemctl disable officer-vnc 2>/dev/null || true
|
||||||
echo "VNC_PASSWORD=$VNC_PASS" >> "$ENV_FILE"
|
sudo rm -f /etc/systemd/system/officer-vnc.service
|
||||||
echo "VNC_PORT=5901" >> "$ENV_FILE"
|
sudo systemctl daemon-reload
|
||||||
fi
|
echo " Done."
|
||||||
echo " Done."
|
|
||||||
|
|
||||||
# --- Verify ---
|
|
||||||
echo ""
|
|
||||||
echo "=== Verification ==="
|
|
||||||
sleep 2
|
|
||||||
if systemctl is-active --quiet officer-vnc; then
|
|
||||||
echo "VNC service: running"
|
|
||||||
else
|
|
||||||
echo "VNC service: FAILED — check 'journalctl -u officer-vnc'"
|
|
||||||
fi
|
|
||||||
|
|
||||||
LISTEN=$(ss -tlnp | grep 5901 | head -1)
|
|
||||||
if echo "$LISTEN" | grep -q '127.0.0.1'; then
|
|
||||||
echo "VNC binding: localhost only (secure)"
|
|
||||||
else
|
|
||||||
echo "VNC binding: WARNING — check 'ss -tlnp | grep 5901'"
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "Setup complete. Restart the Officer server and navigate to /desktop."
|
echo "Setup complete. Per-user VNC sessions are managed by the VNC sidecar."
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { createRouter } from '../../create-router';
|
import { createRouter } from '../../create-router';
|
||||||
import { getVncPassword } from './vnc-config';
|
import { getVncPassword } from './vnc-config';
|
||||||
|
import * as sidecar from '@@/sidecar-registry';
|
||||||
|
|
||||||
export const desktopRouter = createRouter();
|
export const desktopRouter = createRouter();
|
||||||
|
|
||||||
@@ -11,3 +12,12 @@ desktopRouter.get('/vnc-password', async (ctx) => {
|
|||||||
}
|
}
|
||||||
return ctx.json({ password });
|
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 { join } from 'node:path';
|
||||||
import { getHomeDir } from '@@/data-path';
|
import { getHomeDirForRole } from '@@/data-path';
|
||||||
|
|
||||||
function getVncDir(email: string, role: string | null): string {
|
function getVncDir(email: string, role: string | null): string {
|
||||||
const home = role === 'Super Admin' && process.env.HOME_DIR
|
return join(getHomeDirForRole(email, role), '.vnc');
|
||||||
? process.env.HOME_DIR
|
|
||||||
: getHomeDir(email);
|
|
||||||
return join(home, '.vnc');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getVncPassword(email: string, role: string | null): Promise<string | null> {
|
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;
|
if (!(await file.exists())) return null;
|
||||||
return (await file.text()).trim();
|
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 { ServerWebSocket } from 'bun';
|
||||||
import type { Socket } 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 };
|
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 = {
|
export const desktopWebsocket = {
|
||||||
async open(ws: ServerWebSocket<WSData>) {
|
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: [] };
|
const session: VncSession = { tcpSocket: null, pendingMessages: [] };
|
||||||
sessions.set(ws, session);
|
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 {
|
try {
|
||||||
const tcpSocket = await Bun.connect({
|
const tcpSocket = await Bun.connect({
|
||||||
hostname: '127.0.0.1',
|
hostname: '127.0.0.1',
|
||||||
@@ -36,20 +46,31 @@ export const desktopWebsocket = {
|
|||||||
},
|
},
|
||||||
close() {
|
close() {
|
||||||
sessions.delete(ws);
|
sessions.delete(ws);
|
||||||
try { ws.close(); } catch { /* ignore */ }
|
try {
|
||||||
|
ws.close();
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
},
|
},
|
||||||
error(_socket, err) {
|
error(_socket, err) {
|
||||||
console.error('[desktop] TCP error:', err.message);
|
console.error('[desktop] TCP error:', err.message);
|
||||||
sessions.delete(ws);
|
sessions.delete(ws);
|
||||||
try { ws.close(); } catch { /* ignore */ }
|
try {
|
||||||
|
ws.close();
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
},
|
},
|
||||||
connectError(_socket, err) {
|
connectError(_socket, err) {
|
||||||
console.error('[desktop] TCP connect error:', err.message);
|
console.error('[desktop] TCP connect error:', err.message);
|
||||||
sessions.delete(ws);
|
sessions.delete(ws);
|
||||||
try { ws.close(4004, 'VNC server unavailable'); } catch { /* ignore */ }
|
try {
|
||||||
|
ws.close(4004, 'VNC server unavailable');
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
},
|
},
|
||||||
open(socket) {
|
open(socket) {
|
||||||
// Flush any pending messages
|
|
||||||
for (const msg of session.pendingMessages) {
|
for (const msg of session.pendingMessages) {
|
||||||
socket.write(msg);
|
socket.write(msg);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,6 +83,9 @@ export async function provisionLinuxUser(email: string, username: string): Promi
|
|||||||
const settingsContent = await Bun.file(settingsFile).text();
|
const settingsContent = await Bun.file(settingsFile).text();
|
||||||
await Bun.write(join(claudeDir, 'settings.json'), settingsContent);
|
await Bun.write(join(claudeDir, 'settings.json'), settingsContent);
|
||||||
|
|
||||||
|
// Provision VNC environment
|
||||||
|
await provisionVncEnv(homeDir);
|
||||||
|
|
||||||
// Service user (pastilhas) owns everything — server can always read/write.
|
// Service user (pastilhas) owns everything — server can always read/write.
|
||||||
// User's personal group gives only that user terminal access. Others get nothing.
|
// User's personal group gives only that user terminal access. Others get nothing.
|
||||||
const serviceUser = process.env.USER ?? 'pastilhas';
|
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 });
|
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 {
|
export function deprovisionLinuxUser(email: string, username: string): boolean {
|
||||||
const shellUsername = toShellUsername(username, email);
|
const shellUsername = toShellUsername(username, email);
|
||||||
console.log(`[provision] deprovisioning Linux user ${shellUsername}`);
|
console.log(`[provision] deprovisioning Linux user ${shellUsername}`);
|
||||||
|
|||||||
@@ -2,17 +2,18 @@ import type { ServerWebSocket } from 'bun';
|
|||||||
import type {
|
import type {
|
||||||
SidecarCommand,
|
SidecarCommand,
|
||||||
SidecarEvent,
|
SidecarEvent,
|
||||||
SidecarState,
|
ClaudeState,
|
||||||
ClaudeSpawnParams,
|
ClaudeSpawnParams,
|
||||||
ClaudeSpawnStreamingParams,
|
ClaudeSpawnStreamingParams,
|
||||||
ClaudeCodeResult,
|
ClaudeCodeResult,
|
||||||
PiSpawnParams,
|
PiSpawnParams,
|
||||||
PtyCommand,
|
PtyCommand,
|
||||||
PtyEvent,
|
PtyEvent,
|
||||||
|
VncStartParams,
|
||||||
|
VncSessionInfo,
|
||||||
} from './sidecar/protocol';
|
} from './sidecar/protocol';
|
||||||
import type { SidecarRegistration } from './sidecar/registration-protocol';
|
import type { SidecarRegistration } from './sidecar/registration-protocol';
|
||||||
import type { PiEvent } from './api/pi/types';
|
import type { PiEvent } from './api/pi/types';
|
||||||
import type { Job, EnqueueParams } from './queue/types';
|
|
||||||
|
|
||||||
// ── Types ──
|
// ── Types ──
|
||||||
|
|
||||||
@@ -36,7 +37,7 @@ type EventHandler = (event: SidecarEvent | PtyEvent) => void;
|
|||||||
const sidecars = new Map<string, RegisteredSidecar>();
|
const sidecars = new Map<string, RegisteredSidecar>();
|
||||||
const pending = new Map<string, PendingRequest>();
|
const pending = new Map<string, PendingRequest>();
|
||||||
const eventHandlers = new Map<string, Set<EventHandler>>();
|
const eventHandlers = new Map<string, Set<EventHandler>>();
|
||||||
let cachedState: SidecarState | null = null;
|
let cachedState: ClaudeState | null = null;
|
||||||
let idCounter = 0;
|
let idCounter = 0;
|
||||||
|
|
||||||
function nextId(): string {
|
function nextId(): string {
|
||||||
@@ -81,7 +82,7 @@ export function unregisterSidecar(id: string): void {
|
|||||||
pending.delete(reqId);
|
pending.delete(reqId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear cached state if the process sidecar disconnects
|
// Clear cached state if the claude sidecar disconnects
|
||||||
if (sc.capabilities.includes('proxy')) {
|
if (sc.capabilities.includes('proxy')) {
|
||||||
cachedState = null;
|
cachedState = null;
|
||||||
}
|
}
|
||||||
@@ -110,12 +111,6 @@ function findSidecarByCapability(cap: string): RegisteredSidecar | undefined {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function requireSidecar(cap: string): RegisteredSidecar {
|
|
||||||
const sc = findSidecarByCapability(cap);
|
|
||||||
if (!sc) throw new Error(`No sidecar with capability "${cap}" is connected`);
|
|
||||||
return sc;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Event dispatch ──
|
// ── Event dispatch ──
|
||||||
|
|
||||||
function dispatchEvent(msg: SidecarEvent | PtyEvent) {
|
function dispatchEvent(msg: SidecarEvent | PtyEvent) {
|
||||||
@@ -171,17 +166,17 @@ function sendFire(cap: string, cmd: SidecarCommand | PtyCommand): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Public API (same signatures as sidecar-client.ts) ──
|
// ── Public API ──
|
||||||
|
|
||||||
export function isConnected(): boolean {
|
export function isConnected(): boolean {
|
||||||
return findSidecarByCapability('proxy') !== undefined;
|
return findSidecarByCapability('proxy') !== undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getCachedState(): SidecarState | null {
|
export function getCachedState(): ClaudeState | null {
|
||||||
return cachedState;
|
return cachedState;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function syncState(): Promise<SidecarState> {
|
export async function syncState(): Promise<ClaudeState> {
|
||||||
const res = await sendCommand('proxy', { type: 'state:sync', id: nextId() });
|
const res = await sendCommand('proxy', { type: 'state:sync', id: nextId() });
|
||||||
if (res.type === 'state:sync') {
|
if (res.type === 'state:sync') {
|
||||||
cachedState = res.state;
|
cachedState = res.state;
|
||||||
@@ -272,34 +267,6 @@ export function onPiEvent(handler: (sessionId: string, event: PiEvent) => void):
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Queue ──
|
|
||||||
|
|
||||||
export async function enqueueJob(params: EnqueueParams): Promise<Job> {
|
|
||||||
const res = await sendCommand('queue', { type: 'queue:enqueue', id: nextId(), params });
|
|
||||||
if (res.type === 'queue:enqueued') return res.job;
|
|
||||||
if (res.type === 'queue:error') throw new Error(res.error);
|
|
||||||
throw new Error('Unexpected response');
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function cancelJob(jobId: string): Promise<Job | null> {
|
|
||||||
const res = await sendCommand('queue', { type: 'queue:cancel', id: nextId(), jobId });
|
|
||||||
if (res.type === 'queue:cancelled') return res.job;
|
|
||||||
if (res.type === 'queue:error') throw new Error(res.error);
|
|
||||||
throw new Error('Unexpected response');
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function listJobs(): Promise<Job[]> {
|
|
||||||
const res = await sendCommand('queue', { type: 'queue:list', id: nextId() });
|
|
||||||
if (res.type === 'queue:list') return res.jobs;
|
|
||||||
throw new Error('Unexpected response');
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getJob(jobId: string): Promise<Job | null> {
|
|
||||||
const res = await sendCommand('queue', { type: 'queue:get', id: nextId(), jobId });
|
|
||||||
if (res.type === 'queue:get') return res.job;
|
|
||||||
throw new Error('Unexpected response');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Terminal (PTY sidecar) ──
|
// ── Terminal (PTY sidecar) ──
|
||||||
|
|
||||||
export function sendPtyCommand(cmd: PtyCommand): void {
|
export function sendPtyCommand(cmd: PtyCommand): void {
|
||||||
@@ -313,3 +280,26 @@ export async function sendPtyCommandAsync(cmd: PtyCommand, timeoutMs = DEFAULT_T
|
|||||||
export function isTerminalConnected(): boolean {
|
export function isTerminalConnected(): boolean {
|
||||||
return findSidecarByCapability('terminal') !== undefined;
|
return findSidecarByCapability('terminal') !== undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── VNC ──
|
||||||
|
|
||||||
|
export async function startVnc(params: VncStartParams): Promise<{ port: number; display: number }> {
|
||||||
|
const res = await sendCommand('vnc', { type: 'vnc:start', id: nextId(), params });
|
||||||
|
if (res.type === 'vnc:started') return { port: res.port, display: res.display };
|
||||||
|
if (res.type === 'vnc:error') throw new Error(res.error);
|
||||||
|
throw new Error('Unexpected response');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stopVnc(email: string): void {
|
||||||
|
sendFire('vnc', { type: 'vnc:stop', id: nextId(), email });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getVncStatus(email: string): Promise<VncSessionInfo | null> {
|
||||||
|
const res = await sendCommand('vnc', { type: 'vnc:status', id: nextId(), email });
|
||||||
|
if (res.type === 'vnc:status') return res.session;
|
||||||
|
throw new Error('Unexpected response');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isVncConnected(): boolean {
|
||||||
|
return findSidecarByCapability('vnc') !== undefined;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import type { MessageCost, PiEvent } from '../api/pi/types';
|
import type { MessageCost, PiEvent } from '../api/pi/types';
|
||||||
import type { Job, EnqueueParams, JobProgress } from '../queue/types';
|
|
||||||
|
|
||||||
// ── Envelope ──
|
// ── Envelope ──
|
||||||
|
|
||||||
@@ -23,17 +22,16 @@ export type SidecarCommand =
|
|||||||
| { type: 'pi:abort'; id: string; sessionId: string; requestId: string }
|
| { type: 'pi:abort'; id: string; sessionId: string; requestId: string }
|
||||||
| { type: 'pi:kill'; id: string; sessionId: string }
|
| { type: 'pi:kill'; id: string; sessionId: string }
|
||||||
| { type: 'pi:set-thinking'; id: string; sessionId: string; level: string }
|
| { type: 'pi:set-thinking'; id: string; sessionId: string; level: string }
|
||||||
// Queue
|
// VNC
|
||||||
| { type: 'queue:enqueue'; id: string; params: EnqueueParams }
|
| { type: 'vnc:start'; id: string; params: VncStartParams }
|
||||||
| { type: 'queue:cancel'; id: string; jobId: string }
|
| { type: 'vnc:stop'; id: string; email: string }
|
||||||
| { type: 'queue:list'; id: string }
|
| { type: 'vnc:status'; id: string; email: string };
|
||||||
| { type: 'queue:get'; id: string; jobId: string };
|
|
||||||
|
|
||||||
// ── Responses/Events (sidecar → API server) ──
|
// ── Responses/Events (sidecar → API server) ──
|
||||||
|
|
||||||
export type SidecarEvent =
|
export type SidecarEvent =
|
||||||
| { type: 'pong'; id: string }
|
| { type: 'pong'; id: string }
|
||||||
| { type: 'state:sync'; id: string; state: SidecarState }
|
| { type: 'state:sync'; id: string; state: ClaudeState }
|
||||||
| { type: 'proxy:secret'; id: string; secret: string }
|
| { type: 'proxy:secret'; id: string; secret: string }
|
||||||
// Claude Code
|
// Claude Code
|
||||||
| { type: 'claude:spawned'; id: string; sessionKey: string }
|
| { type: 'claude:spawned'; id: string; sessionKey: string }
|
||||||
@@ -47,22 +45,19 @@ export type SidecarEvent =
|
|||||||
| { type: 'pi:event'; sessionId: string; event: PiEvent }
|
| { type: 'pi:event'; sessionId: string; event: PiEvent }
|
||||||
| { type: 'pi:error'; id: string; error: string }
|
| { type: 'pi:error'; id: string; error: string }
|
||||||
| { type: 'pi:killed'; id: string }
|
| { type: 'pi:killed'; id: string }
|
||||||
// Queue
|
// VNC
|
||||||
| { type: 'queue:enqueued'; id: string; job: Job }
|
| { type: 'vnc:started'; id: string; port: number; display: number }
|
||||||
| { type: 'queue:cancelled'; id: string; job: Job | null }
|
| { type: 'vnc:stopped'; id: string }
|
||||||
| { type: 'queue:list'; id: string; jobs: Job[] }
|
| { type: 'vnc:status'; id: string; session: VncSessionInfo | null }
|
||||||
| { type: 'queue:get'; id: string; job: Job | null }
|
| { type: 'vnc:error'; id: string; error: string }
|
||||||
| { type: 'queue:error'; id: string; error: string }
|
|
||||||
// Generic
|
// Generic
|
||||||
| { type: 'error'; id?: string; error: string };
|
| { type: 'error'; id?: string; error: string };
|
||||||
|
|
||||||
// ── Shared state snapshot ──
|
// ── Claude sidecar state ──
|
||||||
|
|
||||||
export type SidecarState = {
|
export type ClaudeState = {
|
||||||
proxySecret: string;
|
proxySecret: string;
|
||||||
claudeSessions: Record<string, string>; // sessionKey → Claude Code session_id
|
claudeSessions: Record<string, string>; // sessionKey → Claude Code session_id
|
||||||
piSessions: PiSessionInfo[];
|
|
||||||
uptime: number;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PiSessionInfo = {
|
export type PiSessionInfo = {
|
||||||
@@ -114,6 +109,23 @@ export type PiSpawnParams = {
|
|||||||
sessionFile?: string;
|
sessionFile?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ── VNC types ──
|
||||||
|
|
||||||
|
export type VncStartParams = {
|
||||||
|
email: string;
|
||||||
|
username: string;
|
||||||
|
role: string | null;
|
||||||
|
resolution?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type VncSessionInfo = {
|
||||||
|
email: string;
|
||||||
|
display: number;
|
||||||
|
port: number;
|
||||||
|
pid: number;
|
||||||
|
alive: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
// ── PTY types ──
|
// ── PTY types ──
|
||||||
|
|
||||||
export type PtyInitConfig = {
|
export type PtyInitConfig = {
|
||||||
|
|||||||
@@ -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