From c91c9ed7fdc4c7f888f99d17f4664960d3cd2a34 Mon Sep 17 00:00:00 2001 From: SaelixCode Date: Thu, 19 Mar 2026 12:20:32 -0400 Subject: [PATCH] refactor: pivot from ssh proxy to distributed api model - Delete SSHFileAdapter, IFileAdapter, LocalFileAdapter (SSH/SFTP stack) - Remove ssh2, ssh2-sftp-client dependencies; add http-proxy-middleware, http-proxy - NodeRegistry: remote nodes no longer use Dockerode TCP; new getProxyTarget() returns {apiUrl, apiToken} - NodeRegistry.testConnection: remote nodes use HTTP GET /api/auth/check instead of docker.info() - DatabaseService: Node interface swaps SSH/TLS fields for api_url + api_token; legacy columns preserved for DB compat - FileSystemService: reverted to clean local-only fs.promises; adapter pattern fully removed - ComposeService: executeRemote() and SSH log streaming deleted; local-only execution remains - index.ts: add /api/auth/generate-node-token endpoint (long-lived JWT, scope:node_proxy) - index.ts: authMiddleware now accepts Bearer token in addition to cookie (Sencho-to-Sencho auth) - index.ts: remote HTTP proxy middleware intercepts all /api/ requests for remote nodes, strips x-node-id, injects Authorization header, proxies to api_url - index.ts: WS upgrade handler proxies WebSocket connections for remote nodes via http-proxy wsProxyServer - NodeManager.tsx: form reduced to Name, API URL, API Token; Generate Node Token button added inline - NodeContext.tsx: Node interface updated to api_url/api_token Co-Authored-By: Claude Sonnet 4.6 --- .claude/settings.local.json | 21 ++ CHANGELOG.md | 11 +- backend/package-lock.json | 155 ++++---- backend/package.json | 8 +- backend/src/index.ts | 125 +++++-- backend/src/services/ComposeService.ts | 186 ++-------- backend/src/services/DatabaseService.ts | 72 ++-- backend/src/services/FileSystemService.ts | 168 +++------ backend/src/services/NodeRegistry.ts | 124 ++++--- backend/src/services/fs/IFileAdapter.ts | 9 - backend/src/services/fs/LocalFileAdapter.ts | 27 -- backend/src/services/fs/SSHFileAdapter.ts | 125 ------- frontend/src/components/NodeManager.tsx | 376 ++++++-------------- frontend/src/context/NodeContext.tsx | 14 +- 14 files changed, 522 insertions(+), 899 deletions(-) create mode 100644 .claude/settings.local.json delete mode 100644 backend/src/services/fs/IFileAdapter.ts delete mode 100644 backend/src/services/fs/LocalFileAdapter.ts delete mode 100644 backend/src/services/fs/SSHFileAdapter.ts diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 00000000..62a8ae41 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,21 @@ +{ + "env": { + "includeCoAuthoredBy": "false" + }, + "permissions": { + "allow": [ + "Bash(xargs wc:*)", + "Bash(grep -c \"TODO\\\\|FIXME\" /c/Users/Boris/Documents/Projects/Sencho/backend/src/services/*.ts)", + "Bash(git checkout:*)", + "Bash(git pull:*)", + "Bash(git add:*)", + "Bash(git commit:*)", + "Bash(git push:*)", + "mcp__github__create_pull_request", + "Bash(gh auth:*)", + "Bash(npm uninstall:*)", + "Bash(npm install:*)", + "Bash(npx tsc:*)" + ] + } +} diff --git a/CHANGELOG.md b/CHANGELOG.md index 2568e004..8f3339da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,11 +5,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] -- **Added:** TLS certificate support for secure remote Docker daemon connections. -- **Added:** SSH Private Key authentication UI for the Smart Proxy file system. -- **Fixed:** Fatal path.join crash in FileSystemService caused by malformed SSH directory reads. -- **Fixed:** Home dashboard System Stats incorrectly showing local hardware metrics when a remote node was active. -- **Fixed:** UI overlap between the 'Add Node' button and the Settings dialog close icon. +- **Removed:** SSH/SFTP file adapters and remote Docker TCP connections (net negative ~500 lines of code). +- **Added:** Distributed API proxying using http-proxy-middleware for HTTP and WebSockets. +- **Added:** Long-lived JWT generation for Sencho-to-Sencho API authentication (`POST /api/auth/generate-node-token`). +- **Changed:** Node Manager UI vastly simplified — remote nodes now only require an API URL and Token. - **Fixed:** Critical port routing conflict — separated Docker API port (`port`) from SSH/SFTP port (`ssh_port`) in the `nodes` schema. Previously, a single `port` field served both protocols, causing ECONNREFUSED. - **Fixed:** `FileSystemService` now reads the node's `compose_dir` from the database for remote nodes instead of always using the `COMPOSE_DIR` env var. - **Fixed:** SSH/SFTP connections in `SSHFileAdapter`, `ComposeService.executeRemote()`, and `ComposeService.streamLogs()` now use `ssh_port` (default 22) instead of Docker API `port`. @@ -19,7 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Added:** `IFileAdapter`, `LocalFileAdapter`, and `SSHFileAdapter` to abstract all filesystem interactions for remote node support. - **Changed:** `MonitorService` now evaluates limits, fetches metrics, and detects container crashes across all registered nodes concurrently. - **Added:** Node Context Middleware in Express API to dynamically extract `x-node-id` headers and parse WebSocket query parameters. -- **Added:** Remote Nodes Foundation (Strategy B) — `nodes` table in SQLite with auto-seeded default local node. +- **Added:** Remote Nodes Foundation - `nodes` table in SQLite with auto-seeded default local node. - **Added:** `NodeRegistry` service for managing multiple Docker daemon connections (local socket + TCP). - **Added:** Node management API endpoints: list, get, create, update, delete, and test connection. - **Added:** Settings Hub → Nodes tab with full CRUD UI, connection testing, and Docker info display. diff --git a/backend/package-lock.json b/backend/package-lock.json index 3e10f5dd..458df2a9 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -12,6 +12,7 @@ "@types/cors": "^2.8.19", "@types/dockerode": "^4.0.1", "@types/express": "^5.0.6", + "@types/http-proxy": "^1.17.17", "@types/ws": "^8.18.1", "axios": "^1.13.6", "bcrypt": "^6.0.0", @@ -21,10 +22,10 @@ "cors": "^2.8.6", "dockerode": "^4.0.9", "express": "^5.2.1", + "http-proxy": "^1.18.1", + "http-proxy-middleware": "^3.0.5", "jsonwebtoken": "^9.0.3", "node-pty": "^1.1.0", - "ssh2": "^1.17.0", - "ssh2-sftp-client": "^12.1.0", "systeminformation": "^5.31.1", "ws": "^8.19.0", "yaml": "^2.8.2" @@ -33,10 +34,9 @@ "@types/bcrypt": "^6.0.0", "@types/better-sqlite3": "^7.6.13", "@types/cookie-parser": "^1.4.10", + "@types/http-proxy-middleware": "^0.19.3", "@types/jsonwebtoken": "^9.0.10", "@types/node": "^25.3.0", - "@types/ssh2": "^1.15.5", - "@types/ssh2-sftp-client": "^9.0.6", "@types/yaml": "^1.9.6", "nodemon": "^3.1.13", "ts-node": "^10.9.2", @@ -350,6 +350,27 @@ "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", "license": "MIT" }, + "node_modules/@types/http-proxy": { + "version": "1.17.17", + "resolved": "https://registry.npmjs.org/@types/http-proxy/-/http-proxy-1.17.17.tgz", + "integrity": "sha512-ED6LB+Z1AVylNTu7hdzuBqOgMnvG/ld6wGCG8wFnAzKX5uyW2K3WD52v0gnLCTK/VLpXtKckgWuyScYK6cSPaw==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/http-proxy-middleware": { + "version": "0.19.3", + "resolved": "https://registry.npmjs.org/@types/http-proxy-middleware/-/http-proxy-middleware-0.19.3.tgz", + "integrity": "sha512-lnBTx6HCOUeIJMLbI/LaL5EmdKLhczJY5oeXZpX/cXE4rRqb3RmV7VcMpiEfYkmTjipv3h7IAyIINe4plEv7cA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/http-proxy": "*", + "@types/node": "*" + } + }, "node_modules/@types/jsonwebtoken": { "version": "9.0.10", "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", @@ -418,16 +439,6 @@ "@types/node": "^18.11.18" } }, - "node_modules/@types/ssh2-sftp-client": { - "version": "9.0.6", - "resolved": "https://registry.npmjs.org/@types/ssh2-sftp-client/-/ssh2-sftp-client-9.0.6.tgz", - "integrity": "sha512-4+KvXO/V77y9VjI2op2T8+RCGI/GXQAwR0q5Qkj/EJ5YSeyKszqZP6F8i3H3txYoBqjc7sgorqyvBP3+w1EHyg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/ssh2": "^1.0.0" - } - }, "node_modules/@types/ssh2/node_modules/@types/node": { "version": "18.19.130", "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", @@ -739,7 +750,6 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, "license": "MIT", "dependencies": { "fill-range": "^7.1.1" @@ -778,12 +788,6 @@ "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", "license": "BSD-3-Clause" }, - "node_modules/buffer-from": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", - "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "license": "MIT" - }, "node_modules/buildcheck": { "version": "0.0.7", "resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.7.tgz", @@ -953,21 +957,6 @@ "yaml": "^2.x" } }, - "node_modules/concat-stream": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", - "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", - "engines": [ - "node >= 6.0" - ], - "license": "MIT", - "dependencies": { - "buffer-from": "^1.0.0", - "inherits": "^2.0.3", - "readable-stream": "^3.0.2", - "typedarray": "^0.0.6" - } - }, "node_modules/content-disposition": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", @@ -1324,6 +1313,12 @@ "node": ">= 0.6" } }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "license": "MIT" + }, "node_modules/expand-template": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", @@ -1408,7 +1403,6 @@ "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, "license": "MIT", "dependencies": { "to-regex-range": "^5.0.1" @@ -1689,6 +1683,37 @@ "url": "https://opencollective.com/express" } }, + "node_modules/http-proxy": { + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz", + "integrity": "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==", + "license": "MIT", + "dependencies": { + "eventemitter3": "^4.0.0", + "follow-redirects": "^1.0.0", + "requires-port": "^1.0.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/http-proxy-middleware": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-3.0.5.tgz", + "integrity": "sha512-GLZZm1X38BPY4lkXA01jhwxvDoOkkXqjgVyUzVxiEK4iuRu03PZoYHhHRwxnfhQMDuaxi3vVri0YgSro/1oWqg==", + "license": "MIT", + "dependencies": { + "@types/http-proxy": "^1.17.15", + "debug": "^4.3.6", + "http-proxy": "^1.18.1", + "is-glob": "^4.0.3", + "is-plain-object": "^5.0.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, "node_modules/iconv-lite": { "version": "0.7.2", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", @@ -1779,7 +1804,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -1798,7 +1822,6 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, "license": "MIT", "dependencies": { "is-extglob": "^2.1.1" @@ -1811,12 +1834,20 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.12.0" } }, + "node_modules/is-plain-object": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz", + "integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-promise": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", @@ -1981,6 +2012,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, "node_modules/mime-db": { "version": "1.54.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", @@ -2229,7 +2273,6 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, "license": "MIT", "engines": { "node": ">=8.6" @@ -2424,6 +2467,12 @@ "node": ">=0.10.0" } }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "license": "MIT" + }, "node_modules/router": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", @@ -2682,23 +2731,6 @@ "nan": "^2.23.0" } }, - "node_modules/ssh2-sftp-client": { - "version": "12.1.0", - "resolved": "https://registry.npmjs.org/ssh2-sftp-client/-/ssh2-sftp-client-12.1.0.tgz", - "integrity": "sha512-f8+EylryHXPg+pMHYqtK+jPOPXJTBDOdgwOPYNS7mJTkk5bPsLQfOFUVl99PQvUkE5MkbyR5jThBlfeQMXcvrA==", - "license": "Apache-2.0", - "dependencies": { - "concat-stream": "^2.0.0", - "ssh2": "^1.16.0" - }, - "engines": { - "node": ">=18.20.4" - }, - "funding": { - "type": "individual", - "url": "https://square.link/u/4g7sPflL" - } - }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -2823,7 +2855,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, "license": "MIT", "dependencies": { "is-number": "^7.0.0" @@ -2927,12 +2958,6 @@ "node": ">= 0.6" } }, - "node_modules/typedarray": { - "version": "0.0.6", - "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", - "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", - "license": "MIT" - }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", diff --git a/backend/package.json b/backend/package.json index a452faac..1f7a0ffc 100644 --- a/backend/package.json +++ b/backend/package.json @@ -17,10 +17,9 @@ "@types/bcrypt": "^6.0.0", "@types/better-sqlite3": "^7.6.13", "@types/cookie-parser": "^1.4.10", + "@types/http-proxy-middleware": "^0.19.3", "@types/jsonwebtoken": "^9.0.10", "@types/node": "^25.3.0", - "@types/ssh2": "^1.15.5", - "@types/ssh2-sftp-client": "^9.0.6", "@types/yaml": "^1.9.6", "nodemon": "^3.1.13", "ts-node": "^10.9.2", @@ -30,6 +29,7 @@ "@types/cors": "^2.8.19", "@types/dockerode": "^4.0.1", "@types/express": "^5.0.6", + "@types/http-proxy": "^1.17.17", "@types/ws": "^8.18.1", "axios": "^1.13.6", "bcrypt": "^6.0.0", @@ -39,10 +39,10 @@ "cors": "^2.8.6", "dockerode": "^4.0.9", "express": "^5.2.1", + "http-proxy": "^1.18.1", + "http-proxy-middleware": "^3.0.5", "jsonwebtoken": "^9.0.3", "node-pty": "^1.1.0", - "ssh2": "^1.17.0", - "ssh2-sftp-client": "^12.1.0", "systeminformation": "^5.31.1", "ws": "^8.19.0", "yaml": "^2.8.2" diff --git a/backend/src/index.ts b/backend/src/index.ts index b2fb53bd..4ef72ed1 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -12,6 +12,8 @@ import crypto from 'crypto'; import composerize from 'composerize'; import si from 'systeminformation'; import http from 'http'; +import httpProxy from 'http-proxy'; +import { createProxyMiddleware } from 'http-proxy-middleware'; import { spawn, exec } from 'child_process'; import { promisify } from 'util'; import path from 'path'; @@ -92,9 +94,21 @@ declare global { } } +// WebSocket proxy server for forwarding remote node WS connections +const wsProxyServer = httpProxy.createProxyServer({ changeOrigin: true }); +wsProxyServer.on('error', (err, _req, socket: any) => { + console.error('[WS Proxy] Error:', err.message); + try { socket?.destroy(); } catch {} +}); + // Authentication Middleware +// Accepts both cookie auth (browser sessions) and Bearer token auth (Sencho-to-Sencho proxy) const authMiddleware = async (req: Request, res: Response, next: NextFunction): Promise => { - const token = req.cookies[COOKIE_NAME]; + const cookieToken = req.cookies[COOKIE_NAME]; + const bearerToken = req.headers.authorization?.startsWith('Bearer ') + ? req.headers.authorization.slice(7) + : null; + const token = cookieToken || bearerToken; if (!token) { res.status(401).json({ error: 'Authentication required' }); @@ -105,8 +119,9 @@ const authMiddleware = async (req: Request, res: Response, next: NextFunction): const settings = DatabaseService.getInstance().getGlobalSettings(); const jwtSecret = settings.auth_jwt_secret; if (!jwtSecret) throw new Error('No JWT secret'); - const decoded = jwt.verify(token, jwtSecret) as { username: string }; - req.user = { username: decoded.username }; + const decoded = jwt.verify(token, jwtSecret) as { username?: string; scope?: string }; + // Accept both user sessions and node proxy tokens + req.user = { username: decoded.username || 'node-proxy' }; next(); } catch { res.status(401).json({ error: 'Invalid or expired token' }); @@ -263,6 +278,23 @@ app.get('/api/auth/check', authMiddleware, (req: Request, res: Response): void = res.json({ authenticated: true, user: req.user }); }); +// Generate a long-lived node proxy token for Sencho-to-Sencho authentication +app.post('/api/auth/generate-node-token', authMiddleware, async (req: Request, res: Response): Promise => { + try { + const settings = DatabaseService.getInstance().getGlobalSettings(); + const jwtSecret = settings.auth_jwt_secret; + if (!jwtSecret) { + res.status(500).json({ error: 'No JWT secret configured on this instance.' }); + return; + } + // No expiry — this token is managed by the admin who pastes it into the main dashboard + const token = jwt.sign({ scope: 'node_proxy' }, jwtSecret); + res.json({ token }); + } catch (error: any) { + res.status(500).json({ error: error.message || 'Failed to generate node token' }); + } +}); + // Apply authentication middleware to all /api/* routes except /api/auth/* app.use('/api', (req: Request, res: Response, next: NextFunction): void => { if (req.path.startsWith('/auth/')) { @@ -272,6 +304,48 @@ app.use('/api', (req: Request, res: Response, next: NextFunction): void => { authMiddleware(req, res, next); }); +// Remote Node HTTP Proxy +// Intercepts all /api/ requests for remote Distributed API nodes and forwards them +// to the target Sencho instance. Node management and auth routes always execute locally. +app.use('/api/', (req: Request, res: Response, next: NextFunction): void => { + if (req.path.startsWith('/auth/') || req.path.startsWith('/nodes')) { + next(); + return; + } + + const node = NodeRegistry.getInstance().getNode(req.nodeId); + if (!node || node.type !== 'remote') { + next(); + return; + } + + if (!node.api_url || !node.api_token) { + res.status(503).json({ + error: `Remote node "${node.name}" has no API URL or token configured. Update it in Settings → Nodes.` + }); + return; + } + + createProxyMiddleware({ + target: node.api_url, + changeOrigin: true, + on: { + proxyReq: (proxyReq) => { + // Remote Sencho is always "local" to itself — strip node context + proxyReq.removeHeader('x-node-id'); + proxyReq.setHeader('Authorization', `Bearer ${node.api_token!}`); + }, + error: (_err, _req, proxyRes) => { + if (!(proxyRes as Response).headersSent) { + (proxyRes as Response).status(502).json({ + error: `Remote node "${node.name}" is unreachable. Check the API URL and ensure Sencho is running on that host.` + }); + } + }, + }, + })(req, res, next); +}); + // Create HTTP server for WebSocket upgrade handling const server = http.createServer(app); @@ -302,11 +376,25 @@ server.on('upgrade', async (req, socket, head) => { if (!jwtSecret) throw new Error('No JWT secret'); jwt.verify(token, jwtSecret); - // Check if this is a stack logs WebSocket request const url = req.url || ''; const parsedUrl = new URL(url, `http://${req.headers.host || 'localhost'}`); const pathname = parsedUrl.pathname; + // Resolve node context from query param + const nodeIdParam = parsedUrl.searchParams.get('nodeId'); + const nodeId = nodeIdParam ? parseInt(nodeIdParam, 10) : NodeRegistry.getInstance().getDefaultNodeId(); + const node = NodeRegistry.getInstance().getNode(nodeId); + + // Remote Node WebSocket Proxy — forward the entire WS connection to the remote Sencho instance + if (node && node.type === 'remote' && node.api_url && node.api_token) { + const wsTarget = node.api_url.replace(/^https?/, (m) => m === 'https' ? 'wss' : 'ws'); + req.headers['authorization'] = `Bearer ${node.api_token}`; + delete req.headers['x-node-id']; + wsProxyServer.ws(req, socket, head, { target: wsTarget }); + return; + } + + // Local node handling const logsMatch = pathname.match(/^\/api\/stacks\/([^/]+)\/logs$/); const hostConsoleMatch = pathname.match(/^\/api\/system\/host-console/); @@ -315,9 +403,6 @@ server.on('upgrade', async (req, socket, head) => { const logsWss = new WebSocket.Server({ noServer: true }); logsWss.handleUpgrade(req, socket, head, (ws) => { const stackName = decodeURIComponent(logsMatch[1]); - const nodeIdParam = parsedUrl.searchParams.get('nodeId'); - const nodeId = nodeIdParam ? parseInt(nodeIdParam, 10) : NodeRegistry.getInstance().getDefaultNodeId(); - try { ComposeService.getInstance(nodeId).streamLogs(stackName, ws); } catch (error) { @@ -332,17 +417,12 @@ server.on('upgrade', async (req, socket, head) => { hostConsoleWss.handleUpgrade(req, socket, head, (ws) => { let targetDirectory = ''; try { - const reqUrl = new URL(req.url || '', `http://${req.headers.host || 'localhost'}`); - const nodeIdParam = reqUrl.searchParams.get('nodeId'); - const nodeId = nodeIdParam ? parseInt(nodeIdParam, 10) : NodeRegistry.getInstance().getDefaultNodeId(); targetDirectory = FileSystemService.getInstance(nodeId).getBaseDir(); - - const stackParam = reqUrl.searchParams.get('stack'); + const stackParam = parsedUrl.searchParams.get('stack'); if (stackParam) { targetDirectory = path.join(targetDirectory, stackParam); } } catch (e) { - // ignore parsing error, fallback to base dir targetDirectory = FileSystemService.getInstance(NodeRegistry.getInstance().getDefaultNodeId()).getBaseDir(); } try { @@ -1432,7 +1512,7 @@ app.get('/api/nodes/:id', async (req: Request, res: Response) => { // Create a new node app.post('/api/nodes', async (req: Request, res: Response) => { try { - const { name, type, host, port, ssh_port, compose_dir, is_default, ssh_user, ssh_password, ssh_key, tls_ca, tls_cert, tls_key } = req.body; + const { name, type, compose_dir, is_default, api_url, api_token } = req.body; if (!name || typeof name !== 'string') { return res.status(400).json({ error: 'Node name is required' }); @@ -1440,24 +1520,17 @@ app.post('/api/nodes', async (req: Request, res: Response) => { if (!type || !['local', 'remote'].includes(type)) { return res.status(400).json({ error: 'Node type must be "local" or "remote"' }); } - if (type === 'remote' && (!host || typeof host !== 'string')) { - return res.status(400).json({ error: 'Host is required for remote nodes' }); + if (type === 'remote' && (!api_url || typeof api_url !== 'string')) { + return res.status(400).json({ error: 'API URL is required for remote nodes' }); } const id = DatabaseService.getInstance().addNode({ name, type, - host: host || '', - port: port || 2375, - ssh_port: ssh_port || 22, - compose_dir: compose_dir || '/opt/docker', + compose_dir: compose_dir || '/app/compose', is_default: is_default || false, - ssh_user: ssh_user || '', - ssh_password: ssh_password || '', - ssh_key: ssh_key || '', - tls_ca: tls_ca || '', - tls_cert: tls_cert || '', - tls_key: tls_key || '', + api_url: api_url || '', + api_token: api_token || '', }); res.json({ success: true, id }); diff --git a/backend/src/services/ComposeService.ts b/backend/src/services/ComposeService.ts index 44fb0e16..e869f3f7 100644 --- a/backend/src/services/ComposeService.ts +++ b/backend/src/services/ComposeService.ts @@ -1,14 +1,16 @@ -import { spawn, exec } from 'child_process'; -import { promisify } from 'util'; +import { spawn } from 'child_process'; import path from 'path'; import WebSocket from 'ws'; import DockerController from './DockerController'; import { LogFormatter } from './LogFormatter'; import { NodeRegistry } from './NodeRegistry'; -import { Client as SSHClient } from 'ssh2'; - -const execAsync = promisify(exec); +/** + * ComposeService — local docker compose CLI execution. + * + * In the Distributed API model, remote node compose operations are handled + * by the remote Sencho instance. This service only executes commands locally. + */ export class ComposeService { private baseDir: string; private nodeId: number; @@ -22,30 +24,10 @@ export class ComposeService { return new ComposeService(nodeId); } - /** - * Universal command execution (Local or Remote SSH) - */ - private async executeCommand( - command: string, - args: string[], - cwd: string, - ws?: WebSocket, - throwOnError = true - ): Promise { - const node = NodeRegistry.getInstance().getNode(this.nodeId); - if (!node) throw new Error(`Node ${this.nodeId} not found`); - - if (node.type === 'local' || !node.host) { - return this.executeLocal(command, args, cwd, ws, throwOnError); - } else { - return this.executeRemote(node, command, args, cwd, ws, throwOnError); - } - } - - private async executeLocal( - command: string, - args: string[], - cwd: string, + private execute( + command: string, + args: string[], + cwd: string, ws?: WebSocket, throwOnError = true ): Promise { @@ -90,65 +72,9 @@ export class ComposeService { }); } - private async executeRemote( - node: any, - command: string, - args: string[], - cwd: string, - ws?: WebSocket, - throwOnError = true - ): Promise { - return new Promise((resolve, reject) => { - const conn = new SSHClient(); - let errorLog = ''; - - conn.on('ready', () => { - const cmdString = `cd "${cwd}" && ${command} ${args.map(a => `"${a}"`).join(' ')}`; - - conn.exec(cmdString, (err, stream) => { - if (err) { - conn.end(); - if (throwOnError) reject(err); else resolve(); - return; - } - - const onData = (data: any) => { - const text = data.toString(); - errorLog += text; - if (ws && ws.readyState === WebSocket.OPEN) { - ws.send(text); - } - }; - - stream.on('data', onData).stderr.on('data', onData); - - stream.on('close', (code: any, signal: any) => { - conn.end(); - if (ws && ws.readyState === WebSocket.OPEN) { - ws.send(`Command exited with code ${code}\n`); - } - if (code === 0) resolve(); - else if (throwOnError) reject(new Error(errorLog.trim() || `Command failed with code ${code}`)); - else resolve(); - }); - }); - }).on('error', (err) => { - if (ws && ws.readyState === WebSocket.OPEN) ws.send(`SSH Error: ${err.message}\n`); - if (throwOnError) reject(err); else resolve(); - }).connect({ - host: node.host, - port: node.ssh_port || 22, - username: node.ssh_user!, - password: node.ssh_password, - privateKey: node.ssh_key, - readyTimeout: 10000, - }); - }); - } - async runCommand(stackName: string, action: 'down' | 'start' | 'stop' | 'restart', ws?: WebSocket): Promise { const stackDir = path.join(this.baseDir, stackName); - await this.executeCommand('docker', ['compose', action], stackDir, ws); + await this.execute('docker', ['compose', action], stackDir, ws); } async deployStack(stackName: string, ws?: WebSocket): Promise { @@ -165,7 +91,7 @@ export class ComposeService { console.warn(`Failed to clean up legacy containers for ${stackName}:`, e); } - await this.executeCommand('docker', ['compose', 'up', '-d', '--remove-orphans'], stackDir, ws); + await this.execute('docker', ['compose', 'up', '-d', '--remove-orphans'], stackDir, ws); // Post-Deploy Health Probe await new Promise(resolve => setTimeout(resolve, 3000)); @@ -231,13 +157,10 @@ export class ComposeService { let activeProcesses = 0; let streamEndedHandled = false; - - let clientConnections: any[] = []; let localProcesses: ReturnType[] = []; const onWsClose = () => { localProcesses.forEach(cp => { try { cp.kill(); } catch {} }); - clientConnections.forEach(conn => { try { conn.end(); } catch {} }); }; ws.on('close', onWsClose); @@ -253,80 +176,43 @@ export class ComposeService { } }; - const node = NodeRegistry.getInstance().getNode(this.nodeId); - const isRemote = (node && node.type === 'remote' && node.host); - for (const container of containersToLog) { const containerName = container.Names?.[0]?.replace(/^\//, '') || container.Id; activeProcesses++; let lineBuffer = ''; - const sendOutput = (data: Buffer | any) => { + const sendOutput = (data: Buffer) => { if (ws.readyState === WebSocket.OPEN) { lineBuffer += data.toString(); const lines = lineBuffer.split(/\r?\n/); lineBuffer = lines.pop() || ''; for (const line of lines) { - const formattedLine = LogFormatter.process(line); - ws.send(formattedLine + '\r\n'); + ws.send(LogFormatter.process(line) + '\r\n'); } } }; const flushBuffer = () => { - if (lineBuffer && ws.readyState === WebSocket.OPEN) { - const formattedLine = LogFormatter.process(lineBuffer); - ws.send(formattedLine + '\r\n'); - lineBuffer = ''; + if (lineBuffer && ws.readyState === WebSocket.OPEN) { + ws.send(LogFormatter.process(lineBuffer) + '\r\n'); + lineBuffer = ''; } - } + }; - if (isRemote) { - const conn = new SSHClient(); - clientConnections.push(conn); - conn.on('ready', () => { - conn.exec(`docker logs -f --tail 100 "${containerName}"`, { - env: { - ...process.env, - PATH: process.env.PATH || '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin' - } - }, (err, stream) => { - if (err) { - conn.end(); - handleProcessEnd(); - return; - } - stream.on('data', sendOutput).stderr.on('data', sendOutput); - stream.on('close', () => { - flushBuffer(); - conn.end(); - handleProcessEnd(); - }); - }); - }).on('error', handleProcessEnd).connect({ - host: node!.host, - port: node!.ssh_port || 22, - username: node!.ssh_user!, - password: node!.ssh_password, - privateKey: node!.ssh_key, - readyTimeout: 10000, - }); - } else { - const child = spawn('docker', ['logs', '-f', '--tail', '100', containerName], { - env: { - ...process.env, - PATH: process.env.PATH || '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin' - } - }); - localProcesses.push(child); - child.stdout.on('data', sendOutput); - child.stderr.on('data', sendOutput); - child.on('error', handleProcessEnd); - child.on('close', () => { - flushBuffer(); - handleProcessEnd(); - }); - } + const child = spawn('docker', ['logs', '-f', '--tail', '100', containerName], { + env: { + ...process.env, + PATH: process.env.PATH || '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin' + } + }); + localProcesses.push(child); + child.stdout.on('data', sendOutput); + child.stderr.on('data', sendOutput); + child.on('error', handleProcessEnd); + child.on('close', () => { + flushBuffer(); + handleProcessEnd(); + }); } } catch (err) { if (!isClosed && ws.readyState === WebSocket.OPEN) { @@ -360,17 +246,17 @@ export class ComposeService { } sendOutput('=== Pulling latest images ===\n'); - await this.executeCommand('docker', ['compose', 'pull'], stackDir, ws); + await this.execute('docker', ['compose', 'pull'], stackDir, ws); sendOutput('=== Recreating containers ===\n'); - await this.executeCommand('docker', ['compose', 'up', '-d', '--remove-orphans'], stackDir, ws); + await this.execute('docker', ['compose', 'up', '-d', '--remove-orphans'], stackDir, ws); sendOutput('=== Stack updated successfully ===\n'); } public async downStack(stackName: string): Promise { const stackPath = path.join(this.baseDir, stackName); try { - await this.executeCommand('docker', ['compose', 'down'], stackPath, undefined, false); + await this.execute('docker', ['compose', 'down'], stackPath, undefined, false); } catch (error) { console.warn(`[Teardown] Docker down failed or nothing to clean up for ${stackName}`); } diff --git a/backend/src/services/DatabaseService.ts b/backend/src/services/DatabaseService.ts index abfab30a..eb9810ca 100644 --- a/backend/src/services/DatabaseService.ts +++ b/backend/src/services/DatabaseService.ts @@ -29,19 +29,12 @@ export interface Node { id: number; name: string; type: 'local' | 'remote'; - host: string; - port: number; - ssh_port: number; compose_dir: string; is_default: boolean; status: 'online' | 'offline' | 'unknown'; created_at: number; - ssh_user?: string; - ssh_password?: string; - ssh_key?: string; - tls_ca?: string; - tls_cert?: string; - tls_key?: string; + api_url?: string; + api_token?: string; } export interface NotificationHistory { @@ -59,14 +52,12 @@ export class DatabaseService { private constructor() { const dataDir = process.env.DATA_DIR || path.join(process.cwd(), 'data'); - // Ensure data directory exists if (!fs.existsSync(dataDir)) { fs.mkdirSync(dataDir, { recursive: true }); } const dbPath = path.join(dataDir, 'sencho.db'); this.db = new Database(dbPath); - // Default journal mode is safer for arbitrary Docker volume mounts than WAL this.initSchema(); this.migrateJsonConfig(dataDir); @@ -126,7 +117,7 @@ export class DatabaseService { net_tx_mb REAL NOT NULL, timestamp INTEGER NOT NULL ); - + CREATE INDEX IF NOT EXISTS idx_metrics_timestamp ON container_metrics(timestamp); CREATE INDEX IF NOT EXISTS idx_metrics_container ON container_metrics(container_id); @@ -134,8 +125,6 @@ export class DatabaseService { id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE, type TEXT NOT NULL DEFAULT 'local', - host TEXT NOT NULL DEFAULT '', - port INTEGER NOT NULL DEFAULT 2375, compose_dir TEXT NOT NULL DEFAULT '/app/compose', is_default INTEGER DEFAULT 0, status TEXT NOT NULL DEFAULT 'unknown', @@ -147,6 +136,14 @@ export class DatabaseService { const maybeAddCol = (table: string, col: string, def: string) => { try { this.db.prepare(`ALTER TABLE ${table} ADD COLUMN ${col} ${def}`).run(); } catch (e) { /* ignore */ } }; + + // Distributed API model columns + maybeAddCol('nodes', 'api_url', "TEXT DEFAULT ''"); + maybeAddCol('nodes', 'api_token', "TEXT DEFAULT ''"); + + // Legacy SSH/TLS columns preserved for DB backward-compat (no longer read or written) + maybeAddCol('nodes', 'host', "TEXT DEFAULT ''"); + maybeAddCol('nodes', 'port', 'INTEGER DEFAULT 2375'); maybeAddCol('nodes', 'ssh_port', 'INTEGER DEFAULT 22'); maybeAddCol('nodes', 'ssh_user', "TEXT DEFAULT ''"); maybeAddCol('nodes', 'ssh_password', "TEXT DEFAULT ''"); @@ -162,15 +159,15 @@ export class DatabaseService { stmt.run('host_disk_limit', '90'); stmt.run('global_crash', '1'); stmt.run('docker_janitor_gb', '5'); - stmt.run('global_logs_refresh', '5'); // Default 5 seconds - stmt.run('developer_mode', '0'); // Default off + stmt.run('global_logs_refresh', '5'); + stmt.run('developer_mode', '0'); // Seed the default local node if none exists const nodeCount = (this.db.prepare('SELECT COUNT(*) as count FROM nodes').get() as any)?.count || 0; if (nodeCount === 0) { this.db.prepare( - 'INSERT INTO nodes (name, type, host, port, compose_dir, is_default, status, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)' - ).run('Local', 'local', '', 0, process.env.COMPOSE_DIR || '/app/compose', 1, 'online', Date.now()); + 'INSERT INTO nodes (name, type, compose_dir, is_default, status, created_at) VALUES (?, ?, ?, ?, ?, ?)' + ).run('Local', 'local', process.env.COMPOSE_DIR || '/app/compose', 1, 'online', Date.now()); } } @@ -188,8 +185,6 @@ export class DatabaseService { stmt.run('auth_jwt_secret', config.jwtSecret); console.log('Successfully migrated sencho.json credentials to SQLite global_settings.'); - - // Delete the file after migrating fs.unlinkSync(configPath); } } catch (err) { @@ -295,9 +290,8 @@ export class DatabaseService { const stmt = this.db.prepare('INSERT INTO notification_history (level, message, timestamp, is_read) VALUES (?, ?, ?, 0)'); stmt.run(notification.level, notification.message, notification.timestamp); - // Cleanup old notifications (keep last 100) this.db.exec(` - DELETE FROM notification_history + DELETE FROM notification_history WHERE id NOT IN ( SELECT id FROM notification_history ORDER BY timestamp DESC LIMIT 100 ) @@ -343,7 +337,7 @@ export class DatabaseService { // --- Nodes --- public getNodes(): Node[] { - const stmt = this.db.prepare('SELECT * FROM nodes ORDER BY is_default DESC, name ASC'); + const stmt = this.db.prepare('SELECT id, name, type, compose_dir, is_default, status, created_at, api_url, api_token FROM nodes ORDER BY is_default DESC, name ASC'); return stmt.all().map((row: any) => ({ ...row, is_default: row.is_default === 1 @@ -351,43 +345,35 @@ export class DatabaseService { } public getNode(id: number): Node | undefined { - const stmt = this.db.prepare('SELECT * FROM nodes WHERE id = ?'); + const stmt = this.db.prepare('SELECT id, name, type, compose_dir, is_default, status, created_at, api_url, api_token FROM nodes WHERE id = ?'); const row = stmt.get(id) as any; if (!row) return undefined; return { ...row, is_default: row.is_default === 1 }; } public getDefaultNode(): Node | undefined { - const stmt = this.db.prepare('SELECT * FROM nodes WHERE is_default = 1 LIMIT 1'); + const stmt = this.db.prepare('SELECT id, name, type, compose_dir, is_default, status, created_at, api_url, api_token FROM nodes WHERE is_default = 1 LIMIT 1'); const row = stmt.get() as any; if (!row) return undefined; return { ...row, is_default: row.is_default === 1 }; } public addNode(node: Omit): number { - // If this node is set as default, clear other defaults first if (node.is_default) { this.db.prepare('UPDATE nodes SET is_default = 0').run(); } const stmt = this.db.prepare( - 'INSERT INTO nodes (name, type, host, port, ssh_port, compose_dir, is_default, status, created_at, ssh_user, ssh_password, ssh_key, tls_ca, tls_cert, tls_key) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)' + 'INSERT INTO nodes (name, type, compose_dir, is_default, status, created_at, api_url, api_token) VALUES (?, ?, ?, ?, ?, ?, ?, ?)' ); const result = stmt.run( node.name, node.type, - node.host, - node.port, - node.ssh_port || 22, - node.compose_dir, + node.compose_dir || '/app/compose', node.is_default ? 1 : 0, 'unknown', Date.now(), - node.ssh_user || '', - node.ssh_password || '', - node.ssh_key || '', - node.tls_ca || '', - node.tls_cert || '', - node.tls_key || '' + node.api_url || '', + node.api_token || '' ); return result.lastInsertRowid as number; } @@ -396,7 +382,6 @@ export class DatabaseService { const node = this.getNode(id); if (!node) throw new Error(`Node with id ${id} not found`); - // If setting as default, clear other defaults first if (updates.is_default) { this.db.prepare('UPDATE nodes SET is_default = 0').run(); } @@ -406,18 +391,11 @@ export class DatabaseService { if (updates.name !== undefined) { fields.push('name = ?'); values.push(updates.name); } if (updates.type !== undefined) { fields.push('type = ?'); values.push(updates.type); } - if (updates.host !== undefined) { fields.push('host = ?'); values.push(updates.host); } - if (updates.port !== undefined) { fields.push('port = ?'); values.push(updates.port); } - if (updates.ssh_port !== undefined) { fields.push('ssh_port = ?'); values.push(updates.ssh_port); } if (updates.compose_dir !== undefined) { fields.push('compose_dir = ?'); values.push(updates.compose_dir); } if (updates.is_default !== undefined) { fields.push('is_default = ?'); values.push(updates.is_default ? 1 : 0); } if (updates.status !== undefined) { fields.push('status = ?'); values.push(updates.status); } - if (updates.ssh_user !== undefined) { fields.push('ssh_user = ?'); values.push(updates.ssh_user); } - if (updates.ssh_password !== undefined) { fields.push('ssh_password = ?'); values.push(updates.ssh_password); } - if (updates.ssh_key !== undefined) { fields.push('ssh_key = ?'); values.push(updates.ssh_key); } - if (updates.tls_ca !== undefined) { fields.push('tls_ca = ?'); values.push(updates.tls_ca); } - if (updates.tls_cert !== undefined) { fields.push('tls_cert = ?'); values.push(updates.tls_cert); } - if (updates.tls_key !== undefined) { fields.push('tls_key = ?'); values.push(updates.tls_key); } + if (updates.api_url !== undefined) { fields.push('api_url = ?'); values.push(updates.api_url); } + if (updates.api_token !== undefined) { fields.push('api_token = ?'); values.push(updates.api_token); } if (fields.length === 0) return; diff --git a/backend/src/services/FileSystemService.ts b/backend/src/services/FileSystemService.ts index 2dde2121..a9da607a 100644 --- a/backend/src/services/FileSystemService.ts +++ b/backend/src/services/FileSystemService.ts @@ -1,81 +1,59 @@ import path from 'path'; -import { IFileAdapter } from './fs/IFileAdapter'; -import { LocalFileAdapter } from './fs/LocalFileAdapter'; -import { SSHFileAdapter } from './fs/SSHFileAdapter'; +import fs from 'fs'; +import { promises as fsPromises } from 'fs'; import { NodeRegistry } from './NodeRegistry'; +/** + * FileSystemService — local-only file I/O for compose stack management. + * + * In the Distributed API model, remote node file operations are handled + * by the remote Sencho instance itself. This service only operates on + * the local filesystem. + */ export class FileSystemService { private baseDir: string; - private adapter: IFileAdapter; - private nodeId: number; constructor(nodeId?: number) { - this.nodeId = nodeId ?? NodeRegistry.getInstance().getDefaultNodeId(); - - const node = NodeRegistry.getInstance().getNode(this.nodeId); - - if (!node || node.type === 'local' || !node.host) { - this.baseDir = process.env.COMPOSE_DIR || '/app/compose'; - this.adapter = new LocalFileAdapter(); - } else { - this.baseDir = node.compose_dir; - if (!this.baseDir || typeof this.baseDir !== 'string' || this.baseDir.trim() === '') { - throw new Error(`Remote node "${node.name}" has no compose_dir configured. Please set a compose directory in the Node Manager.`); - } - this.adapter = new SSHFileAdapter(node); - } + this.baseDir = NodeRegistry.getInstance().getComposeDir( + nodeId ?? NodeRegistry.getInstance().getDefaultNodeId() + ); } public static getInstance(nodeId?: number): FileSystemService { return new FileSystemService(nodeId); } - /** - * Check if a directory contains a valid compose file - */ private async hasComposeFile(dir: string): Promise { const composeFiles = ['compose.yaml', 'compose.yml', 'docker-compose.yaml', 'docker-compose.yml']; - for (const file of composeFiles) { try { - await this.adapter.access(path.join(dir, file)); + await fsPromises.access(path.join(dir, file)); return true; } catch { - // Continue checking other options + // continue } } - return false; } - /** - * Get the path to the compose file for a stack - * Throws if no compose file is found - */ private async getComposeFilePath(stackName: string): Promise { const stackDir = path.join(this.baseDir, stackName); const composeFiles = ['compose.yaml', 'compose.yml', 'docker-compose.yaml', 'docker-compose.yml']; - for (const file of composeFiles) { const filePath = path.join(stackDir, file); try { - await this.adapter.access(filePath); + await fsPromises.access(filePath); return filePath; } catch { - // Continue checking other options + // continue } } - throw new Error(`No compose file found for stack: ${stackName}`); } - /** - * Get all stacks (directories containing compose files) - * Returns array of stack names (directory names) - */ async getStacks(): Promise { try { - const items = await this.adapter.readdir(this.baseDir, { withFileTypes: true }); + const items = await fsPromises.readdir(this.baseDir, { withFileTypes: true }); const stackNames: string[] = []; for (const item of items) { @@ -83,46 +61,33 @@ export class FileSystemService { if (!item.name || typeof item.name !== 'string') continue; const stackDir = path.join(this.baseDir, item.name); - const hasCompose = await this.hasComposeFile(stackDir); - - if (hasCompose) { + if (await this.hasComposeFile(stackDir)) { stackNames.push(item.name); } } return stackNames; } catch (error: any) { - const nodeName = NodeRegistry.getInstance().getNode(this.nodeId)?.name || 'Unknown'; - console.warn(`[SFTP] Failed to fetch stacks for Node ${nodeName}: ${error.message || error}`); + console.warn(`[FileSystemService] Failed to list stacks: ${error.message}`); return []; } } - /** - * Get the content of a stack's compose file - */ async getStackContent(stackName: string): Promise { try { const filePath = await this.getComposeFilePath(stackName); - return await this.adapter.readFile(filePath, 'utf-8'); + return await fsPromises.readFile(filePath, 'utf-8'); } catch (error) { console.error('Error reading stack content:', error); throw new Error(`Failed to read stack: ${stackName}`); } } - /** - * Save content to a stack's compose file - * Always writes to compose.yaml (standardizing on this filename) - */ async saveStackContent(stackName: string, content: string): Promise { - const stackDir = path.join(this.baseDir, stackName); - const filePath = path.join(stackDir, 'compose.yaml'); - + const filePath = path.join(this.baseDir, stackName, 'compose.yaml'); console.log('Saving to path:', filePath); - try { - await this.adapter.writeFile(filePath, content, 'utf-8'); + await fsPromises.writeFile(filePath, content, 'utf-8'); console.log('File written successfully'); } catch (error) { console.error('Error writing file:', error); @@ -130,55 +95,42 @@ export class FileSystemService { } } - /** - * Check if a stack has an .env file - */ async envExists(stackName: string): Promise { - const envPath = path.join(this.baseDir, stackName, '.env'); try { - await this.adapter.access(envPath); + await fsPromises.access(path.join(this.baseDir, stackName, '.env')); return true; } catch { return false; } } - // Proxy to adapter read/write operations for use in other services and generic routes - async readFile(filePath: string, encoding: BufferEncoding = 'utf-8'): Promise { - return this.adapter.readFile(filePath, encoding); + return fsPromises.readFile(filePath, encoding); } async writeFile(filePath: string, content: string, encoding: BufferEncoding = 'utf-8'): Promise { - return this.adapter.writeFile(filePath, content, encoding); + return fsPromises.writeFile(filePath, content, encoding); } async access(filePath: string): Promise { - return this.adapter.access(filePath); + return fsPromises.access(filePath); } - /** - * Get the content of a stack's .env file - */ async getEnvContent(stackName: string): Promise { const envPath = path.join(this.baseDir, stackName, '.env'); try { - return await this.adapter.readFile(envPath, 'utf-8'); + return await fsPromises.readFile(envPath, 'utf-8'); } catch (error) { console.error('Error reading env file:', error); throw new Error(`Failed to read env file for stack: ${stackName}`); } } - /** - * Save content to a stack's .env file - */ async saveEnvContent(stackName: string, content: string): Promise { const envPath = path.join(this.baseDir, stackName, '.env'); console.log('Saving env to path:', envPath); - try { - await this.adapter.writeFile(envPath, content, 'utf-8'); + await fsPromises.writeFile(envPath, content, 'utf-8'); console.log('Env file written successfully'); } catch (error) { console.error('Error writing env file:', error); @@ -186,33 +138,22 @@ export class FileSystemService { } } - /** - * Create a new stack (directory with boilerplate compose.yaml) - */ async createStack(stackName: string): Promise { - // Validate stack name (no special characters, not empty) if (!stackName || !/^[a-zA-Z0-9_-]+$/.test(stackName)) { throw new Error('Stack name must contain only alphanumeric characters, underscores, or hyphens'); } const stackDir = path.join(this.baseDir, stackName); - // Check if directory already exists try { - await this.adapter.access(stackDir); + await fsPromises.access(stackDir); throw new Error(`Stack "${stackName}" already exists`); } catch (error: any) { - if (error.message.includes('already exists')) { - throw error; - } - // Directory doesn't exist, proceed + if (error.message.includes('already exists')) throw error; } - // Create the directory - await this.adapter.mkdir(stackDir, { recursive: true }); + await fsPromises.mkdir(stackDir, { recursive: true }); - // Write boilerplate compose.yaml - const composePath = path.join(stackDir, 'compose.yaml'); const boilerplate = `services: app: image: nginx:latest @@ -221,7 +162,7 @@ export class FileSystemService { restart: always `; try { - await this.adapter.writeFile(composePath, boilerplate, 'utf-8'); + await fsPromises.writeFile(path.join(stackDir, 'compose.yaml'), boilerplate, 'utf-8'); console.log('Stack created successfully:', stackName); } catch (error) { console.error('Error creating stack:', error); @@ -229,14 +170,10 @@ export class FileSystemService { } } - /** - * Delete a stack (entire directory and its contents) - */ public async deleteStack(stackName: string): Promise { const stackDir = path.join(this.baseDir, stackName); - try { - await this.adapter.rm(stackDir, { recursive: true, force: true }); + await fsPromises.rm(stackDir, { recursive: true, force: true }); console.log('Stack deleted successfully:', stackName); } catch (error: any) { if (error.code !== 'ENOENT') { @@ -246,73 +183,58 @@ export class FileSystemService { } } - /** - * Get the base directory path for stacks - */ getBaseDir(): string { return this.baseDir; } - /** - * Migrate existing flat-file stacks to directory-based structure - * This runs automatically on server startup - */ async migrateFlatToDirectory(): Promise { try { - // Ensure base directory exists try { - await this.adapter.access(this.baseDir); + await fsPromises.access(this.baseDir); } catch { console.log('Creating compose directory:', this.baseDir); - await this.adapter.mkdir(this.baseDir, { recursive: true }); - return; // No files to migrate in a new directory + await fsPromises.mkdir(this.baseDir, { recursive: true }); + return; } - const items = await this.adapter.readdir(this.baseDir, { withFileTypes: true }); + const items = await fsPromises.readdir(this.baseDir, { withFileTypes: true }); for (const item of items) { - // Only process .yml/.yaml files (skip directories and other files) if (!item.isFile()) continue; if (!item.name.endsWith('.yml') && !item.name.endsWith('.yaml')) continue; const stackName = item.name.replace(/\.(yml|yaml)$/, ''); const stackDir = path.join(this.baseDir, stackName); - // Check if target directory already exists try { - await this.adapter.access(stackDir); + await fsPromises.access(stackDir); console.log(`Skipping migration for "${stackName}": directory already exists`); continue; } catch { - // Directory doesn't exist, proceed with migration + // Directory doesn't exist, proceed } console.log(`Migrating stack: ${stackName}`); + await fsPromises.mkdir(stackDir, { recursive: true }); - // Create the stack directory - await this.adapter.mkdir(stackDir, { recursive: true }); - - // Move compose file to new location (standardize on compose.yaml) const oldComposePath = path.join(this.baseDir, item.name); const newComposePath = path.join(stackDir, 'compose.yaml'); - await this.adapter.rename(oldComposePath, newComposePath); + await fsPromises.rename(oldComposePath, newComposePath); - // Move env file if it exists (old pattern: stackname.env) const oldEnvPath = path.join(this.baseDir, `${stackName}.env`); const newEnvPath = path.join(stackDir, '.env'); try { - await this.adapter.access(oldEnvPath); - await this.adapter.rename(oldEnvPath, newEnvPath); + await fsPromises.access(oldEnvPath); + await fsPromises.rename(oldEnvPath, newEnvPath); console.log(`Migrated env file for: ${stackName}`); } catch { - // No env file to migrate, that's fine + // No env file to migrate } console.log(`Successfully migrated stack: ${stackName}`); } } catch (error) { console.error('Migration error:', error); - // Don't throw - allow the server to start even if migration fails } } -} \ No newline at end of file +} diff --git a/backend/src/services/NodeRegistry.ts b/backend/src/services/NodeRegistry.ts index 1b553ed5..00c65b0a 100644 --- a/backend/src/services/NodeRegistry.ts +++ b/backend/src/services/NodeRegistry.ts @@ -1,10 +1,14 @@ import Docker from 'dockerode'; +import axios from 'axios'; import { DatabaseService, Node } from './DatabaseService'; /** - * NodeRegistry: Manages Docker daemon connections for multiple nodes. - * Replaces the old singleton DockerController pattern. Each node - * (local or remote) gets its own dedicated Docker client instance. + * NodeRegistry: Manages connections for multiple nodes. + * + * In the Distributed API model: + * - Local nodes: direct Docker socket connection via Dockerode (unchanged) + * - Remote nodes: HTTP/WS proxy to a remote Sencho instance (api_url + api_token) + * No direct Docker TCP connections are made for remote nodes. */ export class NodeRegistry { private static instance: NodeRegistry; @@ -20,11 +24,10 @@ export class NodeRegistry { } /** - * Get a Docker client for a specific node. - * Creates the connection lazily on first request and caches it. + * Get a Docker client for a LOCAL node only. + * Remote nodes are never accessed via Dockerode — use the HTTP proxy instead. */ public getDocker(nodeId: number): Docker { - // Return cached connection if available if (this.connections.has(nodeId)) { return this.connections.get(nodeId)!; } @@ -36,21 +39,27 @@ export class NodeRegistry { throw new Error(`Node with id ${nodeId} not found`); } - const docker = this.createDockerClient(node); + if (node.type === 'remote') { + throw new Error( + `Node "${node.name}" is a remote Distributed API node. ` + + `Its Docker daemon is not directly accessible — all requests are proxied via HTTP.` + ); + } + + const docker = new Docker(); this.connections.set(nodeId, docker); return docker; } /** * Get the Docker client for the default node. - * This is the backward-compatible path for all existing code. + * Backward-compatible path for local-node code. */ public getDefaultDocker(): Docker { const db = DatabaseService.getInstance(); const defaultNode = db.getDefaultNode(); if (!defaultNode || !defaultNode.id) { - // Absolute fallback: local socket (preserves legacy behavior) return new Docker(); } @@ -58,7 +67,7 @@ export class NodeRegistry { } /** - * Get the default node ID. Returns the ID of the node marked as default. + * Get the default node ID. */ public getDefaultNodeId(): number { const db = DatabaseService.getInstance(); @@ -75,39 +84,21 @@ export class NodeRegistry { } /** - * Create a Docker client based on node configuration. - * - Local nodes: use the default socket (Docker autodetects) - * - Remote nodes: connect via TCP to host:port + * Get the HTTP proxy target for a remote node. + * Returns { apiUrl, apiToken } for use by the HTTP proxy middleware. */ - private createDockerClient(node: Node): Docker { - if (node.type === 'local') { - // Local node: use the default Docker socket - return new Docker(); + public getProxyTarget(nodeId: number): { apiUrl: string; apiToken: string } | null { + const node = DatabaseService.getInstance().getNode(nodeId); + if (!node || node.type !== 'remote' || !node.api_url || !node.api_token) { + return null; } - - // Remote node: connect via Docker TCP API - if (!node.host) { - throw new Error(`Remote node "${node.name}" is missing a host address`); - } - - const dockerOptions: Docker.DockerOptions = { - host: node.host, - port: node.port || 2375, - }; - - // Phase 55.4 — TLS: if all three certs are present, enable secure connection - if (node.tls_ca && node.tls_cert && node.tls_key) { - dockerOptions.ca = node.tls_ca; - dockerOptions.cert = node.tls_cert; - dockerOptions.key = node.tls_key; - } - - return new Docker(dockerOptions); + return { apiUrl: node.api_url, apiToken: node.api_token }; } /** * Test connectivity to a specific node. - * Returns true if we can ping the Docker daemon. + * - Local: pings the Docker daemon directly + * - Remote: makes a GET to /api/auth/check on the remote Sencho instance */ public async testConnection(nodeId: number): Promise<{ success: boolean; error?: string; info?: any }> { const db = DatabaseService.getInstance(); @@ -117,13 +108,21 @@ export class NodeRegistry { return { success: false, error: 'Node not found' }; } + if (node.type === 'remote') { + return this.testRemoteConnection(node); + } + + return this.testLocalConnection(nodeId); + } + + private async testLocalConnection(nodeId: number): Promise<{ success: boolean; error?: string; info?: any }> { + const db = DatabaseService.getInstance(); try { - const docker = this.createDockerClient(node); + const docker = new Docker(); const info = await docker.info(); - // Validate the payload contains actual Docker daemon info, not arbitrary HTML if (!info || !info.OperatingSystem || typeof info.Containers !== 'number') { - throw new Error("Invalid response from Docker API. Did you provide a web port instead of the Docker daemon port?"); + throw new Error('Invalid response from Docker daemon.'); } db.updateNodeStatus(nodeId, 'online'); @@ -147,8 +146,49 @@ export class NodeRegistry { } } + private async testRemoteConnection(node: Node): Promise<{ success: boolean; error?: string; info?: any }> { + const db = DatabaseService.getInstance(); + + if (!node.api_url || !node.api_token) { + return { success: false, error: 'Remote node is missing an API URL or token. Configure it in Settings → Nodes.' }; + } + + try { + const response = await axios.get(`${node.api_url}/api/auth/check`, { + headers: { Authorization: `Bearer ${node.api_token}` }, + timeout: 8000, + }); + + if (response.status === 200) { + db.updateNodeStatus(node.id, 'online'); + return { + success: true, + info: { + name: node.name, + serverVersion: 'Remote Sencho', + os: 'Remote', + architecture: 'Remote', + containers: '—', + containersRunning: '—', + images: '—', + memTotal: 0, + cpus: '—', + } + }; + } + + throw new Error(`Unexpected status ${response.status}`); + } catch (error: any) { + db.updateNodeStatus(node.id, 'offline'); + const msg = error.response?.status === 401 + ? 'Authentication failed — check the API token.' + : (error.message || 'Connection failed'); + return { success: false, error: msg }; + } + } + /** - * Evict a cached connection (e.g., after node config changes). + * Evict a cached Docker connection (e.g., after node config change). */ public evictConnection(nodeId: number): void { this.connections.delete(nodeId); @@ -162,7 +202,7 @@ export class NodeRegistry { } /** - * Get the compose directory for a specific node. + * Get the compose directory for a local node. */ public getComposeDir(nodeId: number): string { const db = DatabaseService.getInstance(); diff --git a/backend/src/services/fs/IFileAdapter.ts b/backend/src/services/fs/IFileAdapter.ts deleted file mode 100644 index 9f8bd331..00000000 --- a/backend/src/services/fs/IFileAdapter.ts +++ /dev/null @@ -1,9 +0,0 @@ -export interface IFileAdapter { - access(filePath: string): Promise; - readdir(dirPath: string, options?: any): Promise; - readFile(filePath: string, encoding: string): Promise; - writeFile(filePath: string, content: string, encoding: string): Promise; - mkdir(dirPath: string, options?: any): Promise; - rm(targetPath: string, options?: any): Promise; - rename(oldPath: string, newPath: string): Promise; -} diff --git a/backend/src/services/fs/LocalFileAdapter.ts b/backend/src/services/fs/LocalFileAdapter.ts deleted file mode 100644 index 3b63a1b7..00000000 --- a/backend/src/services/fs/LocalFileAdapter.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { promises as fs } from 'fs'; -import { IFileAdapter } from './IFileAdapter'; - -export class LocalFileAdapter implements IFileAdapter { - async access(filePath: string): Promise { - return fs.access(filePath); - } - async readdir(dirPath: string, options?: any): Promise { - return fs.readdir(dirPath, options); - } - async readFile(filePath: string, encoding: any): Promise { - const raw = await fs.readFile(filePath, encoding); - return typeof raw === 'string' ? raw : raw.toString(encoding || 'utf-8'); - } - async writeFile(filePath: string, content: string, encoding: any): Promise { - await fs.writeFile(filePath, content, encoding); - } - async mkdir(dirPath: string, options?: any): Promise { - await fs.mkdir(dirPath, options); - } - async rm(targetPath: string, options?: any): Promise { - return fs.rm(targetPath, options); - } - async rename(oldPath: string, newPath: string): Promise { - return fs.rename(oldPath, newPath); - } -} diff --git a/backend/src/services/fs/SSHFileAdapter.ts b/backend/src/services/fs/SSHFileAdapter.ts deleted file mode 100644 index 1f8b3024..00000000 --- a/backend/src/services/fs/SSHFileAdapter.ts +++ /dev/null @@ -1,125 +0,0 @@ -import Client from 'ssh2-sftp-client'; -import { IFileAdapter } from './IFileAdapter'; -import { Node } from '../DatabaseService'; - -export class SSHFileAdapter implements IFileAdapter { - private node: Node; - - constructor(node: Node) { - this.node = node; - } - - private async getClient() { - const sftp = new Client(); - await sftp.connect({ - host: this.node.host, - port: this.node.ssh_port || 22, - username: this.node.ssh_user!, - password: this.node.ssh_password, - privateKey: this.node.ssh_key, - readyTimeout: 10000, - }); - return sftp; - } - - async access(filePath: string): Promise { - const sftp = await this.getClient(); - try { - const exists = await sftp.exists(filePath); - if (!exists) throw Object.assign(new Error(), { code: 'ENOENT' }); - } finally { - await sftp.end(); - } - } - - async readdir(dirPath: string, options?: any): Promise { - const sftp = await this.getClient(); - try { - const list = await sftp.list(dirPath); - // Guard: filter out any entries with missing or non-string names to prevent - // downstream path.join crashes (TypeError on undefined.split) - const valid = list.filter((item: any) => item.name && typeof item.name === 'string'); - if (options?.withFileTypes) { - return valid.map((item: any) => ({ - name: item.name, - isDirectory: () => item.type === 'd', - isFile: () => item.type === '-', - })); - } - return valid.map((item: any) => item.name); - } catch(err: any) { - if(err.code === 2 || err.message.includes('No such file')) throw Object.assign(new Error(), { code: 'ENOENT' }); - throw err; - } finally { - await sftp.end(); - } - } - - async readFile(filePath: string, encoding: any): Promise { - const sftp = await this.getClient(); - try { - const buffer = await sftp.get(filePath); - if (Buffer.isBuffer(buffer)) { - return buffer.toString(encoding as BufferEncoding); - } - return buffer as unknown as string; - } catch(err: any) { - if(err.code === 2 || err.message.includes('No such file')) throw Object.assign(new Error(), { code: 'ENOENT' }); - throw err; - } finally { - await sftp.end(); - } - } - - async writeFile(filePath: string, content: string, encoding: any): Promise { - const sftp = await this.getClient(); - try { - await sftp.put(Buffer.from(content, encoding as BufferEncoding), filePath); - } finally { - await sftp.end(); - } - } - - async mkdir(dirPath: string, options?: any): Promise { - const sftp = await this.getClient(); - try { - const exists = await sftp.exists(dirPath); - if (!exists) { - await sftp.mkdir(dirPath, options?.recursive); - } - } finally { - await sftp.end(); - } - } - - async rm(targetPath: string, options?: any): Promise { - const sftp = await this.getClient(); - try { - const type = await sftp.exists(targetPath); - if (type === 'd') { - await sftp.rmdir(targetPath, options?.recursive); - } else if (type === '-') { - await sftp.delete(targetPath); - } else if (!options?.force) { - throw Object.assign(new Error(), { code: 'ENOENT' }); - } - } catch(err: any) { - if(err.code === 2 || err.message.includes('No such file')) { - if (options?.force) return; - throw Object.assign(new Error(), { code: 'ENOENT' }); - } - throw err; - } finally { - await sftp.end(); - } - } - - async rename(oldPath: string, newPath: string): Promise { - const sftp = await this.getClient(); - try { - await sftp.rename(oldPath, newPath); - } finally { - await sftp.end(); - } - } -} diff --git a/frontend/src/components/NodeManager.tsx b/frontend/src/components/NodeManager.tsx index fbf19806..02a3585b 100644 --- a/frontend/src/components/NodeManager.tsx +++ b/frontend/src/components/NodeManager.tsx @@ -9,46 +9,27 @@ import { Button } from './ui/button'; import { Input } from './ui/input'; import { Label } from './ui/label'; import { Badge } from './ui/badge'; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select'; import { Separator } from './ui/separator'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from './ui/table'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/tooltip'; -import { Plus, Trash2, Wifi, WifiOff, Star, Pencil, Server, Monitor, Globe, ShieldCheck } from 'lucide-react'; +import { Plus, Trash2, Wifi, WifiOff, Star, Pencil, Server, Monitor, Globe, Copy, KeyRound, Check } from 'lucide-react'; interface NodeFormData { name: string; type: 'local' | 'remote'; - host: string; - port: number; - ssh_port: number; + api_url: string; + api_token: string; compose_dir: string; is_default: boolean; - ssh_user: string; - ssh_auth_type: 'password' | 'key'; - ssh_password: string; - ssh_key: string; - tls_enabled: boolean; - tls_ca: string; - tls_cert: string; - tls_key: string; } const defaultFormData: NodeFormData = { name: '', type: 'remote', - host: '', - port: 2375, - ssh_port: 22, - compose_dir: '/opt/docker', + api_url: '', + api_token: '', + compose_dir: '/app/compose', is_default: false, - ssh_user: '', - ssh_auth_type: 'password', - ssh_password: '', - ssh_key: '', - tls_enabled: false, - tls_ca: '', - tls_cert: '', - tls_key: '', }; export function NodeManager() { @@ -62,22 +43,16 @@ export function NodeManager() { const [testing, setTesting] = useState(null); const [testResult, setTestResult] = useState<{ nodeId: number; info: any } | null>(null); + // Node token generation state + const [generatedToken, setGeneratedToken] = useState(null); + const [generatingToken, setGeneratingToken] = useState(false); + const [tokenCopied, setTokenCopied] = useState(false); + const handleCreate = async () => { try { - const payload = { - ...formData, - // Only pass TLS fields if TLS is enabled - tls_ca: formData.tls_enabled ? formData.tls_ca : '', - tls_cert: formData.tls_enabled ? formData.tls_cert : '', - tls_key: formData.tls_enabled ? formData.tls_key : '', - // Only pass the relevant SSH credential - ssh_password: formData.ssh_auth_type === 'password' ? formData.ssh_password : '', - ssh_key: formData.ssh_auth_type === 'key' ? formData.ssh_key : '', - }; - const res = await apiFetch('/nodes', { method: 'POST', - body: JSON.stringify(payload), + body: JSON.stringify(formData), }); if (!res.ok) { const err = await res.json(); @@ -88,8 +63,8 @@ export function NodeManager() { setCreateOpen(false); setFormData(defaultFormData); - // Auto-test the new node connection immediately after creation - if (newNodeId) { + // Auto-test the new node connection immediately + if (newNodeId && formData.type === 'remote') { setTesting(newNodeId); try { const testRes = await apiFetch(`/nodes/${newNodeId}/test`, { method: 'POST' }); @@ -101,7 +76,7 @@ export function NodeManager() { toast.warning(`Node saved, but connection test failed: ${testData.error}`); } } catch { - // Non-fatal — node was created, test just didn't succeed + // Non-fatal } finally { setTesting(null); } @@ -116,18 +91,9 @@ export function NodeManager() { const handleEdit = async () => { if (!editingNodeId) return; try { - const payload = { - ...formData, - tls_ca: formData.tls_enabled ? formData.tls_ca : '', - tls_cert: formData.tls_enabled ? formData.tls_cert : '', - tls_key: formData.tls_enabled ? formData.tls_key : '', - ssh_password: formData.ssh_auth_type === 'password' ? formData.ssh_password : '', - ssh_key: formData.ssh_auth_type === 'key' ? formData.ssh_key : '', - }; - const res = await apiFetch(`/nodes/${editingNodeId}`, { method: 'PUT', - body: JSON.stringify(payload), + body: JSON.stringify(formData), }); if (!res.ok) { const err = await res.json(); @@ -144,23 +110,13 @@ export function NodeManager() { }; const openEditDialog = (node: Node) => { - const hasTls = !!(node.tls_ca && node.tls_cert && node.tls_key); setFormData({ name: node.name, type: node.type, - host: node.host, - port: node.port, - ssh_port: node.ssh_port || 22, + api_url: node.api_url || '', + api_token: node.api_token || '', compose_dir: node.compose_dir, is_default: node.is_default, - ssh_user: node.ssh_user || '', - ssh_auth_type: node.ssh_key ? 'key' : 'password', - ssh_password: node.ssh_password || '', - ssh_key: node.ssh_key || '', - tls_enabled: hasTls, - tls_ca: node.tls_ca || '', - tls_cert: node.tls_cert || '', - tls_key: node.tls_key || '', }); setEditingNodeId(node.id); setEditOpen(true); @@ -203,6 +159,30 @@ export function NodeManager() { } }; + const generateNodeToken = async () => { + setGeneratingToken(true); + setGeneratedToken(null); + try { + const res = await apiFetch('/auth/generate-node-token', { method: 'POST' }); + if (!res.ok) throw new Error('Failed to generate token'); + const { token } = await res.json(); + setGeneratedToken(token); + toast.success('Node token generated'); + } catch (error: any) { + toast.error(error.message || 'Failed to generate token'); + } finally { + setGeneratingToken(false); + } + }; + + const copyToken = async () => { + if (!generatedToken) return; + await navigator.clipboard.writeText(generatedToken); + setTokenCopied(true); + toast.success('Token copied to clipboard'); + setTimeout(() => setTokenCopied(false), 2000); + }; + const getStatusBadge = (status: string) => { switch (status) { case 'online': @@ -221,7 +201,7 @@ export function NodeManager() { }; const renderFormFields = () => ( -
+
-
- - -
- {formData.type === 'remote' && ( <> -
-

How to expose Docker via TCP on the remote machine

-

- Edit /lib/systemd/system/docker.service and update the ExecStart line: -

- - ExecStart=/usr/bin/dockerd -H fd:// -H tcp://0.0.0.0:2375 --containerd=/run/containerd/containerd.sock - -

- Then restart: systemctl daemon-reload && systemctl restart docker +

+ + setFormData({ ...formData, api_url: e.target.value })} + /> +

+ The base URL of the Sencho instance running on the remote machine.

- + setFormData({ ...formData, host: e.target.value })} + id="node-api-token" + type="password" + placeholder="Paste token from remote Sencho → Settings → Nodes → Generate Token" + value={formData.api_token} + onChange={(e) => setFormData({ ...formData, api_token: e.target.value })} /> -
- -
-
- - setFormData({ ...formData, port: parseInt(e.target.value) || 2375 })} - /> -

- Default: 2375 (unencrypted) or 2376 (TLS) -

-
-
- - setFormData({ ...formData, ssh_port: parseInt(e.target.value) || 22 })} - /> -

- Default: 22. Used for file operations (SFTP). -

-
-
- - - - {/* SSH Credentials Section */} -
-

SSH Credentials (for file operations via SFTP)

- -
- - setFormData({ ...formData, ssh_user: e.target.value })} - /> -
- -
- - -
- - {formData.ssh_auth_type === 'password' ? ( -
- - setFormData({ ...formData, ssh_password: e.target.value })} - /> -
- ) : ( -
- -