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

* fix: clean up Cloudflare tunnel timeouts

* fix: couple tunnel socket lifecycle

* fix: validate Docker console messages

* fix: bound homepage proxy responses

* fix: bound reconnect and response failures

* fix: harden persisted and socket state

* fix: support local connections to shared hosts
This commit is contained in:
ZacharyZcR
2026-08-28 10:36:39 +08:00
committed by GitHub
parent 703e8cd037
commit c129666d7f
28 changed files with 689 additions and 157 deletions
+12 -4
View File
@@ -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;
+21 -2
View File
@@ -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,
+57 -1
View File
@@ -11,10 +11,66 @@ export function markRemoteSharedHosts(hosts: SSHHost[]): SSHHost[] {
// Local SQLite ids are positive. Negative ids keep remote-only shared
// 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 {
+9 -1
View File
@@ -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 -3
View File
@@ -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");
+1 -11
View File
@@ -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,
+1
View File
@@ -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,
+24 -4
View File
@@ -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(
+2 -3
View File
@@ -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");
+10
View File
@@ -0,0 +1,10 @@
export function readHiddenRailTabs(): Set<string> {
try {
const raw = localStorage.getItem("hiddenRailTabs");
if (!raw) return new Set();
const parsed: unknown = JSON.parse(raw);
return Array.isArray(parsed) ? new Set(parsed.map(String)) : new Set();
} catch {
return new Set();
}
}
+51 -2
View File
@@ -9,6 +9,7 @@ vi.mock("@/main-axios", () => ({ getRemoteStatsApi: () => remoteApi }));
import {
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();
});
});
+18 -2
View File
@@ -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);
});
});