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

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;