mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-13 04:06:59 +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:
+34
-13
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user