430 lines
13 KiB
TypeScript
430 lines
13 KiB
TypeScript
import type { ServerWebSocket } from 'bun';
|
|
import { mkdirSync, statSync } from 'node:fs';
|
|
import { dirname, join } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { getHomeDir } from '@@/data-path';
|
|
import { officerdb, Users } from 'officerdb';
|
|
|
|
type WSData = { userId: number; email: string; role: string; sandboxed: boolean; sessionId?: string; cwd?: string };
|
|
type ShellInfo = { command: string; args: string[]; name: string };
|
|
type BridgeSession = {
|
|
client: ServerWebSocket<WSData>;
|
|
sidecar: WebSocket | null;
|
|
dockerId: string;
|
|
port: number;
|
|
pendingMessages: string[];
|
|
};
|
|
|
|
type ContainerInfo = {
|
|
userId: number;
|
|
email: string;
|
|
dockerId: string;
|
|
port: number;
|
|
};
|
|
|
|
const HOST_SIDECAR_PORT = 5338;
|
|
|
|
const sessions = new Map<ServerWebSocket<WSData>, BridgeSession>();
|
|
const containerMapPath = join(getHomeDir(''), '..', 'terminal-containers.json');
|
|
|
|
let dockerImageReady = false;
|
|
let containersCache: Record<string, ContainerInfo> | null = null;
|
|
let hostSidecarProcess: ReturnType<typeof import('bun').spawn> | null = null;
|
|
|
|
const sendOutput = (ws: ServerWebSocket<WSData>, data: string) => {
|
|
try {
|
|
ws.send(JSON.stringify({ type: 'output', data }));
|
|
} catch {
|
|
// ws already closed
|
|
}
|
|
};
|
|
|
|
const connectSidecar = async (port: number): Promise<WebSocket> => {
|
|
const delays = [200, 300, 500, 800, 1200, 1600, 2000];
|
|
let lastError: Error | null = null;
|
|
|
|
for (const delay of delays) {
|
|
try {
|
|
const ws = await new Promise<WebSocket>((resolve, reject) => {
|
|
const socket = new WebSocket(`ws://127.0.0.1:${port}`);
|
|
const timeout = setTimeout(() => {
|
|
try {
|
|
socket.close();
|
|
} catch {
|
|
// ignore
|
|
}
|
|
reject(new Error('Terminal sidecar timeout'));
|
|
}, 2000);
|
|
|
|
socket.addEventListener('open', () => {
|
|
clearTimeout(timeout);
|
|
resolve(socket);
|
|
});
|
|
socket.addEventListener('error', () => {
|
|
clearTimeout(timeout);
|
|
reject(new Error('Terminal sidecar connection failed'));
|
|
});
|
|
});
|
|
|
|
return ws;
|
|
} catch (err) {
|
|
lastError = err instanceof Error ? err : new Error('Terminal sidecar connection failed');
|
|
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
}
|
|
}
|
|
|
|
throw lastError ?? new Error('Terminal sidecar connection failed');
|
|
};
|
|
|
|
const ensureDockerImage = () => {
|
|
if (dockerImageReady) return;
|
|
const dockerPath = Bun.which('docker');
|
|
if (!dockerPath) throw new Error('Docker not found');
|
|
|
|
const tag = 'officer-terminal-sidecar:v1';
|
|
const inspect = Bun.spawnSync({ cmd: [dockerPath, 'image', 'inspect', tag], stdout: 'ignore', stderr: 'ignore' });
|
|
if (inspect.exitCode === 0) {
|
|
dockerImageReady = true;
|
|
return;
|
|
}
|
|
|
|
const dockerfilePath = fileURLToPath(new URL('./Dockerfile.terminal-sidecar', import.meta.url));
|
|
const build = Bun.spawnSync({
|
|
cmd: [dockerPath, 'build', '-f', dockerfilePath, '-t', tag, '.'],
|
|
cwd: fileURLToPath(new URL('./', import.meta.url)),
|
|
stdout: 'inherit',
|
|
stderr: 'inherit',
|
|
});
|
|
|
|
if (build.exitCode !== 0) throw new Error('Failed to build terminal sandbox image');
|
|
dockerImageReady = true;
|
|
};
|
|
|
|
const startDockerSidecar = (port: number, homeDir: string, userId: number, email: string): { dockerId: string } => {
|
|
ensureDockerImage();
|
|
const dockerPath = Bun.which('docker') ?? 'docker';
|
|
const dockerId = `officer-terminal-${userId}`;
|
|
const tag = 'officer-terminal-sidecar:v1';
|
|
|
|
// Remove stale container with same name if it exists
|
|
if (dockerContainerExists(dockerId)) {
|
|
Bun.spawnSync({ cmd: [dockerPath, 'rm', '-f', dockerId], stdout: 'ignore', stderr: 'ignore' });
|
|
}
|
|
|
|
const username = (email.split('@')[0] ?? 'officer').replace(/[^a-z0-9_-]/g, '_').slice(0, 32);
|
|
let uid = 1000;
|
|
let gid = 1000;
|
|
try {
|
|
const stats = statSync(homeDir);
|
|
uid = stats.uid;
|
|
gid = stats.gid;
|
|
} catch {
|
|
// fallback to defaults
|
|
}
|
|
|
|
const run = Bun.spawnSync({
|
|
cmd: [
|
|
dockerPath,
|
|
'run',
|
|
'-d',
|
|
'--name',
|
|
dockerId,
|
|
'--restart',
|
|
'unless-stopped',
|
|
'-p',
|
|
`127.0.0.1:${port}:${port}`,
|
|
'-e',
|
|
`TERMINAL_PTY_PORT=${port}`,
|
|
'-e',
|
|
'TERMINAL_PTY_HOST=0.0.0.0',
|
|
'-e',
|
|
`TERMINAL_USER=${username}`,
|
|
'-e',
|
|
`TERMINAL_UID=${uid}`,
|
|
'-e',
|
|
`TERMINAL_GID=${gid}`,
|
|
'-v',
|
|
`${homeDir}:/home/officer`,
|
|
'-w',
|
|
'/home/officer',
|
|
tag,
|
|
],
|
|
stdout: 'inherit',
|
|
stderr: 'inherit',
|
|
});
|
|
|
|
if (run.exitCode !== 0) throw new Error('Failed to start terminal sandbox container');
|
|
return { dockerId };
|
|
};
|
|
|
|
const stopDockerSidecar = (dockerId: string) => {
|
|
const dockerPath = Bun.which('docker') ?? 'docker';
|
|
Bun.spawnSync({ cmd: [dockerPath, 'rm', '-f', dockerId], stdout: 'ignore', stderr: 'ignore' });
|
|
};
|
|
|
|
const readDockerLogs = (dockerId: string) => {
|
|
const dockerPath = Bun.which('docker') ?? 'docker';
|
|
const logs = Bun.spawnSync({ cmd: [dockerPath, 'logs', '--tail', '200', dockerId], stdout: 'pipe', stderr: 'pipe' });
|
|
if (logs.exitCode !== 0) return '';
|
|
return logs.stdout.toString().trim();
|
|
};
|
|
|
|
const loadContainerMap = async (): Promise<Record<string, ContainerInfo>> => {
|
|
if (containersCache) return containersCache;
|
|
const data = await Bun.file(containerMapPath)
|
|
.json()
|
|
.catch(() => ({}));
|
|
containersCache = data as Record<string, ContainerInfo>;
|
|
return containersCache;
|
|
};
|
|
|
|
const saveContainerMap = async (map: Record<string, ContainerInfo>) => {
|
|
containersCache = map;
|
|
await Bun.write(containerMapPath, JSON.stringify(map, null, 2));
|
|
};
|
|
|
|
const getAvailablePort = (map: Record<string, ContainerInfo>, userId: number) => {
|
|
const base = 54000;
|
|
const used = new Set(Object.values(map).map((item) => item.port));
|
|
let port = base + (userId % 1000);
|
|
while (used.has(port)) port += 1;
|
|
return port;
|
|
};
|
|
|
|
const dockerContainerExists = (dockerId: string) => {
|
|
const dockerPath = Bun.which('docker') ?? 'docker';
|
|
const result = Bun.spawnSync({ cmd: [dockerPath, 'ps', '-a', '-q', '-f', `name=${dockerId}`], stdout: 'pipe' });
|
|
return result.exitCode === 0 && result.stdout.toString().trim().length > 0;
|
|
};
|
|
|
|
const dockerContainerRunning = (dockerId: string) => {
|
|
const dockerPath = Bun.which('docker') ?? 'docker';
|
|
const result = Bun.spawnSync({ cmd: [dockerPath, 'ps', '-q', '-f', `name=${dockerId}`], stdout: 'pipe' });
|
|
return result.exitCode === 0 && result.stdout.toString().trim().length > 0;
|
|
};
|
|
|
|
const dockerStart = (dockerId: string) => {
|
|
const dockerPath = Bun.which('docker') ?? 'docker';
|
|
const result = Bun.spawnSync({ cmd: [dockerPath, 'start', dockerId], stdout: 'ignore', stderr: 'ignore' });
|
|
return result.exitCode === 0;
|
|
};
|
|
|
|
const ensureDockerContainer = async (email: string, userId: number, homeDir: string) => {
|
|
const map = await loadContainerMap();
|
|
const existing = map[email];
|
|
if (existing && dockerContainerRunning(existing.dockerId)) return existing;
|
|
|
|
if (existing && dockerContainerExists(existing.dockerId)) {
|
|
if (dockerStart(existing.dockerId)) return existing;
|
|
stopDockerSidecar(existing.dockerId);
|
|
}
|
|
|
|
const port = existing?.port ?? getAvailablePort(map, userId);
|
|
const docker = startDockerSidecar(port, homeDir, userId, email);
|
|
const next = { userId, email, dockerId: docker.dockerId, port };
|
|
map[email] = next;
|
|
await saveContainerMap(map);
|
|
return next;
|
|
};
|
|
|
|
const sidecarAlive = async (port: number): Promise<boolean> => {
|
|
try {
|
|
const res = await fetch(`http://127.0.0.1:${port}`, { signal: AbortSignal.timeout(500) });
|
|
return res.ok;
|
|
} catch {
|
|
return false;
|
|
}
|
|
};
|
|
|
|
const startHostSidecar = async () => {
|
|
if (await sidecarAlive(HOST_SIDECAR_PORT)) {
|
|
console.log(`[terminal] host sidecar already running on port ${HOST_SIDECAR_PORT}`);
|
|
return;
|
|
}
|
|
|
|
if (hostSidecarProcess) {
|
|
hostSidecarProcess.kill();
|
|
await hostSidecarProcess.exited.catch(() => {});
|
|
hostSidecarProcess = null;
|
|
}
|
|
const sidecarPath = fileURLToPath(new URL('./pty-sidecar.mjs', import.meta.url));
|
|
hostSidecarProcess = Bun.spawn({
|
|
cmd: ['bun', sidecarPath],
|
|
env: { ...process.env, TERMINAL_PTY_PORT: String(HOST_SIDECAR_PORT) },
|
|
stdout: 'inherit',
|
|
stderr: 'inherit',
|
|
});
|
|
console.log(`[terminal] host sidecar started on port ${HOST_SIDECAR_PORT}`);
|
|
};
|
|
|
|
export const initTerminalSidecars = async () => {
|
|
await startHostSidecar();
|
|
ensureDockerImage();
|
|
const users = await officerdb.select({ id: Users.id, email: Users.email }).from(Users);
|
|
for (const user of users) {
|
|
const homeDir = getHomeDir(user.email);
|
|
mkdirSync(dirname(homeDir), { recursive: true });
|
|
mkdirSync(homeDir, { recursive: true });
|
|
try {
|
|
await ensureDockerContainer(user.email, user.id, homeDir);
|
|
console.log(`[terminal] sidecar ready for ${user.email}`);
|
|
} catch (err) {
|
|
console.error(`[terminal] failed to start sidecar for ${user.email}:`, err);
|
|
}
|
|
}
|
|
};
|
|
|
|
const containerShell: ShellInfo = { command: '/bin/zsh', args: ['-d', '-i'], name: 'zsh' };
|
|
const containerHome = '/home/officer';
|
|
|
|
const resolveCwd = (home: string, cwd?: string) => {
|
|
if (!cwd || cwd === '~') return home;
|
|
if (cwd.startsWith('~/')) return join(home, cwd.slice(2));
|
|
return home;
|
|
};
|
|
|
|
export const terminalWebsocket = {
|
|
async open(ws: ServerWebSocket<WSData>) {
|
|
const { email, role, sandboxed } = ws.data;
|
|
|
|
if (!sandboxed && role !== 'Super Admin') {
|
|
sendOutput(ws, '\r\n[Permission denied] Host terminal requires Super Admin role.\r\n');
|
|
return;
|
|
}
|
|
|
|
if (!sandboxed) {
|
|
const session: BridgeSession = { client: ws, sidecar: null, dockerId: '', port: HOST_SIDECAR_PORT, pendingMessages: [] };
|
|
sessions.set(ws, session);
|
|
|
|
let sidecar: WebSocket | null = null;
|
|
try {
|
|
sidecar = await connectSidecar(HOST_SIDECAR_PORT);
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : 'Failed to connect host sidecar';
|
|
sendOutput(ws, `\r\n[Terminal error] ${message}\r\n`);
|
|
sessions.delete(ws);
|
|
return;
|
|
}
|
|
|
|
session.sidecar = sidecar;
|
|
|
|
sidecar.addEventListener('message', (ev) => {
|
|
try {
|
|
if (typeof ev.data === 'string') {
|
|
ws.send(ev.data);
|
|
} else {
|
|
ws.send(new TextDecoder().decode(ev.data));
|
|
}
|
|
} catch {
|
|
// ws already closed
|
|
}
|
|
});
|
|
|
|
sidecar.send(
|
|
JSON.stringify({
|
|
type: 'init',
|
|
sessionId: ws.data.sessionId ?? `host-${ws.data.userId}`,
|
|
shell: { command: process.env.SHELL ?? '/bin/zsh', args: ['-i'] },
|
|
cwd: resolveCwd(process.env.HOME!, ws.data.cwd),
|
|
homeDir: process.env.HOME,
|
|
userLabel: email,
|
|
}),
|
|
);
|
|
|
|
for (const msg of session.pendingMessages) sidecar.send(msg);
|
|
session.pendingMessages = [];
|
|
return;
|
|
}
|
|
|
|
const cwd = getHomeDir(email);
|
|
const userRoot = dirname(cwd);
|
|
mkdirSync(userRoot, { recursive: true });
|
|
mkdirSync(cwd, { recursive: true });
|
|
|
|
const session: BridgeSession = { client: ws, sidecar: null, dockerId: '', port: 0, pendingMessages: [] };
|
|
sessions.set(ws, session);
|
|
|
|
let sidecar: WebSocket | null = null;
|
|
let info: ContainerInfo | undefined;
|
|
try {
|
|
info = await ensureDockerContainer(email, ws.data.userId, cwd);
|
|
sidecar = await connectSidecar(info.port);
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : 'Failed to connect terminal sidecar';
|
|
sendOutput(ws, `\r\n[Terminal error] ${message}\r\n`);
|
|
if (info) {
|
|
const logs = readDockerLogs(info.dockerId);
|
|
if (logs) {
|
|
sendOutput(ws, `\r\n[Docker logs]\r\n${logs}\r\n`);
|
|
}
|
|
}
|
|
sendOutput(ws, '\r\n[Process exited]\r\n');
|
|
if (info) stopDockerSidecar(info.dockerId);
|
|
sessions.delete(ws);
|
|
return;
|
|
}
|
|
|
|
session.sidecar = sidecar;
|
|
session.dockerId = info.dockerId;
|
|
session.port = info.port;
|
|
|
|
sidecar.addEventListener('message', (ev) => {
|
|
try {
|
|
if (typeof ev.data === 'string') {
|
|
ws.send(ev.data);
|
|
} else {
|
|
ws.send(new TextDecoder().decode(ev.data));
|
|
}
|
|
} catch {
|
|
// ws already closed
|
|
}
|
|
});
|
|
|
|
sidecar.send(
|
|
JSON.stringify({
|
|
type: 'init',
|
|
sessionId: ws.data.sessionId ?? `default-${ws.data.userId}`,
|
|
shell: containerShell,
|
|
cwd: resolveCwd(containerHome, ws.data.cwd),
|
|
homeDir: containerHome,
|
|
userLabel: email,
|
|
}),
|
|
);
|
|
|
|
for (const msg of session.pendingMessages) sidecar.send(msg);
|
|
session.pendingMessages = [];
|
|
},
|
|
|
|
message(ws: ServerWebSocket<WSData>, raw: string | Buffer) {
|
|
const session = sessions.get(ws);
|
|
if (!session) return;
|
|
|
|
const payload = typeof raw === 'string' ? raw : raw.toString();
|
|
|
|
if (!session.sidecar || session.sidecar.readyState !== WebSocket.OPEN) {
|
|
session.pendingMessages.push(payload);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
session.sidecar.send(payload);
|
|
} catch {
|
|
// ignore
|
|
}
|
|
},
|
|
|
|
close(ws: ServerWebSocket<WSData>) {
|
|
const session = sessions.get(ws);
|
|
if (session?.sidecar) {
|
|
try {
|
|
session.sidecar.close();
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
sessions.delete(ws);
|
|
},
|
|
|
|
drain() {},
|
|
};
|