feat(ssh): support a fixed local port for SSH tunnel layers
This commit is contained in:
1 parent
590a311b5a
commit
620c8690b5
39 files changed
+1732
-102
No files matched your search
@@ -51,6 +51,7 @@ import { detachTunnelProfileLayer, tunnelProfileReferenceLayer, tunnelProfileSum
|
||||
import { insertSqliteRemoteTransportLayer, isSqliteRemoteTransportLayerType, sqliteRemoteTransportError } from "@/lib/connection/sqliteRemoteTransport";
|
||||
import { sanitizeConnectionCredentials } from "@/lib/connection/credentialSanitizer";
|
||||
import { applySshAuthMethod, inferSshAuthMethod } from "@/lib/connection/sshAuthMethod";
|
||||
import { editableSshLocalPort, normalizeSshLocalPort, sshLocalPortState, sshLocalPortSupportedForConnection } from "@/lib/connection/sshLocalPort";
|
||||
import { applySshConfigHostAliasPrefill as prefillSshConfigHostAlias } from "@/lib/connection/sshConfigHosts";
|
||||
import { canPersistConnectionTestResult, connectionEditDraftSyncAction } from "./connectionEditDraftSync";
|
||||
import { createConnectionNoteVisibilityDraft, persistConnectionNoteVisibilityDraft as persistConnectionNoteVisibilityDraftState, resetConnectionNoteVisibilityDraft, setConnectionNoteVisibilityDraft, syncConnectionNoteVisibilityDraft } from "./connectionNoteVisibilityDraft";
|
||||
@@ -555,6 +556,7 @@ function normalizeSshTunnel(hop: Partial<SshTunnelConfig>): SshTunnelConfig {
|
||||
key_passphrase: hop.key_passphrase || "",
|
||||
connect_timeout_secs: Number(hop.connect_timeout_secs) || 5,
|
||||
expose_lan: !!hop.expose_lan,
|
||||
local_port: hop.enabled === false ? normalizeSshLocalPort(hop.local_port) : editableSshLocalPort(hop.local_port),
|
||||
use_ssh_agent: !!hop.use_ssh_agent,
|
||||
ssh_agent_sock_path: hop.ssh_agent_sock_path || "",
|
||||
ssh_agent_key_fingerprint: hop.ssh_agent_key_fingerprint || "",
|
||||
@@ -6634,6 +6636,7 @@ function addHttpTunnel() {
|
||||
function duplicateTransportLayer(layer: TransportLayerConfig) {
|
||||
if (sqliteRemoteTransportRestricted.value && !isSqliteRemoteTransportLayerType(layer.type)) return;
|
||||
const next = normalizeTransportLayer({ ...layer, id: uuid(), name: layer.name ? `${layer.name} copy` : "" });
|
||||
if (next.type === "ssh") delete next.local_port;
|
||||
form.value.transport_layers = sqliteRemoteTransportRestricted.value ? insertSqliteRemoteTransportLayer(transportLayers.value, next) : [...transportLayers.value, next];
|
||||
selectedTransportLayerId.value = next.id;
|
||||
resetTestState();
|
||||
@@ -6719,11 +6722,19 @@ function updateSelectedSshAuthMethod(value: unknown) {
|
||||
resetTestState();
|
||||
}
|
||||
|
||||
function updateSelectedSshLocalPort(value: unknown) {
|
||||
const layer = selectedSshLayer.value;
|
||||
if (!layer) return;
|
||||
layer.local_port = editableSshLocalPort(value);
|
||||
resetTestState();
|
||||
}
|
||||
|
||||
function validateTransportLayers(config: LegacyConnectionConfig) {
|
||||
const layers = config.transport_layers || [];
|
||||
if (config.db_type === "sqlite" && sqliteRemoteTransportError(layers)) {
|
||||
throw new Error(t("connection.sqliteTransportSshOnly"));
|
||||
}
|
||||
const lastEnabledIndex = layers.reduce((last, layer, index) => (layer.enabled === false ? last : index), -1);
|
||||
layers.forEach((layer, index) => {
|
||||
if (layer.enabled === false) return;
|
||||
// Profile-referencing layers are stubs: the shared profile supplies the
|
||||
@@ -6753,6 +6764,10 @@ function validateTransportLayers(config: LegacyConnectionConfig) {
|
||||
if (!Number.isFinite(timeout) || timeout < 1 || timeout > 300) {
|
||||
throw new Error(t("connection.sshHopInvalidTimeout", { hop: label }));
|
||||
}
|
||||
const localPortState = sshLocalPortState(layer.local_port);
|
||||
if (localPortState === "invalid") throw new Error(t("connection.sshHopInvalidLocalPort", { hop: label }));
|
||||
if (localPortState === "valid" && index !== lastEnabledIndex) throw new Error(t("connection.sshHopLocalPortNotLast", { hop: label }));
|
||||
if (localPortState === "valid" && !sshLocalPortSupportedForConnection(config, mqSystemKind.value)) throw new Error(t("connection.sshHopLocalPortUnsupported", { hop: label }));
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -11022,6 +11037,13 @@ function openExternalUrl(url: string) {
|
||||
<span class="text-xs text-muted-foreground">{{ t("connection.sshExposeLan") }}</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-start gap-4">
|
||||
<Label :class="connectionLabelSmallClass">{{ t("connection.sshLocalPort") }}</Label>
|
||||
<div class="col-span-3 space-y-1">
|
||||
<Input :model-value="selectedSshLayer.local_port ?? ''" type="number" min="1" max="65535" step="1" :placeholder="t('connection.sshLocalPortPlaceholder')" :disabled="selectedSshLayer.enabled === false" @update:model-value="updateSelectedSshLocalPort" />
|
||||
<p class="text-xs text-muted-foreground">{{ t("connection.sshLocalPortHint") }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-start gap-4">
|
||||
<span />
|
||||
<label class="col-span-3 flex items-start gap-2 cursor-pointer">
|
||||
|
||||
+334
@@ -0,0 +1,334 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { createApp, defineComponent, h, nextTick, reactive, type App } from "vue";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import i18n from "@/i18n";
|
||||
import type { ConnectionConfig, SshTunnelConfig, TransportLayerConfig } from "@/types/database";
|
||||
|
||||
const { store, settings, backend } = vi.hoisted(() => ({
|
||||
store: {
|
||||
connectionGroupOptions: [],
|
||||
newConnectionGroupId: null,
|
||||
selectedConnectionGroupId: null,
|
||||
addConnection: vi.fn(),
|
||||
updateConnection: vi.fn(),
|
||||
connect: vi.fn(),
|
||||
ensureConnected: vi.fn(),
|
||||
addEphemeralConnection: vi.fn(),
|
||||
removeConnection: vi.fn(),
|
||||
startEditing: vi.fn(),
|
||||
stopEditing: vi.fn(),
|
||||
clearConnectionError: vi.fn(),
|
||||
updateConnectionDatabaseInfo: vi.fn(),
|
||||
applyGlobalTimeouts: vi.fn((config) => config),
|
||||
getConfig: vi.fn(),
|
||||
},
|
||||
settings: {
|
||||
editorSettings: { sidebarShowConnectionNotes: false, globalConnectTimeoutSecs: 10, globalQueryTimeoutSecs: 0 },
|
||||
rememberedDatabaseForConnection: vi.fn(() => ""),
|
||||
persistEditorSettings: vi.fn(),
|
||||
updateEditorSettings: vi.fn(),
|
||||
updateEditorSettingsAndPersist: vi.fn(),
|
||||
},
|
||||
backend: {
|
||||
connectDb: vi.fn(),
|
||||
disconnectDb: vi.fn(),
|
||||
testConnectionWithInfo: vi.fn(),
|
||||
listDatabases: vi.fn(),
|
||||
listPlugins: vi.fn(),
|
||||
listJdbcDrivers: vi.fn(),
|
||||
listJdbcMavenBundles: vi.fn(),
|
||||
listJdbcLocalBundles: vi.fn(),
|
||||
listSshConfigHosts: vi.fn(),
|
||||
listInstalledAgentsLocal: vi.fn(),
|
||||
listenAgentInstallProgress: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/stores/connectionStore", () => ({ useConnectionStore: () => store, CONNECTION_ATTEMPT_CANCELLED_MESSAGE: "cancelled" }));
|
||||
vi.mock("@/stores/settingsStore", async (original) => ({ ...(await original<typeof import("@/stores/settingsStore")>()), useSettingsStore: () => settings }));
|
||||
vi.mock("@/stores/tunnelProfileStore", () => ({ useTunnelProfileStore: () => ({ profiles: [], profileById: () => undefined, init: vi.fn() }) }));
|
||||
vi.mock("@/lib/backend/api", async (original) => ({ ...(await original<typeof import("@/lib/backend/api")>()), ...backend }));
|
||||
vi.mock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
|
||||
vi.mock("@/composables/useToast", () => ({ useToast: () => ({ toast: vi.fn() }) }));
|
||||
|
||||
vi.mock("@/components/ui/dialog", async () => {
|
||||
const { defineComponent, h } = await import("vue");
|
||||
const passthrough = defineComponent({
|
||||
setup:
|
||||
(_props, { slots }) =>
|
||||
() =>
|
||||
h("div", slots.default?.()),
|
||||
});
|
||||
const Dialog = defineComponent({
|
||||
props: { open: Boolean },
|
||||
emits: ["update:open"],
|
||||
setup:
|
||||
(props, { slots }) =>
|
||||
() =>
|
||||
props.open ? h("section", slots.default?.()) : null,
|
||||
});
|
||||
return { Dialog, DialogContent: passthrough, DialogHeader: passthrough, DialogTitle: passthrough, DialogFooter: passthrough };
|
||||
});
|
||||
vi.mock("@/components/ui/button", async () => {
|
||||
const { defineComponent, h } = await import("vue");
|
||||
return {
|
||||
Button: defineComponent({
|
||||
inheritAttrs: false,
|
||||
setup:
|
||||
(_props, { attrs, slots }) =>
|
||||
() =>
|
||||
h("button", attrs, slots.default?.()),
|
||||
}),
|
||||
};
|
||||
});
|
||||
vi.mock("@/components/ui/input", async () => {
|
||||
const { defineComponent, h } = await import("vue");
|
||||
return {
|
||||
Input: defineComponent({
|
||||
props: ["modelValue"],
|
||||
emits: ["update:modelValue"],
|
||||
setup:
|
||||
(props, { attrs, emit }) =>
|
||||
() =>
|
||||
h("input", { ...attrs, value: props.modelValue, onInput: (event: Event) => emit("update:modelValue", (event.target as HTMLInputElement).value) }),
|
||||
}),
|
||||
};
|
||||
});
|
||||
vi.mock("@/components/ui/PasswordInput.vue", async () => {
|
||||
const { defineComponent, h } = await import("vue");
|
||||
return {
|
||||
default: defineComponent({
|
||||
props: ["modelValue"],
|
||||
setup:
|
||||
(props, { attrs }) =>
|
||||
() =>
|
||||
h("input", { ...attrs, type: "password", value: props.modelValue }),
|
||||
}),
|
||||
};
|
||||
});
|
||||
vi.mock("@/components/ui/tabs", async () => {
|
||||
const { defineComponent, h } = await import("vue");
|
||||
const pass = defineComponent({
|
||||
setup:
|
||||
(_props, { slots }) =>
|
||||
() =>
|
||||
h("div", slots.default?.()),
|
||||
});
|
||||
const TabsContent = defineComponent({
|
||||
props: ["value"],
|
||||
setup:
|
||||
(props, { slots }) =>
|
||||
() =>
|
||||
props.value === "transport" ? h("div", slots.default?.()) : null,
|
||||
});
|
||||
return { Tabs: pass, TabsContent, TabsList: pass, TabsTrigger: pass };
|
||||
});
|
||||
vi.mock("@/components/ui/tooltip", async () => {
|
||||
const { defineComponent, h } = await import("vue");
|
||||
const pass = defineComponent({
|
||||
setup:
|
||||
(_props, { slots }) =>
|
||||
() =>
|
||||
h("span", slots.default?.()),
|
||||
});
|
||||
return { HelpTooltip: pass, Tooltip: pass, TooltipContent: pass, TooltipTrigger: pass };
|
||||
});
|
||||
|
||||
import ConnectionDialog from "@/components/connection/ConnectionDialog.vue";
|
||||
|
||||
const mountedApps: App[] = [];
|
||||
|
||||
function sshLayer(id: string, extras: Partial<SshTunnelConfig> = {}): TransportLayerConfig {
|
||||
return { type: "ssh", id, name: `Layer ${id}`, host: `${id}.example.com`, port: 22, user: "root", enabled: true, ...extras };
|
||||
}
|
||||
|
||||
const proxyLayer: TransportLayerConfig = { type: "proxy", id: "proxy", name: "Corporate proxy", enabled: true, proxy_type: "socks5", host: "proxy.example.com", port: 1080 };
|
||||
|
||||
function tunnelled(layers: TransportLayerConfig[], overrides: Partial<ConnectionConfig> = {}): ConnectionConfig {
|
||||
return { id: "pg-1", name: "Postgres", db_type: "postgres", driver_profile: "postgres", driver_label: "PostgreSQL", host: "db.internal", port: 5432, username: "app", password: "secret", database: "app", save_password: true, transport_layers: layers, ...overrides };
|
||||
}
|
||||
|
||||
async function settle() {
|
||||
for (let i = 0; i < 8; i++) await nextTick();
|
||||
}
|
||||
|
||||
async function mountDialog(config: ConnectionConfig) {
|
||||
const props = reactive({ open: true, editConfig: config });
|
||||
store.getConfig.mockReturnValue(config);
|
||||
const container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
const app = createApp(
|
||||
defineComponent({
|
||||
setup: () => () =>
|
||||
h(ConnectionDialog, {
|
||||
...props,
|
||||
"onUpdate:open": (value: boolean) => {
|
||||
props.open = value;
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
app.use(i18n);
|
||||
mountedApps.push(app);
|
||||
app.mount(container);
|
||||
await settle();
|
||||
}
|
||||
|
||||
function localPortInput(): HTMLInputElement | null {
|
||||
const label = [...document.querySelectorAll("label")].find((element) => element.textContent?.trim() === "Local Port");
|
||||
return label?.parentElement?.querySelector("input") ?? null;
|
||||
}
|
||||
|
||||
async function typeLocalPort(value: string) {
|
||||
const input = localPortInput();
|
||||
expect(input, "the selected SSH layer should render the Local Port input").toBeTruthy();
|
||||
input!.value = value;
|
||||
input!.dispatchEvent(new Event("input"));
|
||||
await settle();
|
||||
}
|
||||
|
||||
async function save() {
|
||||
[...document.querySelectorAll("button")].find((button) => button.textContent?.trim() === "Save")!.click();
|
||||
await settle();
|
||||
}
|
||||
|
||||
function savedLayers(): TransportLayerConfig[] {
|
||||
expect(store.updateConnection).toHaveBeenCalledTimes(1);
|
||||
return store.updateConnection.mock.calls[0][0].transport_layers;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
i18n.global.locale.value = "en";
|
||||
for (const fn of Object.values(backend)) fn.mockResolvedValue([]);
|
||||
backend.listenAgentInstallProgress.mockResolvedValue(() => {});
|
||||
store.updateConnection.mockResolvedValue(undefined);
|
||||
store.addConnection.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const app of mountedApps.splice(0)) app.unmount();
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
describe("ConnectionDialog SSH fixed local port", () => {
|
||||
it("shows the saved port with an Auto placeholder and keeps it on save", async () => {
|
||||
await mountDialog(tunnelled([sshLayer("bastion", { local_port: 4059 })]));
|
||||
|
||||
expect(localPortInput()?.value).toBe("4059");
|
||||
expect(localPortInput()?.getAttribute("placeholder")).toBe("Auto");
|
||||
|
||||
await save();
|
||||
|
||||
expect(savedLayers()).toEqual([expect.objectContaining({ id: "bastion", local_port: 4059 })]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["typing a new port", undefined, "4060", 4060],
|
||||
["changing the port", 4059, "4060", 4060],
|
||||
["clearing the port", 4059, "", undefined],
|
||||
])("%s saves a numeric local_port or leaves it unset", async (_name, initial, typed, expected) => {
|
||||
await mountDialog(tunnelled([sshLayer("bastion", { local_port: initial })]));
|
||||
|
||||
await typeLocalPort(typed);
|
||||
await save();
|
||||
|
||||
const layer = savedLayers()[0] as SshTunnelConfig;
|
||||
expect(layer.local_port).toBe(expected);
|
||||
expect("local_port" in JSON.parse(JSON.stringify(layer))).toBe(expected !== undefined);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["70000", "70000"],
|
||||
["0", "0"],
|
||||
["4059.5", "4059.5"],
|
||||
])("refuses to save %s as the port instead of dropping it", async (_name, typed) => {
|
||||
await mountDialog(tunnelled([sshLayer("bastion")]));
|
||||
|
||||
await typeLocalPort(typed);
|
||||
await save();
|
||||
|
||||
expect(store.updateConnection).not.toHaveBeenCalled();
|
||||
expect(document.body.textContent).toContain("Layer bastion: Local port must be between 1 and 65535");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["an earlier enabled layer", [sshLayer("first", { local_port: 4059 }), sshLayer("second")]],
|
||||
["a layer followed by a proxy", [sshLayer("first", { local_port: 4059 }), proxyLayer]],
|
||||
])("refuses a port on %s", async (_name, layers) => {
|
||||
await mountDialog(tunnelled(layers));
|
||||
|
||||
await save();
|
||||
|
||||
expect(store.updateConnection).not.toHaveBeenCalled();
|
||||
expect(document.body.textContent).toContain("Layer first: Local port only applies to the last enabled layer");
|
||||
});
|
||||
|
||||
it("accepts the port on the last enabled layer when later layers are disabled", async () => {
|
||||
await mountDialog(tunnelled([sshLayer("first", { local_port: 4059 }), sshLayer("second", { enabled: false })]));
|
||||
|
||||
await save();
|
||||
|
||||
expect(savedLayers()).toEqual([expect.objectContaining({ id: "first", local_port: 4059 }), expect.objectContaining({ id: "second", enabled: false })]);
|
||||
});
|
||||
|
||||
it("drops an invalid port once its layer is disabled so the backend never receives it", async () => {
|
||||
await mountDialog(tunnelled([sshLayer("first"), sshLayer("second")]));
|
||||
|
||||
await typeLocalPort("70000");
|
||||
document.querySelector<HTMLInputElement>('.connection-transport-layer-option input[type="checkbox"]')!.click();
|
||||
await settle();
|
||||
await save();
|
||||
|
||||
const [first] = savedLayers() as SshTunnelConfig[];
|
||||
expect(first.enabled).toBe(false);
|
||||
expect(first.local_port).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not copy the port when a layer is duplicated", async () => {
|
||||
await mountDialog(tunnelled([sshLayer("first", { local_port: 4059 })]));
|
||||
|
||||
[...document.querySelectorAll("button")].find((button) => button.textContent?.trim() === "Duplicate")!.click();
|
||||
await settle();
|
||||
expect(localPortInput()?.value).toBe("");
|
||||
|
||||
document.querySelector<HTMLElement>(".connection-transport-layer-option")!.click();
|
||||
await settle();
|
||||
expect(localPortInput()?.value).toBe("4059");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["ClickHouse", { db_type: "clickhouse", driver_profile: "clickhouse", driver_label: "ClickHouse", port: 8123 }],
|
||||
["Redis Cluster", { db_type: "redis", driver_profile: "redis", driver_label: "Redis", port: 6379, redis_connection_mode: "cluster", redis_cluster_nodes: "10.0.0.1:6379" }],
|
||||
] as [string, Partial<ConnectionConfig>][])("refuses a port on a %s connection, whose primary path never binds it", async (_name, overrides) => {
|
||||
await mountDialog(tunnelled([sshLayer("bastion", { local_port: 4059 })], overrides));
|
||||
|
||||
await save();
|
||||
|
||||
expect(store.updateConnection).not.toHaveBeenCalled();
|
||||
expect(document.body.textContent).toContain("Layer bastion: Local port is not supported for this connection type");
|
||||
});
|
||||
|
||||
it("saves the same connection type without a port", async () => {
|
||||
await mountDialog(tunnelled([sshLayer("bastion")], { db_type: "clickhouse", driver_profile: "clickhouse", driver_label: "ClickHouse", port: 8123 }));
|
||||
|
||||
await save();
|
||||
|
||||
expect((savedLayers()[0] as SshTunnelConfig).local_port).toBeUndefined();
|
||||
});
|
||||
|
||||
it("accepts a port on a Redis standalone connection", async () => {
|
||||
await mountDialog(tunnelled([sshLayer("bastion", { local_port: 4059 })], { db_type: "redis", driver_profile: "redis", driver_label: "Redis", port: 6379, redis_connection_mode: "standalone" }));
|
||||
|
||||
await save();
|
||||
|
||||
expect(savedLayers()).toEqual([expect.objectContaining({ id: "bastion", local_port: 4059 })]);
|
||||
});
|
||||
|
||||
it("does not offer a port on a layer that is not SSH", async () => {
|
||||
await mountDialog(tunnelled([proxyLayer]));
|
||||
|
||||
expect(localPortInput()).toBeNull();
|
||||
});
|
||||
});
|
||||
Vendored
+39
-2
@@ -1,7 +1,7 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { shallowRef } from "vue";
|
||||
import { connectionDeleteTargetSnapshot, connectionGroupDeleteTargetSnapshot, deleteConnectionsWithGroup, showDeleteConfirm, showDeleteGroupConfirm, sidebarFormTarget } from "@/components/sidebar/sidebarTreeDialogState";
|
||||
import type { TreeNode } from "@/types/database";
|
||||
import type { ConnectionConfig, TreeNode } from "@/types/database";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
toast: vi.fn(),
|
||||
@@ -74,7 +74,9 @@ function connectionStore(selectedTreeNodeIds: string[]) {
|
||||
findConnectionNode: vi.fn((_id: string): TreeNode | null => null),
|
||||
expandConnectionGroups: vi.fn(),
|
||||
isTreeNodeChildrenLoaded: vi.fn(() => false),
|
||||
getConfig: vi.fn(() => undefined),
|
||||
getConfig: vi.fn((_id: string): ConnectionConfig | undefined => undefined),
|
||||
addConnection: vi.fn().mockResolvedValue(undefined),
|
||||
groupIdForConnection: vi.fn((_id: string): string | null => null),
|
||||
isDefaultDatabase: vi.fn(() => false),
|
||||
};
|
||||
}
|
||||
@@ -478,3 +480,38 @@ describe("sidebar connection disconnect selection", () => {
|
||||
expect(store.disconnect).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sidebar connection duplication", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
sidebarFormTarget.value = null;
|
||||
});
|
||||
|
||||
it("copies the connection without the fixed SSH local port of the original", async () => {
|
||||
const node = connectionNode("conn-1");
|
||||
const store = connectionStore([node.id]);
|
||||
const source: ConnectionConfig = {
|
||||
id: "conn-1",
|
||||
name: "Tunnelled",
|
||||
db_type: "postgres",
|
||||
host: "db.internal",
|
||||
port: 5432,
|
||||
username: "app",
|
||||
password: "secret",
|
||||
transport_layers: [{ type: "ssh", id: "bastion", host: "bastion.example.com", port: 22, user: "root", local_port: 4059 }],
|
||||
};
|
||||
store.getConfig.mockReturnValue(source);
|
||||
store.groupIdForConnection.mockReturnValue("group-1");
|
||||
const { duplicateConnection } = runtime(node, store, [node]);
|
||||
|
||||
await duplicateConnection();
|
||||
|
||||
expect(store.addConnection).toHaveBeenCalledTimes(1);
|
||||
const [copy, groupId] = store.addConnection.mock.calls[0];
|
||||
expect(copy).toMatchObject({ name: "Tunnelled (Copy)", host: "db.internal", transport_layers: [{ type: "ssh", id: "bastion", host: "bastion.example.com" }] });
|
||||
expect(copy.id).not.toBe("conn-1");
|
||||
expect(copy.transport_layers[0]).not.toHaveProperty("local_port");
|
||||
expect(groupId).toBe("group-1");
|
||||
expect(source.transport_layers?.[0]).toMatchObject({ local_port: 4059 });
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,7 @@ import { translateBackendError } from "@/i18n/backend-errors";
|
||||
import { copyToClipboard } from "@/lib/common/clipboard";
|
||||
import { uuid } from "@/lib/common/utils";
|
||||
import { connectionFilePath, defaultSqliteBackupFileName, isMemorySqlitePath, sqliteBackupSourcePath } from "@/lib/connection/connectionFile";
|
||||
import { withoutSshLocalPorts } from "@/lib/connection/sshLocalPort";
|
||||
import { hasEnabledTransportLayers } from "@/lib/backend/connectionTransport";
|
||||
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
|
||||
import { revealPathInFileManager } from "@/lib/backend/tauri";
|
||||
@@ -153,7 +154,7 @@ export function useSidebarConnectionMutationRuntime(options: SidebarConnectionMu
|
||||
for (const target of targets) {
|
||||
const config = connectionStore.getConfig(target.connectionId);
|
||||
if (!config) continue;
|
||||
const newConfig = { ...config, id: uuid(), name: `${config.name} (Copy)` };
|
||||
const newConfig = { ...withoutSshLocalPorts(config), id: uuid(), name: `${config.name} (Copy)` };
|
||||
await connectionStore.addConnection(newConfig, connectionStore.groupIdForConnection(target.connectionId));
|
||||
duplicatedCount += 1;
|
||||
}
|
||||
|
||||
@@ -129,6 +129,18 @@ const CASES: { name: string; message: string; key: string; params?: Record<strin
|
||||
message: "SSH layer 1 failed: SSH keyboard-interactive authentication was cancelled",
|
||||
key: "connection.sshTotpCancelled",
|
||||
},
|
||||
{
|
||||
name: "SSH fixed local port already in use",
|
||||
message: "Failed to bind requested local port 4059: Address already in use (os error 48)",
|
||||
key: "connection.sshLocalPortBindFailed",
|
||||
params: { port: "4059", error: "Address already in use (os error 48)" },
|
||||
},
|
||||
{
|
||||
name: "SSH fixed local port on a later layer",
|
||||
message: "SSH layer 2 failed: Failed to bind requested local port 4059: Only one usage of each socket address is normally permitted. (os error 10048)",
|
||||
key: "connection.sshLocalPortBindFailed",
|
||||
params: { port: "4059", error: "Only one usage of each socket address is normally permitted. (os error 10048)" },
|
||||
},
|
||||
{
|
||||
name: "streaming export unsupported",
|
||||
message: "Streaming export is unsupported for this query. Simplify it or use a supported driver.",
|
||||
|
||||
@@ -139,6 +139,7 @@ const patterns: [RegExp, string][] = [
|
||||
|
||||
// SSH keyboard-interactive prompts (for example JumpServer TOTP).
|
||||
[/^(?:SSH layer \d+ failed:\s*)?SSH keyboard-interactive authentication was cancelled$/, "connection.sshTotpCancelled"],
|
||||
[/^(?:SSH layer \d+ failed:\s*)?Failed to bind requested local port (\d+): ([\s\S]+)$/, "connection.sshLocalPortBindFailed"],
|
||||
|
||||
// Query result export limits (crates/dbx-core/src/data/query_result_export.rs)
|
||||
[/^Streaming export is unsupported for this query\. Simplify it or use a supported driver\.$/, "exportProgress.streamingUnsupported"],
|
||||
@@ -202,6 +203,7 @@ const paramNames: Record<string, string | string[]> = {
|
||||
"settings.tunnelsSocksUnsupportedAuth": "method",
|
||||
"settings.tunnelsSocksConnectRejected": "code",
|
||||
"settings.tunnelsSocksUnsupportedAddrType": "type",
|
||||
"connection.sshLocalPortBindFailed": ["port", "error"],
|
||||
"driverStore.jreDirRemoveFailedWindows": ["path", "error"],
|
||||
"driverStore.jreDirRemoveFailed": ["path", "error"],
|
||||
"driverStore.jreInUseByDrivers": ["jre", "drivers"],
|
||||
|
||||
@@ -1259,6 +1259,9 @@ export default withEnglishFallback({
|
||||
sshKeyPassphrasePlaceholder: "Açar şifrələnməyibsə, boş saxlayın",
|
||||
sshKeyPathBrowse: "Gözdən keçir",
|
||||
sshExposeLan: "Tuneli LAN üçün əlçatan et",
|
||||
sshLocalPort: "Lokal port",
|
||||
sshLocalPortPlaceholder: "Avtomatik",
|
||||
sshLocalPortHint: "Təsadüfi boş port üçün boş buraxın. Sabit port bağlantı açıq olduğu müddətdə tuneli həmin lokal portda saxlayır, beləliklə digər alətlər ondan istifadə edə bilər. Yalnız sonuncu qat üçün.",
|
||||
sshAllowExecChannelProxy: "SSH exec-channel ehtiyat üsulunu aktivləşdir (nc)",
|
||||
sshProxyCommand: "ProxyCommand",
|
||||
sshProxyCommandPlaceholder: "nc %h %p",
|
||||
@@ -1332,6 +1335,9 @@ export default withEnglishFallback({
|
||||
sshWorkerUploadConsentAccept: "Yüklə",
|
||||
sshWorkerUploadConsentReject: "Ləğv et",
|
||||
sshHopInvalidTimeout: "{hop}: SSH vaxt limiti 1 ilə 300 saniyə arasında olmalıdır",
|
||||
sshHopInvalidLocalPort: "{hop}: Lokal port 1 ilə 65535 arasında olmalıdır",
|
||||
sshHopLocalPortNotLast: "{hop}: Lokal port yalnız sonuncu aktiv qata aiddir",
|
||||
sshHopLocalPortUnsupported: "{hop}: Lokal port bu bağlantı növü üçün dəstəklənmir",
|
||||
connectTimeout: "Əlaqə üçün vaxt limiti (saniyə)",
|
||||
queryTimeout: "Sorğu üçün vaxt limiti (saniyə)",
|
||||
useGlobalQueryTimeout: "Qlobal",
|
||||
@@ -1429,6 +1435,7 @@ export default withEnglishFallback({
|
||||
cancelConnecting: "Əlaqə qurulmasını ləğv et",
|
||||
connectCancelled: "Əlaqə qurulması ləğv edildi",
|
||||
sshTotpCancelled: "Birdəfəlik kodun daxil edilməsi ləğv edildi",
|
||||
sshLocalPortBindFailed: "{port} lokal portu istifadədədir və ya əlçatan deyil ({error}). Onu istifadə edən proqramı bağlayın və ya başqa lokal port seçin.",
|
||||
},
|
||||
editor: {
|
||||
duckdbDraining: "Əvvəlki DuckDB sorğusu hələ dayandırılır. Bir az sonra yenidən cəhd edin.",
|
||||
|
||||
@@ -1923,6 +1923,9 @@ export default {
|
||||
sshKeyPassphrasePlaceholder: "Leave empty if key is not encrypted",
|
||||
sshKeyPathBrowse: "Browse",
|
||||
sshExposeLan: "Expose tunnel to LAN",
|
||||
sshLocalPort: "Local Port",
|
||||
sshLocalPortPlaceholder: "Auto",
|
||||
sshLocalPortHint: "Leave empty for a random free port. A fixed port keeps the tunnel on that local port while the connection is open, so other tools can use it. Last layer only.",
|
||||
sshAllowExecChannelProxy: "Enable SSH exec-channel fallback (nc)",
|
||||
sshProxyCommand: "ProxyCommand",
|
||||
sshProxyCommandPlaceholder: "nc %h %p",
|
||||
@@ -1998,6 +2001,9 @@ export default {
|
||||
sshWorkerUploadConsentAccept: "Upload",
|
||||
sshWorkerUploadConsentReject: "Cancel",
|
||||
sshHopInvalidTimeout: "{hop}: SSH timeout must be between 1 and 300 seconds",
|
||||
sshHopInvalidLocalPort: "{hop}: Local port must be between 1 and 65535",
|
||||
sshHopLocalPortNotLast: "{hop}: Local port only applies to the last enabled layer",
|
||||
sshHopLocalPortUnsupported: "{hop}: Local port is not supported for this connection type",
|
||||
connectTimeout: "Connection Timeout (seconds)",
|
||||
queryTimeout: "Query Timeout (seconds)",
|
||||
useGlobalQueryTimeout: "Global",
|
||||
@@ -2106,6 +2112,7 @@ export default {
|
||||
cancelConnecting: "Cancel connecting",
|
||||
connectCancelled: "Connection cancelled",
|
||||
sshTotpCancelled: "One-time code entry cancelled",
|
||||
sshLocalPortBindFailed: "Local port {port} is in use or unavailable ({error}). Close the program using it or choose another local port.",
|
||||
},
|
||||
editor: {
|
||||
duckdbDraining: "The previous DuckDB query is still stopping. Please try again shortly.",
|
||||
|
||||
@@ -1540,6 +1540,9 @@ export default withEnglishFallback({
|
||||
sshKeyPassphrasePlaceholder: "Dejar vacío si la clave no está cifrada",
|
||||
sshKeyPathBrowse: "Examinar",
|
||||
sshExposeLan: "Exponer túnel a la red local",
|
||||
sshLocalPort: "Puerto local",
|
||||
sshLocalPortPlaceholder: "Auto",
|
||||
sshLocalPortHint: "Dejar vacío para usar un puerto libre aleatorio. Un puerto fijo mantiene el túnel en ese puerto local mientras la conexión esté abierta, de modo que otras herramientas puedan usarlo. Solo en la última capa.",
|
||||
sshAllowExecChannelProxy: "Activar fallback del canal exec SSH (nc)",
|
||||
sshProxyCommand: "ProxyCommand",
|
||||
sshProxyCommandPlaceholder: "nc %h %p",
|
||||
@@ -1560,6 +1563,9 @@ export default withEnglishFallback({
|
||||
sshAgentSockPath: "Ruta del socket del agente",
|
||||
sshAgentSockPathPlaceholder: "ej. ~/.ssh/agent.sock",
|
||||
sshHopInvalidTimeout: "{hop}: el tiempo de espera SSH debe estar entre 1 y 300 segundos",
|
||||
sshHopInvalidLocalPort: "{hop}: el puerto local debe estar entre 1 y 65535",
|
||||
sshHopLocalPortNotLast: "{hop}: el puerto local solo se aplica a la última capa habilitada",
|
||||
sshHopLocalPortUnsupported: "{hop}: el puerto local no es compatible con este tipo de conexión",
|
||||
connectTimeout: "Tiempo de espera de conexión (segundos)",
|
||||
queryTimeout: "Tiempo de espera de consulta (segundos)",
|
||||
useGlobalQueryTimeout: "Global",
|
||||
@@ -1648,6 +1654,7 @@ export default withEnglishFallback({
|
||||
cancelConnecting: "Cancelar conexión",
|
||||
connectCancelled: "Conexión cancelada",
|
||||
sshTotpCancelled: "Se canceló la introducción del código de un solo uso",
|
||||
sshLocalPortBindFailed: "El puerto local {port} está en uso o no está disponible ({error}). Cierra el programa que lo usa o elige otro puerto local.",
|
||||
kafkaKerberosPrincipal: "Principal",
|
||||
kafkaKerberosKeytab: "Keytab",
|
||||
kafkaKerberosServiceName: "Nombre del servicio",
|
||||
|
||||
@@ -1527,6 +1527,9 @@ export default withEnglishFallback({
|
||||
sshKeyPassphrasePlaceholder: "Biarkan kosong jika key tidak terenkripsi",
|
||||
sshKeyPathBrowse: "Jelajahi",
|
||||
sshExposeLan: "Ekspos tunnel ke LAN",
|
||||
sshLocalPort: "Port Lokal",
|
||||
sshLocalPortPlaceholder: "Otomatis",
|
||||
sshLocalPortHint: "Biarkan kosong untuk memakai port acak yang masih bebas. Port yang ditentukan akan mempertahankan tunnel pada port lokal tersebut selama koneksi terbuka, sehingga alat lain dapat memakainya. Hanya untuk lapisan terakhir.",
|
||||
sshAllowExecChannelProxy: "Aktifkan fallback exec-channel SSH (nc)",
|
||||
sshProxyCommand: "ProxyCommand",
|
||||
sshProxyCommandPlaceholder: "nc %h %p",
|
||||
@@ -1597,6 +1600,9 @@ export default withEnglishFallback({
|
||||
sshWorkerUploadConsentAccept: "Unggah",
|
||||
sshWorkerUploadConsentReject: "Batal",
|
||||
sshHopInvalidTimeout: "{hop}: Timeout SSH harus antara 1 dan 300 detik",
|
||||
sshHopInvalidLocalPort: "{hop}: Port lokal harus antara 1 dan 65535",
|
||||
sshHopLocalPortNotLast: "{hop}: Port lokal hanya berlaku untuk lapisan terakhir yang diaktifkan",
|
||||
sshHopLocalPortUnsupported: "{hop}: Port lokal tidak didukung untuk jenis koneksi ini",
|
||||
connectTimeout: "Timeout Koneksi (detik)",
|
||||
queryTimeout: "Timeout Kueri (detik)",
|
||||
useGlobalQueryTimeout: "Global",
|
||||
@@ -1697,6 +1703,7 @@ export default withEnglishFallback({
|
||||
cancelConnecting: "Batalkan koneksi",
|
||||
connectCancelled: "Koneksi dibatalkan",
|
||||
sshTotpCancelled: "Entri kode sekali pakai dibatalkan",
|
||||
sshLocalPortBindFailed: "Port lokal {port} sedang digunakan atau tidak tersedia ({error}). Tutup program yang memakainya atau pilih port lokal lain.",
|
||||
},
|
||||
editor: {
|
||||
duckdbDraining: "Kueri DuckDB sebelumnya masih dalam proses penghentian. Silakan coba lagi sebentar lagi.",
|
||||
|
||||
@@ -1462,6 +1462,9 @@ export default withEnglishFallback({
|
||||
sshKeyPassphrasePlaceholder: "Lascia vuoto se la chiave non è crittografata",
|
||||
sshKeyPathBrowse: "Sfoglia",
|
||||
sshExposeLan: "Esponi tunnel alla LAN",
|
||||
sshLocalPort: "Porta locale",
|
||||
sshLocalPortPlaceholder: "Auto",
|
||||
sshLocalPortHint: "Lascia vuoto per usare una porta libera casuale. Una porta fissa mantiene il tunnel su quella porta locale finché la connessione è aperta, così altri strumenti possono usarlo. Solo per l'ultimo livello.",
|
||||
sshAllowExecChannelProxy: "Abilita il fallback del canale exec SSH (nc)",
|
||||
sshProxyCommand: "ProxyCommand",
|
||||
sshProxyCommandPlaceholder: "nc %h %p",
|
||||
@@ -1482,6 +1485,9 @@ export default withEnglishFallback({
|
||||
sshAgentSockPath: "Percorso socket agent",
|
||||
sshAgentSockPathPlaceholder: "es. ~/.ssh/agent.sock",
|
||||
sshHopInvalidTimeout: "{hop}: Il timeout SSH deve essere compreso tra 1 e 300 secondi",
|
||||
sshHopInvalidLocalPort: "{hop}: La porta locale deve essere compresa tra 1 e 65535",
|
||||
sshHopLocalPortNotLast: "{hop}: La porta locale si applica solo all'ultimo livello abilitato",
|
||||
sshHopLocalPortUnsupported: "{hop}: La porta locale non è supportata per questo tipo di connessione",
|
||||
connectTimeout: "Timeout Connessione (secondi)",
|
||||
queryTimeout: "Timeout Query (secondi)",
|
||||
useGlobalQueryTimeout: "Globale",
|
||||
@@ -1560,6 +1566,7 @@ export default withEnglishFallback({
|
||||
cancelConnecting: "Annulla connessione",
|
||||
connectCancelled: "Connessione annullata",
|
||||
sshTotpCancelled: "Inserimento del codice monouso annullato",
|
||||
sshLocalPortBindFailed: "La porta locale {port} è in uso o non disponibile ({error}). Chiudi il programma che la usa o scegli un'altra porta locale.",
|
||||
kafkaKerberosPrincipal: "Principal",
|
||||
kafkaKerberosKeytab: "Keytab",
|
||||
kafkaKerberosServiceName: "Nome del servizio",
|
||||
|
||||
@@ -1432,6 +1432,9 @@ export default withEnglishFallback({
|
||||
sshKeyPassphrasePlaceholder: "鍵が暗号化されていない場合は空のまま",
|
||||
sshKeyPathBrowse: "参照",
|
||||
sshExposeLan: "トンネルをLANに公開",
|
||||
sshLocalPort: "ローカルポート",
|
||||
sshLocalPortPlaceholder: "自動",
|
||||
sshLocalPortHint: "空欄の場合は空いているポートをランダムに使用します。固定ポートを指定すると、接続が開いている間はトンネルがそのローカルポートを使い続けるため、他のツールからも利用できます。最後の階層のみ対象です。",
|
||||
sshAllowExecChannelProxy: "SSH 実行チャネルのフォールバックを有効化(nc)",
|
||||
sshProxyCommand: "ProxyCommand",
|
||||
sshProxyCommandPlaceholder: "nc %h %p",
|
||||
@@ -1452,6 +1455,9 @@ export default withEnglishFallback({
|
||||
sshAgentSockPath: "Agentソケットパス",
|
||||
sshAgentSockPathPlaceholder: "例: ~/.ssh/agent.sock",
|
||||
sshHopInvalidTimeout: "{hop}: SSHタイムアウトは1〜300秒の範囲で指定してください",
|
||||
sshHopInvalidLocalPort: "{hop}: ローカルポートは1〜65535の範囲で指定してください",
|
||||
sshHopLocalPortNotLast: "{hop}: ローカルポートは最後の有効な階層にのみ適用されます",
|
||||
sshHopLocalPortUnsupported: "{hop}: このタイプの接続ではローカルポートはサポートされていません",
|
||||
connectTimeout: "接続タイムアウト(秒)",
|
||||
queryTimeout: "クエリタイムアウト(秒)",
|
||||
useGlobalQueryTimeout: "グローバル",
|
||||
@@ -1560,6 +1566,7 @@ export default withEnglishFallback({
|
||||
cancelConnecting: "接続をキャンセル",
|
||||
connectCancelled: "接続がキャンセルされました",
|
||||
sshTotpCancelled: "ワンタイムコードの入力をキャンセルしました",
|
||||
sshLocalPortBindFailed: "ローカルポート {port} は使用中か、利用できません({error})。使用しているプログラムを終了するか、別のローカルポートを指定してください。",
|
||||
kafkaKerberosPrincipal: "プリンシパル",
|
||||
kafkaKerberosKeytab: "Keytab",
|
||||
kafkaKerberosServiceName: "サービス名",
|
||||
|
||||
@@ -1582,6 +1582,9 @@ export default withEnglishFallback({
|
||||
sshKeyPassphrasePlaceholder: "키가 암호화되지 않은 경우 비워 두세요",
|
||||
sshKeyPathBrowse: "찾아보기",
|
||||
sshExposeLan: "터널을 LAN에 노출",
|
||||
sshLocalPort: "로컬 포트",
|
||||
sshLocalPortPlaceholder: "자동",
|
||||
sshLocalPortHint: "비워 두면 사용 가능한 임의의 포트를 사용합니다. 고정 포트를 지정하면 연결이 열려 있는 동안 터널이 해당 로컬 포트를 유지하므로 다른 도구에서도 사용할 수 있습니다. 마지막 계층에만 적용됩니다.",
|
||||
sshAllowExecChannelProxy: "SSH exec 채널 대체(nc) 활성화",
|
||||
sshProxyCommand: "ProxyCommand",
|
||||
sshProxyCommandPlaceholder: "nc %h %p",
|
||||
@@ -1652,6 +1655,9 @@ export default withEnglishFallback({
|
||||
sshWorkerUploadConsentAccept: "업로드",
|
||||
sshWorkerUploadConsentReject: "취소",
|
||||
sshHopInvalidTimeout: "{hop}: SSH 제한 시간은 1에서 300초 사이여야 합니다",
|
||||
sshHopInvalidLocalPort: "{hop}: 로컬 포트는 1에서 65535 사이여야 합니다",
|
||||
sshHopLocalPortNotLast: "{hop}: 로컬 포트는 마지막으로 활성화된 계층에만 적용됩니다",
|
||||
sshHopLocalPortUnsupported: "{hop}: 이 연결 유형에서는 로컬 포트를 지원하지 않습니다",
|
||||
connectTimeout: "연결 제한 시간 (초)",
|
||||
queryTimeout: "쿼리 제한 시간 (초)",
|
||||
useGlobalQueryTimeout: "전역",
|
||||
@@ -1748,6 +1754,7 @@ export default withEnglishFallback({
|
||||
cancelConnecting: "연결 취소",
|
||||
connectCancelled: "연결이 취소되었습니다",
|
||||
sshTotpCancelled: "일회용 코드 입력이 취소되었습니다",
|
||||
sshLocalPortBindFailed: "로컬 포트 {port}을(를) 사용 중이거나 사용할 수 없습니다({error}). 해당 포트를 사용하는 프로그램을 종료하거나 다른 로컬 포트를 지정하세요.",
|
||||
mqttSearchPayloadPlaceholder: "메시지 Payload 검색...",
|
||||
mqttNoMatchingMessages: "현재 Payload 필터 조건과 일치하는 로드된 메시지가 없습니다.",
|
||||
mqttCollapsePayload: "Payload 접기",
|
||||
|
||||
@@ -1579,6 +1579,9 @@ export default withEnglishFallback({
|
||||
sshKeyPassphrasePlaceholder: "Pozostaw puste, jeśli klucz nie jest zaszyfrowany",
|
||||
sshKeyPathBrowse: "Przeglądaj",
|
||||
sshExposeLan: "Udostępnij tunel w sieci LAN",
|
||||
sshLocalPort: "Port lokalny",
|
||||
sshLocalPortPlaceholder: "Auto",
|
||||
sshLocalPortHint: "Pozostaw puste, aby użyć losowego wolnego portu. Stały port utrzymuje tunel na tym porcie lokalnym, dopóki połączenie jest otwarte, dzięki czemu mogą z niego korzystać inne narzędzia. Dotyczy tylko ostatniej warstwy.",
|
||||
sshAllowExecChannelProxy: "Włącz zapasowe przekazywanie przez kanał SSH exec (nc)",
|
||||
sshProxyCommand: "ProxyCommand",
|
||||
sshProxyCommandPlaceholder: "nc %h %p",
|
||||
@@ -1649,6 +1652,9 @@ export default withEnglishFallback({
|
||||
sshWorkerUploadConsentAccept: "Prześlij",
|
||||
sshWorkerUploadConsentReject: "Anuluj",
|
||||
sshHopInvalidTimeout: "{hop}: limit czasu SSH musi wynosić od 1 do 300 sekund",
|
||||
sshHopInvalidLocalPort: "{hop}: port lokalny musi mieścić się w zakresie od 1 do 65535",
|
||||
sshHopLocalPortNotLast: "{hop}: port lokalny dotyczy tylko ostatniej włączonej warstwy",
|
||||
sshHopLocalPortUnsupported: "{hop}: port lokalny nie jest obsługiwany dla tego typu połączenia",
|
||||
connectTimeout: "Limit czasu połączenia (sekundy)",
|
||||
queryTimeout: "Limit czasu zapytania (sekundy)",
|
||||
useGlobalQueryTimeout: "Globalne",
|
||||
@@ -1741,6 +1747,7 @@ export default withEnglishFallback({
|
||||
cancelConnecting: "Anuluj łączenie",
|
||||
connectCancelled: "Anulowano połączenie",
|
||||
sshTotpCancelled: "Anulowano wprowadzanie kodu jednorazowego",
|
||||
sshLocalPortBindFailed: "Port lokalny {port} jest zajęty lub niedostępny ({error}). Zamknij program, który go używa, lub wybierz inny port lokalny.",
|
||||
},
|
||||
editor: {
|
||||
duckdbDraining: "Poprzednie zapytanie DuckDB jest nadal zatrzymywane. Spróbuj ponownie za chwilę.",
|
||||
|
||||
@@ -1463,6 +1463,9 @@ export default withEnglishFallback({
|
||||
sshKeyPassphrasePlaceholder: "Deixe vazio se a chave não estiver criptografada",
|
||||
sshKeyPathBrowse: "Procurar",
|
||||
sshExposeLan: "Expor túnel à LAN",
|
||||
sshLocalPort: "Porta local",
|
||||
sshLocalPortPlaceholder: "Auto",
|
||||
sshLocalPortHint: "Deixe vazio para usar uma porta livre aleatória. Uma porta fixa mantém o túnel nessa porta local enquanto a conexão estiver aberta, para que outras ferramentas possam usá-lo. Somente na última camada.",
|
||||
sshAllowExecChannelProxy: "Ativar fallback do canal exec SSH (nc)",
|
||||
sshProxyCommand: "ProxyCommand",
|
||||
sshProxyCommandPlaceholder: "nc %h %p",
|
||||
@@ -1483,6 +1486,9 @@ export default withEnglishFallback({
|
||||
sshAgentSockPath: "Caminho do socket do agente",
|
||||
sshAgentSockPathPlaceholder: "ex. ~/.ssh/agent.sock",
|
||||
sshHopInvalidTimeout: "{hop}: o timeout SSH deve estar entre 1 e 300 segundos",
|
||||
sshHopInvalidLocalPort: "{hop}: a porta local deve estar entre 1 e 65535",
|
||||
sshHopLocalPortNotLast: "{hop}: a porta local se aplica somente à última camada habilitada",
|
||||
sshHopLocalPortUnsupported: "{hop}: a porta local não é compatível com este tipo de conexão",
|
||||
connectTimeout: "Timeout de Conexão (segundos)",
|
||||
queryTimeout: "Timeout de Consulta (segundos)",
|
||||
useGlobalQueryTimeout: "Global",
|
||||
@@ -1561,6 +1567,7 @@ export default withEnglishFallback({
|
||||
cancelConnecting: "Cancelar conexão",
|
||||
connectCancelled: "Conexão cancelada",
|
||||
sshTotpCancelled: "Entrada do código de uso único cancelada",
|
||||
sshLocalPortBindFailed: "A porta local {port} está em uso ou indisponível ({error}). Feche o programa que a está usando ou escolha outra porta local.",
|
||||
kafkaKerberosPrincipal: "Principal",
|
||||
kafkaKerberosKeytab: "Keytab",
|
||||
kafkaKerberosServiceName: "Nome do serviço",
|
||||
|
||||
@@ -1678,6 +1678,9 @@ export default withEnglishFallback({
|
||||
sshKeyPassphrasePlaceholder: "Оставьте пустым, если ключ не зашифрован",
|
||||
sshKeyPathBrowse: "Обзор",
|
||||
sshExposeLan: "Открыть туннель для локальной сети",
|
||||
sshLocalPort: "Локальный порт",
|
||||
sshLocalPortPlaceholder: "Авто",
|
||||
sshLocalPortHint: "Оставьте пустым, чтобы использовать случайный свободный порт. Фиксированный порт сохраняет туннель на этом локальном порту, пока соединение открыто, поэтому им могут пользоваться другие инструменты. Только для последнего слоя.",
|
||||
sshAllowExecChannelProxy: "Включить резервный вариант SSH exec-channel (nc)",
|
||||
sshProxyCommand: "ProxyCommand",
|
||||
sshProxyCommandPlaceholder: "nc %h %p",
|
||||
@@ -1748,6 +1751,9 @@ export default withEnglishFallback({
|
||||
sshWorkerUploadConsentAccept: "Загрузить",
|
||||
sshWorkerUploadConsentReject: "Отмена",
|
||||
sshHopInvalidTimeout: "{hop}: тайм-аут SSH должен быть от 1 до 300 секунд",
|
||||
sshHopInvalidLocalPort: "{hop}: локальный порт должен быть от 1 до 65535",
|
||||
sshHopLocalPortNotLast: "{hop}: локальный порт применяется только к последнему включённому слою",
|
||||
sshHopLocalPortUnsupported: "{hop}: локальный порт не поддерживается для этого типа соединения",
|
||||
connectTimeout: "Тайм-аут подключения (секунды)",
|
||||
queryTimeout: "Тайм-аут запроса (секунды)",
|
||||
useGlobalQueryTimeout: "Глобальный",
|
||||
@@ -1846,6 +1852,7 @@ export default withEnglishFallback({
|
||||
cancelConnecting: "Отменить подключение",
|
||||
connectCancelled: "Подключение отменено",
|
||||
sshTotpCancelled: "Ввод одноразового кода отменён",
|
||||
sshLocalPortBindFailed: "Локальный порт {port} занят или недоступен ({error}). Закройте программу, которая его использует, или выберите другой локальный порт.",
|
||||
httpTunnelProtocol: "Протокол туннеля",
|
||||
httpTunnelProtocolDbx: "Скрипт DBX TCP",
|
||||
httpTunnelProtocolNavicat: "Navicat MySQL HTTP",
|
||||
|
||||
@@ -1873,6 +1873,9 @@ export default withEnglishFallback({
|
||||
sshKeyPassphrasePlaceholder: "เว้นว่างหาก Key ไม่ได้เข้ารหัส",
|
||||
sshKeyPathBrowse: "เรียกดู",
|
||||
sshExposeLan: "เปิดเผย Tunnel ให้ LAN",
|
||||
sshLocalPort: "Local Port",
|
||||
sshLocalPortPlaceholder: "อัตโนมัติ",
|
||||
sshLocalPortHint: "เว้นว่างเพื่อใช้ Port ว่างแบบสุ่ม Port ที่กำหนดตายตัวจะคง Tunnel ไว้ที่ Local Port นั้นตลอดที่การเชื่อมต่อเปิดอยู่ เพื่อให้เครื่องมืออื่นใช้งานได้ ใช้ได้กับชั้นสุดท้ายเท่านั้น",
|
||||
sshAllowExecChannelProxy: "เปิดใช้ทางเลือก SSH Exec-channel (nc)",
|
||||
sshProxyCommand: "ProxyCommand",
|
||||
sshProxyCommandPlaceholder: "nc %h %p",
|
||||
@@ -1943,6 +1946,9 @@ export default withEnglishFallback({
|
||||
sshWorkerUploadConsentAccept: "อัปโหลด",
|
||||
sshWorkerUploadConsentReject: "ยกเลิก",
|
||||
sshHopInvalidTimeout: "{hop}: SSH Timeout ต้องอยู่ระหว่าง 1 ถึง 300 วินาที",
|
||||
sshHopInvalidLocalPort: "{hop}: Local Port ต้องอยู่ระหว่าง 1 ถึง 65535",
|
||||
sshHopLocalPortNotLast: "{hop}: Local Port ใช้ได้กับชั้นสุดท้ายที่เปิดใช้งานเท่านั้น",
|
||||
sshHopLocalPortUnsupported: "{hop}: ไม่รองรับ Local Port สำหรับการเชื่อมต่อประเภทนี้",
|
||||
connectTimeout: "Timeout การเชื่อมต่อ (วินาที)",
|
||||
queryTimeout: "Timeout ของ Query (วินาที)",
|
||||
useGlobalQueryTimeout: "ส่วนกลาง",
|
||||
@@ -2043,6 +2049,7 @@ export default withEnglishFallback({
|
||||
cancelConnecting: "ยกเลิกการเชื่อมต่อ",
|
||||
connectCancelled: "ยกเลิกการเชื่อมต่อแล้ว",
|
||||
sshTotpCancelled: "ยกเลิกการป้อน Code แบบใช้ครั้งเดียวแล้ว",
|
||||
sshLocalPortBindFailed: "Local Port {port} ถูกใช้งานอยู่หรือไม่พร้อมใช้งาน ({error}) ปิดโปรแกรมที่ใช้ Port นี้อยู่ หรือเลือก Local Port อื่น",
|
||||
httpTunnelProtocol: "โปรโตคอลทันเนล",
|
||||
httpTunnelProtocolDbx: "สคริปต์ DBX TCP",
|
||||
httpTunnelProtocolNavicat: "Navicat MySQL HTTP",
|
||||
|
||||
@@ -1258,6 +1258,9 @@ export default withEnglishFallback({
|
||||
sshKeyPassphrasePlaceholder: "Anahtar şifrelenmemişse boş bırakın",
|
||||
sshKeyPathBrowse: "Gözat",
|
||||
sshExposeLan: "Tüneli yerel ağa aç",
|
||||
sshLocalPort: "Yerel Port",
|
||||
sshLocalPortPlaceholder: "Otomatik",
|
||||
sshLocalPortHint: "Rastgele boş bir port için boş bırakın. Sabit port, bağlantı açıkken tüneli o yerel portta tutar; böylece diğer araçlar da kullanabilir. Yalnızca son katman için geçerlidir.",
|
||||
sshAllowExecChannelProxy: "SSH exec-channel yedeğini (nc) etkinleştir",
|
||||
sshProxyCommand: "ProxyCommand",
|
||||
sshProxyCommandPlaceholder: "nc %h %p",
|
||||
@@ -1331,6 +1334,9 @@ export default withEnglishFallback({
|
||||
sshWorkerUploadConsentAccept: "Yükle",
|
||||
sshWorkerUploadConsentReject: "İptal",
|
||||
sshHopInvalidTimeout: "{hop}: SSH zaman aşımı 1 ile 300 saniye arasında olmalıdır",
|
||||
sshHopInvalidLocalPort: "{hop}: Yerel port 1 ile 65535 arasında olmalıdır",
|
||||
sshHopLocalPortNotLast: "{hop}: Yerel port yalnızca son etkin katmana uygulanır",
|
||||
sshHopLocalPortUnsupported: "{hop}: Yerel port bu bağlantı türü için desteklenmiyor",
|
||||
connectTimeout: "Bağlantı Zaman Aşımı (saniye)",
|
||||
queryTimeout: "Sorgu Zaman Aşımı (saniye)",
|
||||
useGlobalQueryTimeout: "Genel",
|
||||
@@ -1428,6 +1434,7 @@ export default withEnglishFallback({
|
||||
cancelConnecting: "Bağlanmayı iptal et",
|
||||
connectCancelled: "Bağlantı iptal edildi",
|
||||
sshTotpCancelled: "Tek kullanımlık kod girişi iptal edildi",
|
||||
sshLocalPortBindFailed: "{port} yerel portu kullanımda veya kullanılamıyor ({error}). Onu kullanan programı kapatın veya başka bir yerel port seçin.",
|
||||
},
|
||||
editor: {
|
||||
duckdbDraining: "Önceki DuckDB sorgusu hâlâ durduruluyor. Lütfen birazdan tekrar deneyin.",
|
||||
|
||||
@@ -1786,6 +1786,7 @@ export default withEnglishFallback({
|
||||
cancelConnecting: "取消连接",
|
||||
connectCancelled: "连接已取消",
|
||||
sshTotpCancelled: "已取消输入一次性验证码",
|
||||
sshLocalPortBindFailed: "本地端口 {port} 已被占用或不可用({error})。请关闭占用该端口的程序,或选择其他本地端口。",
|
||||
connectSuccess: "已连接 {name}",
|
||||
connectFailed: "连接失败:{message}",
|
||||
objectCacheRefreshFailed: "对象缓存刷新失败:{message}",
|
||||
@@ -1860,6 +1861,9 @@ export default withEnglishFallback({
|
||||
sshKeyPassphrasePlaceholder: "密钥未加密则留空",
|
||||
sshKeyPathBrowse: "浏览",
|
||||
sshExposeLan: "允许局域网访问隧道",
|
||||
sshLocalPort: "本地端口",
|
||||
sshLocalPortPlaceholder: "自动",
|
||||
sshLocalPortHint: "留空则使用随机空闲端口。指定固定端口后,连接打开期间隧道会一直使用该本地端口,其他工具也可以使用。仅对最后一层生效。",
|
||||
sshAllowExecChannelProxy: "启用 SSH 执行通道回退(nc)",
|
||||
sshProxyCommand: "ProxyCommand",
|
||||
sshProxyCommandPlaceholder: "nc %h %p",
|
||||
@@ -1935,6 +1939,9 @@ export default withEnglishFallback({
|
||||
sshWorkerUploadConsentAccept: "上传",
|
||||
sshWorkerUploadConsentReject: "取消",
|
||||
sshHopInvalidTimeout: "{hop}:SSH 超时时间必须在 1 到 300 秒之间",
|
||||
sshHopInvalidLocalPort: "{hop}:本地端口必须在 1 到 65535 之间",
|
||||
sshHopLocalPortNotLast: "{hop}:本地端口仅适用于最后一个已启用的层",
|
||||
sshHopLocalPortUnsupported: "{hop}:此连接类型不支持本地端口",
|
||||
connectTimeout: "连接超时(秒)",
|
||||
queryTimeout: "查询超时(秒)",
|
||||
useGlobalQueryTimeout: "全局",
|
||||
|
||||
@@ -1472,6 +1472,9 @@ export default withEnglishFallback({
|
||||
sshKeyPassphrasePlaceholder: "金鑰未加密則留空",
|
||||
sshKeyPathBrowse: "瀏覽",
|
||||
sshExposeLan: "允許區域網路存取隧道",
|
||||
sshLocalPort: "本機埠",
|
||||
sshLocalPortPlaceholder: "自動",
|
||||
sshLocalPortHint: "留空則使用隨機空閒埠。指定固定埠後,連線開啟期間隧道會一直使用該本機埠,其他工具也可以使用。僅對最後一層生效。",
|
||||
sshAllowExecChannelProxy: "啟用 SSH 執行通道回退(nc)",
|
||||
sshProxyCommand: "ProxyCommand",
|
||||
sshProxyCommandPlaceholder: "nc %h %p",
|
||||
@@ -1492,6 +1495,9 @@ export default withEnglishFallback({
|
||||
sshAgentSockPath: "Agent Socket 路徑",
|
||||
sshAgentSockPathPlaceholder: "如 ~/.ssh/agent.sock",
|
||||
sshHopInvalidTimeout: "{hop}:SSH 逾時時間必須在 1 到 300 秒之間",
|
||||
sshHopInvalidLocalPort: "{hop}:本機埠必須在 1 到 65535 之間",
|
||||
sshHopLocalPortNotLast: "{hop}:本機埠僅適用於最後一個已啟用的層",
|
||||
sshHopLocalPortUnsupported: "{hop}:此連線類型不支援本機埠",
|
||||
connectTimeout: "連線逾時(秒)",
|
||||
queryTimeout: "查詢逾時(秒)",
|
||||
useGlobalQueryTimeout: "全域",
|
||||
@@ -1569,6 +1575,7 @@ export default withEnglishFallback({
|
||||
cancelConnecting: "取消連接",
|
||||
connectCancelled: "連接已取消",
|
||||
sshTotpCancelled: "已取消輸入一次性驗證碼",
|
||||
sshLocalPortBindFailed: "本機埠 {port} 已被佔用或無法使用({error})。請關閉佔用該埠的程式,或選擇其他本機埠。",
|
||||
kafkaKerberosPrincipal: "Principal",
|
||||
kafkaKerberosKeytab: "Keytab",
|
||||
kafkaKerberosServiceName: "服務名稱",
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { editableSshLocalPort, normalizeSshLocalPort, sshLocalPortState, sshLocalPortSupportedForConnection, withoutSshLocalPorts, type SshLocalPortState } from "@/lib/connection/sshLocalPort";
|
||||
import type { ConnectionConfig, TransportLayerConfig } from "@/types/database";
|
||||
|
||||
const CASES: { name: string; value: unknown; normalized: number | undefined; state: SshLocalPortState; editable: number | undefined }[] = [
|
||||
{ name: "lowest port", value: 1, normalized: 1, state: "valid", editable: 1 },
|
||||
{ name: "typical port", value: 4059, normalized: 4059, state: "valid", editable: 4059 },
|
||||
{ name: "highest port", value: 65535, normalized: 65535, state: "valid", editable: 65535 },
|
||||
{ name: "numeric string", value: "4059", normalized: 4059, state: "valid", editable: 4059 },
|
||||
{ name: "numeric string with spaces", value: " 4059 ", normalized: 4059, state: "valid", editable: 4059 },
|
||||
{ name: "undefined", value: undefined, normalized: undefined, state: "unset", editable: undefined },
|
||||
{ name: "null", value: null, normalized: undefined, state: "unset", editable: undefined },
|
||||
{ name: "empty string", value: "", normalized: undefined, state: "unset", editable: undefined },
|
||||
{ name: "blank string", value: " ", normalized: undefined, state: "unset", editable: undefined },
|
||||
{ name: "zero", value: 0, normalized: undefined, state: "invalid", editable: 0 },
|
||||
{ name: "zero string", value: "0", normalized: undefined, state: "invalid", editable: 0 },
|
||||
{ name: "negative", value: -1, normalized: undefined, state: "invalid", editable: -1 },
|
||||
{ name: "negative string", value: "-1", normalized: undefined, state: "invalid", editable: Number.NaN },
|
||||
{ name: "above range", value: 65536, normalized: undefined, state: "invalid", editable: 65536 },
|
||||
{ name: "far above range", value: 70000, normalized: undefined, state: "invalid", editable: 70000 },
|
||||
{ name: "above range string", value: "70000", normalized: undefined, state: "invalid", editable: 70000 },
|
||||
{ name: "fraction", value: 4059.5, normalized: undefined, state: "invalid", editable: 4059.5 },
|
||||
{ name: "fraction string", value: "4059.5", normalized: undefined, state: "invalid", editable: Number.NaN },
|
||||
{ name: "NaN", value: Number.NaN, normalized: undefined, state: "invalid", editable: Number.NaN },
|
||||
{ name: "Infinity", value: Number.POSITIVE_INFINITY, normalized: undefined, state: "invalid", editable: Number.POSITIVE_INFINITY },
|
||||
{ name: "hex string", value: "0x10", normalized: undefined, state: "invalid", editable: Number.NaN },
|
||||
{ name: "text", value: "abc", normalized: undefined, state: "invalid", editable: Number.NaN },
|
||||
{ name: "boolean", value: true, normalized: undefined, state: "invalid", editable: Number.NaN },
|
||||
];
|
||||
|
||||
describe("ssh local port helpers", () => {
|
||||
it.each(CASES)("normalizes $name", ({ value, normalized }) => {
|
||||
expect(normalizeSshLocalPort(value)).toBe(normalized);
|
||||
});
|
||||
|
||||
it.each(CASES)("classifies $name", ({ value, state }) => {
|
||||
expect(sshLocalPortState(value)).toBe(state);
|
||||
});
|
||||
|
||||
it.each(CASES)("keeps $name reportable in the form", ({ value, editable }) => {
|
||||
expect(editableSshLocalPort(value)).toBe(editable);
|
||||
});
|
||||
|
||||
it("keeps an out-of-range value classified as invalid after the form round trip", () => {
|
||||
expect(sshLocalPortState(editableSshLocalPort("70000"))).toBe("invalid");
|
||||
expect(sshLocalPortState(editableSshLocalPort("abc"))).toBe("invalid");
|
||||
expect(sshLocalPortState(editableSshLocalPort(""))).toBe("unset");
|
||||
});
|
||||
});
|
||||
|
||||
describe("sshLocalPortSupportedForConnection", () => {
|
||||
type Connection = Parameters<typeof sshLocalPortSupportedForConnection>[0];
|
||||
const CASES: { name: string; config: Connection; mqSystemKind?: string; supported: boolean }[] = [
|
||||
{ name: "postgres", config: { db_type: "postgres" }, supported: true },
|
||||
{ name: "mysql", config: { db_type: "mysql" }, supported: true },
|
||||
{ name: "redis without a mode", config: { db_type: "redis" }, supported: true },
|
||||
{ name: "redis standalone", config: { db_type: "redis", redis_connection_mode: "standalone" }, supported: true },
|
||||
{ name: "redis cluster", config: { db_type: "redis", redis_connection_mode: "cluster" }, supported: false },
|
||||
{ name: "redis sentinel", config: { db_type: "redis", redis_connection_mode: "sentinel" }, supported: false },
|
||||
{ name: "a redis mode left on another database type", config: { db_type: "postgres", redis_connection_mode: "cluster" }, supported: true },
|
||||
{ name: "sqlite over ssh", config: { db_type: "sqlite" }, supported: false },
|
||||
{ name: "clickhouse", config: { db_type: "clickhouse" }, supported: false },
|
||||
{ name: "turso", config: { db_type: "turso" }, supported: false },
|
||||
{ name: "cloudflare d1", config: { db_type: "cloudflare-d1" }, supported: false },
|
||||
{ name: "rocketmq by driver profile", config: { db_type: "mq", driver_profile: "rocketmq" }, supported: false },
|
||||
{ name: "rocketmq by saved admin config", config: { db_type: "mq", external_config: { systemKind: "rocketmq" } }, supported: false },
|
||||
{ name: "kafka", config: { db_type: "mq", driver_profile: "kafka", external_config: { systemKind: "kafka" } }, supported: true },
|
||||
{ name: "rabbitmq", config: { db_type: "mq", driver_profile: "rabbitmq" }, supported: true },
|
||||
{ name: "pulsar", config: { db_type: "mq", driver_profile: "pulsar" }, supported: true },
|
||||
{ name: "saved admin kind wins over a stale driver profile", config: { db_type: "mq", driver_profile: "pulsar", external_config: { systemKind: "rocketmq" } }, supported: false },
|
||||
{ name: "rocketmq picked in the form", config: { db_type: "mq", driver_profile: "kafka", external_config: { systemKind: "kafka" } }, mqSystemKind: "rocketmq", supported: false },
|
||||
{ name: "kafka picked in the form over a saved rocketmq kind", config: { db_type: "mq", driver_profile: "rocketmq", external_config: { systemKind: "rocketmq" } }, mqSystemKind: "kafka", supported: true },
|
||||
{ name: "form kind ignored for other database types", config: { db_type: "postgres" }, mqSystemKind: "rocketmq", supported: true },
|
||||
];
|
||||
|
||||
it.each(CASES)("reports $name as supported: $supported", ({ config, mqSystemKind, supported }) => {
|
||||
expect(sshLocalPortSupportedForConnection(config, mqSystemKind)).toBe(supported);
|
||||
});
|
||||
});
|
||||
|
||||
describe("withoutSshLocalPorts", () => {
|
||||
const ssh = (id: string, localPort?: number): TransportLayerConfig => ({ type: "ssh", id, host: `${id}.example.com`, port: 22, user: "root", ...(localPort === undefined ? {} : { local_port: localPort }) });
|
||||
const proxy: TransportLayerConfig = { type: "proxy", id: "proxy", proxy_type: "socks5", host: "proxy.example.com", port: 1080 };
|
||||
const http: TransportLayerConfig = { type: "http_tunnel", id: "http", url: "https://tunnel.example.com", token: "", protocol: "dbx_tcp" };
|
||||
const config = (layers?: TransportLayerConfig[]): ConnectionConfig => ({ id: "c", name: "C", db_type: "postgres", host: "db", port: 5432, username: "u", password: "p", transport_layers: layers });
|
||||
|
||||
it("drops the fixed port from every ssh layer and leaves other layers untouched", () => {
|
||||
const layers = [http, ssh("a", 4059), proxy, ssh("b", 4060), ssh("c")];
|
||||
const copy = withoutSshLocalPorts(config(layers));
|
||||
|
||||
expect(copy.transport_layers?.map((layer) => layer.id)).toEqual(["http", "a", "proxy", "b", "c"]);
|
||||
for (const layer of copy.transport_layers ?? []) expect(layer).not.toHaveProperty("local_port");
|
||||
expect(copy.transport_layers?.[0]).toBe(http);
|
||||
expect(copy.transport_layers?.[2]).toBe(proxy);
|
||||
expect(copy.transport_layers?.[1]).toMatchObject({ type: "ssh", id: "a", host: "a.example.com", port: 22, user: "root" });
|
||||
});
|
||||
|
||||
it("does not mutate its input", () => {
|
||||
const original = config([ssh("a", 4059)]);
|
||||
const copy = withoutSshLocalPorts(original);
|
||||
|
||||
expect(copy).not.toBe(original);
|
||||
expect(original.transport_layers?.[0]).toMatchObject({ local_port: 4059 });
|
||||
});
|
||||
|
||||
it("keeps the rest of the config and tolerates a missing layer list", () => {
|
||||
const copy = withoutSshLocalPorts(config());
|
||||
|
||||
expect(copy).toEqual(config());
|
||||
expect(copy).not.toBe(config());
|
||||
expect(withoutSshLocalPorts({ ...config([]), name: "Renamed" })).toMatchObject({ name: "Renamed", transport_layers: [] });
|
||||
});
|
||||
});
|
||||
@@ -93,6 +93,18 @@ describe("connectionConfigTransfer", () => {
|
||||
expect(imported.connections[0]).toMatchObject({ id: "imported-id", sidebar_auto_load_all_tables: true });
|
||||
});
|
||||
|
||||
it("keeps the fixed SSH local port through snapshot, plaintext scrub, and import", () => {
|
||||
const source = conn("tunnelled", "Tunnelled", { transport_layers: [{ type: "ssh", id: "layer", host: "bastion", port: 22, user: "root", password: "ssh-password", local_port: 4059 }] });
|
||||
const snapshot = snapshotConnectionsForExport([source], { connectTimeoutSecs: () => 7, queryTimeoutSecs: () => 12 });
|
||||
const scrubbed = scrubConnectionForPlaintextExport(snapshot[0]);
|
||||
const serialized = JSON.parse(JSON.stringify(buildConnectionConfigBundle([scrubbed], null, [])));
|
||||
const imported = prepareConnectionConfigImport(parseConnectionConfigObject(serialized), [], [], () => "imported-id");
|
||||
|
||||
expect(snapshot[0].transport_layers?.[0]).toMatchObject({ local_port: 4059 });
|
||||
expect(scrubbed.transport_layers?.[0]).toMatchObject({ local_port: 4059, password: "" });
|
||||
expect(imported.connections[0].transport_layers?.[0]).toMatchObject({ local_port: 4059, host: "bastion" });
|
||||
});
|
||||
|
||||
it("keeps stable ids and removes secrets from plaintext exports", () => {
|
||||
const exported = scrubConnectionForPlaintextExport(
|
||||
conn("stable", "A", {
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { ConnectionConfig, DatabaseType, TransportLayerConfig } from "@/types/database";
|
||||
import { isCloudflareD1Connection } from "./cloudflareD1";
|
||||
|
||||
export type SshLocalPortState = "unset" | "valid" | "invalid";
|
||||
|
||||
function toPortNumber(value: unknown): number {
|
||||
if (typeof value === "number") return value;
|
||||
return typeof value === "string" && /^\s*\d+\s*$/.test(value) ? Number(value) : Number.NaN;
|
||||
}
|
||||
|
||||
export function normalizeSshLocalPort(value: unknown): number | undefined {
|
||||
const port = toPortNumber(value);
|
||||
return Number.isInteger(port) && port >= 1 && port <= 65535 ? port : undefined;
|
||||
}
|
||||
|
||||
export function sshLocalPortState(value: unknown): SshLocalPortState {
|
||||
if (value === undefined || value === null) return "unset";
|
||||
if (typeof value === "string" && value.trim() === "") return "unset";
|
||||
return normalizeSshLocalPort(value) === undefined ? "invalid" : "valid";
|
||||
}
|
||||
|
||||
/** Like `normalizeSshLocalPort`, but keeps an out-of-range value as a number so form validation can report it. */
|
||||
export function editableSshLocalPort(value: unknown): number | undefined {
|
||||
return sshLocalPortState(value) === "unset" ? undefined : toPortNumber(value);
|
||||
}
|
||||
|
||||
// These types never bind the static tunnel on their primary path: SQLite reaches its remote worker over SSH, and
|
||||
// ClickHouse, Turso and Cloudflare D1 route through a dynamic SOCKS5 port when SSH is the last layer.
|
||||
const NO_STATIC_TUNNEL_DB_TYPES = new Set<DatabaseType>(["sqlite", "clickhouse", "turso"]);
|
||||
|
||||
type SshLocalPortConnection = Pick<ConnectionConfig, "db_type"> & Partial<Pick<ConnectionConfig, "driver_profile" | "redis_connection_mode" | "external_config">>;
|
||||
|
||||
function isRocketMqConnection(config: SshLocalPortConnection, mqSystemKind?: string): boolean {
|
||||
if (config.db_type !== "mq") return false;
|
||||
const savedSystemKind = (config.external_config as { systemKind?: unknown } | undefined)?.systemKind;
|
||||
return (mqSystemKind ?? savedSystemKind ?? config.driver_profile) === "rocketmq";
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the connection's primary tunnel can listen on a fixed local port (Redis Cluster and Sentinel route per node).
|
||||
* `mqSystemKind` is the message-queue kind picked in the form, which the backend reads from `external_config.systemKind`.
|
||||
*/
|
||||
export function sshLocalPortSupportedForConnection(config: SshLocalPortConnection, mqSystemKind?: string): boolean {
|
||||
if (NO_STATIC_TUNNEL_DB_TYPES.has(config.db_type) || isCloudflareD1Connection(config) || isRocketMqConnection(config, mqSystemKind)) return false;
|
||||
return config.db_type !== "redis" || (config.redis_connection_mode !== "cluster" && config.redis_connection_mode !== "sentinel");
|
||||
}
|
||||
|
||||
export function withoutSshLocalPorts<T extends { transport_layers?: TransportLayerConfig[] }>(config: T): T {
|
||||
if (!config.transport_layers) return { ...config };
|
||||
return {
|
||||
...config,
|
||||
transport_layers: config.transport_layers.map((layer) => {
|
||||
if (layer.type !== "ssh") return layer;
|
||||
const copy = { ...layer };
|
||||
delete copy.local_port;
|
||||
return copy;
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -199,6 +199,7 @@ import { isXuguPublicSynonymScope, isXuguSchedulerJobScope, isXuguSyntheticScope
|
||||
import { filterNacosNamespacesForSidebar, normalizeNacosNamespacesForDisplay } from "@/lib/nacos/nacosNamespaceVisibility";
|
||||
import { buildPackageMemberNodes, markPackageNodesExpandable, packageMemberGroupOwnerId } from "@/lib/sidebar/packageMembers";
|
||||
import { configuredDatabaseProductName, connectionConfigFingerprint, normalizeDatabaseConnectionInfo } from "@/lib/connection/connectionDatabaseInfo";
|
||||
import { withoutSshLocalPorts } from "@/lib/connection/sshLocalPort";
|
||||
import { driverProfileObjectTreeProfileForConnection } from "@/lib/database/driverProfileExtensions";
|
||||
import { createMetadataLoadTrace, logMetadataLoadTrace, MetadataLoadCoordinator, type MetadataLoadTraceLogger } from "@/lib/metadata/metadataLoadCoordinator";
|
||||
import type { MetadataScopeInput } from "@/lib/metadata/metadataLoadScope";
|
||||
@@ -4241,7 +4242,7 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
for (const entry of clipboard.connections) {
|
||||
await addConnection(
|
||||
{
|
||||
...entry.config,
|
||||
...withoutSshLocalPorts(entry.config),
|
||||
id: uuid(),
|
||||
name: `${entry.config.name} (Copy)`,
|
||||
},
|
||||
|
||||
@@ -317,6 +317,14 @@ export interface SshTunnelConfig {
|
||||
key_passphrase?: string;
|
||||
connect_timeout_secs?: number;
|
||||
expose_lan?: boolean;
|
||||
/**
|
||||
* Fixed local port (1-65535) the tunnel listens on while the connection is
|
||||
* open, so other local tools can reuse it. Only the last enabled layer uses
|
||||
* it, and only when it is an inline SSH layer (not a shared tunnel profile).
|
||||
* Absent means a random free port. It binds `0.0.0.0` instead of `127.0.0.1`
|
||||
* when `expose_lan` is on.
|
||||
*/
|
||||
local_port?: number;
|
||||
use_ssh_agent?: boolean;
|
||||
ssh_agent_sock_path?: string;
|
||||
/**
|
||||
|
||||
@@ -1183,6 +1183,7 @@ mod tests {
|
||||
key_passphrase: passphrase.to_string(),
|
||||
connect_timeout_secs: 5,
|
||||
expose_lan: false,
|
||||
local_port: None,
|
||||
use_ssh_agent: false,
|
||||
ssh_agent_sock_path: String::new(),
|
||||
ssh_agent_key_fingerprint: String::new(),
|
||||
|
||||
@@ -437,6 +437,11 @@ pub struct AppState {
|
||||
pub nacos_registry: crate::nacos::NacosAdminRegistry,
|
||||
duckdb_worker_process_isolation: AtomicBool,
|
||||
duckdb_worker_max_processes: AtomicUsize,
|
||||
/// Whether a saved connection's fixed SSH `local_port` is bound. Off by
|
||||
/// default so a state that lives beside the app (scheduled backup jobs,
|
||||
/// backup workers, stdio MCP, CLI) never fights it for the same port. Only
|
||||
/// the long-lived primary runtimes (desktop app, dbx-web server) turn it on.
|
||||
honor_fixed_ssh_local_ports: AtomicBool,
|
||||
/// PostgreSQL TLS cancel context, keyed by pool_key.
|
||||
/// Used to reconstruct a TLS connector compatible with the original connection when cancelling.
|
||||
postgres_cancel_contexts: Arc<RwLock<HashMap<String, db::postgres::PostgresCancelContext>>>,
|
||||
@@ -471,6 +476,67 @@ pub struct AppState {
|
||||
pub mq_registry: crate::mq::MqAdminRegistry,
|
||||
}
|
||||
|
||||
/// Fixed local port requested by the last transport layer, only for the
|
||||
/// connection's own primary tunnel. Test, probe, and plugin-action endpoints
|
||||
/// resolve under another id and keep a random port, so they never collide with
|
||||
/// the live tunnel. One-time drafts do too, and Redis Cluster and Sentinel
|
||||
/// route their primary path per node instead of through one static tunnel.
|
||||
fn primary_tunnel_fixed_local_port(
|
||||
honor_fixed_ssh_local_ports: bool,
|
||||
connection_id: &str,
|
||||
config: &ConnectionConfig,
|
||||
transport_layers: &[TransportLayerConfig],
|
||||
) -> Option<u16> {
|
||||
if !honor_fixed_ssh_local_ports
|
||||
|| connection_id != config.id
|
||||
|| config.one_time
|
||||
|| config.uses_redis_cluster()
|
||||
|| config.uses_redis_sentinel()
|
||||
|| runtime_config::is_transient_runtime_config_id(connection_id)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
last_ssh_layer_local_port(transport_layers)
|
||||
}
|
||||
|
||||
fn last_ssh_layer_local_port(transport_layers: &[TransportLayerConfig]) -> Option<u16> {
|
||||
match transport_layers.last() {
|
||||
Some(TransportLayerConfig::Ssh(ssh)) if ssh.profile_id.is_empty() => ssh.local_port.filter(|port| *port != 0),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Kafka with a single `127.0.0.1` bootstrap server binds its SSH tunnel to the
|
||||
/// bootstrap port so the advertised listeners stay reachable, which leaves no
|
||||
/// room for a separate fixed local port.
|
||||
#[cfg(any(feature = "mq-admin", test))]
|
||||
fn kafka_loopback_local_port_error(bootstrap_port: u16, transport_layers: &[TransportLayerConfig]) -> Option<String> {
|
||||
last_ssh_layer_local_port(transport_layers).filter(|port| *port != bootstrap_port).map(|_| {
|
||||
format!(
|
||||
"Kafka with a single 127.0.0.1 bootstrap server listens on bootstrap port {bootstrap_port}; leave Local Port empty or set it to {bootstrap_port}."
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// Bootstrap port of a Kafka connection whose single `127.0.0.1` bootstrap
|
||||
/// server is reached through a final SSH layer.
|
||||
#[cfg(feature = "mq-admin")]
|
||||
fn kafka_ssh_loopback_bootstrap_port(
|
||||
config: &ConnectionConfig,
|
||||
transport_layers: &[TransportLayerConfig],
|
||||
) -> Result<Option<u16>, String> {
|
||||
if config.db_type != DatabaseType::MessageQueue
|
||||
|| !matches!(transport_layers.last(), Some(TransportLayerConfig::Ssh(_)))
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
let mqc = crate::mq::config::MqAdminConfig::from_connection(config)?;
|
||||
if mqc.system_kind != crate::mq::types::MqSystemKind::Kafka {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(kafka_single_loopback_bootstrap_endpoint(&mqc.extra).map(|(_, port)| port))
|
||||
}
|
||||
|
||||
fn transport_layers_through_last_ssh(layers: &[TransportLayerConfig]) -> Result<&[TransportLayerConfig], String> {
|
||||
let Some(last_ssh_index) = layers.iter().rposition(|layer| matches!(layer, TransportLayerConfig::Ssh(_))) else {
|
||||
return Err("Connection has no enabled SSH tunnel layer".to_string());
|
||||
@@ -1800,6 +1866,7 @@ impl AppState {
|
||||
nacos_registry: crate::nacos::NacosAdminRegistry::new(),
|
||||
duckdb_worker_process_isolation: AtomicBool::new(false),
|
||||
duckdb_worker_max_processes: AtomicUsize::new(DUCKDB_WORKER_MAX_PROCESSES_DEFAULT),
|
||||
honor_fixed_ssh_local_ports: AtomicBool::new(false),
|
||||
postgres_cancel_contexts: Arc::new(RwLock::new(HashMap::new())),
|
||||
mysql_preserved_transactions: Arc::new(RwLock::new(HashSet::new())),
|
||||
transaction_sessions: Arc::new(RwLock::new(HashMap::new())),
|
||||
@@ -1907,6 +1974,10 @@ impl AppState {
|
||||
self.duckdb_worker_process_isolation.store(enabled, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub fn set_honor_fixed_ssh_local_ports(&self, enabled: bool) {
|
||||
self.honor_fixed_ssh_local_ports.store(enabled, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub fn set_duckdb_worker_max_processes(&self, max_processes: usize) {
|
||||
self.duckdb_worker_max_processes.store(normalize_duckdb_worker_max_processes(max_processes), Ordering::Relaxed);
|
||||
}
|
||||
@@ -3891,11 +3962,13 @@ impl AppState {
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
let local_port = db::transport_layer_tunnel::start_transport_layers(
|
||||
let fixed_local_port = self.static_tunnel_fixed_local_port(connection_id, config, &transport_layers)?;
|
||||
let local_port = db::transport_layer_tunnel::start_transport_layers_with_final_ssh_local_port(
|
||||
connection_id,
|
||||
&transport_layers,
|
||||
&remote_host,
|
||||
remote_port,
|
||||
fixed_local_port,
|
||||
&self.tunnels,
|
||||
&self.proxy_tunnels,
|
||||
&self.http_tunnels,
|
||||
@@ -3905,6 +3978,26 @@ impl AppState {
|
||||
Ok(ConnectionEndpoint { host: "127.0.0.1".to_string(), port: local_port, proxy: None })
|
||||
}
|
||||
|
||||
fn static_tunnel_fixed_local_port(
|
||||
&self,
|
||||
connection_id: &str,
|
||||
config: &ConnectionConfig,
|
||||
transport_layers: &[TransportLayerConfig],
|
||||
) -> Result<Option<u16>, String> {
|
||||
// `mq_admin_config_for_connection` binds the bootstrap port itself for this Kafka
|
||||
// layout and would tear down a tunnel opened here on the user's port first.
|
||||
#[cfg(feature = "mq-admin")]
|
||||
if let Some(bootstrap_port) = kafka_ssh_loopback_bootstrap_port(config, transport_layers)? {
|
||||
return kafka_loopback_local_port_error(bootstrap_port, transport_layers).map_or(Ok(None), Err);
|
||||
}
|
||||
Ok(primary_tunnel_fixed_local_port(
|
||||
self.honor_fixed_ssh_local_ports.load(Ordering::Relaxed),
|
||||
connection_id,
|
||||
config,
|
||||
transport_layers,
|
||||
))
|
||||
}
|
||||
|
||||
async fn clickhouse_proxy_for_transport_layers(
|
||||
&self,
|
||||
connection_id: &str,
|
||||
@@ -4309,11 +4402,17 @@ impl AppState {
|
||||
let transport_layers = self.resolved_transport_layers(config).await?;
|
||||
let (amqp_host, amqp_port) = crate::mq::adapters::rabbitmq::primary_amqp_endpoint(&mqc)?;
|
||||
let management_endpoint = crate::mq::adapters::rabbitmq::management_endpoint(&mqc)?;
|
||||
let amqp_local_port = db::transport_layer_tunnel::start_transport_layers(
|
||||
let amqp_local_port = db::transport_layer_tunnel::start_transport_layers_with_final_ssh_local_port(
|
||||
connection_id,
|
||||
&transport_layers,
|
||||
&amqp_host,
|
||||
amqp_port,
|
||||
primary_tunnel_fixed_local_port(
|
||||
self.honor_fixed_ssh_local_ports.load(Ordering::Relaxed),
|
||||
connection_id,
|
||||
config,
|
||||
&transport_layers,
|
||||
),
|
||||
&self.tunnels,
|
||||
&self.proxy_tunnels,
|
||||
&self.http_tunnels,
|
||||
@@ -4363,6 +4462,9 @@ impl AppState {
|
||||
if let Some((bootstrap_host, bootstrap_port)) = kafka_single_loopback_bootstrap_endpoint(&mqc.extra) {
|
||||
let transport_layers = self.resolved_transport_layers(config).await?;
|
||||
if matches!(transport_layers.last(), Some(TransportLayerConfig::Ssh(_))) {
|
||||
if let Some(error) = kafka_loopback_local_port_error(bootstrap_port, &transport_layers) {
|
||||
return Err(error);
|
||||
}
|
||||
let local_port = db::transport_layer_tunnel::start_transport_layers_with_final_ssh_local_port(
|
||||
connection_id,
|
||||
&transport_layers,
|
||||
@@ -7741,14 +7843,15 @@ mod tests {
|
||||
connection_configs_session_credentials_compatible, connection_probe_endpoints, connection_remote_endpoint,
|
||||
connection_url_for_endpoint, database_connection_config, database_connection_config_with_catalog,
|
||||
gaussdb_identifier_quote_from_query_result, gaussdb_m_jdbc_config_for_endpoint, gaussdb_uses_m_jdbc_driver,
|
||||
kafka_single_loopback_bootstrap_endpoint, keepalive_failure_proves_pool_dead, metadata_connection_config,
|
||||
metadata_pool_database, mysql_metadata_fallback_url, mysql_pool_setup_queries, oceanbase_mysql_setup_queries,
|
||||
prestosql_jdbc_config_for_endpoint, redact_transport_url, redacted_connection_url_for_endpoint,
|
||||
redis_sentinel_transport_id, redis_sentinel_transport_prefix, report_connection_liveness_lost,
|
||||
sqlserver_legacy_agent_config, sqlserver_legacy_driver_error, sqlserver_uses_legacy_driver,
|
||||
task_client_session_id, transport_layers_through_last_ssh, upsert_connection_url_param, uses_bare_mysql_pool,
|
||||
uses_tcp_probe, validate_connection_url_params, validate_h2_database_path, AppState, MysqlMode, PoolKind,
|
||||
TxnConnection, GAUSSDB_M_JDBC_DRIVER_CLASS, GAUSSDB_M_JDBC_DRIVER_PROFILE, PRESTOSQL_JDBC_DRIVER_CLASS,
|
||||
kafka_loopback_local_port_error, kafka_single_loopback_bootstrap_endpoint, keepalive_failure_proves_pool_dead,
|
||||
metadata_connection_config, metadata_pool_database, mysql_metadata_fallback_url, mysql_pool_setup_queries,
|
||||
oceanbase_mysql_setup_queries, prestosql_jdbc_config_for_endpoint, primary_tunnel_fixed_local_port,
|
||||
redact_transport_url, redacted_connection_url_for_endpoint, redis_sentinel_transport_id,
|
||||
redis_sentinel_transport_prefix, report_connection_liveness_lost, sqlserver_legacy_agent_config,
|
||||
sqlserver_legacy_driver_error, sqlserver_uses_legacy_driver, task_client_session_id,
|
||||
transport_layers_through_last_ssh, upsert_connection_url_param, uses_bare_mysql_pool, uses_tcp_probe,
|
||||
validate_connection_url_params, validate_h2_database_path, AppState, MysqlMode, PoolKind, TxnConnection,
|
||||
GAUSSDB_M_JDBC_DRIVER_CLASS, GAUSSDB_M_JDBC_DRIVER_PROFILE, PRESTOSQL_JDBC_DRIVER_CLASS,
|
||||
};
|
||||
use crate::agent_connection::{
|
||||
agent_connect_params, mongo_legacy_error_with_auth_hint, mongo_uses_legacy_driver,
|
||||
@@ -9070,6 +9173,191 @@ mod tests {
|
||||
assert_eq!(transport_layers_through_last_ssh(&layers).unwrap(), &[before, ssh]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn primary_tunnel_fixed_local_port_applies_only_to_the_primary_ssh_tunnel() {
|
||||
let ssh = |port: Option<u16>, profile_id: &str| {
|
||||
let mut layer = ssh_layer("ssh", profile_id);
|
||||
layer.local_port = port;
|
||||
TransportLayerConfig::Ssh(layer)
|
||||
};
|
||||
let proxy = || TransportLayerConfig::Proxy(proxy_layer("proxy", ""));
|
||||
let config = |id: &str, one_time: bool, redis_mode: Option<&str>| {
|
||||
let mut config = mysql_config(None);
|
||||
config.id = id.to_string();
|
||||
config.one_time = one_time;
|
||||
if let Some(mode) = redis_mode {
|
||||
config.db_type = DatabaseType::Redis;
|
||||
config.redis_connection_mode = Some(mode.to_string());
|
||||
}
|
||||
config
|
||||
};
|
||||
|
||||
let cases = [
|
||||
(
|
||||
"last ssh layer with a port",
|
||||
true,
|
||||
"conn",
|
||||
config("conn", false, None),
|
||||
vec![ssh(Some(4059), "")],
|
||||
Some(4059),
|
||||
),
|
||||
(
|
||||
"ssh after a proxy layer",
|
||||
true,
|
||||
"conn",
|
||||
config("conn", false, None),
|
||||
vec![proxy(), ssh(Some(4059), "")],
|
||||
Some(4059),
|
||||
),
|
||||
("ssh without a port", true, "conn", config("conn", false, None), vec![ssh(None, "")], None),
|
||||
("port zero", true, "conn", config("conn", false, None), vec![ssh(Some(0), "")], None),
|
||||
(
|
||||
"proxy is the last layer",
|
||||
true,
|
||||
"conn",
|
||||
config("conn", false, None),
|
||||
vec![ssh(Some(4059), ""), proxy()],
|
||||
None,
|
||||
),
|
||||
(
|
||||
"ssh is not the last ssh layer",
|
||||
true,
|
||||
"conn",
|
||||
config("conn", false, None),
|
||||
vec![ssh(Some(4059), ""), ssh(None, "")],
|
||||
None,
|
||||
),
|
||||
(
|
||||
"layer references a tunnel profile",
|
||||
true,
|
||||
"conn",
|
||||
config("conn", false, None),
|
||||
vec![ssh(Some(4059), "shared")],
|
||||
None,
|
||||
),
|
||||
("no transport layers", true, "conn", config("conn", false, None), vec![], None),
|
||||
("flag off", false, "conn", config("conn", false, None), vec![ssh(Some(4059), "")], None),
|
||||
("test connection id", true, "conn:test", config("conn", false, None), vec![ssh(Some(4059), "")], None),
|
||||
(
|
||||
"plugin action id",
|
||||
true,
|
||||
"conn:plugin-action:refresh",
|
||||
config("conn", false, None),
|
||||
vec![ssh(Some(4059), "")],
|
||||
None,
|
||||
),
|
||||
(
|
||||
"ssh test id",
|
||||
true,
|
||||
"__connection_ssh_test__:1",
|
||||
config("conn", false, None),
|
||||
vec![ssh(Some(4059), "")],
|
||||
None,
|
||||
),
|
||||
(
|
||||
"temporary test probe",
|
||||
true,
|
||||
"__test_1",
|
||||
config("__test_1", false, None),
|
||||
vec![ssh(Some(4059), "")],
|
||||
None,
|
||||
),
|
||||
(
|
||||
"visible database draft",
|
||||
true,
|
||||
"__visible_draft_1",
|
||||
config("__visible_draft_1", false, None),
|
||||
vec![ssh(Some(4059), "")],
|
||||
None,
|
||||
),
|
||||
(
|
||||
"visible schema draft",
|
||||
true,
|
||||
"__visible_schema_draft_1",
|
||||
config("__visible_schema_draft_1", false, None),
|
||||
vec![ssh(Some(4059), "")],
|
||||
None,
|
||||
),
|
||||
("one-time draft", true, "conn", config("conn", true, None), vec![ssh(Some(4059), "")], None),
|
||||
(
|
||||
"production database picker draft",
|
||||
true,
|
||||
"__production_database_draft_1",
|
||||
config("__production_database_draft_1", true, None),
|
||||
vec![ssh(Some(4059), "")],
|
||||
None,
|
||||
),
|
||||
("redis cluster", true, "conn", config("conn", false, Some("cluster")), vec![ssh(Some(4059), "")], None),
|
||||
("redis sentinel", true, "conn", config("conn", false, Some("sentinel")), vec![ssh(Some(4059), "")], None),
|
||||
(
|
||||
"redis standalone",
|
||||
true,
|
||||
"conn",
|
||||
config("conn", false, Some("standalone")),
|
||||
vec![ssh(Some(4059), "")],
|
||||
Some(4059),
|
||||
),
|
||||
];
|
||||
|
||||
for (label, honor, connection_id, config, layers, expected) in cases {
|
||||
assert_eq!(primary_tunnel_fixed_local_port(honor, connection_id, &config, &layers), expected, "{label}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kafka_loopback_bootstrap_rejects_a_different_fixed_local_port() {
|
||||
let ssh = |port: Option<u16>, profile_id: &str| {
|
||||
let mut layer = ssh_layer("ssh", profile_id);
|
||||
layer.local_port = port;
|
||||
TransportLayerConfig::Ssh(layer)
|
||||
};
|
||||
let proxy = || TransportLayerConfig::Proxy(proxy_layer("proxy", ""));
|
||||
let rejected = "Kafka with a single 127.0.0.1 bootstrap server listens on bootstrap port 9093; leave Local Port empty or set it to 9093.";
|
||||
|
||||
let cases = [
|
||||
("no local port", vec![ssh(None, "")], None),
|
||||
("local port equals the bootstrap port", vec![ssh(Some(9093), "")], None),
|
||||
("local port zero", vec![ssh(Some(0), "")], None),
|
||||
("local port differs", vec![ssh(Some(4059), "")], Some(rejected)),
|
||||
("local port differs behind a proxy", vec![proxy(), ssh(Some(4059), "")], Some(rejected)),
|
||||
("tunnel profile owns its own port", vec![ssh(Some(4059), "shared")], None),
|
||||
];
|
||||
|
||||
for (label, layers, expected) in cases {
|
||||
assert_eq!(kafka_loopback_local_port_error(9093, &layers).as_deref(), expected, "{label}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn connection_endpoint_binds_the_fixed_ssh_local_port_only_for_opted_in_primary_tunnels() {
|
||||
let (state, dir) = test_app_state().await;
|
||||
let held = std::net::TcpListener::bind(("127.0.0.1", 0)).unwrap();
|
||||
let fixed_port = held.local_addr().unwrap().port();
|
||||
let mut config = mysql_config(None);
|
||||
config.transport_layers = vec![unreachable_ssh_layer(fixed_port)];
|
||||
let bind_error = format!("Failed to bind requested local port {fixed_port}");
|
||||
|
||||
// A fresh state, like the one every scheduled backup job builds, keeps random ports.
|
||||
let fresh = state.connection_host_port(&config.id, &config).await.unwrap_err();
|
||||
assert!(!fresh.contains(&bind_error), "{fresh}");
|
||||
|
||||
state.set_honor_fixed_ssh_local_ports(true);
|
||||
let primary = state.connection_host_port(&config.id, &config).await.unwrap_err();
|
||||
assert!(primary.contains(&bind_error), "{primary}");
|
||||
|
||||
let probe_id = format!("{}:test", config.id);
|
||||
let probe = state.connection_host_port(&probe_id, &config).await.unwrap_err();
|
||||
assert!(!probe.contains(&bind_error), "{probe}");
|
||||
|
||||
let mut draft = config.clone();
|
||||
draft.id = "__production_database_draft_1".to_string();
|
||||
draft.one_time = true;
|
||||
let draft_error = state.connection_host_port(&draft.id, &draft).await.unwrap_err();
|
||||
assert!(!draft_error.contains(&bind_error), "{draft_error}");
|
||||
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ssh_tunnel_test_rejects_chain_without_ssh() {
|
||||
let layers = vec![TransportLayerConfig::Proxy(ProxyTunnelConfig {
|
||||
@@ -9126,6 +9414,7 @@ mod tests {
|
||||
key_passphrase: String::new(),
|
||||
connect_timeout_secs: 5,
|
||||
expose_lan: false,
|
||||
local_port: None,
|
||||
use_ssh_agent: false,
|
||||
ssh_agent_sock_path: String::new(),
|
||||
ssh_agent_key_fingerprint: String::new(),
|
||||
@@ -12039,6 +12328,7 @@ for line in sys.stdin:
|
||||
key_passphrase: String::new(),
|
||||
connect_timeout_secs: 5,
|
||||
expose_lan: false,
|
||||
local_port: None,
|
||||
use_ssh_agent: false,
|
||||
ssh_agent_sock_path: String::new(),
|
||||
ssh_agent_key_fingerprint: String::new(),
|
||||
@@ -12049,6 +12339,20 @@ for line in sys.stdin:
|
||||
}
|
||||
}
|
||||
|
||||
/// SSH layer that asks for `local_port` but points at a closed SSH port, so a
|
||||
/// connect attempt fails right after (or instead of) binding the local port.
|
||||
fn unreachable_ssh_layer(local_port: u16) -> TransportLayerConfig {
|
||||
let closed_port = std::net::TcpListener::bind(("127.0.0.1", 0)).unwrap().local_addr().unwrap().port();
|
||||
let mut ssh = ssh_layer("ssh-1", "");
|
||||
ssh.host = "127.0.0.1".to_string();
|
||||
ssh.port = closed_port;
|
||||
ssh.user = "user".to_string();
|
||||
ssh.auth_method = "none".to_string();
|
||||
ssh.connect_timeout_secs = 2;
|
||||
ssh.local_port = Some(local_port);
|
||||
TransportLayerConfig::Ssh(ssh)
|
||||
}
|
||||
|
||||
fn proxy_layer(id: &str, profile_id: &str) -> ProxyTunnelConfig {
|
||||
ProxyTunnelConfig {
|
||||
id: id.to_string(),
|
||||
@@ -12622,6 +12926,79 @@ for line in sys.stdin:
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[cfg(feature = "mq-admin")]
|
||||
#[tokio::test]
|
||||
async fn rabbitmq_amqp_tunnel_binds_the_fixed_ssh_local_port_for_the_primary_connection_only() {
|
||||
let (state, dir) = test_app_state().await;
|
||||
state.set_honor_fixed_ssh_local_ports(true);
|
||||
let held = std::net::TcpListener::bind(("127.0.0.1", 0)).unwrap();
|
||||
let fixed_port = held.local_addr().unwrap().port();
|
||||
let mut config = mysql_config(None);
|
||||
config.id = "fixed-rabbitmq".to_string();
|
||||
config.db_type = DatabaseType::MessageQueue;
|
||||
config.host = "rabbit.internal".to_string();
|
||||
config.port = 5672;
|
||||
config.external_config = Some(serde_json::json!({
|
||||
"systemKind": "rabbitmq",
|
||||
"adminUrl": "http://management.internal:15672/rmq",
|
||||
"auth": { "kind": "none" },
|
||||
"extra": { "addresses": "rabbit.internal:5672", "virtualHost": "/" }
|
||||
}));
|
||||
config.transport_layers = vec![unreachable_ssh_layer(fixed_port)];
|
||||
let bind_error = format!("Failed to bind requested local port {fixed_port}");
|
||||
|
||||
let primary = state.mq_admin_config_for_connection(&config.id, &config).await.unwrap_err();
|
||||
assert!(primary.contains(&bind_error), "{primary}");
|
||||
|
||||
let probe = state.mq_admin_config_for_connection("fixed-rabbitmq:test", &config).await.unwrap_err();
|
||||
assert!(!probe.contains(&bind_error), "{probe}");
|
||||
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[cfg(feature = "mq-admin")]
|
||||
#[tokio::test]
|
||||
async fn kafka_loopback_bootstrap_over_ssh_keeps_the_bootstrap_port_instead_of_the_fixed_local_port() {
|
||||
let (state, dir) = test_app_state().await;
|
||||
state.set_honor_fixed_ssh_local_ports(true);
|
||||
let bootstrap = std::net::TcpListener::bind(("127.0.0.1", 0)).unwrap();
|
||||
let bootstrap_port = bootstrap.local_addr().unwrap().port();
|
||||
let other = std::net::TcpListener::bind(("127.0.0.1", 0)).unwrap();
|
||||
let other_port = other.local_addr().unwrap().port();
|
||||
let rejected = format!(
|
||||
"Kafka with a single 127.0.0.1 bootstrap server listens on bootstrap port {bootstrap_port}; leave Local Port empty or set it to {bootstrap_port}."
|
||||
);
|
||||
|
||||
for (label, local_port, expect_rejected) in
|
||||
[("same as the bootstrap port", bootstrap_port, false), ("different from it", other_port, true)]
|
||||
{
|
||||
let mut config = mysql_config(None);
|
||||
config.id = "fixed-kafka".to_string();
|
||||
config.db_type = DatabaseType::MessageQueue;
|
||||
config.host = String::new();
|
||||
config.port = 0;
|
||||
config.external_config = Some(serde_json::json!({
|
||||
"systemKind": "kafka",
|
||||
"adminUrl": "",
|
||||
"auth": { "kind": "none" },
|
||||
"extra": { "bootstrapServers": format!("127.0.0.1:{bootstrap_port}") }
|
||||
}));
|
||||
config.transport_layers = vec![unreachable_ssh_layer(local_port)];
|
||||
let bind_error = format!("Failed to bind requested local port {local_port}");
|
||||
|
||||
let endpoint = state.connection_host_port(&config.id, &config).await.unwrap_err();
|
||||
assert_eq!(endpoint == rejected, expect_rejected, "{label}: {endpoint}");
|
||||
assert!(!endpoint.contains(&bind_error), "{label}: {endpoint}");
|
||||
|
||||
if expect_rejected {
|
||||
let admin = state.mq_admin_config_for_connection(&config.id, &config).await.unwrap_err();
|
||||
assert_eq!(admin, rejected, "{label}");
|
||||
}
|
||||
}
|
||||
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn nacos_admin_config_allows_domain_server_addr_without_transport_override() {
|
||||
let (state, dir) = test_app_state().await;
|
||||
|
||||
@@ -4140,6 +4140,7 @@ mod tests {
|
||||
key_passphrase: "hop-passphrase".to_string(),
|
||||
connect_timeout_secs: 5,
|
||||
expose_lan: false,
|
||||
local_port: None,
|
||||
use_ssh_agent: false,
|
||||
ssh_agent_sock_path: String::new(),
|
||||
ssh_agent_key_fingerprint: String::new(),
|
||||
@@ -5828,6 +5829,7 @@ mod tests {
|
||||
key_passphrase: String::new(),
|
||||
connect_timeout_secs: 5,
|
||||
expose_lan: false,
|
||||
local_port: None,
|
||||
use_ssh_agent: false,
|
||||
ssh_agent_sock_path: String::new(),
|
||||
ssh_agent_key_fingerprint: String::new(),
|
||||
|
||||
@@ -7110,6 +7110,11 @@ impl Storage {
|
||||
let mut copy: ConnectionConfig = serde_json::from_str(&source_json).map_err(|error| error.to_string())?;
|
||||
copy.id = copy_id.clone();
|
||||
copy.name = copy_name;
|
||||
for layer in &mut copy.transport_layers {
|
||||
if let TransportLayerConfig::Ssh(ssh) = layer {
|
||||
ssh.local_port = None;
|
||||
}
|
||||
}
|
||||
let copy_json = serde_json::to_string(©).map_err(|error| error.to_string())?;
|
||||
tx.execute("INSERT INTO connections (id, config_json) VALUES (?1, ?2)", params![copy_id, copy_json])
|
||||
.map_err(|error| error.to_string())?;
|
||||
@@ -11733,6 +11738,7 @@ mod tests {
|
||||
key_passphrase: String::new(),
|
||||
connect_timeout_secs: 5,
|
||||
expose_lan: false,
|
||||
local_port: None,
|
||||
use_ssh_agent: false,
|
||||
ssh_agent_sock_path: String::new(),
|
||||
ssh_agent_key_fingerprint: String::new(),
|
||||
|
||||
@@ -180,6 +180,7 @@ fn jump_hop_from_entry(leaf: &SshTunnelConfig, entry: SshConfigHostEntry) -> Ssh
|
||||
key_passphrase: leaf.key_passphrase.clone(),
|
||||
connect_timeout_secs: leaf.connect_timeout_secs,
|
||||
expose_lan: false,
|
||||
local_port: None,
|
||||
use_ssh_agent: leaf.use_ssh_agent,
|
||||
ssh_agent_sock_path: leaf.ssh_agent_sock_path.clone(),
|
||||
ssh_agent_key_fingerprint,
|
||||
@@ -579,6 +580,7 @@ mod tests {
|
||||
key_passphrase: String::new(),
|
||||
connect_timeout_secs: 5,
|
||||
expose_lan: false,
|
||||
local_port: None,
|
||||
use_ssh_agent: false,
|
||||
ssh_agent_sock_path: String::new(),
|
||||
ssh_agent_key_fingerprint: String::new(),
|
||||
|
||||
@@ -1788,31 +1788,17 @@ impl TunnelManager {
|
||||
requested_local_port: Option<u16>,
|
||||
proxy_command: &str,
|
||||
) -> Result<u16, String> {
|
||||
{
|
||||
let mut tunnels = self.tunnels.lock().await;
|
||||
if let Some(port) = Self::get_active_port(&mut tunnels, connection_id, TunnelKind::Fixed)? {
|
||||
if requested_local_port.is_none_or(|requested| requested == port) {
|
||||
return Ok(port);
|
||||
}
|
||||
}
|
||||
let healthy = Self::healthy_port(&*self.tunnels.lock().await, connection_id, TunnelKind::Fixed);
|
||||
if let Some(port) = healthy.filter(|port| requested_local_port.is_none_or(|requested| requested == *port)) {
|
||||
return Ok(port);
|
||||
}
|
||||
|
||||
let start_lock = self.start_lock(connection_id).await;
|
||||
let _start_guard = start_lock.lock().await;
|
||||
|
||||
// A concurrent caller may have completed while this task waited.
|
||||
{
|
||||
let mut tunnels = self.tunnels.lock().await;
|
||||
if let Some(port) = Self::get_active_port(&mut tunnels, connection_id, TunnelKind::Fixed)? {
|
||||
if requested_local_port.is_none_or(|requested| requested == port) {
|
||||
return Ok(port);
|
||||
}
|
||||
if let Some(entry) = tunnels.remove(connection_id) {
|
||||
for handle in entry.handles {
|
||||
handle.abort();
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(port) = self.active_port(connection_id, TunnelKind::Fixed, requested_local_port).await? {
|
||||
return Ok(port);
|
||||
}
|
||||
let (handle, local_port, status) = spawn_tunnel(
|
||||
connect_host,
|
||||
@@ -1865,25 +1851,15 @@ impl TunnelManager {
|
||||
allow_exec_channel_proxy: bool,
|
||||
proxy_command: &str,
|
||||
) -> Result<u16, String> {
|
||||
{
|
||||
let mut tunnels = self.tunnels.lock().await;
|
||||
if let Some(port) = Self::get_active_port(&mut tunnels, connection_id, TunnelKind::Socks5)? {
|
||||
return Ok(port);
|
||||
}
|
||||
let healthy = Self::healthy_port(&*self.tunnels.lock().await, connection_id, TunnelKind::Socks5);
|
||||
if let Some(port) = healthy {
|
||||
return Ok(port);
|
||||
}
|
||||
|
||||
let start_lock = self.start_lock(connection_id).await;
|
||||
let _start_guard = start_lock.lock().await;
|
||||
{
|
||||
let mut tunnels = self.tunnels.lock().await;
|
||||
if let Some(port) = Self::get_active_port(&mut tunnels, connection_id, TunnelKind::Socks5)? {
|
||||
return Ok(port);
|
||||
}
|
||||
if let Some(entry) = tunnels.remove(connection_id) {
|
||||
for handle in entry.handles {
|
||||
handle.abort();
|
||||
}
|
||||
}
|
||||
if let Some(port) = self.active_port(connection_id, TunnelKind::Socks5, None).await? {
|
||||
return Ok(port);
|
||||
}
|
||||
|
||||
let (handle, local_port, status) = spawn_socks5_proxy(
|
||||
@@ -1928,35 +1904,75 @@ impl TunnelManager {
|
||||
/// instead of replaying the recorded failure until the retry budget runs
|
||||
/// out.
|
||||
/// * `Ok(Some(port))` — the tunnel is healthy.
|
||||
///
|
||||
/// The handles of an evicted entry are pushed to `evicted` instead of only
|
||||
/// being aborted: the caller must `abort_and_join` them once it has released
|
||||
/// the tunnels lock, because a task keeps its listener bound until it is
|
||||
/// actually dropped.
|
||||
fn get_active_port(
|
||||
tunnels: &mut HashMap<String, TunnelEntry>,
|
||||
connection_id: &str,
|
||||
expected_kind: TunnelKind,
|
||||
evicted: &mut Vec<JoinHandle<()>>,
|
||||
) -> Result<Option<u16>, String> {
|
||||
let Some(entry) = tunnels.get(connection_id) else {
|
||||
return Ok(None);
|
||||
};
|
||||
if entry.kind != expected_kind || entry.handles.iter().all(|h| h.is_finished()) {
|
||||
if let Some(entry) = tunnels.remove(connection_id) {
|
||||
for handle in entry.handles {
|
||||
handle.abort();
|
||||
}
|
||||
}
|
||||
evicted.extend(tunnels.remove(connection_id).into_iter().flat_map(|entry| entry.handles));
|
||||
return Ok(None);
|
||||
}
|
||||
let local_port = entry.local_port;
|
||||
let failure = entry.statuses.iter().find_map(|status| status.failure());
|
||||
if let Some(failure) = failure {
|
||||
if let Some(entry) = tunnels.remove(connection_id) {
|
||||
for handle in entry.handles {
|
||||
handle.abort();
|
||||
}
|
||||
}
|
||||
evicted.extend(tunnels.remove(connection_id).into_iter().flat_map(|entry| entry.handles));
|
||||
return Err(failure);
|
||||
}
|
||||
Ok(Some(local_port))
|
||||
}
|
||||
|
||||
/// Port of a cached tunnel that is usable right now. Unlike
|
||||
/// [`Self::get_active_port`] it never evicts, so the lock-free fast path
|
||||
/// leaves a stale or failed entry to the caller that holds the start lock.
|
||||
/// Evicting there would let a concurrent starter rebind the same fixed port
|
||||
/// while the evicted task still owns its listener.
|
||||
fn healthy_port(
|
||||
tunnels: &HashMap<String, TunnelEntry>,
|
||||
connection_id: &str,
|
||||
expected_kind: TunnelKind,
|
||||
) -> Option<u16> {
|
||||
let entry = tunnels.get(connection_id)?;
|
||||
let usable = entry.kind == expected_kind
|
||||
&& !entry.handles.iter().all(|h| h.is_finished())
|
||||
&& entry.statuses.iter().all(|status| status.failure().is_none());
|
||||
usable.then_some(entry.local_port)
|
||||
}
|
||||
|
||||
/// [`Self::get_active_port`] for a caller that holds the start lock: evicted
|
||||
/// tunnels are joined before returning, so a following bind of the same
|
||||
/// local port cannot hit `EADDRINUSE`. A healthy tunnel on another port than
|
||||
/// `required_port` is evicted too.
|
||||
async fn active_port(
|
||||
&self,
|
||||
connection_id: &str,
|
||||
expected_kind: TunnelKind,
|
||||
required_port: Option<u16>,
|
||||
) -> Result<Option<u16>, String> {
|
||||
let mut evicted = Vec::new();
|
||||
let active = {
|
||||
let mut tunnels = self.tunnels.lock().await;
|
||||
match Self::get_active_port(&mut tunnels, connection_id, expected_kind, &mut evicted) {
|
||||
Ok(Some(port)) if required_port.is_some_and(|required| required != port) => {
|
||||
evicted.extend(tunnels.remove(connection_id).into_iter().flat_map(|entry| entry.handles));
|
||||
Ok(None)
|
||||
}
|
||||
active => active,
|
||||
}
|
||||
};
|
||||
abort_and_join(evicted).await;
|
||||
active
|
||||
}
|
||||
|
||||
pub async fn start_chain(
|
||||
&self,
|
||||
connection_id: &str,
|
||||
@@ -1967,20 +1983,15 @@ impl TunnelManager {
|
||||
if hops.is_empty() {
|
||||
return Err("No SSH tunnel hops configured".to_string());
|
||||
}
|
||||
{
|
||||
let mut tunnels = self.tunnels.lock().await;
|
||||
if let Some(port) = Self::get_active_port(&mut tunnels, connection_id, TunnelKind::Fixed)? {
|
||||
return Ok(port);
|
||||
}
|
||||
let healthy = Self::healthy_port(&*self.tunnels.lock().await, connection_id, TunnelKind::Fixed);
|
||||
if let Some(port) = healthy {
|
||||
return Ok(port);
|
||||
}
|
||||
|
||||
let start_lock = self.start_lock(connection_id).await;
|
||||
let _start_guard = start_lock.lock().await;
|
||||
{
|
||||
let mut tunnels = self.tunnels.lock().await;
|
||||
if let Some(port) = Self::get_active_port(&mut tunnels, connection_id, TunnelKind::Fixed)? {
|
||||
return Ok(port);
|
||||
}
|
||||
if let Some(port) = self.active_port(connection_id, TunnelKind::Fixed, None).await? {
|
||||
return Ok(port);
|
||||
}
|
||||
let mut handles = Vec::new();
|
||||
let mut statuses = Vec::new();
|
||||
@@ -2044,29 +2055,30 @@ impl TunnelManager {
|
||||
/// for this connection" can't be handed a port with no live SSH session
|
||||
/// behind it — the same bug this fix closes for `start_tunnel` et al.
|
||||
pub async fn local_port(&self, connection_id: &str) -> Option<u16> {
|
||||
let mut tunnels = self.tunnels.lock().await;
|
||||
let entry = tunnels.get(connection_id)?;
|
||||
let is_dead = entry.handles.iter().all(|h| h.is_finished())
|
||||
|| entry.statuses.iter().any(|status| status.failure().is_some());
|
||||
if is_dead {
|
||||
if let Some(entry) = tunnels.remove(connection_id) {
|
||||
for handle in entry.handles {
|
||||
handle.abort();
|
||||
}
|
||||
let (local_port, evicted) = {
|
||||
let mut tunnels = self.tunnels.lock().await;
|
||||
let entry = tunnels.get(connection_id)?;
|
||||
let is_dead = entry.handles.iter().all(|h| h.is_finished())
|
||||
|| entry.statuses.iter().any(|status| status.failure().is_some());
|
||||
if is_dead {
|
||||
(None, tunnels.remove(connection_id))
|
||||
} else {
|
||||
(Some(entry.local_port), None)
|
||||
}
|
||||
return None;
|
||||
};
|
||||
if let Some(entry) = evicted {
|
||||
abort_and_join(entry.handles).await;
|
||||
}
|
||||
Some(entry.local_port)
|
||||
local_port
|
||||
}
|
||||
|
||||
pub async fn stop_tunnel(&self, connection_id: &str) {
|
||||
let start_lock = self.start_lock(connection_id).await;
|
||||
let _start_guard = start_lock.lock().await;
|
||||
|
||||
if let Some(entry) = self.tunnels.lock().await.remove(connection_id) {
|
||||
for handle in entry.handles {
|
||||
handle.abort();
|
||||
}
|
||||
let removed = self.tunnels.lock().await.remove(connection_id);
|
||||
if let Some(entry) = removed {
|
||||
abort_and_join(entry.handles).await;
|
||||
}
|
||||
|
||||
let mut start_locks = self.start_locks.lock().await;
|
||||
@@ -2087,15 +2099,23 @@ impl TunnelManager {
|
||||
|
||||
pub async fn stop_all_tunnels(&self) {
|
||||
let tunnels = std::mem::take(&mut *self.tunnels.lock().await);
|
||||
for entry in tunnels.into_values() {
|
||||
for handle in entry.handles {
|
||||
handle.abort();
|
||||
}
|
||||
}
|
||||
abort_and_join(tunnels.into_values().flat_map(|entry| entry.handles).collect()).await;
|
||||
self.start_locks.lock().await.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// Aborts tunnel tasks and waits until they are dropped. A task owns its
|
||||
/// `TcpListener`, so returning before that happens lets a caller that rebinds
|
||||
/// the same fixed local port right away hit `EADDRINUSE`.
|
||||
async fn abort_and_join(handles: Vec<JoinHandle<()>>) {
|
||||
for handle in &handles {
|
||||
handle.abort();
|
||||
}
|
||||
for handle in handles {
|
||||
let _ = handle.await;
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn spawn_tunnel(
|
||||
connect_host: &str,
|
||||
@@ -2267,16 +2287,50 @@ async fn bind_tunnel_listener(
|
||||
};
|
||||
|
||||
let bind_addr = if expose_to_lan { "0.0.0.0" } else { "127.0.0.1" };
|
||||
let listener = TcpListener::bind((bind_addr, local_port)).await.map_err(|error| {
|
||||
let bind_error = |error: std::io::Error| {
|
||||
if requested_local_port.is_some() {
|
||||
format!("Failed to bind requested local port {local_port}: {error}")
|
||||
} else {
|
||||
format!("Failed to bind local port: {error}")
|
||||
}
|
||||
})?;
|
||||
};
|
||||
#[cfg(any(
|
||||
target_os = "macos",
|
||||
target_os = "ios",
|
||||
target_os = "freebsd",
|
||||
target_os = "netbsd",
|
||||
target_os = "openbsd",
|
||||
target_os = "dragonfly"
|
||||
))]
|
||||
if requested_local_port.is_some() {
|
||||
probe_sibling_bind_address(bind_addr, local_port).map_err(bind_error)?;
|
||||
}
|
||||
let listener = TcpListener::bind((bind_addr, local_port)).await.map_err(bind_error)?;
|
||||
Ok((listener, local_port))
|
||||
}
|
||||
|
||||
/// BSD and macOS let a bind succeed next to a listener on the other of
|
||||
/// `127.0.0.1` / `0.0.0.0` for the same port once SO_REUSEADDR is set, which
|
||||
/// tokio always does. A requested port would then be taken silently and traffic
|
||||
/// for one address would reach the wrong listener. Binding the sibling address
|
||||
/// first makes that conflict fail the way it does on Linux. The probe only
|
||||
/// binds, so it never accepts a connection.
|
||||
#[cfg(any(
|
||||
target_os = "macos",
|
||||
target_os = "ios",
|
||||
target_os = "freebsd",
|
||||
target_os = "netbsd",
|
||||
target_os = "openbsd",
|
||||
target_os = "dragonfly"
|
||||
))]
|
||||
fn probe_sibling_bind_address(bind_addr: &str, port: u16) -> std::io::Result<()> {
|
||||
let sibling: std::net::Ipv4Addr =
|
||||
if bind_addr == "0.0.0.0" { std::net::Ipv4Addr::LOCALHOST } else { std::net::Ipv4Addr::UNSPECIFIED };
|
||||
let socket = tokio::net::TcpSocket::new_v4()?;
|
||||
socket.set_reuseaddr(true)?;
|
||||
socket.bind(std::net::SocketAddr::from((sibling, port)))
|
||||
}
|
||||
|
||||
pub fn effective_hop_timeout(hop: &SshTunnelConfig) -> u64 {
|
||||
if hop.connect_timeout_secs == 0 {
|
||||
crate::models::connection::default_ssh_connect_timeout_secs()
|
||||
@@ -2333,16 +2387,19 @@ mod tests {
|
||||
use super::resolve_ssh_agent_socket_path;
|
||||
use super::SshClient;
|
||||
use super::{
|
||||
agent_attempts_stopped_error, bind_tunnel_listener, classify_agent_attempt, connect_and_authenticate,
|
||||
describe_terminal_auth_failure, effective_hop_timeout, netcat_proxy_command, normalize_ssh_fingerprint,
|
||||
openssh_padding_len, plan_chain, read_ssh_string, sanitize_unencrypted_openssh_comment_bytes,
|
||||
server_offers_keyboard_interactive, server_offers_password, ssh_client_config, tofu_prompt_deadline,
|
||||
AgentAttempt, HostKeyState, HostKeyVerifier, PlannedTunnel, TunnelEntry, TunnelKind, TunnelManager,
|
||||
TunnelStatus, TOFU_PROMPT_TIMEOUT,
|
||||
abort_and_join, agent_attempts_stopped_error, bind_tunnel_listener, classify_agent_attempt,
|
||||
connect_and_authenticate, describe_terminal_auth_failure, effective_hop_timeout, netcat_proxy_command,
|
||||
normalize_ssh_fingerprint, openssh_padding_len, plan_chain, read_ssh_string,
|
||||
sanitize_unencrypted_openssh_comment_bytes, server_offers_keyboard_interactive, server_offers_password,
|
||||
ssh_client_config, tofu_prompt_deadline, AgentAttempt, HostKeyState, HostKeyVerifier, PlannedTunnel,
|
||||
TunnelEntry, TunnelKind, TunnelManager, TunnelStatus, TOFU_PROMPT_TIMEOUT,
|
||||
};
|
||||
use super::{windows_ssh_agent_endpoints, WindowsSshAgentEndpoint, WINDOWS_OPENSSH_AGENT_PIPE};
|
||||
use crate::db::http_tunnel::HttpTunnelManager;
|
||||
use crate::db::proxy_tunnel::ProxyTunnelManager;
|
||||
use crate::db::ssh_prompt;
|
||||
use crate::models::connection::{default_ssh_connect_timeout_secs, SshTunnelConfig};
|
||||
use crate::db::transport_layer_tunnel::{start_transport_layers_with_final_ssh_local_port, stop_transport_layers};
|
||||
use crate::models::connection::{default_ssh_connect_timeout_secs, SshTunnelConfig, TransportLayerConfig};
|
||||
use russh::client;
|
||||
use russh::client::{AuthResult, Handler};
|
||||
use russh::keys::decode_secret_key;
|
||||
@@ -2529,6 +2586,42 @@ mod tests {
|
||||
assert!(error.contains(&format!("requested local port {port}")));
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
#[tokio::test]
|
||||
async fn requested_local_port_conflicts_with_a_listener_on_the_sibling_address() {
|
||||
// (label, address another listener already holds, expose_to_lan, expect a conflict)
|
||||
let cases = [
|
||||
("free port, loopback bind", None, false, false),
|
||||
("free port, LAN bind", None, true, false),
|
||||
("same loopback address", Some("127.0.0.1"), false, true),
|
||||
("wildcard listener under a loopback bind", Some("0.0.0.0"), false, true),
|
||||
("loopback listener under a LAN bind", Some("127.0.0.1"), true, true),
|
||||
];
|
||||
|
||||
for (label, held_address, expose_to_lan, expect_conflict) in cases {
|
||||
let (holder, port) = match held_address {
|
||||
Some(address) => {
|
||||
let holder = std::net::TcpListener::bind((address, 0)).unwrap();
|
||||
let port = holder.local_addr().unwrap().port();
|
||||
(Some(holder), port)
|
||||
}
|
||||
None => (None, free_local_port().await),
|
||||
};
|
||||
|
||||
let bound = bind_tunnel_listener(expose_to_lan, Some(port)).await;
|
||||
|
||||
match (bound, expect_conflict) {
|
||||
(Ok((_listener, local_port)), false) => assert_eq!(local_port, port, "{label}"),
|
||||
(Err(error), true) => {
|
||||
assert!(error.contains(&format!("Failed to bind requested local port {port}")), "{label}: {error}")
|
||||
}
|
||||
(Ok(_), true) => panic!("{label}: the bind should have failed"),
|
||||
(Err(error), false) => panic!("{label}: the bind should have succeeded: {error}"),
|
||||
}
|
||||
drop(holder);
|
||||
}
|
||||
}
|
||||
|
||||
fn hop(id: &str, host: &str, port: u16) -> SshTunnelConfig {
|
||||
SshTunnelConfig {
|
||||
profile_id: String::new(),
|
||||
@@ -2543,6 +2636,7 @@ mod tests {
|
||||
key_passphrase: String::new(),
|
||||
connect_timeout_secs: 5,
|
||||
expose_lan: false,
|
||||
local_port: None,
|
||||
use_ssh_agent: false,
|
||||
ssh_agent_sock_path: String::new(),
|
||||
ssh_agent_key_fingerprint: String::new(),
|
||||
@@ -2774,8 +2868,81 @@ mod tests {
|
||||
TunnelEntry { handles: vec![handle], statuses: vec![status], local_port: 54321, kind: TunnelKind::Fixed },
|
||||
)]);
|
||||
|
||||
assert_eq!(TunnelManager::get_active_port(&mut tunnels, "conn", TunnelKind::Fixed), Ok(Some(54321)));
|
||||
let mut evicted = Vec::new();
|
||||
|
||||
assert_eq!(
|
||||
TunnelManager::get_active_port(&mut tunnels, "conn", TunnelKind::Fixed, &mut evicted),
|
||||
Ok(Some(54321))
|
||||
);
|
||||
assert!(tunnels.contains_key("conn"));
|
||||
assert!(evicted.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn healthy_port_reports_only_usable_entries_and_never_evicts() {
|
||||
let failed = Arc::new(TunnelStatus::new_connected());
|
||||
failed.record_failure("SSH public key authentication failed".to_string());
|
||||
let finished = tokio::spawn(async {});
|
||||
while !finished.is_finished() {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
|
||||
// (label, handle, status, kind, expected port)
|
||||
let cases = [
|
||||
(
|
||||
"healthy",
|
||||
tokio::spawn(std::future::pending::<()>()),
|
||||
Arc::new(TunnelStatus::new_connected()),
|
||||
TunnelKind::Fixed,
|
||||
Some(54321),
|
||||
),
|
||||
("recorded failure", tokio::spawn(std::future::pending::<()>()), failed, TunnelKind::Fixed, None),
|
||||
("every task finished", finished, Arc::new(TunnelStatus::new_connected()), TunnelKind::Fixed, None),
|
||||
(
|
||||
"other kind",
|
||||
tokio::spawn(std::future::pending::<()>()),
|
||||
Arc::new(TunnelStatus::new_connected()),
|
||||
TunnelKind::Socks5,
|
||||
None,
|
||||
),
|
||||
];
|
||||
|
||||
for (label, handle, status, kind, expected) in cases {
|
||||
let mut tunnels = std::collections::HashMap::from([(
|
||||
"conn".to_string(),
|
||||
TunnelEntry { handles: vec![handle], statuses: vec![status], local_port: 54321, kind },
|
||||
)]);
|
||||
|
||||
assert_eq!(TunnelManager::healthy_port(&tunnels, "conn", TunnelKind::Fixed), expected, "{label}");
|
||||
assert!(tunnels.contains_key("conn"), "{label}");
|
||||
for entry in tunnels.drain().map(|(_, entry)| entry) {
|
||||
abort_and_join(entry.handles).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn evicting_a_failed_tunnel_releases_its_local_port_before_the_error_is_returned() {
|
||||
let manager = TunnelManager::new(std::env::temp_dir().to_path_buf());
|
||||
let listener = TcpListener::bind(("127.0.0.1", 0)).await.unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let status = Arc::new(TunnelStatus::new_connected());
|
||||
status.record_failure("SSH tunnel lost its session and could not reconnect".to_string());
|
||||
// The reconnect loop keeps its listener while it backs off, so the task does too.
|
||||
let handle = tokio::spawn(async move {
|
||||
let _listener = listener;
|
||||
std::future::pending::<()>().await;
|
||||
});
|
||||
manager.tunnels.lock().await.insert(
|
||||
"fixed-port".to_string(),
|
||||
TunnelEntry { handles: vec![handle], statuses: vec![status], local_port: port, kind: TunnelKind::Fixed },
|
||||
);
|
||||
|
||||
let error = start_echo_tunnel(&manager, 22, Some(port)).await.unwrap_err();
|
||||
|
||||
assert!(error.contains("could not reconnect"), "{error}");
|
||||
assert!(!manager.tunnels.lock().await.contains_key("fixed-port"));
|
||||
std::net::TcpListener::bind(("127.0.0.1", port)).expect("the evicted tunnel must have released its port");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
@@ -3538,6 +3705,158 @@ uveF/dLmnVN1IriEyEvHAAAACGRieC10ZXN0AQIDBAU=
|
||||
server_task.abort();
|
||||
}
|
||||
|
||||
async fn start_direct_echo_server(
|
||||
data_dir: &std::path::Path,
|
||||
) -> (u16, Arc<std::sync::Mutex<Option<(String, u32)>>>, tokio::task::JoinHandle<()>) {
|
||||
let server_key = decode_secret_key(TEST_SERVER_KEY_PEM, None).expect("decode test server key");
|
||||
let server_config = server::Config { keys: vec![server_key], ..Default::default() };
|
||||
let ssh_port = portpicker::pick_unused_port().expect("no free port");
|
||||
let target = Arc::new(std::sync::Mutex::new(None));
|
||||
let mut server = DirectEchoServer { target: target.clone() };
|
||||
let server_task = tokio::spawn(async move {
|
||||
let _ = server.run_on_address(Arc::new(server_config), ("127.0.0.1", ssh_port)).await;
|
||||
});
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
HostKeyVerifier::new(data_dir.join("known_hosts"))
|
||||
.learn("127.0.0.1", ssh_port, &test_server_public_key())
|
||||
.unwrap();
|
||||
(ssh_port, target, server_task)
|
||||
}
|
||||
|
||||
async fn free_local_port() -> u16 {
|
||||
let probe = TcpListener::bind(("127.0.0.1", 0)).await.unwrap();
|
||||
probe.local_addr().unwrap().port()
|
||||
}
|
||||
|
||||
async fn start_echo_tunnel(
|
||||
manager: &TunnelManager,
|
||||
ssh_port: u16,
|
||||
requested_local_port: Option<u16>,
|
||||
) -> Result<u16, String> {
|
||||
manager
|
||||
.start_tunnel_on_local_port(
|
||||
"fixed-port",
|
||||
"127.0.0.1",
|
||||
ssh_port,
|
||||
"127.0.0.1",
|
||||
ssh_port,
|
||||
"user",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
false,
|
||||
"",
|
||||
"",
|
||||
"none",
|
||||
5,
|
||||
"db.internal",
|
||||
5432,
|
||||
false,
|
||||
false,
|
||||
requested_local_port,
|
||||
"",
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn assert_echoes_through(local_port: u16) {
|
||||
let mut client = TcpStream::connect(("127.0.0.1", local_port)).await.unwrap();
|
||||
client.write_all(b"ping").await.unwrap();
|
||||
let mut echoed = [0_u8; 4];
|
||||
tokio::time::timeout(Duration::from_secs(5), client.read_exact(&mut echoed))
|
||||
.await
|
||||
.expect("tunnel echo timeout")
|
||||
.expect("tunnel echo read");
|
||||
assert_eq!(&echoed, b"ping");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn requested_local_port_tunnel_listens_on_that_port_and_forwards() {
|
||||
let _guard = prompt_test_lock().lock().await;
|
||||
ssh_prompt::clear_ssh_prompt_gateway();
|
||||
let dir = tempdir().unwrap();
|
||||
let (ssh_port, target, server_task) = start_direct_echo_server(dir.path()).await;
|
||||
let manager = TunnelManager::new(dir.path().to_path_buf());
|
||||
let local_port = free_local_port().await;
|
||||
|
||||
let bound = start_echo_tunnel(&manager, ssh_port, Some(local_port)).await.unwrap();
|
||||
|
||||
assert_eq!(bound, local_port);
|
||||
assert_echoes_through(local_port).await;
|
||||
assert_eq!(*target.lock().unwrap(), Some(("db.internal".to_string(), 5432)));
|
||||
assert_eq!(start_echo_tunnel(&manager, ssh_port, None).await.unwrap(), local_port);
|
||||
|
||||
manager.stop_tunnel("fixed-port").await;
|
||||
server_task.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stopping_a_tunnel_releases_its_requested_local_port_before_returning() {
|
||||
let _guard = prompt_test_lock().lock().await;
|
||||
ssh_prompt::clear_ssh_prompt_gateway();
|
||||
let dir = tempdir().unwrap();
|
||||
let (ssh_port, _target, server_task) = start_direct_echo_server(dir.path()).await;
|
||||
let manager = TunnelManager::new(dir.path().to_path_buf());
|
||||
let local_port = free_local_port().await;
|
||||
|
||||
for stop_all in [false, true] {
|
||||
let label = if stop_all { "stop_all_tunnels" } else { "stop_tunnel" };
|
||||
assert_eq!(start_echo_tunnel(&manager, ssh_port, Some(local_port)).await.unwrap(), local_port, "{label}");
|
||||
if stop_all {
|
||||
manager.stop_all_tunnels().await;
|
||||
} else {
|
||||
manager.stop_tunnel("fixed-port").await;
|
||||
}
|
||||
|
||||
let rebound = start_echo_tunnel(&manager, ssh_port, Some(local_port))
|
||||
.await
|
||||
.unwrap_or_else(|error| panic!("rebind after {label} failed: {error}"));
|
||||
|
||||
assert_eq!(rebound, local_port, "{label}");
|
||||
assert_echoes_through(local_port).await;
|
||||
manager.stop_tunnel("fixed-port").await;
|
||||
}
|
||||
server_task.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn transport_layers_bind_the_requested_final_ssh_local_port_across_restarts() {
|
||||
let _guard = prompt_test_lock().lock().await;
|
||||
ssh_prompt::clear_ssh_prompt_gateway();
|
||||
let dir = tempdir().unwrap();
|
||||
let (ssh_port, target, server_task) = start_direct_echo_server(dir.path()).await;
|
||||
let manager = TunnelManager::new(dir.path().to_path_buf());
|
||||
let proxy_tunnels = ProxyTunnelManager::new();
|
||||
let http_tunnels = HttpTunnelManager::new();
|
||||
let local_port = free_local_port().await;
|
||||
let layers = vec![TransportLayerConfig::Ssh(SshTunnelConfig {
|
||||
password: String::new(),
|
||||
auth_method: "none".to_string(),
|
||||
..hop("ssh-layer", "127.0.0.1", ssh_port)
|
||||
})];
|
||||
|
||||
for restart in 0..2 {
|
||||
let bound = start_transport_layers_with_final_ssh_local_port(
|
||||
"conn",
|
||||
&layers,
|
||||
"db.internal",
|
||||
5432,
|
||||
Some(local_port),
|
||||
&manager,
|
||||
&proxy_tunnels,
|
||||
&http_tunnels,
|
||||
)
|
||||
.await
|
||||
.unwrap_or_else(|error| panic!("start {restart} failed: {error}"));
|
||||
|
||||
assert_eq!(bound, local_port, "start {restart}");
|
||||
assert_echoes_through(local_port).await;
|
||||
stop_transport_layers("conn", layers.len(), &manager, &proxy_tunnels, &http_tunnels).await;
|
||||
}
|
||||
assert_eq!(*target.lock().unwrap(), Some(("db.internal".to_string(), 5432)));
|
||||
server_task.abort();
|
||||
}
|
||||
|
||||
struct NetcatFallbackServer {
|
||||
command: Arc<std::sync::Mutex<Vec<u8>>>,
|
||||
}
|
||||
@@ -4140,6 +4459,72 @@ uveF/dLmnVN1IriEyEvHAAAACGRieC10ZXN0AQIDBAU=
|
||||
server_task.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn restarting_a_failed_tunnel_on_the_same_local_port_reports_the_ssh_error_not_a_bind_conflict() {
|
||||
let _guard = prompt_test_lock().lock().await;
|
||||
ssh_prompt::clear_ssh_prompt_gateway();
|
||||
|
||||
let publickey_attempts = Arc::new(AtomicUsize::new(0));
|
||||
let (ssh_port, server_task) = start_key_once_then_reject_server(publickey_attempts).await;
|
||||
let dir = tempdir().unwrap();
|
||||
HostKeyVerifier::new(dir.path().join("known_hosts"))
|
||||
.learn("127.0.0.1", ssh_port, &test_server_public_key())
|
||||
.unwrap();
|
||||
let key_path = dir.path().join("id_ed25519");
|
||||
std::fs::write(&key_path, TEST_SERVER_KEY_PEM).unwrap();
|
||||
let key_path = key_path.to_str().unwrap().to_string();
|
||||
let manager = TunnelManager::new(dir.path().to_path_buf());
|
||||
let local_port = free_local_port().await;
|
||||
let start = || {
|
||||
manager.start_tunnel_on_local_port(
|
||||
"fixed-reconnect",
|
||||
"127.0.0.1",
|
||||
ssh_port,
|
||||
"127.0.0.1",
|
||||
ssh_port,
|
||||
"user",
|
||||
"",
|
||||
&key_path,
|
||||
"",
|
||||
false,
|
||||
"",
|
||||
"",
|
||||
"key",
|
||||
5,
|
||||
"db.internal",
|
||||
3306,
|
||||
false,
|
||||
false,
|
||||
Some(local_port),
|
||||
"",
|
||||
)
|
||||
};
|
||||
assert_eq!(start().await.unwrap(), local_port);
|
||||
|
||||
// The first client makes the server drop the session; the reconnect is then refused.
|
||||
let mut client = TcpStream::connect(("127.0.0.1", local_port)).await.unwrap();
|
||||
let mut buf = [0_u8; 1];
|
||||
let _ = tokio::time::timeout(Duration::from_secs(5), client.read(&mut buf)).await;
|
||||
let failed = async {
|
||||
loop {
|
||||
let failure = manager.tunnels.lock().await.get("fixed-reconnect").and_then(|e| e.statuses[0].failure());
|
||||
if failure.is_some() {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
};
|
||||
tokio::time::timeout(Duration::from_secs(30), failed).await.expect("the reconnect should have been refused");
|
||||
|
||||
let evicted = start().await.unwrap_err();
|
||||
assert!(evicted.contains("could not reconnect"), "{evicted}");
|
||||
let restarted = start().await.unwrap_err();
|
||||
assert!(!restarted.contains("Failed to bind requested local port"), "{restarted}");
|
||||
|
||||
manager.stop_tunnel("fixed-reconnect").await;
|
||||
server_task.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejected_public_key_reports_what_the_server_refused() {
|
||||
let _guard = prompt_test_lock().lock().await;
|
||||
|
||||
@@ -411,6 +411,7 @@ mod tests {
|
||||
key_passphrase: String::new(),
|
||||
connect_timeout_secs: 5,
|
||||
expose_lan: false,
|
||||
local_port: None,
|
||||
use_ssh_agent: false,
|
||||
ssh_agent_sock_path: String::new(),
|
||||
ssh_agent_key_fingerprint: String::new(),
|
||||
|
||||
@@ -3045,6 +3045,7 @@ mod unavailable_backend_tests {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use dbx_core::models::connection::TransportLayerConfig;
|
||||
use std::{
|
||||
io::{Read, Write},
|
||||
net::TcpListener,
|
||||
@@ -3141,8 +3142,27 @@ mod tests {
|
||||
async fn local_connection_mutations_succeed_without_desktop() {
|
||||
let data_dir = tempfile::tempdir().unwrap();
|
||||
let backend = connection_mutation_backend(data_dir.path()).await;
|
||||
backend.add_connection_for_mcp(mutation_test_connection()).await.unwrap();
|
||||
let mut source = mutation_test_connection();
|
||||
source.transport_layers = serde_json::from_value(json!([
|
||||
{ "type": "ssh", "id": "ssh-1", "host": "bastion", "port": 22, "user": "deploy", "local_port": 4059 }
|
||||
]))
|
||||
.unwrap();
|
||||
backend.add_connection_for_mcp(source).await.unwrap();
|
||||
backend.duplicate_connection_for_mcp("source", "copy", "Copy").await.unwrap();
|
||||
let ssh_local_ports = |connections: &[ConnectionConfig], id: &str| -> Vec<Option<u16>> {
|
||||
let connection = connections.iter().find(|connection| connection.id == id).unwrap();
|
||||
connection
|
||||
.transport_layers
|
||||
.iter()
|
||||
.filter_map(|layer| match layer {
|
||||
TransportLayerConfig::Ssh(ssh) => Some(ssh.local_port),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let connections = backend.load_connections().await.unwrap();
|
||||
assert_eq!(ssh_local_ports(&connections, "source"), vec![Some(4059)]);
|
||||
assert_eq!(ssh_local_ports(&connections, "copy"), vec![None]);
|
||||
assert!(backend.remove_connection_for_mcp("source").await.unwrap());
|
||||
let connections = backend.load_connections().await.unwrap();
|
||||
assert_eq!(connections.len(), 1);
|
||||
@@ -4820,6 +4840,50 @@ mod tests {
|
||||
assert!(!plugin.compatibility.compatible, "{:?}", plugin.compatibility.errors);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn standalone_open_leaves_fixed_ssh_local_ports_to_the_desktop_app() {
|
||||
let held = std::net::TcpListener::bind(("127.0.0.1", 0)).unwrap();
|
||||
let fixed_port = held.local_addr().unwrap().port();
|
||||
let closed_port = std::net::TcpListener::bind(("127.0.0.1", 0)).unwrap().local_addr().unwrap().port();
|
||||
let config: ConnectionConfig = serde_json::from_value(json!({
|
||||
"id": "tunnelled",
|
||||
"name": "tunnelled",
|
||||
"db_type": "mysql",
|
||||
"host": "db.internal",
|
||||
"port": 3306,
|
||||
"username": "root",
|
||||
"password": "",
|
||||
"database": null,
|
||||
"transport_layers": [{
|
||||
"type": "ssh",
|
||||
"id": "ssh-1",
|
||||
"host": "127.0.0.1",
|
||||
"port": closed_port,
|
||||
"user": "user",
|
||||
"auth_method": "none",
|
||||
"connect_timeout_secs": 2,
|
||||
"local_port": fixed_port
|
||||
}]
|
||||
}))
|
||||
.unwrap();
|
||||
let bind_error = format!("Failed to bind requested local port {fixed_port}");
|
||||
|
||||
let standalone_dir = tempfile::tempdir().unwrap();
|
||||
let standalone_path = standalone_dir.path().join("dbx.db");
|
||||
drop(Storage::open(&standalone_path).await.unwrap());
|
||||
let standalone = LocalBackend::open(&standalone_path).await.unwrap();
|
||||
let error = standalone.state().connection_host_port(&config.id, &config).await.unwrap_err();
|
||||
assert!(!error.contains(&bind_error), "{error}");
|
||||
|
||||
let embedded_dir = tempfile::tempdir().unwrap();
|
||||
let storage = Storage::open(&embedded_dir.path().join("dbx.db")).await.unwrap();
|
||||
let app_state = Arc::new(AppState::new(storage));
|
||||
app_state.set_honor_fixed_ssh_local_ports(true);
|
||||
let embedded = LocalBackend::from_app_state(app_state, embedded_dir.path().to_path_buf());
|
||||
let error = embedded.state().connection_host_port(&config.id, &config).await.unwrap_err();
|
||||
assert!(error.contains(&bind_error), "{error}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_plugin_directory_honors_desktop_storage_settings() {
|
||||
let data_dir = Path::new("C:/Users/user/AppData/Roaming/com.dbx.app");
|
||||
|
||||
@@ -493,6 +493,12 @@ pub struct SshTunnelConfig {
|
||||
pub connect_timeout_secs: u64,
|
||||
#[serde(default)]
|
||||
pub expose_lan: bool,
|
||||
/// Fixed local port for the connection's primary tunnel when this is the
|
||||
/// last transport layer. Unset means a random free port. A shared tunnel
|
||||
/// profile (`profile_id`) supplies its own configuration, so a fixed port
|
||||
/// is not applied to a layer that references one.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub local_port: Option<u16>,
|
||||
#[serde(default)]
|
||||
pub use_ssh_agent: bool,
|
||||
/// Custom SSH agent socket path (e.g. `~/.ssh/agent.sock`).
|
||||
@@ -3892,6 +3898,40 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ssh_layer_local_port_is_optional_and_round_trips() {
|
||||
let cases = [(serde_json::json!({}), None), (serde_json::json!({ "local_port": 4059 }), Some(4059_u16))];
|
||||
for (layer_fields, expected) in cases {
|
||||
let mut layer =
|
||||
serde_json::json!({ "type": "ssh", "id": "ssh-1", "host": "bastion", "port": 22, "user": "deploy" });
|
||||
layer.as_object_mut().unwrap().extend(layer_fields.as_object().unwrap().clone());
|
||||
let config: ConnectionConfig = serde_json::from_value(serde_json::json!({
|
||||
"id": "id",
|
||||
"name": "name",
|
||||
"db_type": "mysql",
|
||||
"host": "10.1.2.3",
|
||||
"port": 3306,
|
||||
"username": "root",
|
||||
"password": "",
|
||||
"database": null,
|
||||
"transport_layers": [layer]
|
||||
}))
|
||||
.unwrap();
|
||||
let TransportLayerConfig::Ssh(ssh) = &config.transport_layers[0] else {
|
||||
panic!("expected ssh layer");
|
||||
};
|
||||
assert_eq!(ssh.local_port, expected);
|
||||
|
||||
let saved = serde_json::to_value(&config).unwrap();
|
||||
match expected {
|
||||
Some(port) => assert_eq!(saved["transport_layers"][0]["local_port"], port),
|
||||
None => assert!(saved["transport_layers"][0].get("local_port").is_none()),
|
||||
}
|
||||
let restored: ConnectionConfig = serde_json::from_value(saved).unwrap();
|
||||
assert_eq!(restored.transport_layers, config.transport_layers);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn query_timeout_zero_disables_timeout() {
|
||||
let mut config = mysql_config("root", "", None);
|
||||
|
||||
@@ -569,6 +569,8 @@ async fn serve() -> Result<(), String> {
|
||||
))
|
||||
};
|
||||
|
||||
app_state.set_honor_fixed_ssh_local_ports(true);
|
||||
|
||||
// Password hash: env var takes priority, then database
|
||||
let password_disabled = std::env::var("DBX_DISABLE_PASSWORD")
|
||||
.map(|v| matches!(v.trim().to_lowercase().as_str(), "1" | "true" | "yes" | "on"))
|
||||
|
||||
@@ -17,7 +17,7 @@ DBX 支持三类隧道/代理:
|
||||
| 代理 | 网络环境要求通过 SOCKS5 或 HTTP CONNECT 代理。 |
|
||||
| HTTP 隧道 | 只有内网 Web 服务器能访问数据库,不能使用 SSH/VPN。 |
|
||||
|
||||
对网络数据库来说,DBX 会先在当前运行环境中打开一个临时端口,让数据库驱动连接这个端口,再由隧道/代理层把数据库协议字节转发到真实目标。一个连接可以组合多层传输配置,DBX 会按照连接窗口中的顺序建立链路。
|
||||
对网络数据库来说,DBX 会先在当前运行环境中打开一个本地端口(默认是随机端口,SSH 层也可以使用固定端口),让数据库驱动连接这个端口,再由隧道/代理层把数据库协议字节转发到真实目标。一个连接可以组合多层传输配置,DBX 会按照连接窗口中的顺序建立链路。
|
||||
|
||||
## 连接内配置与共享配置
|
||||
|
||||
@@ -41,6 +41,7 @@ SSH 隧道是内置能力,不需要在终端里手动执行端口转发命令
|
||||
| SSH 端口 | SSH 端口,默认 `22` |
|
||||
| SSH 用户 | 登录 SSH 服务器的用户名 |
|
||||
| 连接超时 | DBX 等待 SSH 连接建立的最长时间,默认 5 秒 |
|
||||
| 本地端口 | 可选。隧道监听的固定端口;留空则使用随机空闲端口 |
|
||||
|
||||
<Tabs groupId="ssh-auth" items={['密钥认证(推荐)', '密钥+密码', '密码认证', '无凭据']}>
|
||||
<Tab value="密钥认证(推荐)">
|
||||
@@ -84,6 +85,23 @@ DBX 会在发送凭据前验证 SSH 主机密钥。已知密钥从系统 `known_
|
||||
|
||||
桌面版中,这意味着当前电脑可能被局域网设备访问。Docker/Web 版中,监听发生在服务器或容器内,是否能从容器外访问还取决于端口发布、容器网络和主机防火墙。
|
||||
|
||||
### 固定本地端口
|
||||
|
||||
默认情况下,DBX 会为 SSH 隧道选择一个随机的空闲本地端口。在 **本地端口**(1-65535)中填写端口,例如 `4059`,隧道就会监听这个端口。`psql`、脚本等本机其他工具可以通过 `127.0.0.1:4059` 使用同一条隧道。
|
||||
|
||||
- 端口只在该连接于 DBX 中保持打开期间被占用,连接关闭后释放;
|
||||
- 只有最后一个已启用的层可以使用,并且该层必须是直接配置在连接上的 SSH 层。引用 **设置 → 隧道** 中共享配置的层没有本地端口字段;
|
||||
- 如果端口已被占用或无法绑定,连接会失败,并提示你关闭占用端口的程序或换一个端口;
|
||||
- 连接的 **测试** 按钮、**测试 SSH 隧道**、生产数据库选择器及其他一次性草稿、定时备份,以及独立运行的 stdio MCP 服务或命令行工具使用随机端口,不会和已经打开的连接冲突。只配置了一个 `127.0.0.1` bootstrap 服务器的 Kafka 例外,见下文;
|
||||
- 解析到多跳 `ProxyJump` 链的 `~/.ssh/config` 主机别名暂不支持固定本地端口,连接时会返回错误。请把跳板机单独添加为第一层,并让最后一层指向目标的真实 `HostName` 或不带 `ProxyJump` 的别名,或者留空本地端口;
|
||||
- RabbitMQ 的 AMQP 连接使用固定端口,管理 API 隧道仍使用随机端口;
|
||||
- 只配置了一个 `127.0.0.1` bootstrap 服务器的 Kafka 始终监听该 bootstrap 端口。请留空本地端口,或将其设为该端口;设为其他端口会返回错误;
|
||||
- SQLite(经 SSH)、ClickHouse、Turso、RocketMQ、Redis Cluster 和 Redis Sentinel 不支持本地端口,因为它们通过远程 worker、SOCKS5 转发或按节点隧道连接,而不是使用单个本地隧道端口。设置了本地端口时,连接窗口会显示错误;
|
||||
- 通过 SOCKS5 转发的插件连接(`proxy_route`)会忽略本地端口;
|
||||
- Docker/Web 版中,固定端口在服务器或容器内打开,而不是在浏览器所在的电脑上。
|
||||
|
||||
<Callout type="warn">启用“允许局域网访问”后,固定端口会监听 `0.0.0.0`,任何能通过该端口访问到你这台机器的设备都可以使用这条隧道。只应在可信网络并配合防火墙使用。</Callout>
|
||||
|
||||
## 代理
|
||||
|
||||
如果当前网络要求通过代理访问数据库,可以添加代理层,而不是配置 SSH。
|
||||
|
||||
@@ -17,7 +17,7 @@ DBX supports three tunnel / proxy types:
|
||||
| Proxy | Your network requires SOCKS5 or HTTP CONNECT proxy access. |
|
||||
| HTTP tunnel | Only a web server can reach the database, and SSH/VPN is not available. |
|
||||
|
||||
For network databases, DBX opens a temporary port in its current runtime and points the database driver at that port. The tunnel / proxy layer forwards protocol bytes to the real target. A connection may combine multiple transport layers, which DBX establishes in the order shown in the connection dialog.
|
||||
For network databases, DBX opens a local port in its current runtime (a random one by default, or a fixed one for an SSH layer) and points the database driver at that port. The tunnel / proxy layer forwards protocol bytes to the real target. A connection may combine multiple transport layers, which DBX establishes in the order shown in the connection dialog.
|
||||
|
||||
## Per-Connection and Shared Profiles
|
||||
|
||||
@@ -41,6 +41,7 @@ SSH tunnels are built in. You do not need to create a manual terminal port forwa
|
||||
| SSH Port | Port for SSH (default: 22) |
|
||||
| SSH User | Username for SSH authentication |
|
||||
| Connect Timeout | Maximum time DBX waits while opening the SSH connection; default is 5 seconds |
|
||||
| Local Port | Optional fixed port the tunnel listens on; empty uses a random free port |
|
||||
|
||||
<Tabs groupId="ssh-auth" items={['Private Key (Recommended)', 'Private Key + Password', 'Password', 'No Credential']}>
|
||||
<Tab value="Private Key (Recommended)">
|
||||
@@ -84,6 +85,23 @@ DBX verifies the SSH host key before it sends a credential. Trusted keys come fr
|
||||
|
||||
On Desktop, this may make the temporary port reachable through your computer. In Docker/Web, the listener is inside the server or container; external reachability still depends on published ports, container networking, and the host firewall.
|
||||
|
||||
### Fixed Local Port
|
||||
|
||||
By default, DBX picks a random free local port for the SSH tunnel. Set **Local Port** (1-65535) to make the tunnel listen on a port you choose instead, for example `4059`. Other local tools, such as `psql` or a script, can then connect through the same tunnel at `127.0.0.1:4059`.
|
||||
|
||||
- The port is held only while the connection is open in DBX. It is released when the connection is closed.
|
||||
- Only the last enabled layer can use it, and only when that layer is an SSH layer configured directly on the connection. A layer that references a shared profile from **Settings → Tunnels** has no Local Port field.
|
||||
- If the port is already in use or cannot be bound, connecting fails with a message that asks you to close the other program or choose another port.
|
||||
- The connection **Test** button, **Test SSH tunnel**, the production-database picker and other one-time drafts, scheduled backups, and a standalone stdio MCP server or the CLI use a random port, so they do not collide with a connection that is already open. Kafka with a single `127.0.0.1` bootstrap server is the exception described below.
|
||||
- A host alias from `~/.ssh/config` that resolves to a multi-hop `ProxyJump` chain does not support a fixed local port yet, and connecting returns an error. Add the jump host as its own first layer and point the last layer at the target's real `HostName` or at an alias without `ProxyJump`, or leave Local Port empty.
|
||||
- RabbitMQ uses the fixed port for the AMQP connection. The management API tunnel keeps a random port.
|
||||
- Kafka with a single `127.0.0.1` bootstrap server always listens on the bootstrap port. Leave Local Port empty or set it to that port; a different port returns an error.
|
||||
- Local Port is not supported for SQLite over SSH, ClickHouse, Turso, RocketMQ, Redis Cluster, or Redis Sentinel, because they connect through a remote worker, a SOCKS5 route, or per-node tunnels instead of one local tunnel port. The connection dialog shows an error if it is set.
|
||||
- Plugin connections that route through SOCKS5 (`proxy_route`) ignore Local Port.
|
||||
- In Docker/Web, the fixed port is opened on the server or container, not on the computer running the browser.
|
||||
|
||||
<Callout type="warn">With **Expose tunnel to LAN** on, the fixed port listens on `0.0.0.0`, so any device that can reach your machine on that port can use the tunnel. Use it only on trusted networks with appropriate firewall rules.</Callout>
|
||||
|
||||
## Proxy
|
||||
|
||||
Use a proxy layer when your environment requires an outbound proxy instead of SSH.
|
||||
|
||||
+15
-1
@@ -2,7 +2,7 @@ import { test } from "vitest";
|
||||
import assert from "node:assert/strict";
|
||||
import { createPinia, setActivePinia } from "pinia";
|
||||
import { useConnectionStore } from "../../apps/desktop/src/stores/connectionStore.ts";
|
||||
import type { ConnectionConfig, SidebarLayout, TreeNode } from "../../apps/desktop/src/types/database.ts";
|
||||
import type { ConnectionConfig, SidebarLayout, TransportLayerConfig, TreeNode } from "../../apps/desktop/src/types/database.ts";
|
||||
|
||||
function installMemoryStorage() {
|
||||
const values = new Map<string, string>();
|
||||
@@ -155,3 +155,17 @@ test("pastes multiple copied connections in the copied order", async () => {
|
||||
assert.deepEqual(connectionLabels(store.treeNodes), ["First", "Second", "Second (Copy)", "First (Copy)"]);
|
||||
});
|
||||
});
|
||||
|
||||
test("pasted connections do not claim the fixed SSH local port of the original", async () => {
|
||||
const proxy: TransportLayerConfig = { type: "proxy", id: "proxy", proxy_type: "socks5", host: "proxy.example.com", port: 1080 };
|
||||
const tunnel: TransportLayerConfig = { type: "ssh", id: "bastion", host: "bastion.example.com", port: 22, user: "root" };
|
||||
const original: ConnectionConfig = { ...conn("conn-1", "Tunnelled MySQL"), transport_layers: [{ ...tunnel, local_port: 4059 }, proxy] };
|
||||
|
||||
await withConnectionStore([original], { groups: [], order: [{ type: "connection", id: "conn-1" }] }, async (store) => {
|
||||
store.copyConnectionsToTreeClipboard(["conn-1"]);
|
||||
await store.pasteConnectionClipboard(null);
|
||||
|
||||
assert.deepEqual(store.connections.find((connection) => connection.name === "Tunnelled MySQL (Copy)")?.transport_layers, [tunnel, proxy]);
|
||||
assert.deepEqual(store.connections.find((connection) => connection.id === "conn-1")?.transport_layers, [{ ...tunnel, local_port: 4059 }, proxy]);
|
||||
});
|
||||
});
|
||||
@@ -2126,6 +2126,7 @@ pub fn run() {
|
||||
dbx_core::db::sqlite_worker::enable_sqlite_ssh_runtime(env!("CARGO_PKG_VERSION"));
|
||||
state.set_duckdb_worker_process_isolation_enabled(desktop_settings.duckdb_worker_process_isolation);
|
||||
state.set_duckdb_worker_max_processes(desktop_settings.duckdb_worker_max_processes);
|
||||
state.set_honor_fixed_ssh_local_ports(true);
|
||||
let oidc_app_handle = app.handle().clone();
|
||||
state.set_mongo_oidc_browser_opener(Arc::new(move |url| {
|
||||
oidc_app_handle
|
||||
|
||||
Reference in new issue
Block a user