- Add systemd service discovery to backend - Add Proxmox LXC/VM detection - Add hostType field to config for better host categorization - Fix SSH trust between hosts (ubuntu/grizzley -> truenas/proxmox) - Add SSH terminal support via xterm.js - Add filebrowser for browsing host filesystems - Update frontend types and components for new node types
266 lines
6.6 KiB
TypeScript
266 lines
6.6 KiB
TypeScript
/**
|
|
* Files API Endpoint
|
|
*
|
|
* GET /api/files/:host/:container - Get volume mounts for a container
|
|
*/
|
|
|
|
import { Router } from 'express';
|
|
import { Client } from 'ssh2';
|
|
import { readFileSync } from 'fs';
|
|
import { homedir } from 'os';
|
|
import { getHostConfigs } from '../config';
|
|
|
|
const router = Router();
|
|
|
|
interface SSHConnectionConfig {
|
|
host: string;
|
|
port?: number;
|
|
username: string;
|
|
privateKey?: Buffer;
|
|
}
|
|
|
|
interface VolumeMount {
|
|
source: string;
|
|
destination: string;
|
|
mode: string;
|
|
}
|
|
|
|
interface FilesResponse {
|
|
volumes: VolumeMount[];
|
|
error?: string;
|
|
}
|
|
|
|
async function connectSSH(config: SSHConnectionConfig, timeout: number = 30000): Promise<Client> {
|
|
return new Promise((resolve, reject) => {
|
|
const conn = new Client();
|
|
|
|
const timeoutId = setTimeout(() => {
|
|
conn.end();
|
|
reject(new Error('Connection timeout'));
|
|
}, timeout);
|
|
|
|
conn.on('ready', () => {
|
|
clearTimeout(timeoutId);
|
|
resolve(conn);
|
|
});
|
|
|
|
conn.on('error', (err) => {
|
|
clearTimeout(timeoutId);
|
|
reject(err);
|
|
});
|
|
|
|
conn.connect({
|
|
host: config.host,
|
|
port: config.port || 22,
|
|
username: config.username,
|
|
privateKey: config.privateKey,
|
|
readyTimeout: timeout,
|
|
});
|
|
});
|
|
}
|
|
|
|
async function execSSH(conn: Client, command: string): Promise<string> {
|
|
return new Promise((resolve, reject) => {
|
|
conn.exec(command, (err, stream) => {
|
|
if (err) {
|
|
reject(err);
|
|
return;
|
|
}
|
|
let output = '';
|
|
stream.on('close', () => resolve(output));
|
|
stream.on('data', (data: Buffer) => { output += data.toString(); });
|
|
stream.stderr.on('data', (data: Buffer) => { output += data.toString(); });
|
|
});
|
|
});
|
|
}
|
|
|
|
async function getContainerVolumes(
|
|
ip: string,
|
|
containerName: string,
|
|
sshUser: string,
|
|
sshKeyPath?: string,
|
|
sshPort?: number
|
|
): Promise<FilesResponse> {
|
|
try {
|
|
// Load SSH key
|
|
const keyPath = sshKeyPath || `${homedir()}/.ssh/id_ed25519`;
|
|
let privateKey: Buffer | undefined;
|
|
try {
|
|
privateKey = readFileSync(keyPath);
|
|
} catch {
|
|
// If key file doesn't exist, try without it
|
|
}
|
|
|
|
const sshConfig: SSHConnectionConfig = {
|
|
host: ip,
|
|
port: sshPort || 22,
|
|
username: sshUser,
|
|
privateKey,
|
|
};
|
|
|
|
const conn = await connectSSH(sshConfig, 30000);
|
|
|
|
// Run docker inspect to get mounts
|
|
const command = `docker inspect ${containerName} --format '{{json .Mounts}}'`;
|
|
const output = await execSSH(conn, command);
|
|
|
|
conn.end();
|
|
|
|
// Parse JSON output
|
|
let mounts: any[] = [];
|
|
try {
|
|
mounts = JSON.parse(output.trim()) || [];
|
|
} catch {
|
|
// If parsing fails, return empty array
|
|
return { volumes: [] };
|
|
}
|
|
|
|
// Transform to VolumeMount format
|
|
const volumes: VolumeMount[] = mounts.map((mount: any) => ({
|
|
source: mount.Source || mount.Source || '',
|
|
destination: mount.Destination || mount.Destination || '',
|
|
mode: mount.Mode || mount.Mode || 'rw',
|
|
})).filter((v: VolumeMount) => v.source && v.destination);
|
|
|
|
return { volumes };
|
|
} catch (error: any) {
|
|
return {
|
|
volumes: [],
|
|
error: error.message || 'Failed to get container volumes',
|
|
};
|
|
}
|
|
}
|
|
|
|
// GET /api/files/:host/:container - Get volume mounts for a container
|
|
router.get('/files/:host/:container', async (req, res) => {
|
|
try {
|
|
const { host, container } = req.params;
|
|
|
|
const hosts = getHostConfigs();
|
|
const hostConfig = hosts.find(h => h.name.toLowerCase() === host.toLowerCase());
|
|
|
|
if (!hostConfig) {
|
|
const response: FilesResponse = {
|
|
volumes: [],
|
|
error: `Host '${host}' not found in configuration`,
|
|
};
|
|
return res.status(404).json(response);
|
|
}
|
|
|
|
const result = await getContainerVolumes(
|
|
hostConfig.ip,
|
|
container,
|
|
hostConfig.sshUser,
|
|
hostConfig.sshKeyPath,
|
|
hostConfig.sshPort
|
|
);
|
|
|
|
res.json(result);
|
|
} catch (error: unknown) {
|
|
const errMsg = error instanceof Error ? error.message : 'Failed to get container volumes';
|
|
const response: FilesResponse = {
|
|
volumes: [],
|
|
error: errMsg,
|
|
};
|
|
res.status(500).json(response);
|
|
}
|
|
});
|
|
|
|
interface FileEntry {
|
|
name: string;
|
|
path: string;
|
|
type: 'file' | 'directory' | 'symlink';
|
|
size: number;
|
|
modified: string;
|
|
}
|
|
|
|
interface BrowseResponse {
|
|
path: string;
|
|
files: FileEntry[];
|
|
error?: string;
|
|
}
|
|
|
|
async function browseDirectory(
|
|
ip: string,
|
|
path: string,
|
|
sshUser: string,
|
|
sshKeyPath?: string,
|
|
sshPort?: number
|
|
): Promise<BrowseResponse> {
|
|
try {
|
|
const keyPath = sshKeyPath || `${homedir()}/.ssh/id_ed25519`;
|
|
let privateKey: Buffer | undefined;
|
|
try {
|
|
privateKey = require('fs').readFileSync(keyPath);
|
|
} catch {
|
|
// ignore
|
|
}
|
|
|
|
const sshConfig: SSHConnectionConfig = {
|
|
host: ip,
|
|
port: sshPort || 22,
|
|
username: sshUser,
|
|
privateKey,
|
|
};
|
|
|
|
const conn = await connectSSH(sshConfig, 30000);
|
|
|
|
const command = `ls -la --time-style=long-iso "${path}" 2>/dev/null | tail -n +2`;
|
|
const output = await execSSH(conn, command);
|
|
|
|
conn.end();
|
|
|
|
const files: FileEntry[] = output.trim().split('\n').filter(Boolean).map(line => {
|
|
const parts = line.split(/\s+/);
|
|
const perms = parts[0];
|
|
const size = parseInt(parts[4], 10) || 0;
|
|
const modified = parts[5] + ' ' + parts[6];
|
|
const name = parts.slice(8).join(' ');
|
|
const type = perms.startsWith('d') ? 'directory' :
|
|
perms.startsWith('l') ? 'symlink' : 'file';
|
|
|
|
return {
|
|
name,
|
|
path: path === '/' ? `/${name}` : `${path}/${name}`,
|
|
type,
|
|
size,
|
|
modified,
|
|
};
|
|
});
|
|
|
|
return { path, files };
|
|
} catch (error: unknown) {
|
|
const errMsg = error instanceof Error ? error.message : 'Failed to browse directory';
|
|
return { path, files: [], error: errMsg };
|
|
}
|
|
}
|
|
|
|
router.get('/files/browse/:host', async (req, res) => {
|
|
try {
|
|
const { host } = req.params;
|
|
const path = (req.query.path as string) || '/';
|
|
|
|
const hosts = getHostConfigs();
|
|
const hostConfig = hosts.find(h => h.name.toLowerCase() === host.toLowerCase());
|
|
|
|
if (!hostConfig) {
|
|
return res.status(404).json({ path, files: [], error: `Host '${host}' not found` });
|
|
}
|
|
|
|
const result = await browseDirectory(
|
|
hostConfig.ip,
|
|
path,
|
|
hostConfig.sshUser,
|
|
hostConfig.sshKeyPath,
|
|
hostConfig.sshPort
|
|
);
|
|
|
|
res.json(result);
|
|
} catch (error: unknown) {
|
|
const errMsg = error instanceof Error ? error.message : 'Failed to browse directory';
|
|
res.status(500).json({ path: req.query.path as string || '/', files: [], error: errMsg });
|
|
}
|
|
});
|
|
|
|
export default router;
|