Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 27 additions & 7 deletions src/backend/hosts/tunnel/c2s-relay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -25,39 +26,58 @@ export type C2SOpenMessage = {
const permissionManager = PermissionManager.getInstance();
let c2sRemoteStreamCounter = 0;

async function resolveC2STunnelSource(
export async function resolveC2SSourceHostId(
tunnelConfig: Partial<TunnelConfig>,
userId: string,
): Promise<TunnelConfig> {
findHostIdBySyncId: (syncId: string) => Promise<number | null>,
): Promise<number> {
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<TunnelConfig>,
userId: string,
): Promise<TunnelConfig> {
const sourceHostId = await resolveC2SSourceHostId(tunnelConfig, (syncId) =>
createCurrentHostResolutionRepository().findHostIdBySyncId(syncId),
);

const accessInfo = await permissionManager.canAccessHost(
userId,
tunnelConfig.sourceHostId,
sourceHostId,
"connect",
);
if (!accessInfo.hasAccess) {
throw new Error("Access denied to this host");
}

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:
tunnelConfig.tunnelType ||
(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:
Expand Down
31 changes: 31 additions & 0 deletions src/backend/tests/hosts/tunnel/c2s-source-host.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
2 changes: 2 additions & 0 deletions src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -482,6 +482,7 @@ export interface TunnelConnection {
tunnelType?: "local" | "remote";
bindHost?: string;
sourceHostId?: number;
sourceHostSyncId?: string;
sourceHostName?: string;
sourcePort: number;
endpointPort: number;
Expand All @@ -508,6 +509,7 @@ export interface TunnelConfig {
targetHost?: string;

sourceHostId: number;
sourceHostSyncId?: string;
tunnelIndex: number;

requestingUserId?: string;
Expand Down
13 changes: 12 additions & 1 deletion src/ui/user/C2STunnelPresetManager.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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,
Expand Down
Loading