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
|
// 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);
|
||||||
|
|||||||
@@ -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" });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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,
|
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);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,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");
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user