diff --git a/electron/main.cjs b/electron/main.cjs index e9d3c434..f648f58c 100644 --- a/electron/main.cjs +++ b/electron/main.cjs @@ -536,6 +536,16 @@ function httpFetch(url, options = {}) { // Node's http/https modules never auto-decompress, so an unhandled // content-encoding here silently turns the body into garbage bytes. let stream = res; + const maxResponseBytes = options.maxResponseBytes || 10 * 1024 * 1024; + let responseBytes = 0; + let settled = false; + const fail = (error) => { + if (settled) return; + settled = true; + stream.destroy(); + req.destroy(); + reject(error); + }; const encoding = (res.headers["content-encoding"] || "") .toLowerCase() .trim(); @@ -552,8 +562,17 @@ function httpFetch(url, options = {}) { return; } - stream.on("data", (chunk) => chunks.push(chunk)); + stream.on("data", (chunk) => { + responseBytes += chunk.length; + if (responseBytes > maxResponseBytes) { + fail(new Error(`Response exceeds ${maxResponseBytes} bytes`)); + return; + } + chunks.push(chunk); + }); stream.on("end", () => { + if (settled) return; + settled = true; const data = Buffer.concat(chunks).toString("utf8"); resolve({ ok: res.statusCode >= 200 && res.statusCode < 300, @@ -562,7 +581,7 @@ function httpFetch(url, options = {}) { json: () => Promise.resolve(JSON.parse(data)), }); }); - stream.on("error", reject); + stream.on("error", fail); }); req.on("error", reject); diff --git a/electron/remote-sync.cjs b/electron/remote-sync.cjs index 55cc8734..5aaafaad 100644 --- a/electron/remote-sync.cjs +++ b/electron/remote-sync.cjs @@ -37,7 +37,20 @@ function writeJson(filePath, value) { if (!fs.existsSync(userDataPath)) { fs.mkdirSync(userDataPath, { recursive: true }); } - fs.writeFileSync(filePath, JSON.stringify(value, null, 2)); + const temporaryPath = `${filePath}.${process.pid}-${Date.now()}.tmp`; + try { + fs.writeFileSync(temporaryPath, JSON.stringify(value, null, 2), { + mode: 0o600, + }); + fs.renameSync(temporaryPath, filePath); + } catch (error) { + try { + fs.unlinkSync(temporaryPath); + } catch { + // already absent + } + throw error; + } } function getDesktopSettingsPath() { diff --git a/src/backend/database/routes/homepage-favicon-routes.ts b/src/backend/database/routes/homepage-favicon-routes.ts index 77d3d333..cbeddaa3 100644 --- a/src/backend/database/routes/homepage-favicon-routes.ts +++ b/src/backend/database/routes/homepage-favicon-routes.ts @@ -12,6 +12,7 @@ const faviconCache = new Map< >(); const CACHE_SIZE = 100; const CACHE_TTL_MS = 1000 * 60 * 60 * 24; // 24 hours +const MAX_FAVICON_BYTES = 512 * 1024; function evictIfNeeded() { if (faviconCache.size >= CACHE_SIZE) { @@ -25,14 +26,31 @@ function fetchUrl(url: string): Promise<{ data: Buffer; contentType: string }> { const mod = url.startsWith("https") ? https : http; const req = mod.get(url, { timeout: 5000 }, (res) => { const chunks: Buffer[] = []; - res.on("data", (chunk: Buffer) => chunks.push(chunk)); + let bytes = 0; + let settled = false; + const fail = (error: Error) => { + if (settled) return; + settled = true; + res.destroy(); + reject(error); + }; + res.on("data", (chunk: Buffer) => { + bytes += chunk.length; + if (bytes > MAX_FAVICON_BYTES) { + fail(new Error("Favicon response too large")); + return; + } + chunks.push(chunk); + }); res.on("end", () => { + if (settled) return; + settled = true; resolve({ data: Buffer.concat(chunks), contentType: res.headers["content-type"] || "image/x-icon", }); }); - res.on("error", reject); + res.on("error", fail); }); req.on("error", reject); req.on("timeout", () => { @@ -95,7 +113,7 @@ homepageFaviconRouter.get("/", async (req: Request, res: Response) => { res.setHeader("Content-Type", contentType); res.setHeader("Cache-Control", "public, max-age=86400"); res.send(data); - } catch (err) { + } catch { homepageLogger.warn("Failed to fetch favicon", { domain }); res.status(500).json({ error: "Failed to fetch favicon" }); } diff --git a/src/backend/database/routes/homepage-proxy-routes.ts b/src/backend/database/routes/homepage-proxy-routes.ts index 930cc2bd..ec92e99c 100644 --- a/src/backend/database/routes/homepage-proxy-routes.ts +++ b/src/backend/database/routes/homepage-proxy-routes.ts @@ -1,11 +1,10 @@ import { getErrorMessage } from "../../utils/error-message.js"; import express, { type Request, type Response } from "express"; -import https from "https"; -import http from "http"; -import { lookup } from "dns/promises"; -import { isIP } from "net"; import { homepageLogger } from "../../utils/logger.js"; -import { isBlockedAddress } from "../../utils/safe-outbound-fetch.js"; +import { + readResponseTextLimited, + safeOutboundFetch, +} from "../../utils/safe-outbound-fetch.js"; export const homepageProxyRouter = express.Router(); @@ -17,68 +16,20 @@ interface ProxyCacheEntry { const proxyCache = new Map(); const CACHE_SIZE = 50; const FETCH_TIMEOUT_MS = 8000; - -async function resolvePublicUrl(rawUrl: string): Promise<{ - url: URL; - address: string; -}> { - const url = new URL(rawUrl); - if ( - !["http:", "https:"].includes(url.protocol) || - url.username || - url.password - ) { - throw new Error("Invalid URL"); - } - - const hostname = url.hostname.replace(/^\[|\]$/g, ""); - const addresses = isIP(hostname) - ? [{ address: hostname }] - : await lookup(hostname, { all: true, verbatim: true }); - if ( - addresses.length === 0 || - addresses.some(({ address }) => isBlockedAddress(address)) - ) { - throw new Error("Private destinations are not allowed"); - } - - return { url, address: addresses[0].address }; -} +const MAX_RESPONSE_BYTES = 5 * 1024 * 1024; +const MAX_CACHE_TTL_SECONDS = 24 * 60 * 60; async function fetchJson(rawUrl: string): Promise { - const { url, address } = await resolvePublicUrl(rawUrl); - return new Promise((resolve, reject) => { - const mod = url.protocol === "https:" ? https : http; - const req = mod.get( - { - protocol: url.protocol, - hostname: address, - port: url.port || undefined, - path: `${url.pathname}${url.search}`, - headers: { Host: url.host }, - servername: url.protocol === "https:" ? url.hostname : undefined, - timeout: FETCH_TIMEOUT_MS, - }, - (res) => { - const chunks: Buffer[] = []; - res.on("data", (chunk: Buffer) => chunks.push(chunk)); - res.on("end", () => { - try { - const text = Buffer.concat(chunks).toString("utf-8"); - resolve(JSON.parse(text)); - } catch { - reject(new Error("Response is not valid JSON")); - } - }); - res.on("error", reject); - }, - ); - req.on("error", reject); - req.on("timeout", () => { - req.destroy(); - reject(new Error("Fetch timeout")); - }); + const response = await safeOutboundFetch(rawUrl, { + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), }); + if (!response.ok) throw new Error(`Proxy fetch failed: ${response.status}`); + const text = await readResponseTextLimited(response, MAX_RESPONSE_BYTES); + try { + return JSON.parse(text); + } catch { + throw new Error("Response is not valid JSON"); + } } /** @@ -109,7 +60,9 @@ async function fetchJson(rawUrl: string): Promise { */ homepageProxyRouter.get("/", async (req: Request, res: Response) => { const targetUrl = req.query.url as string; - const ttl = Math.max(10, Number(req.query.ttl) || 60) * 1000; + const ttl = + Math.min(MAX_CACHE_TTL_SECONDS, Math.max(10, Number(req.query.ttl) || 60)) * + 1000; if (!targetUrl) return res.status(400).json({ error: "url is required" }); try { diff --git a/src/backend/database/routes/homepage-rss-routes.ts b/src/backend/database/routes/homepage-rss-routes.ts index e42cd51b..b88e4508 100644 --- a/src/backend/database/routes/homepage-rss-routes.ts +++ b/src/backend/database/routes/homepage-rss-routes.ts @@ -1,6 +1,9 @@ import express, { type Request, type Response } from "express"; import { homepageLogger } from "../../utils/logger.js"; -import { safeOutboundFetch } from "../../utils/safe-outbound-fetch.js"; +import { + readResponseTextLimited, + safeOutboundFetch, +} from "../../utils/safe-outbound-fetch.js"; export const homepageRssRouter = express.Router(); @@ -8,6 +11,7 @@ const rssCache = new Map(); const CACHE_TTL_MS = 1000 * 60 * 15; // 15 minutes const CACHE_SIZE = 50; const FETCH_TIMEOUT_MS = 8000; +const MAX_RESPONSE_BYTES = 5 * 1024 * 1024; interface RssItem { title: string; @@ -21,7 +25,7 @@ function fetchXml(url: string): Promise { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), }).then(async (res) => { if (!res.ok) throw new Error(`RSS fetch failed: ${res.status}`); - return res.text(); + return readResponseTextLimited(res, MAX_RESPONSE_BYTES); }); } @@ -131,7 +135,7 @@ homepageRssRouter.get("/", async (req: Request, res: Response) => { rssCache.set(feedUrl, { data: items, expires: Date.now() + CACHE_TTL_MS }); res.json(items.slice(0, max)); - } catch (err) { + } catch { homepageLogger.warn("Failed to fetch RSS feed", { feedUrl }); res.status(500).json({ error: "Failed to fetch feed" }); } diff --git a/src/backend/database/routes/host.ts b/src/backend/database/routes/host.ts index 3a2237ac..8b3490f5 100644 --- a/src/backend/database/routes/host.ts +++ b/src/backend/database/routes/host.ts @@ -1812,6 +1812,78 @@ router.get( }, ); +/** + * Returns the minimum authentication material needed by the desktop app to + * connect to a shared host from the recipient's own network. The response is + * deliberately transient: callers must not persist it or include it in logs. + */ +router.get( + "/db/host/:id/local-connection-auth", + authenticateJWT, + permissionManager.requirePermission("hosts.view"), + requireDataAccess, + async (req: Request, res: Response) => { + const hostId = Number(req.params.id); + const userId = (req as AuthenticatedRequest).userId; + + if (!isNonEmptyString(userId) || !Number.isInteger(hostId) || hostId <= 0) { + return res.status(400).json({ error: "Invalid userId or hostId" }); + } + + try { + const access = await permissionManager.canAccessHost( + userId, + hostId, + "connect", + ); + if (!access.hasAccess || !access.isShared) { + return res.status(404).json({ error: "Shared host not found" }); + } + + const repository = createCurrentHostResolutionRepository(); + const ownerId = await repository.findHostOwnerId(hostId); + const host = ownerId + ? await repository.findHostById(hostId, ownerId) + : null; + if (!host) { + return res.status(404).json({ error: "Shared host not found" }); + } + + const resolved = await resolveHostCredentials( + { + ...transformHostResponse(host), + isShared: true, + permissionLevel: access.permissionLevel, + }, + userId, + ); + + res.setHeader("Cache-Control", "no-store"); + return res.json({ + username: resolved.username, + authType: resolved.authType, + password: resolved.password || null, + key: resolved.key || null, + keyPassword: resolved.keyPassword || null, + keyType: resolved.keyType || null, + }); + } catch (error) { + sshLogger.error( + "Failed to resolve shared host local authentication", + error, + { + operation: "shared_host_local_auth_resolve", + hostId, + userId, + }, + ); + return res + .status(500) + .json({ error: "Failed to resolve shared host authentication" }); + } + }, +); + /** * @openapi * /host/db/host/{id}/password: diff --git a/src/backend/hosts/cloudflare-websocket.ts b/src/backend/hosts/cloudflare-websocket.ts new file mode 100644 index 00000000..87779240 --- /dev/null +++ b/src/backend/hosts/cloudflare-websocket.ts @@ -0,0 +1,64 @@ +import { Duplex } from "node:stream"; +import type { RawData, WebSocket } from "ws"; + +export function waitForWebSocketOpen( + socket: WebSocket, + timeoutMs: number, +): Promise { + return new Promise((resolve, reject) => { + const cleanup = () => { + clearTimeout(timer); + socket.off("open", onOpen); + socket.off("error", onError); + }; + const onOpen = () => { + cleanup(); + resolve(); + }; + const onError = (error: Error) => { + cleanup(); + socket.terminate(); + reject(error); + }; + const timer = setTimeout(() => { + cleanup(); + socket.terminate(); + reject(new Error("Cloudflare tunnel timeout")); + }, timeoutMs); + + socket.once("open", onOpen); + socket.once("error", onError); + }); +} + +export function createWebSocketDuplex(socket: WebSocket): Duplex { + const duplex = new Duplex({ + read() {}, + write(chunk, _encoding, callback) { + try { + socket.send(chunk, (error) => callback(error || undefined)); + } catch (error) { + callback(error instanceof Error ? error : new Error(String(error))); + } + }, + destroy(error, callback) { + cleanup(); + socket.terminate(); + callback(error); + }, + }); + + const onMessage = (data: RawData) => duplex.push(data); + const onClose = () => duplex.destroy(); + const onError = () => duplex.destroy(); + const cleanup = () => { + socket.off("message", onMessage); + socket.off("close", onClose); + socket.off("error", onError); + }; + + socket.on("message", onMessage); + socket.on("close", onClose); + socket.on("error", onError); + return duplex; +} diff --git a/src/backend/hosts/docker/console.ts b/src/backend/hosts/docker/console.ts index 334a3b20..897892c7 100644 --- a/src/backend/hosts/docker/console.ts +++ b/src/backend/hosts/docker/console.ts @@ -20,6 +20,13 @@ import { HOST_NOT_ON_THIS_SERVER_MESSAGE, } from "../terminal/host-identity.js"; import { extractWebSocketToken } from "../../utils/ws-auth.js"; +import { + asObject, + asString, + MAX_WS_MESSAGE_BYTES, + parseWsMessage, + toTerminalDimension, +} from "../../utils/ws-message.js"; const sshLogger = systemLogger; @@ -38,6 +45,7 @@ const activeSessions = new Map(); const wss = new WebSocketServer({ host: "127.0.0.1", port: 30009, + maxPayload: MAX_WS_MESSAGE_BYTES, }); wss.on("error", (error) => { @@ -294,7 +302,17 @@ wss.on("connection", async (ws: WebSocket, req) => { } const authManagerInstance = AuthManager.getInstance(); - const payload = await authManagerInstance.verifyJWTToken(token); + let payload; + try { + payload = await authManagerInstance.verifyJWTToken(token); + } catch (error) { + sshLogger.warn("Docker console JWT verification failed", { + operation: "docker_console_auth_error", + error: getErrorMessage(error), + }); + ws.close(1008, "Authentication required"); + return; + } if (!payload?.userId || payload.pendingTOTP) { ws.close(1008, "Authentication required"); return; @@ -316,20 +334,29 @@ wss.on("connection", async (ws: WebSocket, req) => { } }, 30000); + const cleanup = () => { + clearInterval(wsPingInterval); + if (!sshSession) return; + sshSession.stream?.end(); + sshSession.client.end(); + activeSessions.delete(sessionId); + sshSession = null; + }; + ws.on("message", async (data) => { try { - const message = JSON.parse(data.toString()); + const message = parseWsMessage(data); switch (message.type) { case "connect": { - const { hostConfig, containerId, shell, cols, rows } = - message.data as { - hostConfig: SSHHost; - containerId: string; - shell?: string; - cols?: number; - rows?: number; - }; + const connectData = asObject(message.data); + const hostConfig = asObject( + connectData.hostConfig, + ) as unknown as SSHHost; + const containerId = asString(connectData.containerId); + const shell = asString(connectData.shell) || undefined; + const cols = toTerminalDimension(connectData.cols) || 80; + const rows = toTerminalDimension(connectData.rows) || 24; const hostId = hostConfig?.id; @@ -595,8 +622,8 @@ wss.on("connection", async (ws: WebSocket, req) => { { pty: { term: "xterm-256color", - cols: cols || 80, - rows: rows || 24, + cols, + rows, }, }, (err, stream) => { @@ -694,7 +721,7 @@ wss.on("connection", async (ws: WebSocket, req) => { sshLogger.error("Failed to connect to container", error, { operation: "console_connect", sessionId, - containerId: message.data.containerId, + containerId, }); ws.send( @@ -712,15 +739,19 @@ wss.on("connection", async (ws: WebSocket, req) => { case "input": { if (sshSession && sshSession.stream) { - sshSession.stream.write(message.data); + const input = asString(message.data); + if (input) sshSession.stream.write(input); } break; } case "resize": { if (sshSession && sshSession.stream) { - const { cols, rows } = message.data; - sshSession.stream.setWindow(rows, cols, rows, cols); + const dimensions = asObject(message.data); + const cols = toTerminalDimension(dimensions.cols); + const rows = toTerminalDimension(dimensions.rows); + if (cols && rows) + sshSession.stream.setWindow(rows, cols, rows, cols); } break; } @@ -772,7 +803,6 @@ wss.on("connection", async (ws: WebSocket, req) => { }); ws.on("close", () => { - clearInterval(wsPingInterval); sshLogger.info("Docker console disconnected", { operation: "docker_console_disconnect", sessionId, @@ -780,13 +810,7 @@ wss.on("connection", async (ws: WebSocket, req) => { hostId: sshSession?.hostId, containerId: sshSession?.containerId, }); - if (sshSession) { - if (sshSession.stream) { - sshSession.stream.end(); - } - sshSession.client.end(); - activeSessions.delete(sessionId); - } + cleanup(); }); ws.on("error", (error) => { @@ -795,13 +819,7 @@ wss.on("connection", async (ws: WebSocket, req) => { sessionId, }); - if (sshSession) { - if (sshSession.stream) { - sshSession.stream.end(); - } - sshSession.client.end(); - activeSessions.delete(sessionId); - } + cleanup(); }); }); diff --git a/src/backend/hosts/terminal/index.ts b/src/backend/hosts/terminal/index.ts index 1292f6a4..d6811078 100644 --- a/src/backend/hosts/terminal/index.ts +++ b/src/backend/hosts/terminal/index.ts @@ -68,6 +68,10 @@ import { resolveServerJumpHosts, } from "./host-identity.js"; import { extractWebSocketToken } from "../../utils/ws-auth.js"; +import { + createWebSocketDuplex, + waitForWebSocketOpen, +} from "../cloudflare-websocket.js"; interface ConnectToHostData { cols: number; @@ -3285,24 +3289,9 @@ wss.on("connection", async (ws: WebSocket, req) => { }, }); - await new Promise((resolve, reject) => { - cfWs.on("open", () => resolve()); - cfWs.on("error", (err) => reject(err)); - setTimeout( - () => reject(new Error("Cloudflare tunnel timeout")), - 30000, - ); - }); + await waitForWebSocketOpen(cfWs, 30000); - const { Duplex } = await import("stream"); - const duplexStream = new Duplex({ - read() {}, - write(chunk, _encoding, callback) { - cfWs.send(chunk, callback); - }, - }); - cfWs.on("message", (data) => duplexStream.push(data)); - cfWs.on("close", () => duplexStream.push(null)); + const duplexStream = createWebSocketDuplex(cfWs); connectConfig.sock = duplexStream as unknown as typeof connectConfig.sock; diff --git a/src/backend/tests/hosts/cloudflare-websocket.test.ts b/src/backend/tests/hosts/cloudflare-websocket.test.ts new file mode 100644 index 00000000..48875adb --- /dev/null +++ b/src/backend/tests/hosts/cloudflare-websocket.test.ts @@ -0,0 +1,74 @@ +import { EventEmitter } from "node:events"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { WebSocket } from "ws"; +import { + createWebSocketDuplex, + waitForWebSocketOpen, +} from "../../hosts/cloudflare-websocket.js"; + +function setupSocket() { + const socket = new EventEmitter() as EventEmitter & { + terminate: ReturnType; + send: ReturnType; + }; + socket.terminate = vi.fn(); + socket.send = vi.fn(); + return socket as unknown as WebSocket; +} + +describe("waitForWebSocketOpen", () => { + afterEach(() => vi.useRealTimers()); + + it("clears listeners and leaves an opened socket alive", async () => { + vi.useFakeTimers(); + const socket = setupSocket(); + const result = waitForWebSocketOpen(socket, 30_000); + + socket.emit("open"); + + await expect(result).resolves.toBeUndefined(); + expect(socket.terminate).not.toHaveBeenCalled(); + expect(socket.listenerCount("open")).toBe(0); + expect(socket.listenerCount("error")).toBe(0); + expect(vi.getTimerCount()).toBe(0); + }); + + it("terminates a socket that does not open before the deadline", async () => { + vi.useFakeTimers(); + const socket = setupSocket(); + const result = waitForWebSocketOpen(socket, 30_000); + const rejection = expect(result).rejects.toThrow( + "Cloudflare tunnel timeout", + ); + + await vi.advanceTimersByTimeAsync(30_000); + + await rejection; + expect(socket.terminate).toHaveBeenCalledOnce(); + expect(socket.listenerCount("open")).toBe(0); + expect(socket.listenerCount("error")).toBe(0); + }); + + it("terminates the socket when the SSH duplex is destroyed", () => { + const socket = setupSocket(); + const duplex = createWebSocketDuplex(socket); + + duplex.destroy(); + + expect(socket.terminate).toHaveBeenCalledOnce(); + expect(socket.listenerCount("message")).toBe(0); + expect(socket.listenerCount("close")).toBe(0); + expect(socket.listenerCount("error")).toBe(0); + }); + + it("handles established socket errors without an unhandled event", () => { + const socket = setupSocket(); + const duplex = createWebSocketDuplex(socket); + + socket.emit("error", new Error("connection lost")); + + expect(duplex.destroyed).toBe(true); + expect(socket.terminate).toHaveBeenCalledOnce(); + expect(socket.listenerCount("error")).toBe(0); + }); +}); diff --git a/src/backend/tests/utils/safe-outbound-fetch.test.ts b/src/backend/tests/utils/safe-outbound-fetch.test.ts index fe54d398..4ba8c866 100644 --- a/src/backend/tests/utils/safe-outbound-fetch.test.ts +++ b/src/backend/tests/utils/safe-outbound-fetch.test.ts @@ -3,6 +3,7 @@ import type { LookupAddress, LookupOptions } from "dns"; import { createDnsLookupHook, isBlockedAddress, + readResponseTextLimited, } from "../../utils/safe-outbound-fetch.js"; describe("isBlockedAddress", () => { @@ -47,6 +48,37 @@ describe("isBlockedAddress", () => { }); }); +describe("readResponseTextLimited", () => { + it("reads a response within the configured limit", async () => { + const response = new Response("hello"); + await expect(readResponseTextLimited(response, 5)).resolves.toBe("hello"); + }); + + it("rejects a declared oversized response before buffering it", async () => { + const response = new Response("small", { + headers: { "content-length": "100" }, + }); + await expect(readResponseTextLimited(response, 10)).rejects.toThrow( + "Response exceeds 10 bytes", + ); + }); + + it("stops a chunked response once its actual body exceeds the limit", async () => { + const response = new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode("12345")); + controller.enqueue(new TextEncoder().encode("6")); + controller.close(); + }, + }), + ); + await expect(readResponseTextLimited(response, 5)).rejects.toThrow( + "Response exceeds 5 bytes", + ); + }); +}); + function runHook( addresses: LookupAddress[] | string | undefined, error: NodeJS.ErrnoException | null = null, diff --git a/src/backend/utils/safe-outbound-fetch.ts b/src/backend/utils/safe-outbound-fetch.ts index 23d7cc38..958a5021 100644 --- a/src/backend/utils/safe-outbound-fetch.ts +++ b/src/backend/utils/safe-outbound-fetch.ts @@ -157,6 +157,35 @@ export interface OutboundTlsOptions { rejectUnauthorized?: boolean; } +export async function readResponseTextLimited( + response: Response, + maxBytes: number, +): Promise { + const declaredLength = Number(response.headers.get("content-length")); + if (Number.isFinite(declaredLength) && declaredLength > maxBytes) { + await response.body?.cancel().catch(() => {}); + throw new Error(`Response exceeds ${maxBytes} bytes`); + } + + if (!response.body) return ""; + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + total += value.byteLength; + if (total > maxBytes) { + await reader.cancel().catch(() => {}); + throw new Error(`Response exceeds ${maxBytes} bytes`); + } + chunks.push(value); + } + + return Buffer.concat(chunks, total).toString("utf8"); +} + export async function safeOutboundFetch( rawUrl: string, options: RequestInit, diff --git a/src/backend/utils/ws-message.ts b/src/backend/utils/ws-message.ts index 483a8a81..8aa380fb 100644 --- a/src/backend/utils/ws-message.ts +++ b/src/backend/utils/ws-message.ts @@ -3,7 +3,7 @@ import type { RawData } from "ws"; // Cap on a single decoded text frame. Anything larger is almost certainly // abuse - the legitimate control messages here are tiny, and terminal input is // bounded by what a user can type or paste. -const MAX_MESSAGE_BYTES = 1024 * 1024; +export const MAX_WS_MESSAGE_BYTES = 1024 * 1024; export class WsMessageError extends Error {} @@ -27,7 +27,7 @@ export function parseWsMessage(raw: RawData): { type: string; data: unknown; } { - if (rawByteLength(raw) > MAX_MESSAGE_BYTES) { + if (rawByteLength(raw) > MAX_WS_MESSAGE_BYTES) { throw new WsMessageError("Message too large"); } diff --git a/src/ui/api/rbac-api.ts b/src/ui/api/rbac-api.ts index 313a95fa..31d92c3f 100644 --- a/src/ui/api/rbac-api.ts +++ b/src/ui/api/rbac-api.ts @@ -320,10 +320,14 @@ export async function revokeHostAccess( export async function getHostAuthOverride( hostId: number, protocol: AuthOverrideProtocol, + remoteShared = false, ): Promise<{ protocol: AuthOverrideProtocol; credentialId: number | null }> { try { - const response = await rbacApi.get( - `/rbac/host-access/${hostId}/auth/${protocol}`, + const api = remoteShared ? await getConnectedRemoteApi() : rbacApi; + if (!api) throw new Error("Remote server is not connected"); + const targetHostId = remoteShared ? Math.abs(hostId) : hostId; + const response = await api.get( + `/rbac/host-access/${targetHostId}/auth/${protocol}`, ); return response.data; } catch (error) { @@ -335,14 +339,18 @@ export async function setHostAuthOverride( hostId: number, protocol: AuthOverrideProtocol, credentialId: number | null, + remoteShared = false, ): Promise<{ success: boolean; protocol: AuthOverrideProtocol; credentialId: number | null; }> { try { - const response = await rbacApi.put( - `/rbac/host-access/${hostId}/auth/${protocol}`, + const api = remoteShared ? await getConnectedRemoteApi() : rbacApi; + if (!api) throw new Error("Remote server is not connected"); + const targetHostId = remoteShared ? Math.abs(hostId) : hostId; + const response = await api.put( + `/rbac/host-access/${targetHostId}/auth/${protocol}`, { credentialId }, ); return response.data; diff --git a/src/ui/features/terminal/Terminal.tsx b/src/ui/features/terminal/Terminal.tsx index 2dd15e29..d6d43302 100644 --- a/src/ui/features/terminal/Terminal.tsx +++ b/src/ui/features/terminal/Terminal.tsx @@ -88,6 +88,7 @@ import { isPhysicalShortcutKey, isTabKeyEvent } from "./terminal-key-event.ts"; import { installTouchWheelCoordinator } from "./touch-wheel-coordinator.ts"; import { loadTouchInputSettings } from "./touch-input-settings-store.ts"; import { quoteTerminalImagePath } from "./terminal-image-path.ts"; +import { hydrateLocalSharedHostAuth } from "@/lib/remote-server-api.ts"; import { getUserPreferences, parseCustomKeybindings, @@ -1265,6 +1266,7 @@ const TerminalInner = forwardRef( let baseWsUrl: string; let wsProtocols: string[] = []; + let outboundHostConfig = hostConfig; if (isDev) { baseWsUrl = `${window.location.protocol === "https:" ? "wss" : "ws"}://localhost:30002`; @@ -1287,6 +1289,22 @@ const TerminalInner = forwardRef( isConnectingRef.current = false; return; } + if (origin === "local") { + try { + outboundHostConfig = await hydrateLocalSharedHostAuth(hostConfig); + } catch (error) { + const message = getErrorMessage( + error, + "Failed to load shared SSH authentication", + ); + setIsConnected(false); + setIsConnecting(false); + updateConnectionError(message); + addLog({ type: "error", stage: "auth", message }); + isConnectingRef.current = false; + return; + } + } baseWsUrl = resolvedUrl.url; wsProtocols = resolvedUrl.protocols; } else { @@ -1319,13 +1337,14 @@ const TerminalInner = forwardRef( isReconnectingRef.current = false; setIsConnecting(true); - setupWebSocketListeners(ws, cols, rows); + setupWebSocketListeners(ws, cols, rows, outboundHostConfig); } function setupWebSocketListeners( ws: WebSocket, cols: number, rows: number, + outboundHostConfig: TerminalHostConfig, ) { ws.addEventListener("open", () => { alternateScreenModeRef.current = false; @@ -1402,7 +1421,7 @@ const TerminalInner = forwardRef( data: { cols, rows, - hostConfig, + hostConfig: outboundHostConfig, initialPath, executeCommand, tmuxAttachSession, diff --git a/src/ui/lib/remote-server-api.ts b/src/ui/lib/remote-server-api.ts index 81a7ad0a..b8da4f62 100644 --- a/src/ui/lib/remote-server-api.ts +++ b/src/ui/lib/remote-server-api.ts @@ -11,10 +11,66 @@ export function markRemoteSharedHosts(hosts: SSHHost[]): SSHHost[] { // Local SQLite ids are positive. Negative ids keep remote-only shared // rows distinct while syncId remains the delegated backend identity. id: -Math.abs(host.id), - connectionOrigin: "remote" as const, })); } +export interface SharedHostConnectionAuth { + username?: string | null; + authType?: string | null; + password?: string | null; + key?: string | null; + keyPassword?: string | null; + keyType?: string | null; +} + +export async function getRemoteSharedHostConnectionAuth( + localHostId: number, +): Promise { + if (localHostId >= 0) { + throw new Error("Expected a remote shared host id"); + } + const api = await getConnectedRemoteApi(); + if (!api) throw new Error("Remote server is not connected"); + const response = await api.get( + `/host/db/host/${Math.abs(localHostId)}/local-connection-auth`, + ); + return response.data; +} + +export async function hydrateLocalSharedHostAuth< + T extends { + id?: number; + isShared?: unknown; + syncId?: string | null; + credentialId?: number; + username: string; + authType?: string; + password?: string; + key?: string; + keyPassword?: string; + keyType?: string; + }, +>(host: T): Promise { + if (!host.isShared || typeof host.id !== "number" || host.id >= 0) { + return host; + } + + const auth = await getRemoteSharedHostConnectionAuth(host.id); + return { + ...host, + // This row deliberately does not exist in the embedded database. Avoid + // asking the local backend to resolve its remote sync identity again. + syncId: null, + credentialId: undefined, + username: auth.username || host.username, + authType: auth.authType || host.authType, + password: auth.password || undefined, + key: auth.key || undefined, + keyPassword: auth.keyPassword || undefined, + keyType: auth.keyType || undefined, + }; +} + export async function getConnectedRemoteApi(): Promise { if (!isElectron()) return null; try { diff --git a/src/ui/lib/useConnectionRetry.ts b/src/ui/lib/useConnectionRetry.ts index fdba4c17..1af7d0da 100644 --- a/src/ui/lib/useConnectionRetry.ts +++ b/src/ui/lib/useConnectionRetry.ts @@ -64,6 +64,7 @@ export function useConnectionRetry({ null, ); const isMountedRef = useRef(true); + const markFailedRef = useRef<() => void>(() => {}); const clearTimers = useCallback(() => { if (retryTimeoutRef.current) { @@ -79,7 +80,13 @@ export function useConnectionRetry({ const runConnect = useCallback(() => { if (!isMountedRef.current) return; setStatus("connecting"); - void connectRef.current(); + try { + void Promise.resolve(connectRef.current()).catch(() => { + markFailedRef.current(); + }); + } catch { + markFailedRef.current(); + } }, []); const scheduleRetry = useCallback(() => { @@ -130,6 +137,7 @@ export function useConnectionRetry({ setNextRetryInMs(null); } }, [clearTimers, scheduleRetry]); + markFailedRef.current = markFailed; const retryNow = useCallback(() => { clearTimers(); diff --git a/src/ui/onboarding/steps/AiAssistantStep.tsx b/src/ui/onboarding/steps/AiAssistantStep.tsx index ac01613d..13a6d2b1 100644 --- a/src/ui/onboarding/steps/AiAssistantStep.tsx +++ b/src/ui/onboarding/steps/AiAssistantStep.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { Check } from "lucide-react"; import { saveUserPreferences } from "@/main-axios"; +import { readHiddenRailTabs } from "@/sidebar/hidden-rail-tabs"; /** * Asks once, plainly. @@ -18,9 +19,7 @@ export function AiAssistantStep() { function apply(next: boolean) { setEnabled(next); - const hidden = new Set( - JSON.parse(localStorage.getItem("hiddenRailTabs") ?? "[]"), - ); + const hidden = readHiddenRailTabs(); if (next) hidden.delete("ai"); else hidden.add("ai"); diff --git a/src/ui/shell/MobileBottomBar.tsx b/src/ui/shell/MobileBottomBar.tsx index 353e55d7..6fd53a90 100644 --- a/src/ui/shell/MobileBottomBar.tsx +++ b/src/ui/shell/MobileBottomBar.tsx @@ -12,17 +12,7 @@ import type { RailView } from "@/sidebar/AppRail"; import { visibleRailItems } from "@/sidebar/rail-items"; import { useAiAvailability } from "@/hooks/use-ai-availability"; import type { SplitMode } from "@/types/ui-types"; - -function readHiddenRailTabs(): Set { - try { - const raw = localStorage.getItem("hiddenRailTabs"); - if (!raw) return new Set(); - const parsed = JSON.parse(raw); - return Array.isArray(parsed) ? new Set(parsed.map(String)) : new Set(); - } catch { - return new Set(); - } -} +import { readHiddenRailTabs } from "@/sidebar/hidden-rail-tabs"; export function MobileBottomBar({ railView, diff --git a/src/ui/shell/tabUtils.tsx b/src/ui/shell/tabUtils.tsx index 216bce99..59ac9339 100644 --- a/src/ui/shell/tabUtils.tsx +++ b/src/ui/shell/tabUtils.tsx @@ -232,6 +232,7 @@ function hostToSSHHost(h: Host): SSHHost { tunnelConnections: [], connectionType: "ssh", connectionOrigin: h.connectionOrigin ?? null, + isShared: h.isShared ?? false, // Carries the host's identity to a delegated backend. Without it the // remote side resolves our local row id against its own table. syncId: h.syncId ?? null, diff --git a/src/ui/sidebar/HostAuthOverrideModal.tsx b/src/ui/sidebar/HostAuthOverrideModal.tsx index 18ed47be..88eeeaf0 100644 --- a/src/ui/sidebar/HostAuthOverrideModal.tsx +++ b/src/ui/sidebar/HostAuthOverrideModal.tsx @@ -21,6 +21,7 @@ import { type AuthOverrideProtocol, } from "@/types/auth-protocols"; import { mapCredentials } from "./HostManagerData"; +import { getConnectedRemoteApi } from "@/lib/remote-server-api"; export function HostAuthOverrideModal({ open, @@ -44,6 +45,7 @@ export function HostAuthOverrideModal({ const ownerAuthShared = overrideState?.ownerAuthShared ?? (protocol === "ssh" ? !!host.shareSshAuth : false); + const remoteShared = !!host.isShared && Number(host.id) < 0; useEffect(() => { if (!open) return; @@ -51,9 +53,18 @@ export function HostAuthOverrideModal({ setLoading(true); setLoadError(false); + const credentialsRequest = remoteShared + ? getConnectedRemoteApi().then((api) => { + if (!api) throw new Error("Remote server is not connected"); + return api.get("/credentials").then((response) => response.data); + }) + : getCredentials(); + Promise.all([ - getCredentials(), - getHostAuthOverride(Number(host.id), protocol), + credentialsRequest, + remoteShared + ? getHostAuthOverride(Number(host.id), protocol, true) + : getHostAuthOverride(Number(host.id), protocol), ]) .then(([credentialResult, overrideResult]) => { if (cancelled) return; @@ -76,13 +87,22 @@ export function HostAuthOverrideModal({ return () => { cancelled = true; }; - }, [host.id, open, protocol]); + }, [host.id, open, protocol, remoteShared]); const handleSave = async () => { setSaving(true); try { const credentialId = selectedId ? Number(selectedId) : null; - await setHostAuthOverride(Number(host.id), protocol, credentialId); + if (remoteShared) { + await setHostAuthOverride( + Number(host.id), + protocol, + credentialId, + true, + ); + } else { + await setHostAuthOverride(Number(host.id), protocol, credentialId); + } toast.success( credentialId === null ? t( diff --git a/src/ui/sidebar/UserProfilePanel.tsx b/src/ui/sidebar/UserProfilePanel.tsx index 9a55f8c4..9df7454b 100644 --- a/src/ui/sidebar/UserProfilePanel.tsx +++ b/src/ui/sidebar/UserProfilePanel.tsx @@ -68,6 +68,7 @@ import { User, X, } from "lucide-react"; +import { readHiddenRailTabs } from "@/sidebar/hidden-rail-tabs"; import { SettingRow, FakeSwitch } from "@/components/section-card"; import { visibleRailItems } from "./rail-items"; import { InterfacePresetSettings } from "./InterfacePresetSettings"; @@ -719,9 +720,7 @@ export function UserProfilePanel({ const applyAiEnabled = (enabled: boolean) => { setAiAssistantEnabled(enabled); - const hidden = new Set( - JSON.parse(localStorage.getItem("hiddenRailTabs") ?? "[]"), - ); + const hidden = readHiddenRailTabs(); if (enabled) hidden.delete("ai"); else hidden.add("ai"); diff --git a/src/ui/sidebar/hidden-rail-tabs.ts b/src/ui/sidebar/hidden-rail-tabs.ts new file mode 100644 index 00000000..f3defa98 --- /dev/null +++ b/src/ui/sidebar/hidden-rail-tabs.ts @@ -0,0 +1,10 @@ +export function readHiddenRailTabs(): Set { + try { + const raw = localStorage.getItem("hiddenRailTabs"); + if (!raw) return new Set(); + const parsed: unknown = JSON.parse(raw); + return Array.isArray(parsed) ? new Set(parsed.map(String)) : new Set(); + } catch { + return new Set(); + } +} diff --git a/src/ui/tests/lib/remote-server-api.test.ts b/src/ui/tests/lib/remote-server-api.test.ts index 6f1a6bce..a81cdec0 100644 --- a/src/ui/tests/lib/remote-server-api.test.ts +++ b/src/ui/tests/lib/remote-server-api.test.ts @@ -9,6 +9,7 @@ vi.mock("@/main-axios", () => ({ getRemoteStatsApi: () => remoteApi })); import { getConnectedRemoteApi, + hydrateLocalSharedHostAuth, markRemoteSharedHosts, resolveRemoteHostId, } from "@/lib/remote-server-api"; @@ -50,15 +51,63 @@ describe("remote server API", () => { it("keeps only shared remote hosts and gives them collision-free ids", () => { const rows = markRemoteSharedHosts([ { id: 4, isShared: false }, - { id: 9, isShared: true, syncId: "shared-host" }, + { + id: 9, + isShared: true, + syncId: "shared-host", + connectionOrigin: "local", + }, ] as SSHHost[]); expect(rows).toEqual([ expect.objectContaining({ id: -9, syncId: "shared-host", - connectionOrigin: "remote", + connectionOrigin: "local", }), ]); }); + + it("hydrates a remote shared host for a local connection without persisting remote ids", async () => { + isElectron.mockReturnValue(true); + invoke.mockResolvedValue({ serverUrl: "https://termix.example" }); + remoteApi.get.mockResolvedValue({ + data: { + username: "recipient", + authType: "key", + key: "PRIVATE KEY", + keyPassword: "passphrase", + keyType: "ed25519", + }, + }); + + const result = await hydrateLocalSharedHostAuth({ + id: -9, + isShared: true, + syncId: "shared-host", + credentialId: 77, + username: "owner", + authType: "key", + }); + + expect(remoteApi.get).toHaveBeenCalledWith( + "/host/db/host/9/local-connection-auth", + ); + expect(result).toEqual( + expect.objectContaining({ + id: -9, + syncId: null, + credentialId: undefined, + username: "recipient", + key: "PRIVATE KEY", + keyPassword: "passphrase", + }), + ); + }); + + it("leaves local and owned hosts untouched", async () => { + const host = { id: 9, isShared: false, username: "root" }; + await expect(hydrateLocalSharedHostAuth(host)).resolves.toBe(host); + expect(remoteApi.get).not.toHaveBeenCalled(); + }); }); diff --git a/src/ui/tests/lib/useConnectionRetry.test.ts b/src/ui/tests/lib/useConnectionRetry.test.ts index d73cebf1..572af416 100644 --- a/src/ui/tests/lib/useConnectionRetry.test.ts +++ b/src/ui/tests/lib/useConnectionRetry.test.ts @@ -1,5 +1,9 @@ -import { describe, expect, it } from "vitest"; -import { computeReconnectDelay } from "../../lib/useConnectionRetry.ts"; +import { renderHook, waitFor } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { + computeReconnectDelay, + useConnectionRetry, +} from "../../lib/useConnectionRetry.ts"; describe("connection retry delay", () => { it("uses exponential backoff with bounded jitter", () => { @@ -12,3 +16,15 @@ describe("connection retry delay", () => { expect(computeReconnectDelay(8, 2000, 8000, () => 1)).toBe(8000); }); }); + +describe("useConnectionRetry", () => { + it("turns a rejected async connection into a failed state", async () => { + const connect = vi.fn().mockRejectedValue(new Error("offline")); + const { result } = renderHook(() => + useConnectionRetry({ connect, enabled: false }), + ); + + await waitFor(() => expect(result.current.status).toBe("error")); + expect(connect).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/ui/tests/sidebar/HostAuthOverrideModal.test.tsx b/src/ui/tests/sidebar/HostAuthOverrideModal.test.tsx index de275172..095e5fd6 100644 --- a/src/ui/tests/sidebar/HostAuthOverrideModal.test.tsx +++ b/src/ui/tests/sidebar/HostAuthOverrideModal.test.tsx @@ -19,7 +19,14 @@ const toast = vi.hoisted(() => ({ error: vi.fn(), })); +const remote = vi.hoisted(() => ({ + get: vi.fn(), +})); + vi.mock("@/main-axios", () => api); +vi.mock("@/lib/remote-server-api", () => ({ + getConnectedRemoteApi: vi.fn(async () => remote), +})); vi.mock("sonner", () => ({ toast })); vi.mock("react-i18next", () => ({ useTranslation: () => ({ @@ -49,6 +56,7 @@ beforeEach(() => { api.setHostAuthOverride.mockReset(); toast.success.mockReset(); toast.error.mockReset(); + remote.get.mockReset(); api.getCredentials.mockResolvedValue([ { id: 7, @@ -68,6 +76,7 @@ beforeEach(() => { success: true, credentialId: 8, }); + remote.get.mockResolvedValue({ data: [] }); }); afterEach(cleanup); @@ -150,6 +159,37 @@ describe("HostAuthOverrideModal", () => { ).toBeTruthy(); }); + it("loads credentials and overrides from the remote server for remote-only shared hosts", async () => { + remote.get.mockResolvedValue({ + data: [ + { + id: 19, + name: "Remote personal key", + username: "alice", + authType: "key", + }, + ], + }); + api.getHostAuthOverride.mockResolvedValue({ credentialId: 19 }); + + render( + {}} + host={{ ...host, id: "-42" }} + protocol="ssh" + />, + ); + + const select = await screen.findByLabelText( + "hosts.sharing.authOverrideCredentialLabel", + ); + expect((select as HTMLSelectElement).value).toBe("19"); + expect(remote.get).toHaveBeenCalledWith("/credentials"); + expect(api.getCredentials).not.toHaveBeenCalled(); + expect(api.getHostAuthOverride).toHaveBeenCalledWith(-42, "ssh", true); + }); + it("renders empty and load-error states", async () => { api.getCredentials.mockResolvedValueOnce([]); const { unmount } = render( diff --git a/src/ui/tests/sidebar/HostManagerData.test.ts b/src/ui/tests/sidebar/HostManagerData.test.ts index a45556fb..b801b042 100644 --- a/src/ui/tests/sidebar/HostManagerData.test.ts +++ b/src/ui/tests/sidebar/HostManagerData.test.ts @@ -15,4 +15,18 @@ describe("sshHostToHost", () => { expect(host.wolBroadcastAddress).toBe("192.168.0.255"); }); + + it("preserves remote shared-host identity for local connection auth", () => { + const host = sshHostToHost({ + id: -12, + name: "shared", + ip: "10.0.0.2", + port: 22, + username: "root", + isShared: true, + } as SSHHostWithStatus); + + expect(host.id).toBe("-12"); + expect(host.isShared).toBe(true); + }); }); diff --git a/src/ui/tests/sidebar/hidden-rail-tabs.test.ts b/src/ui/tests/sidebar/hidden-rail-tabs.test.ts new file mode 100644 index 00000000..04e996db --- /dev/null +++ b/src/ui/tests/sidebar/hidden-rail-tabs.test.ts @@ -0,0 +1,18 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { readHiddenRailTabs } from "../../sidebar/hidden-rail-tabs"; + +describe("readHiddenRailTabs", () => { + beforeEach(() => localStorage.clear()); + + it("returns stored tab identifiers", () => { + localStorage.setItem("hiddenRailTabs", JSON.stringify(["ai", "hosts"])); + expect([...readHiddenRailTabs()]).toEqual(["ai", "hosts"]); + }); + + it("recovers from malformed or non-list storage", () => { + localStorage.setItem("hiddenRailTabs", "{broken"); + expect(readHiddenRailTabs().size).toBe(0); + localStorage.setItem("hiddenRailTabs", JSON.stringify({ ai: true })); + expect(readHiddenRailTabs().size).toBe(0); + }); +});