feat: migrate from static config to database and add authentication

- Replaced static hosts.json and staticConfig.ts with SQLite database (Prisma)

- Implemented JWT authentication and Login UI

- Added dynamic API routes for hosts, topology, and settings

- Updated UI components to fetch and manage state dynamically

- Added Settings interface for managing hosts and topology nodes
This commit is contained in:
2026-02-25 14:07:11 -08:00
parent df02542c26
commit 0910c966a5
37 changed files with 1884 additions and 645 deletions

View File

@@ -1,76 +1,31 @@
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { homedir } from 'os';
import { PrismaClient } from '@prisma/client';
import { HostConfig } from './types';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const CONFIG_FILE = path.join(__dirname, 'hosts.json');
function parseEnvHosts(): HostConfig[] {
const hostsEnv = process.env.SSH_HOSTS;
if (!hostsEnv) return [];
const hosts: HostConfig[] = [];
const entries = hostsEnv.split(',').map(h => h.trim()).filter(Boolean);
for (const entry of entries) {
const [name, ip] = entry.split(':');
if (name && ip) {
hosts.push({
name: name.trim(),
ip: ip.trim(),
sshUser: process.env.SSH_USER || 'bear',
sshKeyPath: process.env.SSH_KEY,
sshPort: process.env.SSH_PORT ? parseInt(process.env.SSH_PORT, 10) : 22,
});
}
}
return hosts;
}
function parseJsonConfig(): HostConfig[] {
if (!fs.existsSync(CONFIG_FILE)) {
console.error('Config file not found:', CONFIG_FILE);
return [];
}
const prisma = new PrismaClient();
export async function getHostConfigs(): Promise<HostConfig[]> {
try {
const content = fs.readFileSync(CONFIG_FILE, 'utf-8');
const data = JSON.parse(content);
if (!data.hosts || !Array.isArray(data.hosts)) {
console.error('No hosts array in config');
return [];
}
const hosts = data.hosts.map((h: Partial<HostConfig>) => ({
name: h.name || '',
ip: h.ip || '',
const dbConfigs = await prisma.hostConfig.findMany();
return dbConfigs.map(h => ({
name: h.name,
ip: h.ip,
sshUser: h.sshUser || 'bear',
sshKeyPath: h.sshKeyPath?.replace(/^~/, homedir()),
sshPort: h.sshPort || 22,
})).filter((h: HostConfig) => h.name && h.ip);
console.error('Loaded hosts:', JSON.stringify(hosts));
return hosts;
} catch (e: any) {
console.error('Config parse error:', e.message);
hostType: h.hostType,
}));
} catch (error) {
console.error('Error fetching host configs from DB:', error);
return [];
}
}
export function getHostConfigs(): HostConfig[] {
const envHosts = parseEnvHosts();
if (envHosts.length > 0) {
return envHosts;
export async function hasConfig(): Promise<boolean> {
try {
const count = await prisma.hostConfig.count();
return count > 0;
} catch (error) {
return false;
}
return parseJsonConfig();
}
export function hasConfig(): boolean {
return getHostConfigs().length > 0;
}

View File

@@ -1,52 +0,0 @@
{
"hosts": [
{
"name": "ubuntu",
"ip": "192.168.50.61",
"sshUser": "bear",
"sshKeyPath": "~/.ssh/id_ed25519",
"sshPort": 22,
"hostType": "docker-host"
},
{
"name": "grizzley",
"ip": "192.168.50.84",
"sshUser": "bear",
"sshKeyPath": "~/.ssh/id_ed25519",
"sshPort": 22,
"hostType": "docker-host"
},
{
"name": "truenas",
"ip": "192.168.50.12",
"sshUser": "root",
"sshKeyPath": "~/.ssh/id_ed25519",
"sshPort": 22,
"hostType": "truenas"
},
{
"name": "proxmox",
"ip": "192.168.50.11",
"sshUser": "root",
"sshKeyPath": "~/.ssh/id_ed25519",
"sshPort": 22,
"hostType": "proxmox"
},
{
"name": "ice",
"ip": "192.168.50.197",
"sshUser": "bear",
"sshKeyPath": "~/.ssh/id_ed25519",
"sshPort": 22,
"hostType": "docker-host"
},
{
"name": "panda",
"ip": "192.168.50.196",
"sshUser": "bear",
"sshKeyPath": "~/.ssh/id_ed25519",
"sshPort": 22,
"hostType": "docker-host"
}
]
}

View File

@@ -9,6 +9,10 @@ import configRouter from './routes/config';
import statsRouter from './routes/stats';
import filesRouter from './routes/files';
import terminalRouter from './routes/terminal';
import authRouter from './routes/auth';
import topologyRouter from './routes/topology';
import hostsRouter from './routes/hosts';
import { requireAuth } from './middleware/auth';
import { getHostConfigs } from './config';
import { requestLogger, logger } from './middleware/requestLogger';
import { errorHandler } from './middleware/errorHandler';
@@ -17,10 +21,13 @@ const app = express();
const httpServer = createServer(app);
const PORT = 3001;
const allowedOrigins = ['http://localhost:3000', 'http://localhost:4173'];
const corsOrigin = process.env.CORS_ORIGIN ? [process.env.CORS_ORIGIN, ...allowedOrigins] : allowedOrigins;
// --- Socket.IO setup (websocket-engineer skill) ---
const io = new Server(httpServer, {
cors: {
origin: process.env.CORS_ORIGIN || 'http://localhost:3000',
origin: corsOrigin,
credentials: true,
},
pingInterval: 25000,
@@ -45,7 +52,7 @@ app.use(helmet({
// CORS — restrict to configured origins
app.use(cors({
origin: process.env.CORS_ORIGIN || 'http://localhost:3000',
origin: corsOrigin,
credentials: true,
}));
@@ -85,18 +92,23 @@ app.get('/api/health', (_req, res) => {
// --- Debug endpoint (dev only) ---
if (process.env.NODE_ENV !== 'production') {
app.get('/api/debug-config', (_req, res) => {
const hosts = getHostConfigs();
app.get('/api/debug-config', async (_req, res) => {
const hosts = await getHostConfigs();
res.json({ hosts });
});
}
// --- Routes ---
app.use('/api', discoverRouter);
app.use('/api', configRouter);
app.use('/api', statsRouter);
app.use('/api', filesRouter);
app.use('/api', terminalRouter);
// --- Public Routes ---
app.use('/api', authRouter);
// --- Protected Routes ---
app.use('/api', requireAuth, discoverRouter);
app.use('/api', requireAuth, configRouter);
app.use('/api', requireAuth, statsRouter);
app.use('/api', requireAuth, filesRouter);
app.use('/api', requireAuth, terminalRouter);
app.use('/api', requireAuth, topologyRouter);
app.use('/api', requireAuth, hostsRouter);
// --- Global error handler (must be last) ---
app.use(errorHandler);

27
server/middleware/auth.ts Normal file
View File

@@ -0,0 +1,27 @@
import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
export interface AuthRequest extends Request {
user?: {
id: string;
username: string;
};
}
const JWT_SECRET = process.env.JWT_SECRET || 'fallback-secret-for-development-only';
export const requireAuth = (req: AuthRequest, res: Response, next: NextFunction) => {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
return res.status(401).json({ status: 'error', message: 'Authentication required' });
}
const token = authHeader.split(' ')[1];
try {
const decoded = jwt.verify(token, JWT_SECRET) as { id: string; username: string };
req.user = decoded;
next();
} catch (error) {
return res.status(401).json({ status: 'error', message: 'Invalid or expired token' });
}
};

78
server/routes/auth.ts Normal file
View File

@@ -0,0 +1,78 @@
import { Router } from 'express';
import bcrypt from 'bcrypt';
import jwt from 'jsonwebtoken';
import { PrismaClient } from '@prisma/client';
import rateLimit from 'express-rate-limit';
const router = Router();
const prisma = new PrismaClient();
const JWT_SECRET = process.env.JWT_SECRET || 'fallback-secret-for-development-only';
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 5, // Limit each IP to 5 failed login attempts per window
message: { status: 'error', message: 'Too many login attempts, please try again later' },
skipSuccessfulRequests: true,
});
// Check if setup is required (no users exist)
router.get('/auth/status', async (req, res) => {
try {
const userCount = await prisma.user.count();
res.json({ setupRequired: userCount === 0 });
} catch (error) {
res.status(500).json({ status: 'error', message: 'Database error' });
}
});
// Initial Setup (Only works if no users exist)
router.post('/auth/setup', async (req, res) => {
try {
const userCount = await prisma.user.count();
if (userCount > 0) {
return res.status(403).json({ status: 'error', message: 'Setup has already been completed' });
}
const { username, password } = req.body;
if (!username || !password || password.length < 8) {
return res.status(400).json({ status: 'error', message: 'Invalid username or password must be at least 8 characters' });
}
const hashedPassword = await bcrypt.hash(password, 10);
const user = await prisma.user.create({
data: { username, password: hashedPassword },
});
const token = jwt.sign({ id: user.id, username: user.username }, JWT_SECRET, { expiresIn: '7d' });
res.json({ status: 'success', token, user: { id: user.id, username: user.username } });
} catch (error) {
res.status(500).json({ status: 'error', message: 'Failed to create user' });
}
});
// Login
router.post('/auth/login', loginLimiter, async (req, res) => {
try {
const { username, password } = req.body;
if (!username || !password) {
return res.status(400).json({ status: 'error', message: 'Username and password required' });
}
const user = await prisma.user.findUnique({ where: { username } });
if (!user) {
return res.status(401).json({ status: 'error', message: 'Invalid credentials' });
}
const valid = await bcrypt.compare(password, user.password);
if (!valid) {
return res.status(401).json({ status: 'error', message: 'Invalid credentials' });
}
const token = jwt.sign({ id: user.id, username: user.username }, JWT_SECRET, { expiresIn: '7d' });
res.json({ status: 'success', token, user: { id: user.id, username: user.username } });
} catch (error) {
res.status(500).json({ status: 'error', message: 'Login failed' });
}
});
export default router;

View File

@@ -163,7 +163,7 @@ router.get('/config/:host/:container', async (req, res) => {
try {
// Find host config
const hostConfigs = getHostConfigs();
const hostConfigs = await getHostConfigs();
const hostConfig = hostConfigs.find(h => h.name === host);
if (!hostConfig) {

View File

@@ -7,10 +7,13 @@
import { Router } from 'express';
import { execSync } from 'child_process';
import { homedir } from 'os';
import { PrismaClient } from '@prisma/client';
import { getHostConfigs } from '../config';
import { DiscoveryResponse } from '../types';
import { io } from '../index';
const router = Router();
const prisma = new PrismaClient();
interface HostDiscoveryResult {
name: string;
@@ -35,11 +38,11 @@ async function discoverHost(
console.error(`DEBUG: ${name} keyPath=${keyPath}, user=${sshUser}`);
const keyArg = `-i ${keyPath}`;
const portArg = sshPort && sshPort !== 22 ? `-p ${sshPort}` : '';
const dockerCmd = `ssh -o ConnectTimeout=5 -o StrictHostKeyChecking=no ${keyArg} ${portArg} ${sshUser}@${ip} "docker ps --format '{{.Names}}'" 2>/dev/null`;
const dockerOutput = execSync(dockerCmd, { encoding: 'utf-8', timeout: 15000 });
const containers = dockerOutput.trim().split('\n').filter(c => c.trim());
let services: string[] = [];
if (hostType !== 'proxmox') {
try {
@@ -50,7 +53,7 @@ async function discoverHost(
console.error(`DEBUG: ${name} systemd discovery failed`);
}
}
let vms: Array<{ id: string; name: string; status: string; type: 'lxc' | 'qemu' }> = [];
if (hostType === 'proxmox' || name === 'proxmox') {
try {
@@ -63,7 +66,7 @@ async function discoverHost(
vms.push({ id: parts[0], name: parts[1], status: parts[2] || 'unknown', type: 'lxc' });
}
}
const vmCmd = `ssh -o ConnectTimeout=5 -o StrictHostKeyChecking=no ${keyArg} ${portArg} ${sshUser}@${ip} "qm list" 2>/dev/null`;
const vmOutput = execSync(vmCmd, { encoding: 'utf-8', timeout: 10000 });
const vmLines = vmOutput.trim().split('\n').slice(1);
@@ -77,7 +80,7 @@ async function discoverHost(
console.error(`DEBUG: ${name} Proxmox discovery failed`);
}
}
return {
name,
ip,
@@ -97,18 +100,53 @@ async function discoverHost(
}
}
// POST /api/discover - Discover all hosts via SSH
// POST /api/discover - Trigger discovery but return cached immediately
router.post('/discover', async (req, res) => {
try {
const hosts = getHostConfigs();
// 1. Fetch from cache immediately for fast loading
const cachedSetting = await prisma.settings.findUnique({ where: { key: 'last_discovery' } });
let previousState: DiscoveryResponse = {
hosts: [],
timestamp: new Date().toISOString(),
errors: [],
};
if (cachedSetting && cachedSetting.value) {
try {
previousState = JSON.parse(cachedSetting.value);
} catch (e) {
console.error('Failed to parse cached discovery state');
}
}
// 2. Return cached response to unblock the UI request
res.json(previousState);
// 3. Kick off background discovery
runBackgroundDiscovery();
} catch (error: any) {
res.status(500).json({
hosts: [],
timestamp: new Date().toISOString(),
errors: [error.message || 'Cache retrieval failed'],
});
}
});
async function runBackgroundDiscovery() {
try {
const hosts = await getHostConfigs();
if (hosts.length === 0) {
const response: DiscoveryResponse = {
hosts: [],
timestamp: new Date().toISOString(),
errors: ['No hosts configured'],
};
return res.json(response);
await cacheAndEmit(response);
return;
}
const results: HostDiscoveryResult[] = [];
@@ -134,7 +172,7 @@ router.post('/discover', async (req, res) => {
});
}
}
const errors: string[] = [];
results.forEach((result: HostDiscoveryResult) => {
if (!result.online && result.error) {
@@ -155,15 +193,26 @@ router.post('/discover', async (req, res) => {
errors,
};
res.json(response);
await cacheAndEmit(response);
} catch (error: any) {
const response: DiscoveryResponse = {
hosts: [],
timestamp: new Date().toISOString(),
errors: [error.message || 'Discovery failed'],
};
res.status(500).json(response);
console.error('Background discovery failed:', error);
}
});
}
async function cacheAndEmit(response: DiscoveryResponse) {
try {
// Cache the standard response to the DB
await prisma.settings.upsert({
where: { key: 'last_discovery' },
update: { value: JSON.stringify(response) },
create: { key: 'last_discovery', value: JSON.stringify(response) },
});
// Broadcast the full update via Socket.IO
io.emit('topology:update', response);
} catch (err) {
console.error('Failed to cache and emit discovery results:', err);
}
}
export default router;

View File

@@ -136,7 +136,7 @@ router.get('/files/:host/:container', async (req, res) => {
try {
const { host, container } = req.params;
const hosts = getHostConfigs();
const hosts = await getHostConfigs();
const hostConfig = hosts.find(h => h.name.toLowerCase() === host.toLowerCase());
if (!hostConfig) {
@@ -240,7 +240,7 @@ router.get('/files/browse/:host', async (req, res) => {
const { host } = req.params;
const path = (req.query.path as string) || '/';
const hosts = getHostConfigs();
const hosts = await getHostConfigs();
const hostConfig = hosts.find(h => h.name.toLowerCase() === host.toLowerCase());
if (!hostConfig) {

50
server/routes/hosts.ts Normal file
View File

@@ -0,0 +1,50 @@
import { Router } from 'express';
import { PrismaClient } from '@prisma/client';
const router = Router();
const prisma = new PrismaClient();
// GET all host configurations
router.get('/hosts', async (req, res) => {
try {
const hosts = await prisma.hostConfig.findMany();
res.json({ status: 'success', hosts });
} catch (error) {
res.status(500).json({ status: 'error', message: 'Failed to fetch host configs' });
}
});
// POST a new host configuration
router.post('/hosts', async (req, res) => {
try {
const host = await prisma.hostConfig.create({ data: req.body });
res.json({ status: 'success', host });
} catch (error) {
res.status(400).json({ status: 'error', message: 'Failed to create host config. Ensure name is unique.' });
}
});
// PUT (update) a host configuration
router.put('/hosts/:id', async (req, res) => {
try {
const host = await prisma.hostConfig.update({
where: { id: req.params.id },
data: req.body,
});
res.json({ status: 'success', host });
} catch (error) {
res.status(400).json({ status: 'error', message: 'Failed to update host config' });
}
});
// DELETE a host configuration
router.delete('/hosts/:id', async (req, res) => {
try {
await prisma.hostConfig.delete({ where: { id: req.params.id } });
res.json({ status: 'success' });
} catch (error) {
res.status(400).json({ status: 'error', message: 'Failed to delete host config' });
}
});
export default router;

View File

@@ -131,7 +131,7 @@ router.get('/stats/:host/:container', async (req, res) => {
const { host, container } = req.params;
// Find host config by name
const hosts = getHostConfigs();
const hosts = await getHostConfigs();
const hostConfig = hosts.find(h => h.name.toLowerCase() === host.toLowerCase());
if (!hostConfig) {

View File

@@ -18,7 +18,7 @@ router.post('/terminal/exec', async (req, res) => {
return res.status(400).json({ error: 'Missing host or command' });
}
const hosts = getHostConfigs();
const hosts = await getHostConfigs();
const hostConfig = hosts.find(h => h.name === hostName);
if (!hostConfig) {
@@ -44,7 +44,7 @@ router.post('/terminal/exec', async (req, res) => {
router.get('/terminal/hosts', async (_req, res) => {
try {
const hosts = getHostConfigs();
const hosts = await getHostConfigs();
res.json({
hosts: hosts.map(h => ({
name: h.name,

71
server/routes/topology.ts Normal file
View File

@@ -0,0 +1,71 @@
import { Router } from 'express';
import { PrismaClient } from '@prisma/client';
const router = Router();
const prisma = new PrismaClient();
// GET all nodes and edges
router.get('/topology', async (req, res) => {
try {
const nodes = await prisma.networkNode.findMany();
const edges = await prisma.networkEdge.findMany();
res.json({ status: 'success', nodes, edges });
} catch (error) {
res.status(500).json({ status: 'error', message: 'Failed to fetch topology' });
}
});
// POST a new node
router.post('/topology/nodes', async (req, res) => {
try {
const node = await prisma.networkNode.create({ data: req.body });
res.json({ status: 'success', node });
} catch (error) {
res.status(400).json({ status: 'error', message: 'Failed to create node' });
}
});
// PUT (update) a node
router.put('/topology/nodes/:id', async (req, res) => {
try {
const node = await prisma.networkNode.update({
where: { id: req.params.id },
data: req.body,
});
res.json({ status: 'success', node });
} catch (error) {
res.status(400).json({ status: 'error', message: 'Failed to update node' });
}
});
// DELETE a node
router.delete('/topology/nodes/:id', async (req, res) => {
try {
await prisma.networkNode.delete({ where: { id: req.params.id } });
res.json({ status: 'success' });
} catch (error) {
res.status(400).json({ status: 'error', message: 'Failed to delete node' });
}
});
// POST a new edge
router.post('/topology/edges', async (req, res) => {
try {
const edge = await prisma.networkEdge.create({ data: req.body });
res.json({ status: 'success', edge });
} catch (error) {
res.status(400).json({ status: 'error', message: 'Failed to create edge' });
}
});
// DELETE an edge
router.delete('/topology/edges/:id', async (req, res) => {
try {
await prisma.networkEdge.delete({ where: { id: req.params.id } });
res.json({ status: 'success' });
} catch (error) {
res.status(400).json({ status: 'error', message: 'Failed to delete edge' });
}
});
export default router;