From c42cd40a2cdcc6850e3c003772d470fb64ea8def Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Sun, 23 Aug 2026 22:55:45 +0800 Subject: [PATCH] Fix synced client tunnel endpoints (#1302) --- src/backend/hosts/tunnel/c2s-relay.ts | 34 +++++++++++++++---- .../hosts/tunnel/c2s-source-host.test.ts | 31 +++++++++++++++++ src/types/index.ts | 2 ++ src/ui/user/C2STunnelPresetManager.tsx | 13 ++++++- 4 files changed, 72 insertions(+), 8 deletions(-) create mode 100644 src/backend/tests/hosts/tunnel/c2s-source-host.test.ts diff --git a/src/backend/hosts/tunnel/c2s-relay.ts b/src/backend/hosts/tunnel/c2s-relay.ts index e9cd6584..4a9634dd 100644 --- a/src/backend/hosts/tunnel/c2s-relay.ts +++ b/src/backend/hosts/tunnel/c2s-relay.ts @@ -14,6 +14,7 @@ import { } from "./ssh-primitives.js"; import { sendC2SMessage, writeC2SRemoteChunk } from "./c2s-relay-utils.js"; import { getTunnelMode } from "./utils.js"; +import { createCurrentHostResolutionRepository } from "../../database/repositories/factory.js"; export type C2SOpenMessage = { type: "open" | "test"; @@ -25,17 +26,36 @@ export type C2SOpenMessage = { const permissionManager = PermissionManager.getInstance(); let c2sRemoteStreamCounter = 0; +export async function resolveC2SSourceHostId( + tunnelConfig: Partial, + findHostIdBySyncId: (syncId: string) => Promise, +): Promise { + const sourceHostSyncId = tunnelConfig.sourceHostSyncId?.trim(); + if (sourceHostSyncId) { + const remoteHostId = await findHostIdBySyncId(sourceHostSyncId); + if (!remoteHostId) { + throw new Error("Endpoint SSH host was not found on the remote server"); + } + return remoteHostId; + } + + if (!tunnelConfig.sourceHostId) { + throw new Error("Endpoint SSH host is required"); + } + return tunnelConfig.sourceHostId; +} + async function resolveC2STunnelSource( tunnelConfig: Partial, userId: string, ): Promise { - if (!tunnelConfig.sourceHostId) { - throw new Error("Endpoint SSH host is required"); - } + const sourceHostId = await resolveC2SSourceHostId(tunnelConfig, (syncId) => + createCurrentHostResolutionRepository().findHostIdBySyncId(syncId), + ); const accessInfo = await permissionManager.canAccessHost( userId, - tunnelConfig.sourceHostId, + sourceHostId, "connect", ); if (!accessInfo.hasAccess) { @@ -43,13 +63,13 @@ async function resolveC2STunnelSource( } const { resolveHostById } = await import("../host-resolver.js"); - const resolvedHost = await resolveHostById(tunnelConfig.sourceHostId, userId); + const resolvedHost = await resolveHostById(sourceHostId, userId); if (!resolvedHost) { throw new Error("Endpoint SSH host not found"); } return { - name: tunnelConfig.name || `c2s:${tunnelConfig.sourceHostId}`, + name: tunnelConfig.name || `c2s:${sourceHostId}`, scope: "c2s", mode: tunnelConfig.mode || "local", tunnelType: @@ -57,7 +77,7 @@ async function resolveC2STunnelSource( (tunnelConfig.mode === "remote" ? "remote" : "local"), bindHost: tunnelConfig.bindHost, targetHost: tunnelConfig.targetHost || "127.0.0.1", - sourceHostId: resolvedHost.id || tunnelConfig.sourceHostId, + sourceHostId: resolvedHost.id || sourceHostId, tunnelIndex: tunnelConfig.tunnelIndex || 0, requestingUserId: userId, hostName: diff --git a/src/backend/tests/hosts/tunnel/c2s-source-host.test.ts b/src/backend/tests/hosts/tunnel/c2s-source-host.test.ts new file mode 100644 index 00000000..ab03ee2a --- /dev/null +++ b/src/backend/tests/hosts/tunnel/c2s-source-host.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it, vi } from "vitest"; +import { resolveC2SSourceHostId } from "../../../hosts/tunnel/c2s-relay.js"; + +describe("resolveC2SSourceHostId", () => { + it("uses the remote row matching the stable sync id", async () => { + const findBySyncId = vi.fn().mockResolvedValue(42); + + await expect( + resolveC2SSourceHostId( + { sourceHostId: 7, sourceHostSyncId: "host-sync-id" }, + findBySyncId, + ), + ).resolves.toBe(42); + expect(findBySyncId).toHaveBeenCalledWith("host-sync-id"); + }); + + it("keeps legacy local ids when no sync id is available", async () => { + await expect( + resolveC2SSourceHostId({ sourceHostId: 7 }, vi.fn()), + ).resolves.toBe(7); + }); + + it("does not silently fall back to a mismatched id", async () => { + await expect( + resolveC2SSourceHostId( + { sourceHostId: 7, sourceHostSyncId: "missing" }, + vi.fn().mockResolvedValue(null), + ), + ).rejects.toThrow("not found on the remote server"); + }); +}); diff --git a/src/types/index.ts b/src/types/index.ts index dfb8a2c2..0ffa2b68 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -482,6 +482,7 @@ export interface TunnelConnection { tunnelType?: "local" | "remote"; bindHost?: string; sourceHostId?: number; + sourceHostSyncId?: string; sourceHostName?: string; sourcePort: number; endpointPort: number; @@ -508,6 +509,7 @@ export interface TunnelConfig { targetHost?: string; sourceHostId: number; + sourceHostSyncId?: string; tunnelIndex: number; requestingUserId?: string; diff --git a/src/ui/user/C2STunnelPresetManager.tsx b/src/ui/user/C2STunnelPresetManager.tsx index 6e84f633..b604b382 100644 --- a/src/ui/user/C2STunnelPresetManager.tsx +++ b/src/ui/user/C2STunnelPresetManager.tsx @@ -347,10 +347,20 @@ export function C2STunnelPresetManager(): React.ReactElement { getSSHHosts(), ]); setHosts(nextHosts); + const hostsById = new Map(nextHosts.map((host) => [host.id, host])); const normalizedConfig = Array.isArray(config) ? (config as TunnelConnection[]) .filter((tunnel) => tunnel.scope === "c2s") - .map(normalizeClientTunnel) + .map((tunnel) => { + const sourceHost = tunnel.sourceHostId + ? hostsById.get(tunnel.sourceHostId) + : undefined; + return normalizeClientTunnel({ + ...tunnel, + sourceHostSyncId: + tunnel.sourceHostSyncId || sourceHost?.syncId || undefined, + }); + }) : []; setLocalConfig(normalizedConfig); setSavedLocalConfig(normalizedConfig); @@ -473,6 +483,7 @@ export function C2STunnelPresetManager(): React.ReactElement { if (!host) return; updateTunnel(index, { sourceHostId: host.id, + sourceHostSyncId: host.syncId || undefined, sourceHostName: host.name, endpointHost: host.name, endpointPort: 22,