feat(docker): pick an SSH configuration for TCP/SSH Docker connections (#2737)

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>
This commit is contained in:
Vadim Lazuk
2026-11-11 23:36:44 +08:00
committed by GitHub
co-authored by Inf1mum
parent 8fe2d35d62
commit fefa135f5a
4 changed files with 108 additions and 3 deletions
+66
View File
@@ -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) => <ConnectionDialog existing={existing} onDone={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<SshConfigValid, "user" | "host" | "port">): 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<EndpointKind>(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" && (
<>
<span className={styles.fieldLabel}>
<T>SSH configuration:</T>
</span>
<div className={styles.fieldRow}>
<SshConfigSelect
value={shown}
onChange={pickSsh}
none="None — type the Engine API URL below"
aria-label="SSH configuration"
/>
<Button
title="Settings › Tools › SSH Configurations"
aria-label="Edit SSH Configurations"
onClick={() => void executeAction("file.settings", SSH_SETTINGS_PAGE)}
>
…
</Button>
</div>
{unusable && (
<span className={styles.hint}>
<T>Docker over SSH uses the system ssh: choose a configuration with OpenSSH config authentication.</T>
</span>
)}
<label className={styles.fieldLabel} htmlFor="docker-conn-host">
<T>Engine API URL:</T>
</label>
+32 -1
View File
@@ -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", () => {
@@ -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) })),
+4 -1
View File
@@ -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 配置。"
}