mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-08-29 10:21:34 +00:00
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:
+21
-2
@@ -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);
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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" });
|
||||
}
|
||||
|
||||
@@ -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<string, ProxyCacheEntry>();
|
||||
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<unknown> {
|
||||
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<unknown> {
|
||||
*/
|
||||
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 {
|
||||
|
||||
@@ -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<string, { data: RssItem[]; expires: number }>();
|
||||
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<string> {
|
||||
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" });
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<string, SSHSession>();
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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<void>((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;
|
||||
|
||||
@@ -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 {
|
||||
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,
|
||||
|
||||
@@ -157,6 +157,35 @@ export interface OutboundTlsOptions {
|
||||
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(
|
||||
rawUrl: string,
|
||||
options: RequestInit,
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
+12
-4
@@ -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;
|
||||
|
||||
@@ -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<TerminalHandle, SSHTerminalProps>(
|
||||
|
||||
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<TerminalHandle, SSHTerminalProps>(
|
||||
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<TerminalHandle, SSHTerminalProps>(
|
||||
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<TerminalHandle, SSHTerminalProps>(
|
||||
data: {
|
||||
cols,
|
||||
rows,
|
||||
hostConfig,
|
||||
hostConfig: outboundHostConfig,
|
||||
initialPath,
|
||||
executeCommand,
|
||||
tmuxAttachSession,
|
||||
|
||||
@@ -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<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> {
|
||||
if (!isElectron()) return null;
|
||||
try {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<string>(
|
||||
JSON.parse(localStorage.getItem("hiddenRailTabs") ?? "[]"),
|
||||
);
|
||||
const hidden = readHiddenRailTabs();
|
||||
if (next) hidden.delete("ai");
|
||||
else hidden.add("ai");
|
||||
|
||||
|
||||
@@ -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<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();
|
||||
}
|
||||
}
|
||||
import { readHiddenRailTabs } from "@/sidebar/hidden-rail-tabs";
|
||||
|
||||
export function MobileBottomBar({
|
||||
railView,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<string>(
|
||||
JSON.parse(localStorage.getItem("hiddenRailTabs") ?? "[]"),
|
||||
);
|
||||
const hidden = readHiddenRailTabs();
|
||||
if (enabled) hidden.delete("ai");
|
||||
else hidden.add("ai");
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
<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 () => {
|
||||
api.getCredentials.mockResolvedValueOnce([]);
|
||||
const { unmount } = render(
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user