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"}
+
setShowAddNode(true)}
+ className="gap-1.5 text-xs text-green-400 border-green-500/40 hover:bg-green-500/10"
+ >
+ Add Node via SSH
+
) : (
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.
*/