From 3be643992df2e8453d145c76c95a55d08b97cee3 Mon Sep 17 00:00:00 2001 From: bboxwtf Date: Sat, 21 Mar 2026 22:37:59 +0000 Subject: [PATCH] fix(nodes): SSH join-node fix + Test Connection button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause: gateway binary was built before SwarmJoinNodeViaSSH was added → 404 on /api/swarm/join-node → tRPC threw 'Gateway unavailable' error. Gateway fixes: - Rebuilt gateway image (golang:1.23-alpine + go mod tidy → clean go.sum) - SwarmJoinNodeViaSSH now live in binary (was already coded, not compiled) - NEW: POST /api/swarm/ssh-test handler (SwarmSSHTest) • Dials SSH with 10s timeout • Runs 'docker version' on remote to check Docker availability • Returns {ok, sshOk, dockerOk, dockerVersion, error, step} - Route registered: r.Post("/swarm/ssh-test", h.SwarmSSHTest) Server fixes: - gateway-proxy.ts: added testSSHConnection() → POST /api/swarm/ssh-test - routers.ts: added nodes.sshTest mutation (input: host/port/user/password) UI (client/src/pages/Nodes.tsx) — AddNodeDialog rewritten: - Separate state for testResult and joinResult (two independent panels) - NEW: yellow 'Test Connection' button → calls nodes.sshTest shows SSH OK + Docker version, or human-readable error - 'Join Swarm' button remains independent - Human-readable error messages per step: ssh_connect → 'Cannot connect — check IP, port, SSH running' docker_join → 'docker swarm join failed: ...' trpc → 'Gateway unavailable — check gateway container' - Input changes clear stale test/join results - ✕ close button in dialog header - Disabled state unified (busy || joinDone) --- client/src/pages/Nodes.tsx | 219 ++++++++++++++++++++----------- gateway/cmd/gateway/main.go | 1 + gateway/go.mod | 1 - gateway/go.sum | 4 +- gateway/internal/api/handlers.go | 70 ++++++++++ server/gateway-proxy.ts | 21 +++ server/routers.ts | 17 +++ 7 files changed, 251 insertions(+), 82 deletions(-) diff --git a/client/src/pages/Nodes.tsx b/client/src/pages/Nodes.tsx index 1a56324..a01c196 100644 --- a/client/src/pages/Nodes.tsx +++ b/client/src/pages/Nodes.tsx @@ -31,29 +31,49 @@ import { // ─── Add Node via SSH Dialog ───────────────────────────────────────────────── +type TestResult = { ok: boolean; sshOk?: boolean; dockerOk?: boolean; dockerVersion?: string; error?: string; step?: string }; +type JoinResult = { ok: boolean; output?: string; error?: string; note?: string; command?: string; step?: string }; + function AddNodeDialog({ onClose, onSuccess }: { onClose: () => void; onSuccess: () => void }) { - const [host, setHost] = useState(""); - const [port, setPort] = useState(22); - const [user, setUser] = useState("root"); + const [host, setHost] = useState(""); + const [port, setPort] = useState(22); + const [user, setUser] = useState("root"); const [password, setPassword] = useState(""); - const [role, setRole] = useState<"worker" | "manager">("worker"); - const [result, setResult] = useState<{ ok: boolean; output?: string; error?: string; note?: string; command?: string } | null>(null); + const [role, setRole] = useState<"worker" | "manager">("worker"); const [showPassword, setShowPassword] = useState(false); + const [testResult, setTestResult] = useState(null); + const [joinResult, setJoinResult] = useState(null); + + const testMut = trpc.nodes.sshTest.useMutation({ + onSuccess: (data) => setTestResult(data as TestResult), + onError: (e) => setTestResult({ ok: false, error: e.message, step: "trpc" }), + }); const joinMut = trpc.nodes.joinNode.useMutation({ onSuccess: (data) => { - setResult(data); - if (data.ok) onSuccess(); + setJoinResult(data as JoinResult); + if ((data as JoinResult).ok) onSuccess(); }, - onError: (e) => setResult({ ok: false, error: e.message }), + onError: (e) => setJoinResult({ ok: false, error: e.message, step: "trpc" }), }); + const busy = testMut.isPending || joinMut.isPending; + const canAct = !!host.trim() && !!user.trim() && !!password && !busy; + const joinDone = joinResult?.ok === true; + + const handleTest = () => { + setTestResult(null); + setJoinResult(null); + testMut.mutate({ host: host.trim(), port, user: user.trim(), password }); + }; + const handleJoin = () => { - setResult(null); + setJoinResult(null); joinMut.mutate({ host: host.trim(), port, user: user.trim(), password, role }); }; - const canSubmit = host.trim() && user.trim() && password && !joinMut.isPending; + const inputClass = "h-8 text-xs font-mono"; + const disabled = busy || joinDone; return (
@@ -62,9 +82,11 @@ function AddNodeDialog({ onClose, onSuccess }: { onClose: () => void; onSuccess: animate={{ opacity: 1, scale: 1 }} className="bg-card border border-border rounded-lg p-6 w-full max-w-md shadow-2xl" > + {/* Header */}

Add Node to Swarm via SSH

+
@@ -72,59 +94,40 @@ function AddNodeDialog({ onClose, onSuccess }: { onClose: () => void; onSuccess:
- setHost(e.target.value)} - placeholder="192.168.1.100" - className="h-8 text-xs font-mono" - disabled={joinMut.isPending} - /> + { setHost(e.target.value); setTestResult(null); setJoinResult(null); }} + placeholder="192.168.1.100" className={inputClass} disabled={disabled} />
- setPort(parseInt(e.target.value) || 22)} - className="h-8 text-xs font-mono" - min={1} max={65535} - disabled={joinMut.isPending} - /> + setPort(parseInt(e.target.value) || 22)} + className={inputClass} min={1} max={65535} disabled={disabled} />
{/* User */}
- setUser(e.target.value)} - placeholder="root" - className="h-8 text-xs font-mono" - disabled={joinMut.isPending} - /> + { setUser(e.target.value); setTestResult(null); }} + placeholder="root" className={inputClass} disabled={disabled} />
{/* Password */}
-
diff --git a/gateway/cmd/gateway/main.go b/gateway/cmd/gateway/main.go index 84e742e..c9a2d11 100644 --- a/gateway/cmd/gateway/main.go +++ b/gateway/cmd/gateway/main.go @@ -140,6 +140,7 @@ func main() { r.Post("/swarm/services/{id}/scale", h.SwarmScaleService) r.Get("/swarm/join-token", h.SwarmJoinToken) r.Post("/swarm/join-node", h.SwarmJoinNodeViaSSH) + r.Post("/swarm/ssh-test", h.SwarmSSHTest) r.Post("/swarm/shell", h.SwarmShell) r.Get("/swarm/agents", h.SwarmListAgents) r.Post("/swarm/agents/{name}/start", h.SwarmStartAgent) diff --git a/gateway/go.mod b/gateway/go.mod index 8b67cd9..eeafb30 100644 --- a/gateway/go.mod +++ b/gateway/go.mod @@ -6,7 +6,6 @@ require ( github.com/go-chi/chi/v5 v5.2.1 github.com/go-chi/cors v1.2.1 github.com/go-sql-driver/mysql v1.8.1 - github.com/jmoiron/sqlx v1.4.0 github.com/joho/godotenv v1.5.1 golang.org/x/crypto v0.37.0 ) diff --git a/gateway/go.sum b/gateway/go.sum index 4126bf4..3ae30bd 100644 --- a/gateway/go.sum +++ b/gateway/go.sum @@ -6,11 +6,11 @@ github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4= github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= -github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nuvVzoTd+M1hukKvKQLaDc= -github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o= +golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw= diff --git a/gateway/internal/api/handlers.go b/gateway/internal/api/handlers.go index 15e2e2f..3fa6755 100644 --- a/gateway/internal/api/handlers.go +++ b/gateway/internal/api/handlers.go @@ -1518,3 +1518,73 @@ func (h *Handler) SwarmJoinNodeViaSSH(w http.ResponseWriter, r *http.Request) { "command": joinCmd, }) } + +// POST /api/swarm/ssh-test +// Tests SSH connectivity and checks if Docker is accessible on the remote host. +// Body: { "host": "1.2.3.4", "port": 22, "user": "root", "password": "secret" } +func (h *Handler) SwarmSSHTest(w http.ResponseWriter, r *http.Request) { + var body struct { + Host string `json:"host"` + Port int `json:"port"` + User string `json:"user"` + Password string `json:"password"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + respondError(w, http.StatusBadRequest, "invalid request body") + return + } + if body.Host == "" || body.User == "" || body.Password == "" { + respondError(w, http.StatusBadRequest, "host, user and password are required") + return + } + if body.Port == 0 { + body.Port = 22 + } + + sshCfg := &ssh.ClientConfig{ + User: body.User, + Auth: []ssh.AuthMethod{ + ssh.Password(body.Password), + }, + HostKeyCallback: ssh.InsecureIgnoreHostKey(), + Timeout: 10 * time.Second, + } + addr := fmt.Sprintf("%s:%d", body.Host, body.Port) + log.Printf("[SSHTest] Dialing %s as %s", addr, body.User) + + client, err := ssh.Dial("tcp", addr, sshCfg) + if err != nil { + respond(w, http.StatusOK, map[string]any{ + "ok": false, + "step": "ssh_connect", + "error": fmt.Sprintf("SSH connection failed: %s", err.Error()), + }) + return + } + defer client.Close() + + // Run a quick docker version check to see if Docker daemon is accessible + sess, err := client.NewSession() + if err != nil { + respond(w, http.StatusOK, map[string]any{ + "ok": false, + "step": "ssh_session", + "error": fmt.Sprintf("SSH session failed: %s", err.Error()), + }) + return + } + defer sess.Close() + + out, _ := sess.CombinedOutput("docker version --format '{{.Server.Version}}' 2>/dev/null || echo 'docker_not_found'") + dockerVer := strings.TrimSpace(string(out)) + dockerOk := dockerVer != "" && dockerVer != "docker_not_found" + + log.Printf("[SSHTest] %s — SSH OK, docker: %s", addr, dockerVer) + respond(w, http.StatusOK, map[string]any{ + "ok": true, + "sshOk": true, + "dockerOk": dockerOk, + "dockerVersion": dockerVer, + "host": body.Host, + }) +} diff --git a/server/gateway-proxy.ts b/server/gateway-proxy.ts index 9c7089e..453c518 100644 --- a/server/gateway-proxy.ts +++ b/server/gateway-proxy.ts @@ -716,6 +716,27 @@ export async function joinSwarmNodeViaSSH(opts: { } catch { return null; } } +/** + * Test SSH connectivity and Docker availability on a remote host (no swarm join). + */ +export async function testSSHConnection(opts: { + host: string; + port?: number; + user: string; + password: string; +}): Promise<{ ok: boolean; sshOk?: boolean; dockerOk?: boolean; dockerVersion?: string; error?: string; step?: string } | null> { + try { + const res = await fetch(`${GATEWAY_BASE_URL}/api/swarm/ssh-test`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(opts), + signal: AbortSignal.timeout(20_000), + }); + if (!res.ok) return null; + return res.json(); + } catch { return null; } +} + /** Add a label to a swarm node */ export async function addSwarmNodeLabel(nodeId: string, key: string, value: string): Promise { try { diff --git a/server/routers.ts b/server/routers.ts index 7131ab7..d324d2e 100644 --- a/server/routers.ts +++ b/server/routers.ts @@ -35,6 +35,7 @@ import { stopSwarmAgent, getOllamaModelInfo, joinSwarmNodeViaSSH, + testSSHConnection, } from "./gateway-proxy"; // Shared system user id for non-authenticated agent management @@ -1067,6 +1068,22 @@ export const appRouter = router({ return result; }), + /** + * Test SSH connectivity and Docker availability on a remote host — no swarm join performed. + */ + sshTest: publicProcedure + .input(z.object({ + host: z.string().min(1), + port: z.number().int().min(1).max(65535).default(22), + user: z.string().min(1), + password: z.string().min(1), + })) + .mutation(async ({ input }) => { + const result = await testSSHConnection(input); + if (!result) throw new Error("Gateway unavailable — cannot reach SSH test endpoint"); + return result; + }), + /** * Get live container stats (CPU%, RAM) for all running containers. */