mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-31 04:38:11 +00:00
feat: implement remote tls/ssh security, isolate system stats, and polish ux
- NodeRegistry: wire TLS ca/cert/key into Dockerode when present on node config - index.ts: /api/system/stats now branches on node type — remote nodes use docker.info() for CPU/RAM, local keeps systeminformation; disk gracefully returns null for remote - index.ts: POST /api/nodes now persists tls_ca, tls_cert, tls_key fields - FileSystemService: throw clean error on missing/empty compose_dir instead of crashing path.join - FileSystemService: guard getStacks() against falsy item.name entries - SSHFileAdapter: filter undefined/non-string names from SFTP readdir before returning - NodeManager: add SSH Authentication Type toggle (Password vs Private Key) - NodeManager: add Enable TLS toggle with conditional CA/cert/key textarea fields - NodeManager: auto-test connection immediately after node creation - NodeManager: replace "Strategy B" copy with Docker TCP setup instructions - NodeManager: add pr-8 to header and DialogHeader to prevent overlap with parent dialog X button Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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).
|
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.
|
||||||
|
- **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:** 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`.
|
||||||
|
|||||||
+34
-13
@@ -1009,18 +1009,41 @@ app.get('/api/logs/global/stream', async (req: Request, res: Response) => {
|
|||||||
// Get host system stats
|
// Get host system stats
|
||||||
app.get('/api/system/stats', async (req: Request, res: Response) => {
|
app.get('/api/system/stats', async (req: Request, res: Response) => {
|
||||||
try {
|
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([
|
const [currentLoad, mem, fsSize] = await Promise.all([
|
||||||
si.currentLoad(),
|
si.currentLoad(),
|
||||||
si.mem(),
|
si.mem(),
|
||||||
si.fsSize()
|
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];
|
const mainDisk = fsSize.find(fs => fs.mount === '/' || fs.mount === 'C:') || fsSize[0];
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
@@ -1042,12 +1065,7 @@ app.get('/api/system/stats', async (req: Request, res: Response) => {
|
|||||||
free: mainDisk.available,
|
free: mainDisk.available,
|
||||||
usagePercent: mainDisk.use ? mainDisk.use.toFixed(1) : '0',
|
usagePercent: mainDisk.use ? mainDisk.use.toFixed(1) : '0',
|
||||||
} : null,
|
} : null,
|
||||||
network: {
|
network: { rxBytes: 0, txBytes: 0, rxSec, txSec },
|
||||||
rxBytes,
|
|
||||||
txBytes,
|
|
||||||
rxSec,
|
|
||||||
txSec
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch system stats:', 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
|
// 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 } = 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') {
|
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' });
|
||||||
@@ -1437,6 +1455,9 @@ app.post('/api/nodes', async (req: Request, res: Response) => {
|
|||||||
ssh_user: ssh_user || '',
|
ssh_user: ssh_user || '',
|
||||||
ssh_password: ssh_password || '',
|
ssh_password: ssh_password || '',
|
||||||
ssh_key: ssh_key || '',
|
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 });
|
||||||
|
|||||||
@@ -11,14 +11,17 @@ export class FileSystemService {
|
|||||||
|
|
||||||
constructor(nodeId?: number) {
|
constructor(nodeId?: number) {
|
||||||
this.nodeId = nodeId ?? NodeRegistry.getInstance().getDefaultNodeId();
|
this.nodeId = nodeId ?? NodeRegistry.getInstance().getDefaultNodeId();
|
||||||
|
|
||||||
const node = NodeRegistry.getInstance().getNode(this.nodeId);
|
const node = NodeRegistry.getInstance().getNode(this.nodeId);
|
||||||
|
|
||||||
if (!node || node.type === 'local' || !node.host) {
|
if (!node || node.type === 'local' || !node.host) {
|
||||||
this.baseDir = process.env.COMPOSE_DIR || '/app/compose';
|
this.baseDir = process.env.COMPOSE_DIR || '/app/compose';
|
||||||
this.adapter = new LocalFileAdapter();
|
this.adapter = new LocalFileAdapter();
|
||||||
} else {
|
} 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);
|
this.adapter = new SSHFileAdapter(node);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -77,6 +80,7 @@ export class FileSystemService {
|
|||||||
|
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
if (!item.isDirectory()) continue;
|
if (!item.isDirectory()) 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);
|
const hasCompose = await this.hasComposeFile(stackDir);
|
||||||
|
|||||||
@@ -90,11 +90,19 @@ export class NodeRegistry {
|
|||||||
throw new Error(`Remote node "${node.name}" is missing a host address`);
|
throw new Error(`Remote node "${node.name}" is missing a host address`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Docker({
|
const dockerOptions: Docker.DockerOptions = {
|
||||||
host: node.host,
|
host: node.host,
|
||||||
port: node.port || 2375,
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -36,14 +36,17 @@ export class SSHFileAdapter implements IFileAdapter {
|
|||||||
const sftp = await this.getClient();
|
const sftp = await this.getClient();
|
||||||
try {
|
try {
|
||||||
const list = await sftp.list(dirPath);
|
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) {
|
if (options?.withFileTypes) {
|
||||||
return list.map((item: any) => ({
|
return valid.map((item: any) => ({
|
||||||
name: item.name,
|
name: item.name,
|
||||||
isDirectory: () => item.type === 'd',
|
isDirectory: () => item.type === 'd',
|
||||||
isFile: () => item.type === '-',
|
isFile: () => item.type === '-',
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
return list.map((item: any) => item.name);
|
return valid.map((item: any) => item.name);
|
||||||
} catch(err: any) {
|
} catch(err: any) {
|
||||||
if(err.code === 2 || err.message.includes('No such file')) throw Object.assign(new Error(), { code: 'ENOENT' });
|
if(err.code === 2 || err.message.includes('No such file')) throw Object.assign(new Error(), { code: 'ENOENT' });
|
||||||
throw err;
|
throw err;
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '.
|
|||||||
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 } from 'lucide-react';
|
import { Plus, Trash2, Wifi, WifiOff, Star, Pencil, Server, Monitor, Globe, ShieldCheck } from 'lucide-react';
|
||||||
|
|
||||||
interface NodeFormData {
|
interface NodeFormData {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -24,8 +24,13 @@ interface NodeFormData {
|
|||||||
compose_dir: string;
|
compose_dir: string;
|
||||||
is_default: boolean;
|
is_default: boolean;
|
||||||
ssh_user: string;
|
ssh_user: string;
|
||||||
|
ssh_auth_type: 'password' | 'key';
|
||||||
ssh_password: string;
|
ssh_password: string;
|
||||||
ssh_key: string;
|
ssh_key: string;
|
||||||
|
tls_enabled: boolean;
|
||||||
|
tls_ca: string;
|
||||||
|
tls_cert: string;
|
||||||
|
tls_key: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultFormData: NodeFormData = {
|
const defaultFormData: NodeFormData = {
|
||||||
@@ -37,8 +42,13 @@ const defaultFormData: NodeFormData = {
|
|||||||
compose_dir: '/opt/docker',
|
compose_dir: '/opt/docker',
|
||||||
is_default: false,
|
is_default: false,
|
||||||
ssh_user: '',
|
ssh_user: '',
|
||||||
|
ssh_auth_type: 'password',
|
||||||
ssh_password: '',
|
ssh_password: '',
|
||||||
ssh_key: '',
|
ssh_key: '',
|
||||||
|
tls_enabled: false,
|
||||||
|
tls_ca: '',
|
||||||
|
tls_cert: '',
|
||||||
|
tls_key: '',
|
||||||
};
|
};
|
||||||
|
|
||||||
export function NodeManager() {
|
export function NodeManager() {
|
||||||
@@ -54,17 +64,49 @@ export function NodeManager() {
|
|||||||
|
|
||||||
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(formData),
|
body: JSON.stringify(payload),
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const err = await res.json();
|
const err = await res.json();
|
||||||
throw new Error(err.error || 'Failed to create node');
|
throw new Error(err.error || 'Failed to create node');
|
||||||
}
|
}
|
||||||
|
const { id: newNodeId } = await res.json();
|
||||||
toast.success(`Node "${formData.name}" created successfully`);
|
toast.success(`Node "${formData.name}" created successfully`);
|
||||||
setCreateOpen(false);
|
setCreateOpen(false);
|
||||||
setFormData(defaultFormData);
|
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();
|
await refreshNodes();
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
toast.error(error.message || 'Failed to create node');
|
toast.error(error.message || 'Failed to create node');
|
||||||
@@ -74,9 +116,18 @@ 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(formData),
|
body: JSON.stringify(payload),
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const err = await res.json();
|
const err = await res.json();
|
||||||
@@ -93,6 +144,7 @@ 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,
|
||||||
@@ -102,8 +154,13 @@ export function NodeManager() {
|
|||||||
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_user: node.ssh_user || '',
|
||||||
|
ssh_auth_type: node.ssh_key ? 'key' : 'password',
|
||||||
ssh_password: node.ssh_password || '',
|
ssh_password: node.ssh_password || '',
|
||||||
ssh_key: node.ssh_key || '',
|
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);
|
||||||
@@ -164,7 +221,7 @@ export function NodeManager() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const renderFormFields = () => (
|
const renderFormFields = () => (
|
||||||
<div className="space-y-4 py-4">
|
<div className="space-y-4 py-4 max-h-[65vh] overflow-y-auto pr-1">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="node-name">Name</Label>
|
<Label htmlFor="node-name">Name</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -190,6 +247,19 @@ export function NodeManager() {
|
|||||||
|
|
||||||
{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">
|
||||||
|
<p className="text-xs font-semibold text-amber-800 dark:text-amber-400">How to expose Docker via TCP on the remote machine</p>
|
||||||
|
<p className="text-xs text-amber-700 dark:text-amber-500">
|
||||||
|
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:
|
||||||
|
</p>
|
||||||
|
<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">
|
||||||
|
ExecStart=/usr/bin/dockerd -H fd:// -H tcp://0.0.0.0:2375 --containerd=/run/containerd/containerd.sock
|
||||||
|
</code>
|
||||||
|
<p className="text-xs text-amber-700 dark:text-amber-500">
|
||||||
|
Then restart: <code className="font-mono bg-amber-100 dark:bg-amber-900 px-1 rounded">systemctl daemon-reload && systemctl restart docker</code>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="node-host">Host</Label>
|
<Label htmlFor="node-host">Host</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -231,9 +301,10 @@ export function NodeManager() {
|
|||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
<p className="text-sm font-medium text-muted-foreground">SSH Credentials (for file operations)</p>
|
{/* 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="grid grid-cols-2 gap-4">
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="node-ssh-user">SSH Username</Label>
|
<Label htmlFor="node-ssh-user">SSH Username</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -243,30 +314,116 @@ export function NodeManager() {
|
|||||||
onChange={(e) => setFormData({ ...formData, ssh_user: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, ssh_user: e.target.value })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="node-ssh-password">SSH Password</Label>
|
<Label>Authentication Type</Label>
|
||||||
<Input
|
<Select
|
||||||
id="node-ssh-password"
|
value={formData.ssh_auth_type}
|
||||||
type="password"
|
onValueChange={(v) => setFormData({ ...formData, ssh_auth_type: v as 'password' | 'key' })}
|
||||||
placeholder="Optional if using SSH key"
|
>
|
||||||
value={formData.ssh_password}
|
<SelectTrigger>
|
||||||
onChange={(e) => setFormData({ ...formData, ssh_password: e.target.value })}
|
<SelectValue />
|
||||||
/>
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="password">Password</SelectItem>
|
||||||
|
<SelectItem value="key">Private Key</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<Separator />
|
||||||
<Label htmlFor="node-ssh-key">SSH Private Key</Label>
|
|
||||||
<textarea
|
{/* TLS Security Section */}
|
||||||
id="node-ssh-key"
|
<div className="space-y-3">
|
||||||
className="flex min-h-[100px] 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"
|
<div className="flex items-center justify-between">
|
||||||
placeholder="-----BEGIN OPENSSH PRIVATE KEY-----"
|
<div>
|
||||||
value={formData.ssh_key}
|
<p className="text-sm font-medium flex items-center gap-1.5">
|
||||||
onChange={(e) => setFormData({ ...formData, ssh_key: e.target.value })}
|
<ShieldCheck className="w-4 h-4 text-blue-500" />
|
||||||
/>
|
Enable TLS
|
||||||
<p className="text-xs text-muted-foreground">
|
</p>
|
||||||
Paste the full private key contents. Optional if using password auth.
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
</p>
|
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>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -279,19 +436,17 @@ export function NodeManager() {
|
|||||||
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 })}
|
||||||
/>
|
/>
|
||||||
<div className="text-xs text-muted-foreground space-y-1 mt-1">
|
<p className="text-xs text-muted-foreground">
|
||||||
<p>The root directory where compose stack folders live on this node.</p>
|
The root directory where compose stack folders live on this node. SSH credentials above are used to read and write compose files via SFTP.
|
||||||
<p className="text-amber-600 dark:text-amber-400 font-medium">
|
</p>
|
||||||
Note: Strategy B requires valid SSH credentials to read and edit compose.yaml files. TCP alone only grants container lifecycle management.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
{/* Header — pr-8 ensures the Add Node button clears any parent dialog's X close icon */}
|
||||||
|
<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">
|
||||||
<Server className="w-5 h-5" />
|
<Server className="w-5 h-5" />
|
||||||
@@ -303,13 +458,13 @@ export function NodeManager() {
|
|||||||
</div>
|
</div>
|
||||||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button size="sm" className="gap-1">
|
<Button size="sm" className="gap-1 shrink-0">
|
||||||
<Plus className="w-4 h-4" />
|
<Plus className="w-4 h-4" />
|
||||||
Add Node
|
Add Node
|
||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent className="sm:max-w-4xl">
|
<DialogContent className="max-w-4xl">
|
||||||
<DialogHeader>
|
<DialogHeader className="pr-8">
|
||||||
<DialogTitle>Add Remote Node</DialogTitle>
|
<DialogTitle>Add Remote Node</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
{renderFormFields()}
|
{renderFormFields()}
|
||||||
@@ -450,8 +605,8 @@ export function NodeManager() {
|
|||||||
|
|
||||||
{/* Edit Dialog */}
|
{/* Edit Dialog */}
|
||||||
<Dialog open={editOpen} onOpenChange={setEditOpen}>
|
<Dialog open={editOpen} onOpenChange={setEditOpen}>
|
||||||
<DialogContent className="sm:max-w-4xl">
|
<DialogContent className="max-w-4xl">
|
||||||
<DialogHeader>
|
<DialogHeader className="pr-8">
|
||||||
<DialogTitle>Edit Node</DialogTitle>
|
<DialogTitle>Edit Node</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
{renderFormFields()}
|
{renderFormFields()}
|
||||||
|
|||||||
Reference in New Issue
Block a user