From 37e0a21ec38979aed177dc03f1b0155c5c7c99ef Mon Sep 17 00:00:00 2001 From: bboxwtf Date: Sat, 21 Mar 2026 21:57:43 +0000 Subject: [PATCH] =?UTF-8?q?feat(nodes):=20Add=20Node=20via=20SSH=20?= =?UTF-8?q?=E2=80=94=20join=20any=20host=20to=20Swarm=20cluster?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - client/src/pages/Nodes.tsx: • Added showAddNode state (useState(false)) • Added 'Add Node' button (green, UserPlus icon) in page header toolbar • Added 'Add Node via SSH' button in Nodes tab toolbar (always visible) • Added 'Add Node via SSH' button in empty-state of Nodes tab • Rendered overlay when showAddNode=true; onSuccess refreshes nodesQ + swarmInfoQ then closes dialog • AddNodeDialog component was already implemented: SSH host/port/user/ password/role inputs, live result display (green/red), command echo - server/gateway-proxy.ts: • joinSwarmNodeViaSSH() — POST /api/swarm/join-node with SSH credentials - server/routers.ts: • nodes.joinNode mutation — validates input, calls joinSwarmNodeViaSSH, throws if gateway unavailable - gateway/internal/api/handlers.go: • POST /api/swarm/join-node handler (SwarmJoinNodeViaSSH): retrieves join token, SSHes to remote host via golang.org/x/crypto/ssh, runs 'docker swarm join --token ', handles already-member case as success - gateway/cmd/gateway/main.go: • Route registered: r.Post("/swarm/join-node", h.SwarmJoinNodeViaSSH) - gateway/go.mod + go.sum: • Added golang.org/x/crypto v0.37.0 dependency for SSH client support --- client/src/pages/Nodes.tsx | 224 ++++++++++++++++++++++++++++++- gateway/cmd/gateway/main.go | 1 + gateway/go.mod | 17 ++- gateway/go.sum | 8 +- gateway/internal/api/handlers.go | 114 ++++++++++++++++ server/gateway-proxy.ts | 34 +++++ server/routers.ts | 19 +++ 7 files changed, 406 insertions(+), 11 deletions(-) diff --git a/client/src/pages/Nodes.tsx b/client/src/pages/Nodes.tsx index 6c6633e..1a56324 100644 --- a/client/src/pages/Nodes.tsx +++ b/client/src/pages/Nodes.tsx @@ -26,9 +26,196 @@ import { Plus, Minus, Activity, Loader2, Shield, Bot, ArrowUpRight, Eye, Tag, Power, Rocket, GitBranch, Globe, AlertTriangle, Trash2, Play, Square, - Wifi, WifiOff, Clock, Zap, + Wifi, WifiOff, Clock, Zap, UserPlus, KeyRound, CheckCircle2, XCircle, } from "lucide-react"; +// ─── Add Node via SSH Dialog ───────────────────────────────────────────────── + +function AddNodeDialog({ onClose, onSuccess }: { onClose: () => void; onSuccess: () => void }) { + 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 [showPassword, setShowPassword] = useState(false); + + const joinMut = trpc.nodes.joinNode.useMutation({ + onSuccess: (data) => { + setResult(data); + if (data.ok) onSuccess(); + }, + onError: (e) => setResult({ ok: false, error: e.message }), + }); + + const handleJoin = () => { + setResult(null); + joinMut.mutate({ host: host.trim(), port, user: user.trim(), password, role }); + }; + + const canSubmit = host.trim() && user.trim() && password && !joinMut.isPending; + + return ( +
+ +
+ +

Add Node to Swarm via SSH

+
+ +
+ {/* Host + Port */} +
+
+ + setHost(e.target.value)} + placeholder="192.168.1.100" + className="h-8 text-xs font-mono" + disabled={joinMut.isPending} + /> +
+
+ + setPort(parseInt(e.target.value) || 22)} + className="h-8 text-xs font-mono" + min={1} max={65535} + disabled={joinMut.isPending} + /> +
+
+ + {/* User */} +
+ + setUser(e.target.value)} + placeholder="root" + className="h-8 text-xs font-mono" + disabled={joinMut.isPending} + /> +
+ + {/* Password */} +
+ +
+ setPassword(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && canSubmit && handleJoin()} + placeholder="••••••••" + className="h-8 text-xs font-mono pr-16" + disabled={joinMut.isPending} + /> + +
+
+ + {/* Role */} +
+ +
+ {(["worker", "manager"] as const).map((r) => ( + + ))} +
+

+ {role === "manager" + ? "Manager nodes participate in Raft consensus and can schedule tasks" + : "Worker nodes run tasks but don't participate in cluster management"} +

+
+ + {/* Result */} + {result && ( + +
+ {result.ok + ? <> Node joined successfully! + : <> Failed to join node + } +
+ {result.note &&

{result.note}

} + {result.output && ( +
+                  {result.output}
+                
+ )} + {result.error &&

{result.error}

} + {result.command && ( +
+

Command executed:

+ {result.command} +
+ )} +
+ )} +
+ +
+ + {!result?.ok && ( + + )} +
+
+
+ ); +} + // ─── Helpers ────────────────────────────────────────────────────────────────── function formatMB(mb: number) { @@ -858,6 +1045,7 @@ export default function Nodes() { const [removeTarget, setRemoveTarget] = useState<{ id: string; name: string } | null>(null); const [tasksTarget, setTasksTarget] = useState<{ id: string; name: string } | null>(null); const [showDeploy, setShowDeploy] = useState(false); + const [showAddNode, setShowAddNode] = useState(false); const swarmInfoQ = trpc.nodes.swarmInfo.useQuery(undefined, { refetchInterval: 15000 }); const nodesQ = trpc.nodes.list.useQuery(undefined, { refetchInterval: 10000 }); @@ -934,6 +1122,13 @@ export default function Nodes() { > Deploy Agent + + {nodesQ.isLoading ? (
Loading nodes… @@ -1015,9 +1228,16 @@ export default function Nodes() {

No nodes found

-

+

{swarmInfoQ.isError ? "Cannot connect to Swarm — check gateway logs" : "Swarm has no registered nodes yet"}

+
) : (
diff --git a/gateway/cmd/gateway/main.go b/gateway/cmd/gateway/main.go index 70d4b0d..84e742e 100644 --- a/gateway/cmd/gateway/main.go +++ b/gateway/cmd/gateway/main.go @@ -139,6 +139,7 @@ func main() { r.Get("/swarm/services/{id}/tasks", h.SwarmServiceTasks) 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/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 4cd47d5..8b67cd9 100644 --- a/gateway/go.mod +++ b/gateway/go.mod @@ -3,10 +3,15 @@ module git.softuniq.eu/UniqAI/GoClaw/gateway go 1.23.4 require ( - filippo.io/edwards25519 v1.1.0 // indirect - github.com/go-chi/chi/v5 v5.2.1 // indirect - github.com/go-chi/cors v1.2.1 // indirect - github.com/go-sql-driver/mysql v1.8.1 // indirect - github.com/jmoiron/sqlx v1.4.0 // indirect - github.com/joho/godotenv v1.5.1 // indirect + 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 +) + +require ( + filippo.io/edwards25519 v1.1.0 // indirect + golang.org/x/sys v0.32.0 // indirect ) diff --git a/gateway/go.sum b/gateway/go.sum index fa88e2a..4126bf4 100644 --- a/gateway/go.sum +++ b/gateway/go.sum @@ -6,9 +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:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o= +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= -github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= -github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +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= diff --git a/gateway/internal/api/handlers.go b/gateway/internal/api/handlers.go index d3b8cd9..15e2e2f 100644 --- a/gateway/internal/api/handlers.go +++ b/gateway/internal/api/handlers.go @@ -17,6 +17,7 @@ import ( "git.softuniq.eu/UniqAI/GoClaw/gateway/internal/llm" "git.softuniq.eu/UniqAI/GoClaw/gateway/internal/orchestrator" "git.softuniq.eu/UniqAI/GoClaw/gateway/internal/tools" + "golang.org/x/crypto/ssh" ) // Handler holds all dependencies for HTTP handlers. @@ -1404,3 +1405,116 @@ func min(a, b int) int { } return b } + +// POST /api/swarm/join-node +// Connects to a remote host via SSH and runs "docker swarm join ..." to add it to the cluster. +// Body: { "host": "1.2.3.4", "port": 22, "user": "root", "password": "secret", "role": "worker" } +func (h *Handler) SwarmJoinNodeViaSSH(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"` + Role string `json:"role"` // "worker" | "manager" + } + 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 + } + if body.Role == "" { + body.Role = "worker" + } + + // 1. Get join token from local swarm + tokens, err := h.docker.GetJoinTokens() + if err != nil { + respondError(w, http.StatusInternalServerError, "cannot get join tokens: "+err.Error()) + return + } + managerAddr := h.docker.GetManagerAddr() + var token string + if body.Role == "manager" { + token = tokens.JoinTokens.Manager + } else { + token = tokens.JoinTokens.Worker + } + joinCmd := fmt.Sprintf("docker swarm join --token %s %s", token, managerAddr) + + // 2. Dial SSH to the remote host + sshCfg := &ssh.ClientConfig{ + User: body.User, + Auth: []ssh.AuthMethod{ + ssh.Password(body.Password), + }, + HostKeyCallback: ssh.InsecureIgnoreHostKey(), // acceptable for internal cluster management + Timeout: 15 * time.Second, + } + addr := fmt.Sprintf("%s:%d", body.Host, body.Port) + log.Printf("[SwarmJoinNode] Dialing SSH %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()), + "host": body.Host, + "command": joinCmd, + }) + return + } + defer client.Close() + + // 3. Run docker swarm join on the remote node + 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() + + log.Printf("[SwarmJoinNode] Running on %s: %s", body.Host, joinCmd) + out, err := sess.CombinedOutput(joinCmd) + output := strings.TrimSpace(string(out)) + + if err != nil { + // Node might already be in the swarm — treat "already" as success + if strings.Contains(output, "already") || strings.Contains(output, "This node is already") { + respond(w, http.StatusOK, map[string]any{ + "ok": true, + "output": output, + "note": "node is already part of this swarm", + "command": joinCmd, + }) + return + } + respond(w, http.StatusOK, map[string]any{ + "ok": false, + "step": "docker_join", + "error": fmt.Sprintf("docker swarm join failed: %s", err.Error()), + "output": output, + "command": joinCmd, + }) + return + } + + log.Printf("[SwarmJoinNode] Success: %s joined as %s", body.Host, body.Role) + respond(w, http.StatusOK, map[string]any{ + "ok": true, + "output": output, + "host": body.Host, + "role": body.Role, + "command": joinCmd, + }) +} diff --git a/server/gateway-proxy.ts b/server/gateway-proxy.ts index 83c7522..9c7089e 100644 --- a/server/gateway-proxy.ts +++ b/server/gateway-proxy.ts @@ -682,6 +682,40 @@ export async function execSwarmShell(command: string): Promise<{ output: string; } catch { return null; } } +export interface JoinNodeResult { + ok: boolean; + output?: string; + error?: string; + step?: string; + note?: string; + host?: string; + role?: string; + command?: string; +} + +/** + * SSH into a remote host and run "docker swarm join ..." to add it to the cluster. + * The gateway fetches the current join token automatically. + */ +export async function joinSwarmNodeViaSSH(opts: { + host: string; + port?: number; + user: string; + password: string; + role?: "worker" | "manager"; +}): Promise { + try { + const res = await fetch(`${GATEWAY_BASE_URL}/api/swarm/join-node`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(opts), + signal: AbortSignal.timeout(30_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 5f1c1c4..7131ab7 100644 --- a/server/routers.ts +++ b/server/routers.ts @@ -34,6 +34,7 @@ import { startSwarmAgent, stopSwarmAgent, getOllamaModelInfo, + joinSwarmNodeViaSSH, } from "./gateway-proxy"; // Shared system user id for non-authenticated agent management @@ -1048,6 +1049,24 @@ export const appRouter = router({ return { ok }; }), + /** + * SSH into a remote host and run "docker swarm join ..." to add it to the cluster. + * The gateway fetches the join token automatically. + */ + joinNode: 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), + role: z.enum(["worker", "manager"]).default("worker"), + })) + .mutation(async ({ input }) => { + const result = await joinSwarmNodeViaSSH(input); + if (!result) throw new Error("Gateway unavailable — cannot reach SSH endpoint"); + return result; + }), + /** * Get live container stats (CPU%, RAM) for all running containers. */