diff --git a/CHANGELOG.md b/CHANGELOG.md index cea07437..2568e004 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +- **Added:** TLS certificate support for secure remote Docker daemon connections. +- **Added:** SSH Private Key authentication UI for the Smart Proxy file system. +- **Fixed:** Fatal path.join crash in FileSystemService caused by malformed SSH directory reads. +- **Fixed:** Home dashboard System Stats incorrectly showing local hardware metrics when a remote node was active. +- **Fixed:** UI overlap between the 'Add Node' button and the Settings dialog close icon. - **Fixed:** Critical port routing conflict — separated Docker API port (`port`) from SSH/SFTP port (`ssh_port`) in the `nodes` schema. Previously, a single `port` field served both protocols, causing ECONNREFUSED. - **Fixed:** `FileSystemService` now reads the node's `compose_dir` from the database for remote nodes instead of always using the `COMPOSE_DIR` env var. - **Fixed:** SSH/SFTP connections in `SSHFileAdapter`, `ComposeService.executeRemote()`, and `ComposeService.streamLogs()` now use `ssh_port` (default 22) instead of Docker API `port`. diff --git a/backend/src/index.ts b/backend/src/index.ts index 525149b9..b2fb53bd 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -1009,18 +1009,41 @@ app.get('/api/logs/global/stream', async (req: Request, res: Response) => { // Get host system stats app.get('/api/system/stats', async (req: Request, res: Response) => { try { + const nodeId = req.nodeId ?? NodeRegistry.getInstance().getDefaultNodeId(); + const node = NodeRegistry.getInstance().getNode(nodeId); + + const rxSec = Math.max(0, globalDockerNetwork.rxSec); + const txSec = Math.max(0, globalDockerNetwork.txSec); + + if (node && node.type === 'remote') { + // Remote node: use Docker daemon info for CPU/RAM — disk is not available via Docker API + const docker = NodeRegistry.getInstance().getDocker(nodeId); + const info = await docker.info(); + + res.json({ + cpu: { + usage: '0', + cores: info.NCPU ?? 0, + }, + memory: { + total: info.MemTotal ?? 0, + used: 0, + free: info.MemTotal ?? 0, + usagePercent: '0', + }, + disk: null, + network: { rxBytes: 0, txBytes: 0, rxSec, txSec }, + }); + return; + } + + // Local node: use systeminformation for accurate host metrics const [currentLoad, mem, fsSize] = await Promise.all([ si.currentLoad(), si.mem(), si.fsSize() ]); - let rxSec = Math.max(0, globalDockerNetwork.rxSec); - let txSec = Math.max(0, globalDockerNetwork.txSec); - let rxBytes = 0; - let txBytes = 0; - - // Find the main mount (usually the largest or root mount) const mainDisk = fsSize.find(fs => fs.mount === '/' || fs.mount === 'C:') || fsSize[0]; res.json({ @@ -1042,12 +1065,7 @@ app.get('/api/system/stats', async (req: Request, res: Response) => { free: mainDisk.available, usagePercent: mainDisk.use ? mainDisk.use.toFixed(1) : '0', } : null, - network: { - rxBytes, - txBytes, - rxSec, - txSec - } + network: { rxBytes: 0, txBytes: 0, rxSec, txSec }, }); } catch (error) { console.error('Failed to fetch system stats:', error); @@ -1414,7 +1432,7 @@ app.get('/api/nodes/:id', async (req: Request, res: Response) => { // Create a new node app.post('/api/nodes', async (req: Request, res: Response) => { try { - const { name, type, host, port, ssh_port, compose_dir, is_default, ssh_user, ssh_password, ssh_key } = req.body; + 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; if (!name || typeof name !== 'string') { return res.status(400).json({ error: 'Node name is required' }); @@ -1437,6 +1455,9 @@ app.post('/api/nodes', async (req: Request, res: Response) => { ssh_user: ssh_user || '', ssh_password: ssh_password || '', ssh_key: ssh_key || '', + tls_ca: tls_ca || '', + tls_cert: tls_cert || '', + tls_key: tls_key || '', }); res.json({ success: true, id }); diff --git a/backend/src/services/FileSystemService.ts b/backend/src/services/FileSystemService.ts index f8fc6dde..2dde2121 100644 --- a/backend/src/services/FileSystemService.ts +++ b/backend/src/services/FileSystemService.ts @@ -11,14 +11,17 @@ export class FileSystemService { constructor(nodeId?: number) { this.nodeId = nodeId ?? NodeRegistry.getInstance().getDefaultNodeId(); - + const node = NodeRegistry.getInstance().getNode(this.nodeId); if (!node || node.type === 'local' || !node.host) { this.baseDir = process.env.COMPOSE_DIR || '/app/compose'; this.adapter = new LocalFileAdapter(); } else { - this.baseDir = node.compose_dir || '/app/compose'; + 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); } } @@ -77,6 +80,7 @@ export class FileSystemService { for (const item of items) { if (!item.isDirectory()) continue; + if (!item.name || typeof item.name !== 'string') continue; const stackDir = path.join(this.baseDir, item.name); const hasCompose = await this.hasComposeFile(stackDir); diff --git a/backend/src/services/NodeRegistry.ts b/backend/src/services/NodeRegistry.ts index ba828975..1b553ed5 100644 --- a/backend/src/services/NodeRegistry.ts +++ b/backend/src/services/NodeRegistry.ts @@ -90,11 +90,19 @@ export class NodeRegistry { throw new Error(`Remote node "${node.name}" is missing a host address`); } - return new Docker({ + const dockerOptions: Docker.DockerOptions = { host: node.host, port: node.port || 2375, - // TODO: Phase 55.2 — Add TLS certificate support for secure remote connections - }); + }; + + // 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); } /** diff --git a/backend/src/services/fs/SSHFileAdapter.ts b/backend/src/services/fs/SSHFileAdapter.ts index 0f05b9d7..1f8b3024 100644 --- a/backend/src/services/fs/SSHFileAdapter.ts +++ b/backend/src/services/fs/SSHFileAdapter.ts @@ -36,14 +36,17 @@ export class SSHFileAdapter implements IFileAdapter { 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 list.map((item: any) => ({ + return valid.map((item: any) => ({ name: item.name, isDirectory: () => item.type === 'd', isFile: () => item.type === '-', })); } - return list.map((item: any) => item.name); + 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; diff --git a/frontend/src/components/NodeManager.tsx b/frontend/src/components/NodeManager.tsx index cddaac74..fbf19806 100644 --- a/frontend/src/components/NodeManager.tsx +++ b/frontend/src/components/NodeManager.tsx @@ -13,7 +13,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '. import { Separator } from './ui/separator'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from './ui/table'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/tooltip'; -import { Plus, Trash2, Wifi, WifiOff, Star, Pencil, Server, Monitor, Globe } from 'lucide-react'; +import { Plus, Trash2, Wifi, WifiOff, Star, Pencil, Server, Monitor, Globe, ShieldCheck } from 'lucide-react'; interface NodeFormData { name: string; @@ -24,8 +24,13 @@ interface NodeFormData { compose_dir: string; is_default: boolean; ssh_user: string; + ssh_auth_type: 'password' | 'key'; ssh_password: string; ssh_key: string; + tls_enabled: boolean; + tls_ca: string; + tls_cert: string; + tls_key: string; } const defaultFormData: NodeFormData = { @@ -37,8 +42,13 @@ const defaultFormData: NodeFormData = { compose_dir: '/opt/docker', 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() { @@ -54,17 +64,49 @@ export function NodeManager() { const handleCreate = async () => { try { + const payload = { + ...formData, + // Only pass TLS fields if TLS is enabled + tls_ca: formData.tls_enabled ? formData.tls_ca : '', + tls_cert: formData.tls_enabled ? formData.tls_cert : '', + tls_key: formData.tls_enabled ? formData.tls_key : '', + // Only pass the relevant SSH credential + ssh_password: formData.ssh_auth_type === 'password' ? formData.ssh_password : '', + ssh_key: formData.ssh_auth_type === 'key' ? formData.ssh_key : '', + }; + const res = await apiFetch('/nodes', { method: 'POST', - body: JSON.stringify(formData), + body: JSON.stringify(payload), }); if (!res.ok) { const err = await res.json(); throw new Error(err.error || 'Failed to create node'); } + const { id: newNodeId } = await res.json(); toast.success(`Node "${formData.name}" created successfully`); setCreateOpen(false); setFormData(defaultFormData); + + // Auto-test the new node connection immediately after creation + if (newNodeId) { + setTesting(newNodeId); + try { + const testRes = await apiFetch(`/nodes/${newNodeId}/test`, { method: 'POST' }); + const testData = await testRes.json(); + if (testData.success) { + toast.success(`Connected to "${formData.name}" successfully`); + setTestResult({ nodeId: newNodeId, info: testData.info }); + } else { + toast.warning(`Node saved, but connection test failed: ${testData.error}`); + } + } catch { + // Non-fatal — node was created, test just didn't succeed + } finally { + setTesting(null); + } + } + await refreshNodes(); } catch (error: any) { toast.error(error.message || 'Failed to create node'); @@ -74,9 +116,18 @@ export function NodeManager() { const handleEdit = async () => { if (!editingNodeId) return; try { + const payload = { + ...formData, + tls_ca: formData.tls_enabled ? formData.tls_ca : '', + tls_cert: formData.tls_enabled ? formData.tls_cert : '', + tls_key: formData.tls_enabled ? formData.tls_key : '', + ssh_password: formData.ssh_auth_type === 'password' ? formData.ssh_password : '', + ssh_key: formData.ssh_auth_type === 'key' ? formData.ssh_key : '', + }; + const res = await apiFetch(`/nodes/${editingNodeId}`, { method: 'PUT', - body: JSON.stringify(formData), + body: JSON.stringify(payload), }); if (!res.ok) { const err = await res.json(); @@ -93,6 +144,7 @@ export function NodeManager() { }; const openEditDialog = (node: Node) => { + const hasTls = !!(node.tls_ca && node.tls_cert && node.tls_key); setFormData({ name: node.name, type: node.type, @@ -102,8 +154,13 @@ export function NodeManager() { compose_dir: node.compose_dir, is_default: node.is_default, ssh_user: node.ssh_user || '', + ssh_auth_type: node.ssh_key ? 'key' : 'password', ssh_password: node.ssh_password || '', ssh_key: node.ssh_key || '', + tls_enabled: hasTls, + tls_ca: node.tls_ca || '', + tls_cert: node.tls_cert || '', + tls_key: node.tls_key || '', }); setEditingNodeId(node.id); setEditOpen(true); @@ -164,7 +221,7 @@ export function NodeManager() { }; const renderFormFields = () => ( -
+
+
+

How to expose Docker via TCP on the remote machine

+

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

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

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

+
+
-

SSH Credentials (for file operations)

+ {/* SSH Credentials Section */} +
+

SSH Credentials (for file operations via SFTP)

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