From fefa135f5a4884cd391c05568d6a2bfe0c08c905 Mon Sep 17 00:00:00 2001 From: Vadim Lazuk Date: Wed, 11 Nov 2026 18:36:44 +0300 Subject: [PATCH] feat(docker): pick an SSH configuration for TCP/SSH Docker connections (#2737) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The TCP / SSH host kind of a Docker connection now offers the IDE's SSH Configurations (with … to open Settings › Tools › SSH Configurations), as IntelliJ IDEA does. Picking a configuration with OpenSSH config authentication fills the Engine API URL with ssh://[user@]host[:port], keeping the alias so the system ssh docker runs applies the entry's key, user and jump hosts; an existing ssh:// URL shows its configuration again. Password and key-pair configurations are listed but explained as unusable for now, since docker over SSH goes through the system ssh. Refs #939 Co-authored-by: Inf1mum <8135225+Inf1mum@users.noreply.github.com> --- src/features/services/dialogs.tsx | 66 +++++++++++++++++++ src/features/services/services.test.ts | 33 +++++++++- .../settings/components/SshConfigSelect.tsx | 7 +- src/platform/locales/text/zh.json | 5 +- 4 files changed, 108 insertions(+), 3 deletions(-) diff --git a/src/features/services/dialogs.tsx b/src/features/services/dialogs.tsx index bb96b2322..84f595b39 100644 --- a/src/features/services/dialogs.tsx +++ b/src/features/services/dialogs.tsx @@ -1,7 +1,11 @@ /** Imperative prompt dialogs of the Services view, rendered by one overlay. */ import { Fragment, useState, type ReactNode } from "react"; import { create } from "zustand"; +import { SshConfigSelect } from "@/features/settings/components/SshConfigSelect"; +import { executeAction } from "@/platform/actions"; import type { DockerConnection, DockerEndpoint, DockerRegistry } from "@/platform/ipc"; +import type { SshConfigValid } from "@/platform/remoteSettings"; +import { useWorkflowSettings } from "@/platform/workflowSettings"; import { Button, Dialog, Input, Select, T } from "@/ui"; import { errorMessage, newId, saveConnection, saveRegistry, testRegistryLogin } from "./store"; import styles from "./Services.module.css"; @@ -128,14 +132,53 @@ export function openConnectionDialog(existing?: DockerConnection) { open((close) => ); } +/** `ssh://[user@]host[:port]` of an SSH configuration whose authentication is the OpenSSH + * config: the alias stays in the URL so the system `ssh` that docker runs applies the + * entry's user, key and jump hosts. */ +export function sshConfigUrl(c: Pick): string { + return `ssh://${c.user ? `${c.user}@` : ""}${c.host}${c.port ? `:${c.port}` : ""}`; +} + +/** + * The id of the SSH configuration an Engine API URL was filled from, to show the selection: + * an exact `ssh://[user@]host[:port]` URL matching an OpenSSH-config configuration's user, + * host and port. Anything else (a `tcp://` URL, a different user or port) is manual. + */ +export function sshConfigForUrl(url: string, configs: readonly SshConfigValid[]): string { + const m = /^ssh:\/\/(?:([^@/\s]+)@)?([A-Za-z0-9._-]+)(?::(\d+))?$/i.exec(url.trim()); + if (!m) return ""; + const config = configs.find( + (c) => + c.auth === "openSsh" && + c.host.trim().toLowerCase() === (m[2] ?? "").toLowerCase() && + (!m[1] || c.user === m[1]) && + (!m[3] || c.port === Number(m[3])), + ); + return config?.id ?? ""; +} + +/** Settings › Tools › SSH Configurations, where the TCP/SSH kind's picker is edited. */ +const SSH_SETTINGS_PAGE = "tools.sshConfigurations"; + function ConnectionDialog({ existing, onDone }: { existing?: DockerConnection; onDone: () => void }) { const ep = existing?.endpoint; + const sshConfigs = useWorkflowSettings((w) => w.ssh.configs); const [name, setName] = useState(existing?.name ?? "Docker"); const [kind, setKind] = useState(ep?.kind ?? "socket"); const [path, setPath] = useState(ep?.kind === "socket" ? ep.path : "/var/run/docker.sock"); const [host, setHost] = useState(ep?.kind === "tcp" ? ep.host : ""); const [tls, setTls] = useState(ep?.kind === "tcp" ? (ep.tlsCertDir ?? "") : ""); const [context, setContext] = useState(ep?.kind === "context" ? ep.name : ""); + // The configuration picked in the dropdown; shown even when docker cannot use it yet. + const [picked, setPicked] = useState(() => (ep?.kind === "tcp" ? sshConfigForUrl(ep.host, sshConfigs) : "")); + const fromUrl = sshConfigForUrl(host, sshConfigs); + const shown = fromUrl || picked; + const unusable = !fromUrl && sshConfigs.find((c) => c.id === picked && c.auth !== "openSsh"); + const pickSsh = (id: string) => { + setPicked(id); + const config = sshConfigs.find((c) => c.id === id); + if (config?.auth === "openSsh" && config.host.trim()) setHost(sshConfigUrl(config)); + }; const endpoint = endpointFrom(kind, { path, host, tls, context }); const ok = !!endpoint && name.trim().length > 0; const submit = async () => { @@ -184,6 +227,29 @@ function ConnectionDialog({ existing, onDone }: { existing?: DockerConnection; o )} {kind === "tcp" && ( <> + + SSH configuration: + +
+ + +
+ {unusable && ( + + Docker over SSH uses the system ssh: choose a configuration with OpenSSH config authentication. + + )} diff --git a/src/features/services/services.test.ts b/src/features/services/services.test.ts index cd8a08c32..9ebe83ff3 100644 --- a/src/features/services/services.test.ts +++ b/src/features/services/services.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { EditorState } from "@codemirror/state"; import type { DockerContainer, DockerSnapshot } from "@/platform/ipc"; +import type { SshConfigValid } from "@/platform/remoteSettings"; import { fileIconName } from "@/ui/icons"; import { composeFileIcon, @@ -54,7 +55,7 @@ import { splitLayerInstruction, unitsToBytes, } from "./dashboard"; -import { endpointFrom } from "./dialogs"; +import { endpointFrom, sshConfigForUrl, sshConfigUrl } from "./dialogs"; import { dockerItems } from "./docker"; import { setDockerFilters, useServices } from "./store"; import { addServiceMenu, expandableKeys, indexTree, setProviderRoots, useServicesTree } from "./tree"; @@ -209,6 +210,36 @@ describe("services tree host", () => { expect(endpointFrom("context", { ...f, context: "orbstack" })).toEqual({ kind: "context", name: "orbstack" }); expect(endpointFrom("default", f)).toEqual({ kind: "default" }); }); + + it("fills the Engine API URL from an OpenSSH-config SSH configuration and shows the selection", () => { + const box: SshConfigValid = { + id: "ssh1", + name: "Build server", + host: "build-server", + port: 2222, + user: "ops", + auth: "openSsh", + keyFile: "", + saveSecret: false, + }; + const bare: SshConfigValid = { ...box, id: "ssh2", name: "", host: "bare", port: null, user: "", auth: "openSsh" }; + const password: SshConfigValid = { ...box, id: "ssh3", host: "vault", user: "root", port: null, auth: "password" }; + const configs = [box, bare, password]; + expect(sshConfigUrl(box)).toBe("ssh://ops@build-server:2222"); + expect(sshConfigUrl(bare)).toBe("ssh://bare"); + // The selection follows the URL the field holds; only OpenSSH-config configurations match. + expect(sshConfigForUrl("ssh://ops@build-server:2222", configs)).toBe("ssh1"); + expect(sshConfigForUrl(" ssh://build-server:2222 ", configs)).toBe("ssh1"); + expect(sshConfigForUrl("ssh://build-server", configs)).toBe("ssh1"); + expect(sshConfigForUrl("ssh://bare", configs)).toBe("ssh2"); + // Another user or port than the configuration's, a tcp URL or a password configuration is not a selection. + expect(sshConfigForUrl("ssh://root@build-server", configs)).toBe(""); + expect(sshConfigForUrl("ssh://build-server:22", configs)).toBe(""); + expect(sshConfigForUrl("ssh://root@vault", configs)).toBe(""); + expect(sshConfigForUrl("tcp://build-host:2376", configs)).toBe(""); + expect(sshConfigForUrl("ssh://", configs)).toBe(""); + expect(sshConfigForUrl("", configs)).toBe(""); + }); }); describe("docker run targets", () => { diff --git a/src/features/settings/components/SshConfigSelect.tsx b/src/features/settings/components/SshConfigSelect.tsx index a943efb66..8eb37f295 100644 --- a/src/features/settings/components/SshConfigSelect.tsx +++ b/src/features/settings/components/SshConfigSelect.tsx @@ -14,15 +14,20 @@ export function SshConfigSelect({ value, onChange, none, + configs: given, "aria-label": ariaLabel = "SSH configuration", }: { value: string; onChange: (id: string) => void; /** Label of an empty choice (e.g. "Local machine"); omitted: no empty choice. */ none?: string; + /** The configurations to pick from; omitted: the global `ssh.configs` setting. Forms + * outside the settings window pass the live list (the staged base is not loaded there). */ + configs?: SshConfigValid[]; "aria-label"?: string; }) { - const configs = useSshConfigs(); + const staged = useSshConfigs(); + const configs = given ?? staged; const options: SelectOption[] = [ ...(none !== undefined ? [{ value: "", label: none }] : []), ...configs.map((c) => ({ value: c.id, label: c.name.trim() || sshLabel(c), detail: sshLabel(c) })), diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index eb5aeb91b..3774c5a42 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -7652,5 +7652,8 @@ "Suggested for This Project": "为当前项目推荐", "Featured": "精选", "All Plugins": "全部插件", - "Examples": "示例插件" + "Examples": "示例插件", + "Settings › Tools › SSH Configurations": "设置 › 工具 › SSH 配置", + "Edit SSH Configurations": "编辑 SSH 配置", + "Docker over SSH uses the system ssh: choose a configuration with OpenSSH config authentication.": "通过 SSH 连接 Docker 使用系统 ssh:请选择认证方式为“OpenSSH 配置和身份验证代理”的 SSH 配置。" }