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 <noreply@anthropic.com>
This commit is contained in:
SaelixCode
2026-03-19 12:20:32 -04:00
parent 23730430d7
commit c91c9ed7fd
14 changed files with 522 additions and 899 deletions
+21
View File
@@ -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:*)"
]
}
}
+5 -6
View File
@@ -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). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased] ## [Unreleased]
- **Added:** TLS certificate support for secure remote Docker daemon connections. - **Removed:** SSH/SFTP file adapters and remote Docker TCP connections (net negative ~500 lines of code).
- **Added:** SSH Private Key authentication UI for the Smart Proxy file system. - **Added:** Distributed API proxying using http-proxy-middleware for HTTP and WebSockets.
- **Fixed:** Fatal path.join crash in FileSystemService caused by malformed SSH directory reads. - **Added:** Long-lived JWT generation for Sencho-to-Sencho API authentication (`POST /api/auth/generate-node-token`).
- **Fixed:** Home dashboard System Stats incorrectly showing local hardware metrics when a remote node was active. - **Changed:** Node Manager UI vastly simplified — remote nodes now only require an API URL and Token.
- **Fixed:** UI overlap between the 'Add Node' button and the Settings dialog close icon.
- **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:** 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:** `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`. - **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. - **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. - **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:** 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:** `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:** 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. - **Added:** Settings Hub → Nodes tab with full CRUD UI, connection testing, and Docker info display.
+90 -65
View File
@@ -12,6 +12,7 @@
"@types/cors": "^2.8.19", "@types/cors": "^2.8.19",
"@types/dockerode": "^4.0.1", "@types/dockerode": "^4.0.1",
"@types/express": "^5.0.6", "@types/express": "^5.0.6",
"@types/http-proxy": "^1.17.17",
"@types/ws": "^8.18.1", "@types/ws": "^8.18.1",
"axios": "^1.13.6", "axios": "^1.13.6",
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
@@ -21,10 +22,10 @@
"cors": "^2.8.6", "cors": "^2.8.6",
"dockerode": "^4.0.9", "dockerode": "^4.0.9",
"express": "^5.2.1", "express": "^5.2.1",
"http-proxy": "^1.18.1",
"http-proxy-middleware": "^3.0.5",
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
"node-pty": "^1.1.0", "node-pty": "^1.1.0",
"ssh2": "^1.17.0",
"ssh2-sftp-client": "^12.1.0",
"systeminformation": "^5.31.1", "systeminformation": "^5.31.1",
"ws": "^8.19.0", "ws": "^8.19.0",
"yaml": "^2.8.2" "yaml": "^2.8.2"
@@ -33,10 +34,9 @@
"@types/bcrypt": "^6.0.0", "@types/bcrypt": "^6.0.0",
"@types/better-sqlite3": "^7.6.13", "@types/better-sqlite3": "^7.6.13",
"@types/cookie-parser": "^1.4.10", "@types/cookie-parser": "^1.4.10",
"@types/http-proxy-middleware": "^0.19.3",
"@types/jsonwebtoken": "^9.0.10", "@types/jsonwebtoken": "^9.0.10",
"@types/node": "^25.3.0", "@types/node": "^25.3.0",
"@types/ssh2": "^1.15.5",
"@types/ssh2-sftp-client": "^9.0.6",
"@types/yaml": "^1.9.6", "@types/yaml": "^1.9.6",
"nodemon": "^3.1.13", "nodemon": "^3.1.13",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
@@ -350,6 +350,27 @@
"integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==",
"license": "MIT" "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": { "node_modules/@types/jsonwebtoken": {
"version": "9.0.10", "version": "9.0.10",
"resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz",
@@ -418,16 +439,6 @@
"@types/node": "^18.11.18" "@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": { "node_modules/@types/ssh2/node_modules/@types/node": {
"version": "18.19.130", "version": "18.19.130",
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz",
@@ -739,7 +750,6 @@
"version": "3.0.3", "version": "3.0.3",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"fill-range": "^7.1.1" "fill-range": "^7.1.1"
@@ -778,12 +788,6 @@
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause" "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": { "node_modules/buildcheck": {
"version": "0.0.7", "version": "0.0.7",
"resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.7.tgz", "resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.7.tgz",
@@ -953,21 +957,6 @@
"yaml": "^2.x" "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": { "node_modules/content-disposition": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz",
@@ -1324,6 +1313,12 @@
"node": ">= 0.6" "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": { "node_modules/expand-template": {
"version": "2.0.3", "version": "2.0.3",
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
@@ -1408,7 +1403,6 @@
"version": "7.1.1", "version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"to-regex-range": "^5.0.1" "to-regex-range": "^5.0.1"
@@ -1689,6 +1683,37 @@
"url": "https://opencollective.com/express" "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": { "node_modules/iconv-lite": {
"version": "0.7.2", "version": "0.7.2",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
@@ -1779,7 +1804,6 @@
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
@@ -1798,7 +1822,6 @@
"version": "4.0.3", "version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"is-extglob": "^2.1.1" "is-extglob": "^2.1.1"
@@ -1811,12 +1834,20 @@
"version": "7.0.0", "version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=0.12.0" "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": { "node_modules/is-promise": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
@@ -1981,6 +2012,19 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/mime-db": {
"version": "1.54.0", "version": "1.54.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
@@ -2229,7 +2273,6 @@
"version": "2.3.1", "version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=8.6" "node": ">=8.6"
@@ -2424,6 +2467,12 @@
"node": ">=0.10.0" "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": { "node_modules/router": {
"version": "2.2.0", "version": "2.2.0",
"resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
@@ -2682,23 +2731,6 @@
"nan": "^2.23.0" "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": { "node_modules/statuses": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
@@ -2823,7 +2855,6 @@
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"is-number": "^7.0.0" "is-number": "^7.0.0"
@@ -2927,12 +2958,6 @@
"node": ">= 0.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": { "node_modules/typescript": {
"version": "5.9.3", "version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+4 -4
View File
@@ -17,10 +17,9 @@
"@types/bcrypt": "^6.0.0", "@types/bcrypt": "^6.0.0",
"@types/better-sqlite3": "^7.6.13", "@types/better-sqlite3": "^7.6.13",
"@types/cookie-parser": "^1.4.10", "@types/cookie-parser": "^1.4.10",
"@types/http-proxy-middleware": "^0.19.3",
"@types/jsonwebtoken": "^9.0.10", "@types/jsonwebtoken": "^9.0.10",
"@types/node": "^25.3.0", "@types/node": "^25.3.0",
"@types/ssh2": "^1.15.5",
"@types/ssh2-sftp-client": "^9.0.6",
"@types/yaml": "^1.9.6", "@types/yaml": "^1.9.6",
"nodemon": "^3.1.13", "nodemon": "^3.1.13",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
@@ -30,6 +29,7 @@
"@types/cors": "^2.8.19", "@types/cors": "^2.8.19",
"@types/dockerode": "^4.0.1", "@types/dockerode": "^4.0.1",
"@types/express": "^5.0.6", "@types/express": "^5.0.6",
"@types/http-proxy": "^1.17.17",
"@types/ws": "^8.18.1", "@types/ws": "^8.18.1",
"axios": "^1.13.6", "axios": "^1.13.6",
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
@@ -39,10 +39,10 @@
"cors": "^2.8.6", "cors": "^2.8.6",
"dockerode": "^4.0.9", "dockerode": "^4.0.9",
"express": "^5.2.1", "express": "^5.2.1",
"http-proxy": "^1.18.1",
"http-proxy-middleware": "^3.0.5",
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
"node-pty": "^1.1.0", "node-pty": "^1.1.0",
"ssh2": "^1.17.0",
"ssh2-sftp-client": "^12.1.0",
"systeminformation": "^5.31.1", "systeminformation": "^5.31.1",
"ws": "^8.19.0", "ws": "^8.19.0",
"yaml": "^2.8.2" "yaml": "^2.8.2"
+99 -26
View File
@@ -12,6 +12,8 @@ import crypto from 'crypto';
import composerize from 'composerize'; import composerize from 'composerize';
import si from 'systeminformation'; import si from 'systeminformation';
import http from 'http'; import http from 'http';
import httpProxy from 'http-proxy';
import { createProxyMiddleware } from 'http-proxy-middleware';
import { spawn, exec } from 'child_process'; import { spawn, exec } from 'child_process';
import { promisify } from 'util'; import { promisify } from 'util';
import path from 'path'; 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 // 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<void> => { const authMiddleware = async (req: Request, res: Response, next: NextFunction): Promise<void> => {
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) { if (!token) {
res.status(401).json({ error: 'Authentication required' }); 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 settings = DatabaseService.getInstance().getGlobalSettings();
const jwtSecret = settings.auth_jwt_secret; const jwtSecret = settings.auth_jwt_secret;
if (!jwtSecret) throw new Error('No JWT secret'); if (!jwtSecret) throw new Error('No JWT secret');
const decoded = jwt.verify(token, jwtSecret) as { username: string }; const decoded = jwt.verify(token, jwtSecret) as { username?: string; scope?: string };
req.user = { username: decoded.username }; // Accept both user sessions and node proxy tokens
req.user = { username: decoded.username || 'node-proxy' };
next(); next();
} catch { } catch {
res.status(401).json({ error: 'Invalid or expired token' }); 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 }); 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<void> => {
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/* // Apply authentication middleware to all /api/* routes except /api/auth/*
app.use('/api', (req: Request, res: Response, next: NextFunction): void => { app.use('/api', (req: Request, res: Response, next: NextFunction): void => {
if (req.path.startsWith('/auth/')) { if (req.path.startsWith('/auth/')) {
@@ -272,6 +304,48 @@ app.use('/api', (req: Request, res: Response, next: NextFunction): void => {
authMiddleware(req, res, next); 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<Request, Response>({
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 // Create HTTP server for WebSocket upgrade handling
const server = http.createServer(app); const server = http.createServer(app);
@@ -302,11 +376,25 @@ server.on('upgrade', async (req, socket, head) => {
if (!jwtSecret) throw new Error('No JWT secret'); if (!jwtSecret) throw new Error('No JWT secret');
jwt.verify(token, jwtSecret); jwt.verify(token, jwtSecret);
// Check if this is a stack logs WebSocket request
const url = req.url || ''; const url = req.url || '';
const parsedUrl = new URL(url, `http://${req.headers.host || 'localhost'}`); const parsedUrl = new URL(url, `http://${req.headers.host || 'localhost'}`);
const pathname = parsedUrl.pathname; 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 logsMatch = pathname.match(/^\/api\/stacks\/([^/]+)\/logs$/);
const hostConsoleMatch = pathname.match(/^\/api\/system\/host-console/); 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 }); const logsWss = new WebSocket.Server({ noServer: true });
logsWss.handleUpgrade(req, socket, head, (ws) => { logsWss.handleUpgrade(req, socket, head, (ws) => {
const stackName = decodeURIComponent(logsMatch[1]); const stackName = decodeURIComponent(logsMatch[1]);
const nodeIdParam = parsedUrl.searchParams.get('nodeId');
const nodeId = nodeIdParam ? parseInt(nodeIdParam, 10) : NodeRegistry.getInstance().getDefaultNodeId();
try { try {
ComposeService.getInstance(nodeId).streamLogs(stackName, ws); ComposeService.getInstance(nodeId).streamLogs(stackName, ws);
} catch (error) { } catch (error) {
@@ -332,17 +417,12 @@ server.on('upgrade', async (req, socket, head) => {
hostConsoleWss.handleUpgrade(req, socket, head, (ws) => { hostConsoleWss.handleUpgrade(req, socket, head, (ws) => {
let targetDirectory = ''; let targetDirectory = '';
try { 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(); targetDirectory = FileSystemService.getInstance(nodeId).getBaseDir();
const stackParam = parsedUrl.searchParams.get('stack');
const stackParam = reqUrl.searchParams.get('stack');
if (stackParam) { if (stackParam) {
targetDirectory = path.join(targetDirectory, stackParam); targetDirectory = path.join(targetDirectory, stackParam);
} }
} catch (e) { } catch (e) {
// ignore parsing error, fallback to base dir
targetDirectory = FileSystemService.getInstance(NodeRegistry.getInstance().getDefaultNodeId()).getBaseDir(); targetDirectory = FileSystemService.getInstance(NodeRegistry.getInstance().getDefaultNodeId()).getBaseDir();
} }
try { try {
@@ -1432,7 +1512,7 @@ app.get('/api/nodes/:id', async (req: Request, res: Response) => {
// Create a new node // Create a new node
app.post('/api/nodes', async (req: Request, res: Response) => { app.post('/api/nodes', async (req: Request, res: Response) => {
try { 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') { if (!name || typeof name !== 'string') {
return res.status(400).json({ error: 'Node name is required' }); 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)) { if (!type || !['local', 'remote'].includes(type)) {
return res.status(400).json({ error: 'Node type must be "local" or "remote"' }); return res.status(400).json({ error: 'Node type must be "local" or "remote"' });
} }
if (type === 'remote' && (!host || typeof host !== 'string')) { if (type === 'remote' && (!api_url || typeof api_url !== 'string')) {
return res.status(400).json({ error: 'Host is required for remote nodes' }); return res.status(400).json({ error: 'API URL is required for remote nodes' });
} }
const id = DatabaseService.getInstance().addNode({ const id = DatabaseService.getInstance().addNode({
name, name,
type, type,
host: host || '', compose_dir: compose_dir || '/app/compose',
port: port || 2375,
ssh_port: ssh_port || 22,
compose_dir: compose_dir || '/opt/docker',
is_default: is_default || false, is_default: is_default || false,
ssh_user: ssh_user || '', api_url: api_url || '',
ssh_password: ssh_password || '', api_token: api_token || '',
ssh_key: ssh_key || '',
tls_ca: tls_ca || '',
tls_cert: tls_cert || '',
tls_key: tls_key || '',
}); });
res.json({ success: true, id }); res.json({ success: true, id });
+36 -150
View File
@@ -1,14 +1,16 @@
import { spawn, exec } from 'child_process'; import { spawn } from 'child_process';
import { promisify } from 'util';
import path from 'path'; import path from 'path';
import WebSocket from 'ws'; import WebSocket from 'ws';
import DockerController from './DockerController'; import DockerController from './DockerController';
import { LogFormatter } from './LogFormatter'; import { LogFormatter } from './LogFormatter';
import { NodeRegistry } from './NodeRegistry'; 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 { export class ComposeService {
private baseDir: string; private baseDir: string;
private nodeId: number; private nodeId: number;
@@ -22,30 +24,10 @@ export class ComposeService {
return new ComposeService(nodeId); return new ComposeService(nodeId);
} }
/** private execute(
* Universal command execution (Local or Remote SSH) command: string,
*/ args: string[],
private async executeCommand( cwd: string,
command: string,
args: string[],
cwd: string,
ws?: WebSocket,
throwOnError = true
): Promise<void> {
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,
ws?: WebSocket, ws?: WebSocket,
throwOnError = true throwOnError = true
): Promise<void> { ): Promise<void> {
@@ -90,65 +72,9 @@ export class ComposeService {
}); });
} }
private async executeRemote(
node: any,
command: string,
args: string[],
cwd: string,
ws?: WebSocket,
throwOnError = true
): Promise<void> {
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<void> { async runCommand(stackName: string, action: 'down' | 'start' | 'stop' | 'restart', ws?: WebSocket): Promise<void> {
const stackDir = path.join(this.baseDir, stackName); 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<void> { async deployStack(stackName: string, ws?: WebSocket): Promise<void> {
@@ -165,7 +91,7 @@ export class ComposeService {
console.warn(`Failed to clean up legacy containers for ${stackName}:`, e); 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 // Post-Deploy Health Probe
await new Promise(resolve => setTimeout(resolve, 3000)); await new Promise(resolve => setTimeout(resolve, 3000));
@@ -231,13 +157,10 @@ export class ComposeService {
let activeProcesses = 0; let activeProcesses = 0;
let streamEndedHandled = false; let streamEndedHandled = false;
let clientConnections: any[] = [];
let localProcesses: ReturnType<typeof spawn>[] = []; let localProcesses: ReturnType<typeof spawn>[] = [];
const onWsClose = () => { const onWsClose = () => {
localProcesses.forEach(cp => { try { cp.kill(); } catch {} }); localProcesses.forEach(cp => { try { cp.kill(); } catch {} });
clientConnections.forEach(conn => { try { conn.end(); } catch {} });
}; };
ws.on('close', onWsClose); 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) { for (const container of containersToLog) {
const containerName = container.Names?.[0]?.replace(/^\//, '') || container.Id; const containerName = container.Names?.[0]?.replace(/^\//, '') || container.Id;
activeProcesses++; activeProcesses++;
let lineBuffer = ''; let lineBuffer = '';
const sendOutput = (data: Buffer | any) => { const sendOutput = (data: Buffer) => {
if (ws.readyState === WebSocket.OPEN) { if (ws.readyState === WebSocket.OPEN) {
lineBuffer += data.toString(); lineBuffer += data.toString();
const lines = lineBuffer.split(/\r?\n/); const lines = lineBuffer.split(/\r?\n/);
lineBuffer = lines.pop() || ''; lineBuffer = lines.pop() || '';
for (const line of lines) { for (const line of lines) {
const formattedLine = LogFormatter.process(line); ws.send(LogFormatter.process(line) + '\r\n');
ws.send(formattedLine + '\r\n');
} }
} }
}; };
const flushBuffer = () => { const flushBuffer = () => {
if (lineBuffer && ws.readyState === WebSocket.OPEN) { if (lineBuffer && ws.readyState === WebSocket.OPEN) {
const formattedLine = LogFormatter.process(lineBuffer); ws.send(LogFormatter.process(lineBuffer) + '\r\n');
ws.send(formattedLine + '\r\n'); lineBuffer = '';
lineBuffer = '';
} }
} };
if (isRemote) { const child = spawn('docker', ['logs', '-f', '--tail', '100', containerName], {
const conn = new SSHClient(); env: {
clientConnections.push(conn); ...process.env,
conn.on('ready', () => { PATH: process.env.PATH || '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin'
conn.exec(`docker logs -f --tail 100 "${containerName}"`, { }
env: { });
...process.env, localProcesses.push(child);
PATH: process.env.PATH || '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin' child.stdout.on('data', sendOutput);
} child.stderr.on('data', sendOutput);
}, (err, stream) => { child.on('error', handleProcessEnd);
if (err) { child.on('close', () => {
conn.end(); flushBuffer();
handleProcessEnd(); 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();
});
}
} }
} catch (err) { } catch (err) {
if (!isClosed && ws.readyState === WebSocket.OPEN) { if (!isClosed && ws.readyState === WebSocket.OPEN) {
@@ -360,17 +246,17 @@ export class ComposeService {
} }
sendOutput('=== Pulling latest images ===\n'); 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'); 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'); sendOutput('=== Stack updated successfully ===\n');
} }
public async downStack(stackName: string): Promise<void> { public async downStack(stackName: string): Promise<void> {
const stackPath = path.join(this.baseDir, stackName); const stackPath = path.join(this.baseDir, stackName);
try { try {
await this.executeCommand('docker', ['compose', 'down'], stackPath, undefined, false); await this.execute('docker', ['compose', 'down'], stackPath, undefined, false);
} catch (error) { } catch (error) {
console.warn(`[Teardown] Docker down failed or nothing to clean up for ${stackName}`); console.warn(`[Teardown] Docker down failed or nothing to clean up for ${stackName}`);
} }
+25 -47
View File
@@ -29,19 +29,12 @@ export interface Node {
id: number; id: number;
name: string; name: string;
type: 'local' | 'remote'; type: 'local' | 'remote';
host: string;
port: number;
ssh_port: number;
compose_dir: string; compose_dir: string;
is_default: boolean; is_default: boolean;
status: 'online' | 'offline' | 'unknown'; status: 'online' | 'offline' | 'unknown';
created_at: number; created_at: number;
ssh_user?: string; api_url?: string;
ssh_password?: string; api_token?: string;
ssh_key?: string;
tls_ca?: string;
tls_cert?: string;
tls_key?: string;
} }
export interface NotificationHistory { export interface NotificationHistory {
@@ -59,14 +52,12 @@ export class DatabaseService {
private constructor() { private constructor() {
const dataDir = process.env.DATA_DIR || path.join(process.cwd(), 'data'); const dataDir = process.env.DATA_DIR || path.join(process.cwd(), 'data');
// Ensure data directory exists
if (!fs.existsSync(dataDir)) { if (!fs.existsSync(dataDir)) {
fs.mkdirSync(dataDir, { recursive: true }); fs.mkdirSync(dataDir, { recursive: true });
} }
const dbPath = path.join(dataDir, 'sencho.db'); const dbPath = path.join(dataDir, 'sencho.db');
this.db = new Database(dbPath); this.db = new Database(dbPath);
// Default journal mode is safer for arbitrary Docker volume mounts than WAL
this.initSchema(); this.initSchema();
this.migrateJsonConfig(dataDir); this.migrateJsonConfig(dataDir);
@@ -126,7 +117,7 @@ export class DatabaseService {
net_tx_mb REAL NOT NULL, net_tx_mb REAL NOT NULL,
timestamp INTEGER 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_timestamp ON container_metrics(timestamp);
CREATE INDEX IF NOT EXISTS idx_metrics_container ON container_metrics(container_id); 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, id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE, name TEXT NOT NULL UNIQUE,
type TEXT NOT NULL DEFAULT 'local', 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', compose_dir TEXT NOT NULL DEFAULT '/app/compose',
is_default INTEGER DEFAULT 0, is_default INTEGER DEFAULT 0,
status TEXT NOT NULL DEFAULT 'unknown', status TEXT NOT NULL DEFAULT 'unknown',
@@ -147,6 +136,14 @@ export class DatabaseService {
const maybeAddCol = (table: string, col: string, def: string) => { const maybeAddCol = (table: string, col: string, def: string) => {
try { this.db.prepare(`ALTER TABLE ${table} ADD COLUMN ${col} ${def}`).run(); } catch (e) { /* ignore */ } 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_port', 'INTEGER DEFAULT 22');
maybeAddCol('nodes', 'ssh_user', "TEXT DEFAULT ''"); maybeAddCol('nodes', 'ssh_user', "TEXT DEFAULT ''");
maybeAddCol('nodes', 'ssh_password', "TEXT DEFAULT ''"); maybeAddCol('nodes', 'ssh_password', "TEXT DEFAULT ''");
@@ -162,15 +159,15 @@ export class DatabaseService {
stmt.run('host_disk_limit', '90'); stmt.run('host_disk_limit', '90');
stmt.run('global_crash', '1'); stmt.run('global_crash', '1');
stmt.run('docker_janitor_gb', '5'); stmt.run('docker_janitor_gb', '5');
stmt.run('global_logs_refresh', '5'); // Default 5 seconds stmt.run('global_logs_refresh', '5');
stmt.run('developer_mode', '0'); // Default off stmt.run('developer_mode', '0');
// Seed the default local node if none exists // Seed the default local node if none exists
const nodeCount = (this.db.prepare('SELECT COUNT(*) as count FROM nodes').get() as any)?.count || 0; const nodeCount = (this.db.prepare('SELECT COUNT(*) as count FROM nodes').get() as any)?.count || 0;
if (nodeCount === 0) { if (nodeCount === 0) {
this.db.prepare( this.db.prepare(
'INSERT INTO nodes (name, type, host, port, compose_dir, is_default, status, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)' 'INSERT INTO nodes (name, type, compose_dir, is_default, status, created_at) VALUES (?, ?, ?, ?, ?, ?)'
).run('Local', 'local', '', 0, process.env.COMPOSE_DIR || '/app/compose', 1, 'online', Date.now()); ).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); stmt.run('auth_jwt_secret', config.jwtSecret);
console.log('Successfully migrated sencho.json credentials to SQLite global_settings.'); console.log('Successfully migrated sencho.json credentials to SQLite global_settings.');
// Delete the file after migrating
fs.unlinkSync(configPath); fs.unlinkSync(configPath);
} }
} catch (err) { } 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)'); const stmt = this.db.prepare('INSERT INTO notification_history (level, message, timestamp, is_read) VALUES (?, ?, ?, 0)');
stmt.run(notification.level, notification.message, notification.timestamp); stmt.run(notification.level, notification.message, notification.timestamp);
// Cleanup old notifications (keep last 100)
this.db.exec(` this.db.exec(`
DELETE FROM notification_history DELETE FROM notification_history
WHERE id NOT IN ( WHERE id NOT IN (
SELECT id FROM notification_history ORDER BY timestamp DESC LIMIT 100 SELECT id FROM notification_history ORDER BY timestamp DESC LIMIT 100
) )
@@ -343,7 +337,7 @@ export class DatabaseService {
// --- Nodes --- // --- Nodes ---
public getNodes(): Node[] { 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) => ({ return stmt.all().map((row: any) => ({
...row, ...row,
is_default: row.is_default === 1 is_default: row.is_default === 1
@@ -351,43 +345,35 @@ export class DatabaseService {
} }
public getNode(id: number): Node | undefined { 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; const row = stmt.get(id) as any;
if (!row) return undefined; if (!row) return undefined;
return { ...row, is_default: row.is_default === 1 }; return { ...row, is_default: row.is_default === 1 };
} }
public getDefaultNode(): Node | undefined { 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; const row = stmt.get() as any;
if (!row) return undefined; if (!row) return undefined;
return { ...row, is_default: row.is_default === 1 }; return { ...row, is_default: row.is_default === 1 };
} }
public addNode(node: Omit<Node, 'id' | 'status' | 'created_at'>): number { public addNode(node: Omit<Node, 'id' | 'status' | 'created_at'>): number {
// If this node is set as default, clear other defaults first
if (node.is_default) { if (node.is_default) {
this.db.prepare('UPDATE nodes SET is_default = 0').run(); this.db.prepare('UPDATE nodes SET is_default = 0').run();
} }
const stmt = this.db.prepare( 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( const result = stmt.run(
node.name, node.name,
node.type, node.type,
node.host, node.compose_dir || '/app/compose',
node.port,
node.ssh_port || 22,
node.compose_dir,
node.is_default ? 1 : 0, node.is_default ? 1 : 0,
'unknown', 'unknown',
Date.now(), Date.now(),
node.ssh_user || '', node.api_url || '',
node.ssh_password || '', node.api_token || ''
node.ssh_key || '',
node.tls_ca || '',
node.tls_cert || '',
node.tls_key || ''
); );
return result.lastInsertRowid as number; return result.lastInsertRowid as number;
} }
@@ -396,7 +382,6 @@ export class DatabaseService {
const node = this.getNode(id); const node = this.getNode(id);
if (!node) throw new Error(`Node with id ${id} not found`); if (!node) throw new Error(`Node with id ${id} not found`);
// If setting as default, clear other defaults first
if (updates.is_default) { if (updates.is_default) {
this.db.prepare('UPDATE nodes SET is_default = 0').run(); 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.name !== undefined) { fields.push('name = ?'); values.push(updates.name); }
if (updates.type !== undefined) { fields.push('type = ?'); values.push(updates.type); } 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.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.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.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.api_url !== undefined) { fields.push('api_url = ?'); values.push(updates.api_url); }
if (updates.ssh_password !== undefined) { fields.push('ssh_password = ?'); values.push(updates.ssh_password); } if (updates.api_token !== undefined) { fields.push('api_token = ?'); values.push(updates.api_token); }
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 (fields.length === 0) return; if (fields.length === 0) return;
+45 -123
View File
@@ -1,81 +1,59 @@
import path from 'path'; import path from 'path';
import { IFileAdapter } from './fs/IFileAdapter'; import fs from 'fs';
import { LocalFileAdapter } from './fs/LocalFileAdapter'; import { promises as fsPromises } from 'fs';
import { SSHFileAdapter } from './fs/SSHFileAdapter';
import { NodeRegistry } from './NodeRegistry'; 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 { export class FileSystemService {
private baseDir: string; private baseDir: string;
private adapter: IFileAdapter;
private nodeId: number;
constructor(nodeId?: number) { constructor(nodeId?: number) {
this.nodeId = nodeId ?? NodeRegistry.getInstance().getDefaultNodeId(); this.baseDir = NodeRegistry.getInstance().getComposeDir(
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);
}
} }
public static getInstance(nodeId?: number): FileSystemService { public static getInstance(nodeId?: number): FileSystemService {
return new FileSystemService(nodeId); return new FileSystemService(nodeId);
} }
/**
* Check if a directory contains a valid compose file
*/
private async hasComposeFile(dir: string): Promise<boolean> { private async hasComposeFile(dir: string): Promise<boolean> {
const composeFiles = ['compose.yaml', 'compose.yml', 'docker-compose.yaml', 'docker-compose.yml']; const composeFiles = ['compose.yaml', 'compose.yml', 'docker-compose.yaml', 'docker-compose.yml'];
for (const file of composeFiles) { for (const file of composeFiles) {
try { try {
await this.adapter.access(path.join(dir, file)); await fsPromises.access(path.join(dir, file));
return true; return true;
} catch { } catch {
// Continue checking other options // continue
} }
} }
return false; 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<string> { private async getComposeFilePath(stackName: string): Promise<string> {
const stackDir = path.join(this.baseDir, stackName); const stackDir = path.join(this.baseDir, stackName);
const composeFiles = ['compose.yaml', 'compose.yml', 'docker-compose.yaml', 'docker-compose.yml']; const composeFiles = ['compose.yaml', 'compose.yml', 'docker-compose.yaml', 'docker-compose.yml'];
for (const file of composeFiles) { for (const file of composeFiles) {
const filePath = path.join(stackDir, file); const filePath = path.join(stackDir, file);
try { try {
await this.adapter.access(filePath); await fsPromises.access(filePath);
return filePath; return filePath;
} catch { } catch {
// Continue checking other options // continue
} }
} }
throw new Error(`No compose file found for stack: ${stackName}`); 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<string[]> { async getStacks(): Promise<string[]> {
try { try {
const items = await this.adapter.readdir(this.baseDir, { withFileTypes: true }); const items = await fsPromises.readdir(this.baseDir, { withFileTypes: true });
const stackNames: string[] = []; const stackNames: string[] = [];
for (const item of items) { for (const item of items) {
@@ -83,46 +61,33 @@ export class FileSystemService {
if (!item.name || typeof item.name !== 'string') continue; if (!item.name || typeof item.name !== 'string') continue;
const stackDir = path.join(this.baseDir, item.name); const stackDir = path.join(this.baseDir, item.name);
const hasCompose = await this.hasComposeFile(stackDir); if (await this.hasComposeFile(stackDir)) {
if (hasCompose) {
stackNames.push(item.name); stackNames.push(item.name);
} }
} }
return stackNames; return stackNames;
} catch (error: any) { } catch (error: any) {
const nodeName = NodeRegistry.getInstance().getNode(this.nodeId)?.name || 'Unknown'; console.warn(`[FileSystemService] Failed to list stacks: ${error.message}`);
console.warn(`[SFTP] Failed to fetch stacks for Node ${nodeName}: ${error.message || error}`);
return []; return [];
} }
} }
/**
* Get the content of a stack's compose file
*/
async getStackContent(stackName: string): Promise<string> { async getStackContent(stackName: string): Promise<string> {
try { try {
const filePath = await this.getComposeFilePath(stackName); const filePath = await this.getComposeFilePath(stackName);
return await this.adapter.readFile(filePath, 'utf-8'); return await fsPromises.readFile(filePath, 'utf-8');
} catch (error) { } catch (error) {
console.error('Error reading stack content:', error); console.error('Error reading stack content:', error);
throw new Error(`Failed to read stack: ${stackName}`); 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<void> { async saveStackContent(stackName: string, content: string): Promise<void> {
const stackDir = path.join(this.baseDir, stackName); const filePath = path.join(this.baseDir, stackName, 'compose.yaml');
const filePath = path.join(stackDir, 'compose.yaml');
console.log('Saving to path:', filePath); console.log('Saving to path:', filePath);
try { try {
await this.adapter.writeFile(filePath, content, 'utf-8'); await fsPromises.writeFile(filePath, content, 'utf-8');
console.log('File written successfully'); console.log('File written successfully');
} catch (error) { } catch (error) {
console.error('Error writing file:', 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<boolean> { async envExists(stackName: string): Promise<boolean> {
const envPath = path.join(this.baseDir, stackName, '.env');
try { try {
await this.adapter.access(envPath); await fsPromises.access(path.join(this.baseDir, stackName, '.env'));
return true; return true;
} catch { } catch {
return false; 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<string> { async readFile(filePath: string, encoding: BufferEncoding = 'utf-8'): Promise<string> {
return this.adapter.readFile(filePath, encoding); return fsPromises.readFile(filePath, encoding);
} }
async writeFile(filePath: string, content: string, encoding: BufferEncoding = 'utf-8'): Promise<void> { async writeFile(filePath: string, content: string, encoding: BufferEncoding = 'utf-8'): Promise<void> {
return this.adapter.writeFile(filePath, content, encoding); return fsPromises.writeFile(filePath, content, encoding);
} }
async access(filePath: string): Promise<void> { async access(filePath: string): Promise<void> {
return this.adapter.access(filePath); return fsPromises.access(filePath);
} }
/**
* Get the content of a stack's .env file
*/
async getEnvContent(stackName: string): Promise<string> { async getEnvContent(stackName: string): Promise<string> {
const envPath = path.join(this.baseDir, stackName, '.env'); const envPath = path.join(this.baseDir, stackName, '.env');
try { try {
return await this.adapter.readFile(envPath, 'utf-8'); return await fsPromises.readFile(envPath, 'utf-8');
} catch (error) { } catch (error) {
console.error('Error reading env file:', error); console.error('Error reading env file:', error);
throw new Error(`Failed to read env file for stack: ${stackName}`); 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<void> { async saveEnvContent(stackName: string, content: string): Promise<void> {
const envPath = path.join(this.baseDir, stackName, '.env'); const envPath = path.join(this.baseDir, stackName, '.env');
console.log('Saving env to path:', envPath); console.log('Saving env to path:', envPath);
try { try {
await this.adapter.writeFile(envPath, content, 'utf-8'); await fsPromises.writeFile(envPath, content, 'utf-8');
console.log('Env file written successfully'); console.log('Env file written successfully');
} catch (error) { } catch (error) {
console.error('Error writing env file:', 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<void> { async createStack(stackName: string): Promise<void> {
// Validate stack name (no special characters, not empty)
if (!stackName || !/^[a-zA-Z0-9_-]+$/.test(stackName)) { if (!stackName || !/^[a-zA-Z0-9_-]+$/.test(stackName)) {
throw new Error('Stack name must contain only alphanumeric characters, underscores, or hyphens'); throw new Error('Stack name must contain only alphanumeric characters, underscores, or hyphens');
} }
const stackDir = path.join(this.baseDir, stackName); const stackDir = path.join(this.baseDir, stackName);
// Check if directory already exists
try { try {
await this.adapter.access(stackDir); await fsPromises.access(stackDir);
throw new Error(`Stack "${stackName}" already exists`); throw new Error(`Stack "${stackName}" already exists`);
} catch (error: any) { } catch (error: any) {
if (error.message.includes('already exists')) { if (error.message.includes('already exists')) throw error;
throw error;
}
// Directory doesn't exist, proceed
} }
// Create the directory await fsPromises.mkdir(stackDir, { recursive: true });
await this.adapter.mkdir(stackDir, { recursive: true });
// Write boilerplate compose.yaml
const composePath = path.join(stackDir, 'compose.yaml');
const boilerplate = `services: const boilerplate = `services:
app: app:
image: nginx:latest image: nginx:latest
@@ -221,7 +162,7 @@ export class FileSystemService {
restart: always restart: always
`; `;
try { 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); console.log('Stack created successfully:', stackName);
} catch (error) { } catch (error) {
console.error('Error creating stack:', 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<void> { public async deleteStack(stackName: string): Promise<void> {
const stackDir = path.join(this.baseDir, stackName); const stackDir = path.join(this.baseDir, stackName);
try { try {
await this.adapter.rm(stackDir, { recursive: true, force: true }); await fsPromises.rm(stackDir, { recursive: true, force: true });
console.log('Stack deleted successfully:', stackName); console.log('Stack deleted successfully:', stackName);
} catch (error: any) { } catch (error: any) {
if (error.code !== 'ENOENT') { if (error.code !== 'ENOENT') {
@@ -246,73 +183,58 @@ export class FileSystemService {
} }
} }
/**
* Get the base directory path for stacks
*/
getBaseDir(): string { getBaseDir(): string {
return this.baseDir; return this.baseDir;
} }
/**
* Migrate existing flat-file stacks to directory-based structure
* This runs automatically on server startup
*/
async migrateFlatToDirectory(): Promise<void> { async migrateFlatToDirectory(): Promise<void> {
try { try {
// Ensure base directory exists
try { try {
await this.adapter.access(this.baseDir); await fsPromises.access(this.baseDir);
} catch { } catch {
console.log('Creating compose directory:', this.baseDir); console.log('Creating compose directory:', this.baseDir);
await this.adapter.mkdir(this.baseDir, { recursive: true }); await fsPromises.mkdir(this.baseDir, { recursive: true });
return; // No files to migrate in a new directory 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) { for (const item of items) {
// Only process .yml/.yaml files (skip directories and other files)
if (!item.isFile()) continue; if (!item.isFile()) continue;
if (!item.name.endsWith('.yml') && !item.name.endsWith('.yaml')) continue; if (!item.name.endsWith('.yml') && !item.name.endsWith('.yaml')) continue;
const stackName = item.name.replace(/\.(yml|yaml)$/, ''); const stackName = item.name.replace(/\.(yml|yaml)$/, '');
const stackDir = path.join(this.baseDir, stackName); const stackDir = path.join(this.baseDir, stackName);
// Check if target directory already exists
try { try {
await this.adapter.access(stackDir); await fsPromises.access(stackDir);
console.log(`Skipping migration for "${stackName}": directory already exists`); console.log(`Skipping migration for "${stackName}": directory already exists`);
continue; continue;
} catch { } catch {
// Directory doesn't exist, proceed with migration // Directory doesn't exist, proceed
} }
console.log(`Migrating stack: ${stackName}`); 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 oldComposePath = path.join(this.baseDir, item.name);
const newComposePath = path.join(stackDir, 'compose.yaml'); 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 oldEnvPath = path.join(this.baseDir, `${stackName}.env`);
const newEnvPath = path.join(stackDir, '.env'); const newEnvPath = path.join(stackDir, '.env');
try { try {
await this.adapter.access(oldEnvPath); await fsPromises.access(oldEnvPath);
await this.adapter.rename(oldEnvPath, newEnvPath); await fsPromises.rename(oldEnvPath, newEnvPath);
console.log(`Migrated env file for: ${stackName}`); console.log(`Migrated env file for: ${stackName}`);
} catch { } catch {
// No env file to migrate, that's fine // No env file to migrate
} }
console.log(`Successfully migrated stack: ${stackName}`); console.log(`Successfully migrated stack: ${stackName}`);
} }
} catch (error) { } catch (error) {
console.error('Migration error:', error); console.error('Migration error:', error);
// Don't throw - allow the server to start even if migration fails
} }
} }
} }
+82 -42
View File
@@ -1,10 +1,14 @@
import Docker from 'dockerode'; import Docker from 'dockerode';
import axios from 'axios';
import { DatabaseService, Node } from './DatabaseService'; import { DatabaseService, Node } from './DatabaseService';
/** /**
* NodeRegistry: Manages Docker daemon connections for multiple nodes. * NodeRegistry: Manages connections for multiple nodes.
* Replaces the old singleton DockerController pattern. Each node *
* (local or remote) gets its own dedicated Docker client instance. * 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 { export class NodeRegistry {
private static instance: NodeRegistry; private static instance: NodeRegistry;
@@ -20,11 +24,10 @@ export class NodeRegistry {
} }
/** /**
* Get a Docker client for a specific node. * Get a Docker client for a LOCAL node only.
* Creates the connection lazily on first request and caches it. * Remote nodes are never accessed via Dockerode — use the HTTP proxy instead.
*/ */
public getDocker(nodeId: number): Docker { public getDocker(nodeId: number): Docker {
// Return cached connection if available
if (this.connections.has(nodeId)) { if (this.connections.has(nodeId)) {
return this.connections.get(nodeId)!; return this.connections.get(nodeId)!;
} }
@@ -36,21 +39,27 @@ export class NodeRegistry {
throw new Error(`Node with id ${nodeId} not found`); 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); this.connections.set(nodeId, docker);
return docker; return docker;
} }
/** /**
* Get the Docker client for the default node. * 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 { public getDefaultDocker(): Docker {
const db = DatabaseService.getInstance(); const db = DatabaseService.getInstance();
const defaultNode = db.getDefaultNode(); const defaultNode = db.getDefaultNode();
if (!defaultNode || !defaultNode.id) { if (!defaultNode || !defaultNode.id) {
// Absolute fallback: local socket (preserves legacy behavior)
return new Docker(); 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 { public getDefaultNodeId(): number {
const db = DatabaseService.getInstance(); const db = DatabaseService.getInstance();
@@ -75,39 +84,21 @@ export class NodeRegistry {
} }
/** /**
* Create a Docker client based on node configuration. * Get the HTTP proxy target for a remote node.
* - Local nodes: use the default socket (Docker autodetects) * Returns { apiUrl, apiToken } for use by the HTTP proxy middleware.
* - Remote nodes: connect via TCP to host:port
*/ */
private createDockerClient(node: Node): Docker { public getProxyTarget(nodeId: number): { apiUrl: string; apiToken: string } | null {
if (node.type === 'local') { const node = DatabaseService.getInstance().getNode(nodeId);
// Local node: use the default Docker socket if (!node || node.type !== 'remote' || !node.api_url || !node.api_token) {
return new Docker(); return null;
} }
return { apiUrl: node.api_url, apiToken: node.api_token };
// 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);
} }
/** /**
* Test connectivity to a specific node. * 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 }> { public async testConnection(nodeId: number): Promise<{ success: boolean; error?: string; info?: any }> {
const db = DatabaseService.getInstance(); const db = DatabaseService.getInstance();
@@ -117,13 +108,21 @@ export class NodeRegistry {
return { success: false, error: 'Node not found' }; 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 { try {
const docker = this.createDockerClient(node); const docker = new Docker();
const info = await docker.info(); const info = await docker.info();
// Validate the payload contains actual Docker daemon info, not arbitrary HTML
if (!info || !info.OperatingSystem || typeof info.Containers !== 'number') { 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'); 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 { public evictConnection(nodeId: number): void {
this.connections.delete(nodeId); 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 { public getComposeDir(nodeId: number): string {
const db = DatabaseService.getInstance(); const db = DatabaseService.getInstance();
-9
View File
@@ -1,9 +0,0 @@
export interface IFileAdapter {
access(filePath: string): Promise<void>;
readdir(dirPath: string, options?: any): Promise<any[]>;
readFile(filePath: string, encoding: string): Promise<string>;
writeFile(filePath: string, content: string, encoding: string): Promise<void>;
mkdir(dirPath: string, options?: any): Promise<void>;
rm(targetPath: string, options?: any): Promise<void>;
rename(oldPath: string, newPath: string): Promise<void>;
}
@@ -1,27 +0,0 @@
import { promises as fs } from 'fs';
import { IFileAdapter } from './IFileAdapter';
export class LocalFileAdapter implements IFileAdapter {
async access(filePath: string): Promise<void> {
return fs.access(filePath);
}
async readdir(dirPath: string, options?: any): Promise<any[]> {
return fs.readdir(dirPath, options);
}
async readFile(filePath: string, encoding: any): Promise<string> {
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<void> {
await fs.writeFile(filePath, content, encoding);
}
async mkdir(dirPath: string, options?: any): Promise<void> {
await fs.mkdir(dirPath, options);
}
async rm(targetPath: string, options?: any): Promise<void> {
return fs.rm(targetPath, options);
}
async rename(oldPath: string, newPath: string): Promise<void> {
return fs.rename(oldPath, newPath);
}
}
-125
View File
@@ -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<void> {
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<any[]> {
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<string> {
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<void> {
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<void> {
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<void> {
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<void> {
const sftp = await this.getClient();
try {
await sftp.rename(oldPath, newPath);
} finally {
await sftp.end();
}
}
}
+113 -263
View File
@@ -9,46 +9,27 @@ import { Button } from './ui/button';
import { Input } from './ui/input'; import { Input } from './ui/input';
import { Label } from './ui/label'; import { Label } from './ui/label';
import { Badge } from './ui/badge'; import { Badge } from './ui/badge';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select';
import { Separator } from './ui/separator'; import { Separator } from './ui/separator';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from './ui/table'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from './ui/table';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/tooltip'; 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 { interface NodeFormData {
name: string; name: string;
type: 'local' | 'remote'; type: 'local' | 'remote';
host: string; api_url: string;
port: number; api_token: string;
ssh_port: number;
compose_dir: string; compose_dir: string;
is_default: boolean; 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 = { const defaultFormData: NodeFormData = {
name: '', name: '',
type: 'remote', type: 'remote',
host: '', api_url: '',
port: 2375, api_token: '',
ssh_port: 22, compose_dir: '/app/compose',
compose_dir: '/opt/docker',
is_default: false, 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() { export function NodeManager() {
@@ -62,22 +43,16 @@ export function NodeManager() {
const [testing, setTesting] = useState<number | null>(null); const [testing, setTesting] = useState<number | null>(null);
const [testResult, setTestResult] = useState<{ nodeId: number; info: any } | null>(null); const [testResult, setTestResult] = useState<{ nodeId: number; info: any } | null>(null);
// Node token generation state
const [generatedToken, setGeneratedToken] = useState<string | null>(null);
const [generatingToken, setGeneratingToken] = useState(false);
const [tokenCopied, setTokenCopied] = useState(false);
const handleCreate = async () => { const handleCreate = async () => {
try { 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', { const res = await apiFetch('/nodes', {
method: 'POST', method: 'POST',
body: JSON.stringify(payload), body: JSON.stringify(formData),
}); });
if (!res.ok) { if (!res.ok) {
const err = await res.json(); const err = await res.json();
@@ -88,8 +63,8 @@ export function NodeManager() {
setCreateOpen(false); setCreateOpen(false);
setFormData(defaultFormData); setFormData(defaultFormData);
// Auto-test the new node connection immediately after creation // Auto-test the new node connection immediately
if (newNodeId) { if (newNodeId && formData.type === 'remote') {
setTesting(newNodeId); setTesting(newNodeId);
try { try {
const testRes = await apiFetch(`/nodes/${newNodeId}/test`, { method: 'POST' }); 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}`); toast.warning(`Node saved, but connection test failed: ${testData.error}`);
} }
} catch { } catch {
// Non-fatal — node was created, test just didn't succeed // Non-fatal
} finally { } finally {
setTesting(null); setTesting(null);
} }
@@ -116,18 +91,9 @@ export function NodeManager() {
const handleEdit = async () => { const handleEdit = async () => {
if (!editingNodeId) return; if (!editingNodeId) return;
try { 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}`, { const res = await apiFetch(`/nodes/${editingNodeId}`, {
method: 'PUT', method: 'PUT',
body: JSON.stringify(payload), body: JSON.stringify(formData),
}); });
if (!res.ok) { if (!res.ok) {
const err = await res.json(); const err = await res.json();
@@ -144,23 +110,13 @@ export function NodeManager() {
}; };
const openEditDialog = (node: Node) => { const openEditDialog = (node: Node) => {
const hasTls = !!(node.tls_ca && node.tls_cert && node.tls_key);
setFormData({ setFormData({
name: node.name, name: node.name,
type: node.type, type: node.type,
host: node.host, api_url: node.api_url || '',
port: node.port, api_token: node.api_token || '',
ssh_port: node.ssh_port || 22,
compose_dir: node.compose_dir, compose_dir: node.compose_dir,
is_default: node.is_default, 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); setEditingNodeId(node.id);
setEditOpen(true); 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) => { const getStatusBadge = (status: string) => {
switch (status) { switch (status) {
case 'online': case 'online':
@@ -221,7 +201,7 @@ export function NodeManager() {
}; };
const renderFormFields = () => ( const renderFormFields = () => (
<div className="space-y-4 py-4 max-h-[65vh] overflow-y-auto pr-1"> <div className="space-y-4 py-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="node-name">Name</Label> <Label htmlFor="node-name">Name</Label>
<Input <Input
@@ -232,198 +212,33 @@ export function NodeManager() {
/> />
</div> </div>
<div className="space-y-2">
<Label htmlFor="node-type">Type</Label>
<Select value={formData.type} onValueChange={(v) => setFormData({ ...formData, type: v as 'local' | 'remote' })}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="local">Local (Docker Socket)</SelectItem>
<SelectItem value="remote">Remote (TCP)</SelectItem>
</SelectContent>
</Select>
</div>
{formData.type === 'remote' && ( {formData.type === 'remote' && (
<> <>
<div className="rounded-md border border-amber-200 dark:border-amber-800 bg-amber-50 dark:bg-amber-950/30 p-3 space-y-1.5"> <div className="space-y-2">
<p className="text-xs font-semibold text-amber-800 dark:text-amber-400">How to expose Docker via TCP on the remote machine</p> <Label htmlFor="node-api-url">Sencho API URL</Label>
<p className="text-xs text-amber-700 dark:text-amber-500"> <Input
Edit <code className="font-mono bg-amber-100 dark:bg-amber-900 px-1 rounded">/lib/systemd/system/docker.service</code> and update the ExecStart line: id="node-api-url"
</p> placeholder="http://192.168.1.50:3000"
<code className="block text-xs font-mono bg-amber-100 dark:bg-amber-900 text-amber-900 dark:text-amber-300 px-2 py-1.5 rounded break-all"> value={formData.api_url}
ExecStart=/usr/bin/dockerd -H fd:// -H tcp://0.0.0.0:2375 --containerd=/run/containerd/containerd.sock onChange={(e) => setFormData({ ...formData, api_url: e.target.value })}
</code> />
<p className="text-xs text-amber-700 dark:text-amber-500"> <p className="text-xs text-muted-foreground">
Then restart: <code className="font-mono bg-amber-100 dark:bg-amber-900 px-1 rounded">systemctl daemon-reload && systemctl restart docker</code> The base URL of the Sencho instance running on the remote machine.
</p> </p>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="node-host">Host</Label> <Label htmlFor="node-api-token">API Token</Label>
<Input <Input
id="node-host" id="node-api-token"
placeholder="e.g., 192.168.1.50 or vps.example.com" type="password"
value={formData.host} placeholder="Paste token from remote Sencho → Settings → Nodes → Generate Token"
onChange={(e) => setFormData({ ...formData, host: e.target.value })} value={formData.api_token}
onChange={(e) => setFormData({ ...formData, api_token: e.target.value })}
/> />
</div> <p className="text-xs text-muted-foreground">
Generate this token on the <strong>remote</strong> Sencho instance using the "Generate Node Token" button in its Settings Nodes panel.
<div className="grid grid-cols-2 gap-4"> </p>
<div className="space-y-2">
<Label htmlFor="node-port">Docker API Port</Label>
<Input
id="node-port"
type="number"
placeholder="2375"
value={formData.port}
onChange={(e) => setFormData({ ...formData, port: parseInt(e.target.value) || 2375 })}
/>
<p className="text-xs text-muted-foreground">
Default: 2375 (unencrypted) or 2376 (TLS)
</p>
</div>
<div className="space-y-2">
<Label htmlFor="node-ssh-port">SSH Port</Label>
<Input
id="node-ssh-port"
type="number"
placeholder="22"
value={formData.ssh_port}
onChange={(e) => setFormData({ ...formData, ssh_port: parseInt(e.target.value) || 22 })}
/>
<p className="text-xs text-muted-foreground">
Default: 22. Used for file operations (SFTP).
</p>
</div>
</div>
<Separator />
{/* SSH Credentials Section */}
<div className="space-y-3">
<p className="text-sm font-medium">SSH Credentials <span className="text-muted-foreground font-normal">(for file operations via SFTP)</span></p>
<div className="space-y-2">
<Label htmlFor="node-ssh-user">SSH Username</Label>
<Input
id="node-ssh-user"
placeholder="e.g., root"
value={formData.ssh_user}
onChange={(e) => setFormData({ ...formData, ssh_user: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label>Authentication Type</Label>
<Select
value={formData.ssh_auth_type}
onValueChange={(v) => setFormData({ ...formData, ssh_auth_type: v as 'password' | 'key' })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="password">Password</SelectItem>
<SelectItem value="key">Private Key</SelectItem>
</SelectContent>
</Select>
</div>
{formData.ssh_auth_type === 'password' ? (
<div className="space-y-2">
<Label htmlFor="node-ssh-password">SSH Password</Label>
<Input
id="node-ssh-password"
type="password"
placeholder="Enter SSH password"
value={formData.ssh_password}
onChange={(e) => setFormData({ ...formData, ssh_password: e.target.value })}
/>
</div>
) : (
<div className="space-y-2">
<Label htmlFor="node-ssh-key">SSH Private Key</Label>
<textarea
id="node-ssh-key"
className="flex min-h-[120px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 font-mono"
placeholder="-----BEGIN OPENSSH PRIVATE KEY-----"
value={formData.ssh_key}
onChange={(e) => setFormData({ ...formData, ssh_key: e.target.value })}
/>
<p className="text-xs text-muted-foreground">
Paste the full private key contents (PEM format).
</p>
</div>
)}
</div>
<Separator />
{/* TLS Security Section */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium flex items-center gap-1.5">
<ShieldCheck className="w-4 h-4 text-blue-500" />
Enable TLS
</p>
<p className="text-xs text-muted-foreground mt-0.5">
Secure the Docker TCP connection with mutual TLS (port 2376).
</p>
</div>
<button
type="button"
role="switch"
aria-checked={formData.tls_enabled}
onClick={() => setFormData({ ...formData, tls_enabled: !formData.tls_enabled })}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 ${
formData.tls_enabled ? 'bg-blue-600' : 'bg-input'
}`}
>
<span
className={`inline-block h-4 w-4 rounded-full bg-white shadow transition-transform ${
formData.tls_enabled ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
</div>
{formData.tls_enabled && (
<div className="space-y-3 rounded-md border p-3">
<div className="space-y-2">
<Label htmlFor="node-tls-ca">CA Certificate</Label>
<textarea
id="node-tls-ca"
className="flex min-h-[90px] w-full rounded-md border border-input bg-background px-3 py-2 text-xs ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 font-mono"
placeholder="-----BEGIN CERTIFICATE-----"
value={formData.tls_ca}
onChange={(e) => setFormData({ ...formData, tls_ca: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="node-tls-cert">Client Certificate</Label>
<textarea
id="node-tls-cert"
className="flex min-h-[90px] w-full rounded-md border border-input bg-background px-3 py-2 text-xs ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 font-mono"
placeholder="-----BEGIN CERTIFICATE-----"
value={formData.tls_cert}
onChange={(e) => setFormData({ ...formData, tls_cert: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="node-tls-key">Client Private Key</Label>
<textarea
id="node-tls-key"
className="flex min-h-[90px] w-full rounded-md border border-input bg-background px-3 py-2 text-xs ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 font-mono"
placeholder="-----BEGIN RSA PRIVATE KEY-----"
value={formData.tls_key}
onChange={(e) => setFormData({ ...formData, tls_key: e.target.value })}
/>
</div>
</div>
)}
</div> </div>
</> </>
)} )}
@@ -432,12 +247,12 @@ export function NodeManager() {
<Label htmlFor="node-compose-dir">Compose Directory</Label> <Label htmlFor="node-compose-dir">Compose Directory</Label>
<Input <Input
id="node-compose-dir" id="node-compose-dir"
placeholder="/opt/docker" placeholder="/app/compose"
value={formData.compose_dir} value={formData.compose_dir}
onChange={(e) => setFormData({ ...formData, compose_dir: e.target.value })} onChange={(e) => setFormData({ ...formData, compose_dir: e.target.value })}
/> />
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
The root directory where compose stack folders live on this node. SSH credentials above are used to read and write compose files via SFTP. The root directory where compose stack folders live on this node.
</p> </p>
</div> </div>
</div> </div>
@@ -445,7 +260,7 @@ export function NodeManager() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Header — pr-8 ensures the Add Node button clears any parent dialog's X close icon */} {/* Header */}
<div className="flex items-center justify-between pr-8"> <div className="flex items-center justify-between pr-8">
<div> <div>
<h2 className="text-lg font-semibold flex items-center gap-2"> <h2 className="text-lg font-semibold flex items-center gap-2">
@@ -453,7 +268,7 @@ export function NodeManager() {
Nodes Nodes
</h2> </h2>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Manage Docker daemon connections across local and remote hosts Manage connections to local and remote Sencho instances
</p> </p>
</div> </div>
<Dialog open={createOpen} onOpenChange={setCreateOpen}> <Dialog open={createOpen} onOpenChange={setCreateOpen}>
@@ -463,14 +278,17 @@ export function NodeManager() {
Add Node Add Node
</Button> </Button>
</DialogTrigger> </DialogTrigger>
<DialogContent className="max-w-4xl"> <DialogContent className="max-w-lg">
<DialogHeader className="pr-8"> <DialogHeader className="pr-8">
<DialogTitle>Add Remote Node</DialogTitle> <DialogTitle>Add Remote Node</DialogTitle>
</DialogHeader> </DialogHeader>
{renderFormFields()} {renderFormFields()}
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => setCreateOpen(false)}>Cancel</Button> <Button variant="outline" onClick={() => setCreateOpen(false)}>Cancel</Button>
<Button onClick={handleCreate} disabled={!formData.name || (formData.type === 'remote' && !formData.host)}> <Button
onClick={handleCreate}
disabled={!formData.name || (formData.type === 'remote' && (!formData.api_url || !formData.api_token))}
>
Add Node Add Node
</Button> </Button>
</DialogFooter> </DialogFooter>
@@ -480,6 +298,39 @@ export function NodeManager() {
<Separator /> <Separator />
{/* Generate Node Token — for use on THIS instance as a remote target */}
<div className="rounded-md border p-4 space-y-3">
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-sm font-medium flex items-center gap-2">
<KeyRound className="w-4 h-4 text-blue-500" />
Generate Node Token
</p>
<p className="text-xs text-muted-foreground mt-1">
Create a long-lived token that allows another Sencho instance to use <strong>this</strong> instance as a remote node. Copy it and paste it into the main dashboard's "Add Node" form.
</p>
</div>
<Button
size="sm"
variant="outline"
onClick={generateNodeToken}
disabled={generatingToken}
className="shrink-0"
>
{generatingToken ? 'Generating...' : 'Generate Token'}
</Button>
</div>
{generatedToken && (
<div className="flex items-center gap-2 rounded-md bg-muted p-2">
<code className="flex-1 text-xs font-mono truncate text-muted-foreground">{generatedToken}</code>
<Button size="icon" variant="ghost" className="h-7 w-7 shrink-0" onClick={copyToken}>
{tokenCopied ? <Check className="w-3.5 h-3.5 text-green-500" /> : <Copy className="w-3.5 h-3.5" />}
</Button>
</div>
)}
</div>
{/* Nodes Table */} {/* Nodes Table */}
<div className="rounded-md border overflow-x-auto w-full"> <div className="rounded-md border overflow-x-auto w-full">
<Table> <Table>
@@ -489,7 +340,6 @@ export function NodeManager() {
<TableHead>Name</TableHead> <TableHead>Name</TableHead>
<TableHead>Type</TableHead> <TableHead>Type</TableHead>
<TableHead>Endpoint</TableHead> <TableHead>Endpoint</TableHead>
<TableHead>Compose Dir</TableHead>
<TableHead>Status</TableHead> <TableHead>Status</TableHead>
<TableHead className="text-right">Actions</TableHead> <TableHead className="text-right">Actions</TableHead>
</TableRow> </TableRow>
@@ -519,10 +369,7 @@ export function NodeManager() {
<Badge variant="outline">{node.type === 'local' ? 'Local' : 'Remote'}</Badge> <Badge variant="outline">{node.type === 'local' ? 'Local' : 'Remote'}</Badge>
</TableCell> </TableCell>
<TableCell className="text-muted-foreground text-sm font-mono"> <TableCell className="text-muted-foreground text-sm font-mono">
{node.type === 'local' ? 'docker.sock' : `${node.host}:${node.port}`} {node.type === 'local' ? 'docker.sock' : (node.api_url || '')}
</TableCell>
<TableCell className="text-muted-foreground text-sm font-mono">
{node.compose_dir}
</TableCell> </TableCell>
<TableCell>{getStatusBadge(node.status)}</TableCell> <TableCell>{getStatusBadge(node.status)}</TableCell>
<TableCell className="text-right"> <TableCell className="text-right">
@@ -593,10 +440,10 @@ export function NodeManager() {
Connection Details — {nodes.find(n => n.id === testResult.nodeId)?.name} Connection Details — {nodes.find(n => n.id === testResult.nodeId)?.name}
</h3> </h3>
<div className="grid grid-cols-2 md:grid-cols-3 gap-3 text-sm"> <div className="grid grid-cols-2 md:grid-cols-3 gap-3 text-sm">
<div><span className="text-muted-foreground">Docker:</span> v{testResult.info.serverVersion}</div> <div><span className="text-muted-foreground">Instance:</span> {testResult.info.serverVersion}</div>
<div><span className="text-muted-foreground">OS:</span> {testResult.info.os}</div> <div><span className="text-muted-foreground">OS:</span> {testResult.info.os}</div>
<div><span className="text-muted-foreground">Arch:</span> {testResult.info.architecture}</div> <div><span className="text-muted-foreground">Arch:</span> {testResult.info.architecture}</div>
<div><span className="text-muted-foreground">Containers:</span> {testResult.info.containers} ({testResult.info.containersRunning} running)</div> <div><span className="text-muted-foreground">Containers:</span> {testResult.info.containers}</div>
<div><span className="text-muted-foreground">Images:</span> {testResult.info.images}</div> <div><span className="text-muted-foreground">Images:</span> {testResult.info.images}</div>
<div><span className="text-muted-foreground">CPUs:</span> {testResult.info.cpus}</div> <div><span className="text-muted-foreground">CPUs:</span> {testResult.info.cpus}</div>
</div> </div>
@@ -605,14 +452,17 @@ export function NodeManager() {
{/* Edit Dialog */} {/* Edit Dialog */}
<Dialog open={editOpen} onOpenChange={setEditOpen}> <Dialog open={editOpen} onOpenChange={setEditOpen}>
<DialogContent className="max-w-4xl"> <DialogContent className="max-w-lg">
<DialogHeader className="pr-8"> <DialogHeader className="pr-8">
<DialogTitle>Edit Node</DialogTitle> <DialogTitle>Edit Node</DialogTitle>
</DialogHeader> </DialogHeader>
{renderFormFields()} {renderFormFields()}
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => { setEditOpen(false); setEditingNodeId(null); }}>Cancel</Button> <Button variant="outline" onClick={() => { setEditOpen(false); setEditingNodeId(null); }}>Cancel</Button>
<Button onClick={handleEdit} disabled={!formData.name || (formData.type === 'remote' && !formData.host)}> <Button
onClick={handleEdit}
disabled={!formData.name || (formData.type === 'remote' && !formData.api_url)}
>
Save Changes Save Changes
</Button> </Button>
</DialogFooter> </DialogFooter>
@@ -625,7 +475,7 @@ export function NodeManager() {
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>Delete Node</AlertDialogTitle> <AlertDialogTitle>Delete Node</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
Are you sure you want to remove <strong>{deletingNode?.name}</strong>? This will only remove the node from Sencho it will not affect the remote Docker daemon or any running containers. Are you sure you want to remove <strong>{deletingNode?.name}</strong>? This will only remove the node from Sencho it will not affect the remote instance or any running containers.
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
+2 -12
View File
@@ -5,19 +5,12 @@ export interface Node {
id: number; id: number;
name: string; name: string;
type: 'local' | 'remote'; type: 'local' | 'remote';
host: string;
port: number;
ssh_port: number;
compose_dir: string; compose_dir: string;
is_default: boolean; is_default: boolean;
status: 'online' | 'offline' | 'unknown'; status: 'online' | 'offline' | 'unknown';
created_at: number; created_at: number;
ssh_user?: string; api_url?: string;
ssh_password?: string; api_token?: string;
ssh_key?: string;
tls_ca?: string;
tls_cert?: string;
tls_key?: string;
} }
interface NodeContextType { interface NodeContextType {
@@ -42,7 +35,6 @@ export function NodeProvider({ children }: { children: React.ReactNode }) {
const data = await res.json(); const data = await res.json();
setNodes(data); setNodes(data);
// If no active node is set, select the default node
if (!activeNode) { if (!activeNode) {
const defaultNode = data.find((n: Node) => n.is_default); const defaultNode = data.find((n: Node) => n.is_default);
if (defaultNode) { if (defaultNode) {
@@ -51,12 +43,10 @@ export function NodeProvider({ children }: { children: React.ReactNode }) {
setActiveNodeState(data[0]); setActiveNodeState(data[0]);
} }
} else { } else {
// Refresh the active node's data in case its status changed
const updatedActive = data.find((n: Node) => n.id === activeNode.id); const updatedActive = data.find((n: Node) => n.id === activeNode.id);
if (updatedActive) { if (updatedActive) {
setActiveNodeState(updatedActive); setActiveNodeState(updatedActive);
} else { } else {
// The active node was deleted! Fallback to default
const defaultNode = data.find((n: Node) => n.is_default); const defaultNode = data.find((n: Node) => n.is_default);
if (defaultNode) { if (defaultNode) {
setActiveNodeState(defaultNode); setActiveNodeState(defaultNode);