mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-08-29 18:31:33 +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:
+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