fix: harden connection, payload, and persisted state handling (#1354)

* fix: clean up Cloudflare tunnel timeouts

* fix: couple tunnel socket lifecycle

* fix: validate Docker console messages

* fix: bound homepage proxy responses

* fix: bound reconnect and response failures

* fix: harden persisted and socket state

* fix: support local connections to shared hosts
This commit is contained in:
ZacharyZcR
2026-08-28 10:36:39 +08:00
committed by GitHub
parent 703e8cd037
commit c129666d7f
28 changed files with 689 additions and 157 deletions
+21 -2
View File
@@ -536,6 +536,16 @@ function httpFetch(url, options = {}) {
// Node's http/https modules never auto-decompress, so an unhandled // Node's http/https modules never auto-decompress, so an unhandled
// content-encoding here silently turns the body into garbage bytes. // content-encoding here silently turns the body into garbage bytes.
let stream = res; 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"] || "") const encoding = (res.headers["content-encoding"] || "")
.toLowerCase() .toLowerCase()
.trim(); .trim();
@@ -552,8 +562,17 @@ function httpFetch(url, options = {}) {
return; 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", () => { stream.on("end", () => {
if (settled) return;
settled = true;
const data = Buffer.concat(chunks).toString("utf8"); const data = Buffer.concat(chunks).toString("utf8");
resolve({ resolve({
ok: res.statusCode >= 200 && res.statusCode < 300, ok: res.statusCode >= 200 && res.statusCode < 300,
@@ -562,7 +581,7 @@ function httpFetch(url, options = {}) {
json: () => Promise.resolve(JSON.parse(data)), json: () => Promise.resolve(JSON.parse(data)),
}); });
}); });
stream.on("error", reject); stream.on("error", fail);
}); });
req.on("error", reject); req.on("error", reject);
+14 -1
View File
@@ -37,7 +37,20 @@ function writeJson(filePath, value) {
if (!fs.existsSync(userDataPath)) { if (!fs.existsSync(userDataPath)) {
fs.mkdirSync(userDataPath, { recursive: true }); 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() { function getDesktopSettingsPath() {
@@ -12,6 +12,7 @@ const faviconCache = new Map<
>(); >();
const CACHE_SIZE = 100; const CACHE_SIZE = 100;
const CACHE_TTL_MS = 1000 * 60 * 60 * 24; // 24 hours const CACHE_TTL_MS = 1000 * 60 * 60 * 24; // 24 hours
const MAX_FAVICON_BYTES = 512 * 1024;
function evictIfNeeded() { function evictIfNeeded() {
if (faviconCache.size >= CACHE_SIZE) { 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 mod = url.startsWith("https") ? https : http;
const req = mod.get(url, { timeout: 5000 }, (res) => { const req = mod.get(url, { timeout: 5000 }, (res) => {
const chunks: Buffer[] = []; 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", () => { res.on("end", () => {
if (settled) return;
settled = true;
resolve({ resolve({
data: Buffer.concat(chunks), data: Buffer.concat(chunks),
contentType: res.headers["content-type"] || "image/x-icon", contentType: res.headers["content-type"] || "image/x-icon",
}); });
}); });
res.on("error", reject); res.on("error", fail);
}); });
req.on("error", reject); req.on("error", reject);
req.on("timeout", () => { req.on("timeout", () => {
@@ -95,7 +113,7 @@ homepageFaviconRouter.get("/", async (req: Request, res: Response) => {
res.setHeader("Content-Type", contentType); res.setHeader("Content-Type", contentType);
res.setHeader("Cache-Control", "public, max-age=86400"); res.setHeader("Cache-Control", "public, max-age=86400");
res.send(data); res.send(data);
} catch (err) { } catch {
homepageLogger.warn("Failed to fetch favicon", { domain }); homepageLogger.warn("Failed to fetch favicon", { domain });
res.status(500).json({ error: "Failed to fetch favicon" }); res.status(500).json({ error: "Failed to fetch favicon" });
} }
@@ -1,11 +1,10 @@
import { getErrorMessage } from "../../utils/error-message.js"; import { getErrorMessage } from "../../utils/error-message.js";
import express, { type Request, type Response } from "express"; 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 { 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(); export const homepageProxyRouter = express.Router();
@@ -17,68 +16,20 @@ interface ProxyCacheEntry {
const proxyCache = new Map<string, ProxyCacheEntry>(); const proxyCache = new Map<string, ProxyCacheEntry>();
const CACHE_SIZE = 50; const CACHE_SIZE = 50;
const FETCH_TIMEOUT_MS = 8000; const FETCH_TIMEOUT_MS = 8000;
const MAX_RESPONSE_BYTES = 5 * 1024 * 1024;
async function resolvePublicUrl(rawUrl: string): Promise<{ const MAX_CACHE_TTL_SECONDS = 24 * 60 * 60;
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 };
}
async function fetchJson(rawUrl: string): Promise<unknown> { async function fetchJson(rawUrl: string): Promise<unknown> {
const { url, address } = await resolvePublicUrl(rawUrl); const response = await safeOutboundFetch(rawUrl, {
return new Promise((resolve, reject) => { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
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"));
});
}); });
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<unknown> {
*/ */
homepageProxyRouter.get("/", async (req: Request, res: Response) => { homepageProxyRouter.get("/", async (req: Request, res: Response) => {
const targetUrl = req.query.url as string; 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" }); if (!targetUrl) return res.status(400).json({ error: "url is required" });
try { try {
@@ -1,6 +1,9 @@
import express, { type Request, type Response } from "express"; import express, { type Request, type Response } from "express";
import { homepageLogger } from "../../utils/logger.js"; 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(); export const homepageRssRouter = express.Router();
@@ -8,6 +11,7 @@ const rssCache = new Map<string, { data: RssItem[]; expires: number }>();
const CACHE_TTL_MS = 1000 * 60 * 15; // 15 minutes const CACHE_TTL_MS = 1000 * 60 * 15; // 15 minutes
const CACHE_SIZE = 50; const CACHE_SIZE = 50;
const FETCH_TIMEOUT_MS = 8000; const FETCH_TIMEOUT_MS = 8000;
const MAX_RESPONSE_BYTES = 5 * 1024 * 1024;
interface RssItem { interface RssItem {
title: string; title: string;
@@ -21,7 +25,7 @@ function fetchXml(url: string): Promise<string> {
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
}).then(async (res) => { }).then(async (res) => {
if (!res.ok) throw new Error(`RSS fetch failed: ${res.status}`); 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 }); rssCache.set(feedUrl, { data: items, expires: Date.now() + CACHE_TTL_MS });
res.json(items.slice(0, max)); res.json(items.slice(0, max));
} catch (err) { } catch {
homepageLogger.warn("Failed to fetch RSS feed", { feedUrl }); homepageLogger.warn("Failed to fetch RSS feed", { feedUrl });
res.status(500).json({ error: "Failed to fetch feed" }); res.status(500).json({ error: "Failed to fetch feed" });
} }
+72
View File
@@ -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 * @openapi
* /host/db/host/{id}/password: * /host/db/host/{id}/password:
+64
View File
@@ -0,0 +1,64 @@
import { Duplex } from "node:stream";
import type { RawData, WebSocket } from "ws";
export function waitForWebSocketOpen(
socket: WebSocket,
timeoutMs: number,
): Promise<void> {
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;
}
+49 -31
View File
@@ -20,6 +20,13 @@ import {
HOST_NOT_ON_THIS_SERVER_MESSAGE, HOST_NOT_ON_THIS_SERVER_MESSAGE,
} from "../terminal/host-identity.js"; } from "../terminal/host-identity.js";
import { extractWebSocketToken } from "../../utils/ws-auth.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; const sshLogger = systemLogger;
@@ -38,6 +45,7 @@ const activeSessions = new Map<string, SSHSession>();
const wss = new WebSocketServer({ const wss = new WebSocketServer({
host: "127.0.0.1", host: "127.0.0.1",
port: 30009, port: 30009,
maxPayload: MAX_WS_MESSAGE_BYTES,
}); });
wss.on("error", (error) => { wss.on("error", (error) => {
@@ -294,7 +302,17 @@ wss.on("connection", async (ws: WebSocket, req) => {
} }
const authManagerInstance = AuthManager.getInstance(); 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) { if (!payload?.userId || payload.pendingTOTP) {
ws.close(1008, "Authentication required"); ws.close(1008, "Authentication required");
return; return;
@@ -316,20 +334,29 @@ wss.on("connection", async (ws: WebSocket, req) => {
} }
}, 30000); }, 30000);
const cleanup = () => {
clearInterval(wsPingInterval);
if (!sshSession) return;
sshSession.stream?.end();
sshSession.client.end();
activeSessions.delete(sessionId);
sshSession = null;
};
ws.on("message", async (data) => { ws.on("message", async (data) => {
try { try {
const message = JSON.parse(data.toString()); const message = parseWsMessage(data);
switch (message.type) { switch (message.type) {
case "connect": { case "connect": {
const { hostConfig, containerId, shell, cols, rows } = const connectData = asObject(message.data);
message.data as { const hostConfig = asObject(
hostConfig: SSHHost; connectData.hostConfig,
containerId: string; ) as unknown as SSHHost;
shell?: string; const containerId = asString(connectData.containerId);
cols?: number; const shell = asString(connectData.shell) || undefined;
rows?: number; const cols = toTerminalDimension(connectData.cols) || 80;
}; const rows = toTerminalDimension(connectData.rows) || 24;
const hostId = hostConfig?.id; const hostId = hostConfig?.id;
@@ -595,8 +622,8 @@ wss.on("connection", async (ws: WebSocket, req) => {
{ {
pty: { pty: {
term: "xterm-256color", term: "xterm-256color",
cols: cols || 80, cols,
rows: rows || 24, rows,
}, },
}, },
(err, stream) => { (err, stream) => {
@@ -694,7 +721,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
sshLogger.error("Failed to connect to container", error, { sshLogger.error("Failed to connect to container", error, {
operation: "console_connect", operation: "console_connect",
sessionId, sessionId,
containerId: message.data.containerId, containerId,
}); });
ws.send( ws.send(
@@ -712,15 +739,19 @@ wss.on("connection", async (ws: WebSocket, req) => {
case "input": { case "input": {
if (sshSession && sshSession.stream) { if (sshSession && sshSession.stream) {
sshSession.stream.write(message.data); const input = asString(message.data);
if (input) sshSession.stream.write(input);
} }
break; break;
} }
case "resize": { case "resize": {
if (sshSession && sshSession.stream) { if (sshSession && sshSession.stream) {
const { cols, rows } = message.data; const dimensions = asObject(message.data);
sshSession.stream.setWindow(rows, cols, rows, cols); const cols = toTerminalDimension(dimensions.cols);
const rows = toTerminalDimension(dimensions.rows);
if (cols && rows)
sshSession.stream.setWindow(rows, cols, rows, cols);
} }
break; break;
} }
@@ -772,7 +803,6 @@ wss.on("connection", async (ws: WebSocket, req) => {
}); });
ws.on("close", () => { ws.on("close", () => {
clearInterval(wsPingInterval);
sshLogger.info("Docker console disconnected", { sshLogger.info("Docker console disconnected", {
operation: "docker_console_disconnect", operation: "docker_console_disconnect",
sessionId, sessionId,
@@ -780,13 +810,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
hostId: sshSession?.hostId, hostId: sshSession?.hostId,
containerId: sshSession?.containerId, containerId: sshSession?.containerId,
}); });
if (sshSession) { cleanup();
if (sshSession.stream) {
sshSession.stream.end();
}
sshSession.client.end();
activeSessions.delete(sessionId);
}
}); });
ws.on("error", (error) => { ws.on("error", (error) => {
@@ -795,13 +819,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
sessionId, sessionId,
}); });
if (sshSession) { cleanup();
if (sshSession.stream) {
sshSession.stream.end();
}
sshSession.client.end();
activeSessions.delete(sessionId);
}
}); });
}); });
+6 -17
View File
@@ -68,6 +68,10 @@ import {
resolveServerJumpHosts, resolveServerJumpHosts,
} from "./host-identity.js"; } from "./host-identity.js";
import { extractWebSocketToken } from "../../utils/ws-auth.js"; import { extractWebSocketToken } from "../../utils/ws-auth.js";
import {
createWebSocketDuplex,
waitForWebSocketOpen,
} from "../cloudflare-websocket.js";
interface ConnectToHostData { interface ConnectToHostData {
cols: number; cols: number;
@@ -3285,24 +3289,9 @@ wss.on("connection", async (ws: WebSocket, req) => {
}, },
}); });
await new Promise<void>((resolve, reject) => { await waitForWebSocketOpen(cfWs, 30000);
cfWs.on("open", () => resolve());
cfWs.on("error", (err) => reject(err));
setTimeout(
() => reject(new Error("Cloudflare tunnel timeout")),
30000,
);
});
const { Duplex } = await import("stream"); const duplexStream = createWebSocketDuplex(cfWs);
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));
connectConfig.sock = connectConfig.sock =
duplexStream as unknown as typeof connectConfig.sock; duplexStream as unknown as typeof connectConfig.sock;
@@ -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<typeof vi.fn>;
send: ReturnType<typeof vi.fn>;
};
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);
});
});
@@ -3,6 +3,7 @@ import type { LookupAddress, LookupOptions } from "dns";
import { import {
createDnsLookupHook, createDnsLookupHook,
isBlockedAddress, isBlockedAddress,
readResponseTextLimited,
} from "../../utils/safe-outbound-fetch.js"; } from "../../utils/safe-outbound-fetch.js";
describe("isBlockedAddress", () => { 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( function runHook(
addresses: LookupAddress[] | string | undefined, addresses: LookupAddress[] | string | undefined,
error: NodeJS.ErrnoException | null = null, error: NodeJS.ErrnoException | null = null,
+29
View File
@@ -157,6 +157,35 @@ export interface OutboundTlsOptions {
rejectUnauthorized?: boolean; rejectUnauthorized?: boolean;
} }
export async function readResponseTextLimited(
response: Response,
maxBytes: number,
): Promise<string> {
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( export async function safeOutboundFetch(
rawUrl: string, rawUrl: string,
options: RequestInit, options: RequestInit,
+2 -2
View File
@@ -3,7 +3,7 @@ import type { RawData } from "ws";
// Cap on a single decoded text frame. Anything larger is almost certainly // Cap on a single decoded text frame. Anything larger is almost certainly
// abuse - the legitimate control messages here are tiny, and terminal input is // abuse - the legitimate control messages here are tiny, and terminal input is
// bounded by what a user can type or paste. // 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 {} export class WsMessageError extends Error {}
@@ -27,7 +27,7 @@ export function parseWsMessage(raw: RawData): {
type: string; type: string;
data: unknown; data: unknown;
} { } {
if (rawByteLength(raw) > MAX_MESSAGE_BYTES) { if (rawByteLength(raw) > MAX_WS_MESSAGE_BYTES) {
throw new WsMessageError("Message too large"); throw new WsMessageError("Message too large");
} }
+12 -4
View File
@@ -320,10 +320,14 @@ export async function revokeHostAccess(
export async function getHostAuthOverride( export async function getHostAuthOverride(
hostId: number, hostId: number,
protocol: AuthOverrideProtocol, protocol: AuthOverrideProtocol,
remoteShared = false,
): Promise<{ protocol: AuthOverrideProtocol; credentialId: number | null }> { ): Promise<{ protocol: AuthOverrideProtocol; credentialId: number | null }> {
try { try {
const response = await rbacApi.get( const api = remoteShared ? await getConnectedRemoteApi() : rbacApi;
`/rbac/host-access/${hostId}/auth/${protocol}`, 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; return response.data;
} catch (error) { } catch (error) {
@@ -335,14 +339,18 @@ export async function setHostAuthOverride(
hostId: number, hostId: number,
protocol: AuthOverrideProtocol, protocol: AuthOverrideProtocol,
credentialId: number | null, credentialId: number | null,
remoteShared = false,
): Promise<{ ): Promise<{
success: boolean; success: boolean;
protocol: AuthOverrideProtocol; protocol: AuthOverrideProtocol;
credentialId: number | null; credentialId: number | null;
}> { }> {
try { try {
const response = await rbacApi.put( const api = remoteShared ? await getConnectedRemoteApi() : rbacApi;
`/rbac/host-access/${hostId}/auth/${protocol}`, 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 }, { credentialId },
); );
return response.data; return response.data;
+21 -2
View File
@@ -88,6 +88,7 @@ import { isPhysicalShortcutKey, isTabKeyEvent } from "./terminal-key-event.ts";
import { installTouchWheelCoordinator } from "./touch-wheel-coordinator.ts"; import { installTouchWheelCoordinator } from "./touch-wheel-coordinator.ts";
import { loadTouchInputSettings } from "./touch-input-settings-store.ts"; import { loadTouchInputSettings } from "./touch-input-settings-store.ts";
import { quoteTerminalImagePath } from "./terminal-image-path.ts"; import { quoteTerminalImagePath } from "./terminal-image-path.ts";
import { hydrateLocalSharedHostAuth } from "@/lib/remote-server-api.ts";
import { import {
getUserPreferences, getUserPreferences,
parseCustomKeybindings, parseCustomKeybindings,
@@ -1265,6 +1266,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
let baseWsUrl: string; let baseWsUrl: string;
let wsProtocols: string[] = []; let wsProtocols: string[] = [];
let outboundHostConfig = hostConfig;
if (isDev) { if (isDev) {
baseWsUrl = `${window.location.protocol === "https:" ? "wss" : "ws"}://localhost:30002`; baseWsUrl = `${window.location.protocol === "https:" ? "wss" : "ws"}://localhost:30002`;
@@ -1287,6 +1289,22 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
isConnectingRef.current = false; isConnectingRef.current = false;
return; 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; baseWsUrl = resolvedUrl.url;
wsProtocols = resolvedUrl.protocols; wsProtocols = resolvedUrl.protocols;
} else { } else {
@@ -1319,13 +1337,14 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
isReconnectingRef.current = false; isReconnectingRef.current = false;
setIsConnecting(true); setIsConnecting(true);
setupWebSocketListeners(ws, cols, rows); setupWebSocketListeners(ws, cols, rows, outboundHostConfig);
} }
function setupWebSocketListeners( function setupWebSocketListeners(
ws: WebSocket, ws: WebSocket,
cols: number, cols: number,
rows: number, rows: number,
outboundHostConfig: TerminalHostConfig,
) { ) {
ws.addEventListener("open", () => { ws.addEventListener("open", () => {
alternateScreenModeRef.current = false; alternateScreenModeRef.current = false;
@@ -1402,7 +1421,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
data: { data: {
cols, cols,
rows, rows,
hostConfig, hostConfig: outboundHostConfig,
initialPath, initialPath,
executeCommand, executeCommand,
tmuxAttachSession, tmuxAttachSession,
+57 -1
View File
@@ -11,10 +11,66 @@ export function markRemoteSharedHosts(hosts: SSHHost[]): SSHHost[] {
// Local SQLite ids are positive. Negative ids keep remote-only shared // Local SQLite ids are positive. Negative ids keep remote-only shared
// rows distinct while syncId remains the delegated backend identity. // rows distinct while syncId remains the delegated backend identity.
id: -Math.abs(host.id), 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<SharedHostConnectionAuth> {
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<T> {
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<AxiosInstance | null> { export async function getConnectedRemoteApi(): Promise<AxiosInstance | null> {
if (!isElectron()) return null; if (!isElectron()) return null;
try { try {
+9 -1
View File
@@ -64,6 +64,7 @@ export function useConnectionRetry({
null, null,
); );
const isMountedRef = useRef(true); const isMountedRef = useRef(true);
const markFailedRef = useRef<() => void>(() => {});
const clearTimers = useCallback(() => { const clearTimers = useCallback(() => {
if (retryTimeoutRef.current) { if (retryTimeoutRef.current) {
@@ -79,7 +80,13 @@ export function useConnectionRetry({
const runConnect = useCallback(() => { const runConnect = useCallback(() => {
if (!isMountedRef.current) return; if (!isMountedRef.current) return;
setStatus("connecting"); setStatus("connecting");
void connectRef.current(); try {
void Promise.resolve(connectRef.current()).catch(() => {
markFailedRef.current();
});
} catch {
markFailedRef.current();
}
}, []); }, []);
const scheduleRetry = useCallback(() => { const scheduleRetry = useCallback(() => {
@@ -130,6 +137,7 @@ export function useConnectionRetry({
setNextRetryInMs(null); setNextRetryInMs(null);
} }
}, [clearTimers, scheduleRetry]); }, [clearTimers, scheduleRetry]);
markFailedRef.current = markFailed;
const retryNow = useCallback(() => { const retryNow = useCallback(() => {
clearTimers(); clearTimers();
+2 -3
View File
@@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Check } from "lucide-react"; import { Check } from "lucide-react";
import { saveUserPreferences } from "@/main-axios"; import { saveUserPreferences } from "@/main-axios";
import { readHiddenRailTabs } from "@/sidebar/hidden-rail-tabs";
/** /**
* Asks once, plainly. * Asks once, plainly.
@@ -18,9 +19,7 @@ export function AiAssistantStep() {
function apply(next: boolean) { function apply(next: boolean) {
setEnabled(next); setEnabled(next);
const hidden = new Set<string>( const hidden = readHiddenRailTabs();
JSON.parse(localStorage.getItem("hiddenRailTabs") ?? "[]"),
);
if (next) hidden.delete("ai"); if (next) hidden.delete("ai");
else hidden.add("ai"); else hidden.add("ai");
+1 -11
View File
@@ -12,17 +12,7 @@ import type { RailView } from "@/sidebar/AppRail";
import { visibleRailItems } from "@/sidebar/rail-items"; import { visibleRailItems } from "@/sidebar/rail-items";
import { useAiAvailability } from "@/hooks/use-ai-availability"; import { useAiAvailability } from "@/hooks/use-ai-availability";
import type { SplitMode } from "@/types/ui-types"; import type { SplitMode } from "@/types/ui-types";
import { readHiddenRailTabs } from "@/sidebar/hidden-rail-tabs";
function readHiddenRailTabs(): Set<string> {
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();
}
}
export function MobileBottomBar({ export function MobileBottomBar({
railView, railView,
+1
View File
@@ -232,6 +232,7 @@ function hostToSSHHost(h: Host): SSHHost {
tunnelConnections: [], tunnelConnections: [],
connectionType: "ssh", connectionType: "ssh",
connectionOrigin: h.connectionOrigin ?? null, connectionOrigin: h.connectionOrigin ?? null,
isShared: h.isShared ?? false,
// Carries the host's identity to a delegated backend. Without it the // Carries the host's identity to a delegated backend. Without it the
// remote side resolves our local row id against its own table. // remote side resolves our local row id against its own table.
syncId: h.syncId ?? null, syncId: h.syncId ?? null,
+24 -4
View File
@@ -21,6 +21,7 @@ import {
type AuthOverrideProtocol, type AuthOverrideProtocol,
} from "@/types/auth-protocols"; } from "@/types/auth-protocols";
import { mapCredentials } from "./HostManagerData"; import { mapCredentials } from "./HostManagerData";
import { getConnectedRemoteApi } from "@/lib/remote-server-api";
export function HostAuthOverrideModal({ export function HostAuthOverrideModal({
open, open,
@@ -44,6 +45,7 @@ export function HostAuthOverrideModal({
const ownerAuthShared = const ownerAuthShared =
overrideState?.ownerAuthShared ?? overrideState?.ownerAuthShared ??
(protocol === "ssh" ? !!host.shareSshAuth : false); (protocol === "ssh" ? !!host.shareSshAuth : false);
const remoteShared = !!host.isShared && Number(host.id) < 0;
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
@@ -51,9 +53,18 @@ export function HostAuthOverrideModal({
setLoading(true); setLoading(true);
setLoadError(false); 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([ Promise.all([
getCredentials(), credentialsRequest,
getHostAuthOverride(Number(host.id), protocol), remoteShared
? getHostAuthOverride(Number(host.id), protocol, true)
: getHostAuthOverride(Number(host.id), protocol),
]) ])
.then(([credentialResult, overrideResult]) => { .then(([credentialResult, overrideResult]) => {
if (cancelled) return; if (cancelled) return;
@@ -76,13 +87,22 @@ export function HostAuthOverrideModal({
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [host.id, open, protocol]); }, [host.id, open, protocol, remoteShared]);
const handleSave = async () => { const handleSave = async () => {
setSaving(true); setSaving(true);
try { try {
const credentialId = selectedId ? Number(selectedId) : null; 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( toast.success(
credentialId === null credentialId === null
? t( ? t(
+2 -3
View File
@@ -68,6 +68,7 @@ import {
User, User,
X, X,
} from "lucide-react"; } from "lucide-react";
import { readHiddenRailTabs } from "@/sidebar/hidden-rail-tabs";
import { SettingRow, FakeSwitch } from "@/components/section-card"; import { SettingRow, FakeSwitch } from "@/components/section-card";
import { visibleRailItems } from "./rail-items"; import { visibleRailItems } from "./rail-items";
import { InterfacePresetSettings } from "./InterfacePresetSettings"; import { InterfacePresetSettings } from "./InterfacePresetSettings";
@@ -719,9 +720,7 @@ export function UserProfilePanel({
const applyAiEnabled = (enabled: boolean) => { const applyAiEnabled = (enabled: boolean) => {
setAiAssistantEnabled(enabled); setAiAssistantEnabled(enabled);
const hidden = new Set<string>( const hidden = readHiddenRailTabs();
JSON.parse(localStorage.getItem("hiddenRailTabs") ?? "[]"),
);
if (enabled) hidden.delete("ai"); if (enabled) hidden.delete("ai");
else hidden.add("ai"); else hidden.add("ai");
+10
View File
@@ -0,0 +1,10 @@
export function readHiddenRailTabs(): Set<string> {
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();
}
}
+51 -2
View File
@@ -9,6 +9,7 @@ vi.mock("@/main-axios", () => ({ getRemoteStatsApi: () => remoteApi }));
import { import {
getConnectedRemoteApi, getConnectedRemoteApi,
hydrateLocalSharedHostAuth,
markRemoteSharedHosts, markRemoteSharedHosts,
resolveRemoteHostId, resolveRemoteHostId,
} from "@/lib/remote-server-api"; } 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", () => { it("keeps only shared remote hosts and gives them collision-free ids", () => {
const rows = markRemoteSharedHosts([ const rows = markRemoteSharedHosts([
{ id: 4, isShared: false }, { id: 4, isShared: false },
{ id: 9, isShared: true, syncId: "shared-host" }, {
id: 9,
isShared: true,
syncId: "shared-host",
connectionOrigin: "local",
},
] as SSHHost[]); ] as SSHHost[]);
expect(rows).toEqual([ expect(rows).toEqual([
expect.objectContaining({ expect.objectContaining({
id: -9, id: -9,
syncId: "shared-host", 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();
});
}); });
+18 -2
View File
@@ -1,5 +1,9 @@
import { describe, expect, it } from "vitest"; import { renderHook, waitFor } from "@testing-library/react";
import { computeReconnectDelay } from "../../lib/useConnectionRetry.ts"; import { describe, expect, it, vi } from "vitest";
import {
computeReconnectDelay,
useConnectionRetry,
} from "../../lib/useConnectionRetry.ts";
describe("connection retry delay", () => { describe("connection retry delay", () => {
it("uses exponential backoff with bounded jitter", () => { it("uses exponential backoff with bounded jitter", () => {
@@ -12,3 +16,15 @@ describe("connection retry delay", () => {
expect(computeReconnectDelay(8, 2000, 8000, () => 1)).toBe(8000); 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();
});
});
@@ -19,7 +19,14 @@ const toast = vi.hoisted(() => ({
error: vi.fn(), error: vi.fn(),
})); }));
const remote = vi.hoisted(() => ({
get: vi.fn(),
}));
vi.mock("@/main-axios", () => api); vi.mock("@/main-axios", () => api);
vi.mock("@/lib/remote-server-api", () => ({
getConnectedRemoteApi: vi.fn(async () => remote),
}));
vi.mock("sonner", () => ({ toast })); vi.mock("sonner", () => ({ toast }));
vi.mock("react-i18next", () => ({ vi.mock("react-i18next", () => ({
useTranslation: () => ({ useTranslation: () => ({
@@ -49,6 +56,7 @@ beforeEach(() => {
api.setHostAuthOverride.mockReset(); api.setHostAuthOverride.mockReset();
toast.success.mockReset(); toast.success.mockReset();
toast.error.mockReset(); toast.error.mockReset();
remote.get.mockReset();
api.getCredentials.mockResolvedValue([ api.getCredentials.mockResolvedValue([
{ {
id: 7, id: 7,
@@ -68,6 +76,7 @@ beforeEach(() => {
success: true, success: true,
credentialId: 8, credentialId: 8,
}); });
remote.get.mockResolvedValue({ data: [] });
}); });
afterEach(cleanup); afterEach(cleanup);
@@ -150,6 +159,37 @@ describe("HostAuthOverrideModal", () => {
).toBeTruthy(); ).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(
<HostAuthOverrideModal
open
onOpenChange={() => {}}
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 () => { it("renders empty and load-error states", async () => {
api.getCredentials.mockResolvedValueOnce([]); api.getCredentials.mockResolvedValueOnce([]);
const { unmount } = render( const { unmount } = render(
@@ -15,4 +15,18 @@ describe("sshHostToHost", () => {
expect(host.wolBroadcastAddress).toBe("192.168.0.255"); 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);
});
}); });
@@ -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);
});
});