mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-12 19:57:37 +00:00
Initial commit: Sencho V1 complete with Auth and Dockerization
This commit is contained in:
@@ -0,0 +1,510 @@
|
||||
import express, { Request, Response, NextFunction } from 'express';
|
||||
import cors from 'cors';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import WebSocket from 'ws';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import DockerController from './services/DockerController';
|
||||
import { FileSystemService } from './services/FileSystemService';
|
||||
import { ComposeService } from './services/ComposeService';
|
||||
import { ConfigService } from './services/ConfigService';
|
||||
import composerize from 'composerize';
|
||||
import si from 'systeminformation';
|
||||
import http from 'http';
|
||||
|
||||
const app = express();
|
||||
const PORT = 3000;
|
||||
|
||||
// Environment variables
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-in-production';
|
||||
|
||||
// ConfigService for persistent auth storage
|
||||
const configService = new ConfigService();
|
||||
|
||||
// Cookie settings
|
||||
const COOKIE_NAME = 'sencho_token';
|
||||
const COOKIE_OPTIONS = {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'strict' as const,
|
||||
maxAge: 24 * 60 * 60 * 1000, // 24 hours
|
||||
};
|
||||
|
||||
// Middleware
|
||||
app.use(cors({
|
||||
origin: true,
|
||||
credentials: true,
|
||||
}));
|
||||
app.use(express.json());
|
||||
app.use(cookieParser());
|
||||
|
||||
// Extend Express Request type for user
|
||||
declare module 'express' {
|
||||
interface Request {
|
||||
user?: { username: string };
|
||||
}
|
||||
}
|
||||
|
||||
// Authentication Middleware
|
||||
const authMiddleware = (req: Request, res: Response, next: NextFunction): void => {
|
||||
const token = req.cookies[COOKIE_NAME];
|
||||
|
||||
if (!token) {
|
||||
res.status(401).json({ error: 'Authentication required' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const decoded = jwt.verify(token, JWT_SECRET) as { username: string };
|
||||
req.user = { username: decoded.username };
|
||||
next();
|
||||
} catch {
|
||||
res.status(401).json({ error: 'Invalid or expired token' });
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Auth Routes (no authentication required)
|
||||
|
||||
// Check if setup is needed
|
||||
app.get('/api/auth/status', async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const needsSetup = await configService.needsSetup();
|
||||
res.json({ needsSetup });
|
||||
} catch (error) {
|
||||
console.error('Error checking setup status:', error);
|
||||
res.json({ needsSetup: true });
|
||||
}
|
||||
});
|
||||
|
||||
// Initial setup endpoint
|
||||
app.post('/api/auth/setup', async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
// Check if setup is still needed
|
||||
const needsSetup = await configService.needsSetup();
|
||||
if (!needsSetup) {
|
||||
res.status(400).json({ error: 'Setup has already been completed' });
|
||||
return;
|
||||
}
|
||||
|
||||
const { username, password, confirmPassword } = req.body;
|
||||
|
||||
// Validation
|
||||
if (!username || !password || !confirmPassword) {
|
||||
res.status(400).json({ error: 'All fields are required' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (username.length < 3) {
|
||||
res.status(400).json({ error: 'Username must be at least 3 characters' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
res.status(400).json({ error: 'Password must be at least 6 characters' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
res.status(400).json({ error: 'Passwords do not match' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Save credentials
|
||||
await configService.saveConfig(username, password);
|
||||
|
||||
// Issue JWT and log user in
|
||||
const token = jwt.sign({ username }, JWT_SECRET, { expiresIn: '24h' });
|
||||
res.cookie(COOKIE_NAME, token, COOKIE_OPTIONS);
|
||||
res.json({ success: true, message: 'Setup completed successfully' });
|
||||
} catch (error) {
|
||||
console.error('Setup error:', error);
|
||||
res.status(500).json({ error: 'Failed to complete setup' });
|
||||
}
|
||||
});
|
||||
|
||||
// Login endpoint
|
||||
app.post('/api/auth/login', async (req: Request, res: Response): Promise<void> => {
|
||||
const { username, password } = req.body;
|
||||
|
||||
if (!username || !password) {
|
||||
res.status(400).json({ error: 'Username and password are required' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const isValid = await configService.validateCredentials(username, password);
|
||||
|
||||
if (isValid) {
|
||||
const token = jwt.sign({ username }, JWT_SECRET, { expiresIn: '24h' });
|
||||
res.cookie(COOKIE_NAME, token, COOKIE_OPTIONS);
|
||||
res.json({ success: true, message: 'Login successful' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(401).json({ error: 'Invalid credentials' });
|
||||
} catch (error) {
|
||||
console.error('Login error:', error);
|
||||
res.status(500).json({ error: 'Login failed' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/auth/logout', (req: Request, res: Response): void => {
|
||||
res.clearCookie(COOKIE_NAME, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'strict',
|
||||
});
|
||||
res.json({ success: true, message: 'Logged out successfully' });
|
||||
});
|
||||
|
||||
// Check authentication status
|
||||
app.get('/api/auth/check', authMiddleware, (req: Request, res: Response): void => {
|
||||
res.json({ authenticated: true, user: req.user });
|
||||
});
|
||||
|
||||
// Apply authentication middleware to all /api/* routes except /api/auth/*
|
||||
app.use('/api', (req: Request, res: Response, next: NextFunction): void => {
|
||||
if (req.path.startsWith('/auth/')) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
authMiddleware(req, res, next);
|
||||
});
|
||||
|
||||
// Create HTTP server for WebSocket upgrade handling
|
||||
const server = http.createServer(app);
|
||||
|
||||
// WebSocket server with authentication
|
||||
const wss = new WebSocket.Server({ noServer: true });
|
||||
|
||||
let terminalWs: WebSocket | null = null;
|
||||
|
||||
// Handle WebSocket upgrade with JWT authentication
|
||||
server.on('upgrade', (req, socket, head) => {
|
||||
// Parse cookies from the upgrade request
|
||||
const cookieHeader = req.headers.cookie || '';
|
||||
const cookies = Object.fromEntries(
|
||||
cookieHeader.split(';').map(c => c.trim().split('=')).filter(([k, v]) => k && v)
|
||||
);
|
||||
|
||||
const token = cookies[COOKIE_NAME];
|
||||
|
||||
if (!token) {
|
||||
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
jwt.verify(token, JWT_SECRET);
|
||||
// Authentication successful, proceed with WebSocket connection
|
||||
wss.handleUpgrade(req, socket, head, (ws) => {
|
||||
wss.emit('connection', ws, req);
|
||||
});
|
||||
} catch (error) {
|
||||
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
wss.on('connection', (ws) => {
|
||||
console.log('WebSocket connected');
|
||||
|
||||
ws.on('message', (message) => {
|
||||
try {
|
||||
const data = JSON.parse(message.toString());
|
||||
if (data.action === 'connectTerminal') {
|
||||
terminalWs = ws;
|
||||
} else if (data.action === 'streamStats') {
|
||||
const dockerController = DockerController.getInstance();
|
||||
dockerController.streamStats(data.containerId, ws);
|
||||
} else if (data.action === 'execContainer') {
|
||||
// Handle container exec for bash access
|
||||
const dockerController = DockerController.getInstance();
|
||||
dockerController.execContainer(data.containerId, ws);
|
||||
} else if (data.action === 'input') {
|
||||
// Forward input to exec stream
|
||||
const dockerController = DockerController.getInstance();
|
||||
dockerController.sendExecInput(data.data);
|
||||
} else if (data.action === 'resize') {
|
||||
const dockerController = DockerController.getInstance();
|
||||
dockerController.resizeExec(data.cols, data.rows);
|
||||
}
|
||||
} catch (error) {
|
||||
ws.send(JSON.stringify({ error: 'Invalid message' }));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// API Routes (all protected by authMiddleware)
|
||||
|
||||
app.get('/api/containers', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const dockerController = DockerController.getInstance();
|
||||
const containers = await dockerController.getRunningContainers();
|
||||
res.json(containers);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to fetch containers' });
|
||||
}
|
||||
});
|
||||
|
||||
const fileSystemService = new FileSystemService();
|
||||
|
||||
app.get('/api/stacks', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const files = await fileSystemService.getStackFiles();
|
||||
res.json(files);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to fetch stack files' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/stacks/:filename', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const filename = req.params.filename as string;
|
||||
const content = await fileSystemService.getStackContent(filename);
|
||||
res.send(content);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to read file' });
|
||||
}
|
||||
});
|
||||
|
||||
app.put('/api/stacks/:filename', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const filename = req.params.filename as string;
|
||||
const { content } = req.body;
|
||||
console.log('PUT /api/stacks/:filename', { filename, contentType: typeof content, contentLength: content?.length });
|
||||
if (typeof content !== 'string') {
|
||||
console.error('Content is not a string:', content);
|
||||
return res.status(400).json({ error: 'Content must be a string' });
|
||||
}
|
||||
await fileSystemService.saveStackContent(filename, content);
|
||||
console.log('File saved successfully:', filename);
|
||||
res.json({ message: 'File saved successfully' });
|
||||
} catch (error) {
|
||||
console.error('Failed to save file:', error);
|
||||
res.status(500).json({ error: 'Failed to save file' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/stacks/:filename/env', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const filename = req.params.filename as string;
|
||||
const exists = await fileSystemService.envExists(filename);
|
||||
if (!exists) {
|
||||
return res.status(404).json({ error: 'Env file not found' });
|
||||
}
|
||||
const content = await fileSystemService.getEnvContent(filename);
|
||||
res.send(content);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to read env file' });
|
||||
}
|
||||
});
|
||||
|
||||
app.put('/api/stacks/:filename/env', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const filename = req.params.filename as string;
|
||||
const { content } = req.body;
|
||||
if (typeof content !== 'string') {
|
||||
return res.status(400).json({ error: 'Content must be a string' });
|
||||
}
|
||||
await fileSystemService.saveEnvContent(filename, content);
|
||||
res.json({ message: 'Env file saved successfully' });
|
||||
} catch (error) {
|
||||
console.error('Failed to save env file:', error);
|
||||
res.status(500).json({ error: 'Failed to save env file' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/stacks', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { filename } = req.body;
|
||||
if (!filename || typeof filename !== 'string') {
|
||||
return res.status(400).json({ error: 'Filename is required and must be a string' });
|
||||
}
|
||||
await fileSystemService.createStack(filename);
|
||||
res.json({ message: 'Stack created successfully' });
|
||||
} catch (error) {
|
||||
console.error('Failed to create stack:', error);
|
||||
res.status(500).json({ error: 'Failed to create stack' });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/stacks/:filename', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const filename = req.params.filename as string;
|
||||
await fileSystemService.deleteStack(filename);
|
||||
res.json({ message: 'Stack deleted successfully' });
|
||||
} catch (error) {
|
||||
console.error('Failed to delete stack:', error);
|
||||
res.status(500).json({ error: 'Failed to delete stack' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/stacks/:filename/containers', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const filename = req.params.filename as string;
|
||||
const stackName = filename.replace(/\.yml$/, '');
|
||||
const dockerController = DockerController.getInstance();
|
||||
const containers = await dockerController.getContainersByStack(stackName);
|
||||
res.json(containers);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to fetch containers' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/containers/:id/start', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const id = req.params.id as string;
|
||||
const dockerController = DockerController.getInstance();
|
||||
await dockerController.startContainer(id);
|
||||
res.json({ message: 'Container started' });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to start container' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/containers/:id/stop', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const id = req.params.id as string;
|
||||
const dockerController = DockerController.getInstance();
|
||||
await dockerController.stopContainer(id);
|
||||
res.json({ message: 'Container stopped' });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to stop container' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/containers/:id/restart', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const id = req.params.id as string;
|
||||
const dockerController = DockerController.getInstance();
|
||||
await dockerController.restartContainer(id);
|
||||
res.json({ message: 'Container restarted' });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to restart container' });
|
||||
}
|
||||
});
|
||||
|
||||
const composeService = new ComposeService();
|
||||
|
||||
app.post('/api/stacks/:filename/up', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const filename = req.params.filename as string;
|
||||
composeService.runCommand(filename, 'up', terminalWs || undefined);
|
||||
res.json({ status: 'Command started' });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to start command' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/stacks/:filename/down', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const filename = req.params.filename as string;
|
||||
composeService.runCommand(filename, 'down', terminalWs || undefined);
|
||||
res.json({ status: 'Command started' });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to start command' });
|
||||
}
|
||||
});
|
||||
|
||||
// Update stack: pull images and recreate containers
|
||||
app.post('/api/stacks/:filename/update', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const filename = req.params.filename as string;
|
||||
// Run update asynchronously, don't wait for completion
|
||||
composeService.updateStack(filename, terminalWs || undefined).catch(error => {
|
||||
console.error('Update stack error:', error);
|
||||
});
|
||||
res.json({ status: 'Update started' });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to start update' });
|
||||
}
|
||||
});
|
||||
|
||||
// Docker Run to Compose converter endpoint
|
||||
app.post('/api/convert', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { dockerRun } = req.body;
|
||||
if (!dockerRun || typeof dockerRun !== 'string') {
|
||||
return res.status(400).json({ error: 'dockerRun command is required' });
|
||||
}
|
||||
const yaml = composerize(dockerRun);
|
||||
res.json({ yaml });
|
||||
} catch (error) {
|
||||
console.error('Conversion error:', error);
|
||||
res.status(500).json({ error: 'Failed to convert docker run command' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get all containers stats for dashboard
|
||||
app.get('/api/stats', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const dockerController = DockerController.getInstance();
|
||||
const containers = await dockerController.getRunningContainers();
|
||||
const allContainers = await dockerController.getAllContainers();
|
||||
|
||||
const active = containers.length;
|
||||
const exited = allContainers.filter((c: { State: string }) => c.State === 'exited').length;
|
||||
const total = allContainers.length;
|
||||
|
||||
res.json({ active, exited, total, inactive: total - active - exited });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to fetch stats' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get host system stats
|
||||
app.get('/api/system/stats', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const [currentLoad, mem, fsSize] = await Promise.all([
|
||||
si.currentLoad(),
|
||||
si.mem(),
|
||||
si.fsSize(),
|
||||
]);
|
||||
|
||||
// Find the main mount (usually the largest or root mount)
|
||||
const mainDisk = fsSize.find(fs => fs.mount === '/' || fs.mount === 'C:') || fsSize[0];
|
||||
|
||||
res.json({
|
||||
cpu: {
|
||||
usage: currentLoad.currentLoad.toFixed(1),
|
||||
cores: currentLoad.cpus.length,
|
||||
},
|
||||
memory: {
|
||||
total: mem.total,
|
||||
used: mem.used,
|
||||
free: mem.free,
|
||||
usagePercent: ((mem.used / mem.total) * 100).toFixed(1),
|
||||
},
|
||||
disk: mainDisk ? {
|
||||
fs: mainDisk.fs,
|
||||
mount: mainDisk.mount,
|
||||
total: mainDisk.size,
|
||||
used: mainDisk.used,
|
||||
free: mainDisk.available,
|
||||
usagePercent: mainDisk.use ? mainDisk.use.toFixed(1) : '0',
|
||||
} : null,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch system stats:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch system stats' });
|
||||
}
|
||||
});
|
||||
|
||||
// Serve static files in production (for Docker deployment)
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
app.use(express.static('public'));
|
||||
|
||||
// Handle SPA routing - serve index.html for non-API routes
|
||||
app.get('*', (req: Request, res: Response) => {
|
||||
if (!req.path.startsWith('/api')) {
|
||||
res.sendFile('index.html', { root: 'public' });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
server.listen(PORT, () => {
|
||||
console.log(`Server running on port ${PORT}`);
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
import { spawn } from 'child_process';
|
||||
import path from 'path';
|
||||
import WebSocket from 'ws';
|
||||
|
||||
export class ComposeService {
|
||||
private baseDir: string;
|
||||
|
||||
constructor() {
|
||||
this.baseDir = process.env.COMPOSE_DIR || path.join(process.cwd(), '..', 'mock_data', 'docker', 'compose');
|
||||
}
|
||||
|
||||
runCommand(filename: string, action: 'up' | 'down', ws?: WebSocket) {
|
||||
const filePath = path.join(this.baseDir, filename);
|
||||
const args = action === 'up' ? ['compose', '-f', filePath, 'up', '-d'] : ['compose', '-f', filePath, 'down'];
|
||||
|
||||
const child = spawn('docker', args, { shell: true });
|
||||
|
||||
if (ws) {
|
||||
child.stdout.on('data', (data: Buffer) => {
|
||||
ws.send(data.toString());
|
||||
});
|
||||
|
||||
child.stderr.on('data', (data: Buffer) => {
|
||||
ws.send(data.toString());
|
||||
});
|
||||
|
||||
child.on('close', (code: number | null) => {
|
||||
ws.send(`Command exited with code ${code}\n`);
|
||||
});
|
||||
|
||||
child.on('error', (error: Error) => {
|
||||
ws.send(`Error: ${error.message}\n`);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Update command: pull images first, then recreate containers
|
||||
async updateStack(filename: string, ws?: WebSocket): Promise<void> {
|
||||
const filePath = path.join(this.baseDir, filename);
|
||||
|
||||
const sendOutput = (data: string) => {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(data);
|
||||
}
|
||||
};
|
||||
|
||||
// Step 1: Pull images
|
||||
sendOutput('=== Pulling latest images ===\n');
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const pullProcess = spawn('docker', ['compose', '-f', filePath, 'pull'], { shell: true });
|
||||
|
||||
pullProcess.stdout.on('data', (data: Buffer) => {
|
||||
sendOutput(data.toString());
|
||||
});
|
||||
|
||||
pullProcess.stderr.on('data', (data: Buffer) => {
|
||||
sendOutput(data.toString());
|
||||
});
|
||||
|
||||
pullProcess.on('close', (code: number | null) => {
|
||||
if (code === 0) {
|
||||
sendOutput('=== Images pulled successfully ===\n');
|
||||
resolve();
|
||||
} else {
|
||||
sendOutput(`=== Pull failed with code ${code} ===\n`);
|
||||
reject(new Error(`Pull failed with code ${code}`));
|
||||
}
|
||||
});
|
||||
|
||||
pullProcess.on('error', (error: Error) => {
|
||||
sendOutput(`Pull error: ${error.message}\n`);
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
|
||||
// Step 2: Recreate containers with new images
|
||||
sendOutput('=== Recreating containers ===\n');
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const upProcess = spawn('docker', ['compose', '-f', filePath, 'up', '-d'], { shell: true });
|
||||
|
||||
upProcess.stdout.on('data', (data: Buffer) => {
|
||||
sendOutput(data.toString());
|
||||
});
|
||||
|
||||
upProcess.stderr.on('data', (data: Buffer) => {
|
||||
sendOutput(data.toString());
|
||||
});
|
||||
|
||||
upProcess.on('close', (code: number | null) => {
|
||||
if (code === 0) {
|
||||
sendOutput('=== Stack updated successfully ===\n');
|
||||
resolve();
|
||||
} else {
|
||||
sendOutput(`=== Update failed with code ${code} ===\n`);
|
||||
reject(new Error(`Up failed with code ${code}`));
|
||||
}
|
||||
});
|
||||
|
||||
upProcess.on('error', (error: Error) => {
|
||||
sendOutput(`Update error: ${error.message}\n`);
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import bcrypt from 'bcrypt';
|
||||
|
||||
interface AuthConfig {
|
||||
username: string;
|
||||
passwordHash: string;
|
||||
}
|
||||
|
||||
export class ConfigService {
|
||||
private dataDir: string;
|
||||
private configPath: string;
|
||||
|
||||
constructor() {
|
||||
this.dataDir = process.env.DATA_DIR || path.join(process.cwd(), 'data');
|
||||
this.configPath = path.join(this.dataDir, 'sencho.json');
|
||||
}
|
||||
|
||||
private async ensureDataDir(): Promise<void> {
|
||||
try {
|
||||
await fs.mkdir(this.dataDir, { recursive: true });
|
||||
} catch {
|
||||
// Directory already exists
|
||||
}
|
||||
}
|
||||
|
||||
async needsSetup(): Promise<boolean> {
|
||||
try {
|
||||
const config = await this.readConfig();
|
||||
return !config || !config.username || !config.passwordHash;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
async readConfig(): Promise<AuthConfig | null> {
|
||||
try {
|
||||
const data = await fs.readFile(this.configPath, 'utf-8');
|
||||
return JSON.parse(data);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async saveConfig(username: string, password: string): Promise<void> {
|
||||
await this.ensureDataDir();
|
||||
const saltRounds = 10;
|
||||
const passwordHash = await bcrypt.hash(password, saltRounds);
|
||||
const config: AuthConfig = { username, passwordHash };
|
||||
await fs.writeFile(this.configPath, JSON.stringify(config, null, 2), 'utf-8');
|
||||
}
|
||||
|
||||
async validateCredentials(username: string, password: string): Promise<boolean> {
|
||||
const config = await this.readConfig();
|
||||
if (!config) return false;
|
||||
|
||||
if (username !== config.username) return false;
|
||||
|
||||
return await bcrypt.compare(password, config.passwordHash);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import Docker from 'dockerode';
|
||||
import WebSocket from 'ws';
|
||||
import { Duplex } from 'stream';
|
||||
|
||||
class DockerController {
|
||||
private static instance: DockerController;
|
||||
private docker: Docker;
|
||||
private execStream: Duplex | null = null;
|
||||
private currentExec: Docker.Exec | null = null;
|
||||
|
||||
private constructor() {
|
||||
this.docker = new Docker({ socketPath: '/var/run/docker.sock' });
|
||||
}
|
||||
|
||||
public static getInstance(): DockerController {
|
||||
if (!DockerController.instance) {
|
||||
DockerController.instance = new DockerController();
|
||||
}
|
||||
return DockerController.instance;
|
||||
}
|
||||
|
||||
public async getRunningContainers() {
|
||||
const containers = await this.docker.listContainers({ all: false });
|
||||
return containers;
|
||||
}
|
||||
|
||||
public async getAllContainers() {
|
||||
const containers = await this.docker.listContainers({ all: true });
|
||||
return containers;
|
||||
}
|
||||
|
||||
public async getContainersByStack(stackName: string) {
|
||||
const containers = await this.docker.listContainers({ all: true });
|
||||
// Normalize the stack name: remove all non-alphanumeric characters and lowercase
|
||||
// Docker Compose strips hyphens and underscores from project names
|
||||
const normalizedStackName = stackName.replace(/[^a-zA-Z0-9]/g, '').toLowerCase();
|
||||
|
||||
return containers.filter(container => {
|
||||
if (!container.Labels || !container.Labels['com.docker.compose.project']) {
|
||||
return false;
|
||||
}
|
||||
// Normalize the Docker label for comparison
|
||||
const projectLabel = container.Labels['com.docker.compose.project'].replace(/[^a-zA-Z0-9]/g, '').toLowerCase();
|
||||
return projectLabel === normalizedStackName;
|
||||
});
|
||||
}
|
||||
|
||||
public async startContainer(containerId: string) {
|
||||
const container = this.docker.getContainer(containerId);
|
||||
await container.start();
|
||||
}
|
||||
|
||||
public async stopContainer(containerId: string) {
|
||||
const container = this.docker.getContainer(containerId);
|
||||
await container.stop();
|
||||
}
|
||||
|
||||
public async restartContainer(containerId: string) {
|
||||
const container = this.docker.getContainer(containerId);
|
||||
await container.restart();
|
||||
}
|
||||
|
||||
public async streamStats(containerId: string, ws: WebSocket) {
|
||||
const container = this.docker.getContainer(containerId);
|
||||
const stats = await container.stats({ stream: true });
|
||||
|
||||
stats.on('data', (chunk: Buffer) => {
|
||||
ws.send(chunk.toString());
|
||||
});
|
||||
|
||||
stats.on('error', (err: Error) => {
|
||||
ws.send(JSON.stringify({ error: err.message }));
|
||||
});
|
||||
|
||||
stats.on('end', () => {
|
||||
ws.send(JSON.stringify({ end: true }));
|
||||
});
|
||||
}
|
||||
|
||||
public async execContainer(containerId: string, ws: WebSocket) {
|
||||
try {
|
||||
const container = this.docker.getContainer(containerId);
|
||||
|
||||
// Try bash first, fall back to sh
|
||||
let exec: Docker.Exec;
|
||||
try {
|
||||
exec = await container.exec({
|
||||
AttachStdin: true,
|
||||
AttachStdout: true,
|
||||
AttachStderr: true,
|
||||
Tty: true,
|
||||
Cmd: ['/bin/bash'],
|
||||
});
|
||||
} catch {
|
||||
exec = await container.exec({
|
||||
AttachStdin: true,
|
||||
AttachStdout: true,
|
||||
AttachStderr: true,
|
||||
Tty: true,
|
||||
Cmd: ['/bin/sh'],
|
||||
});
|
||||
}
|
||||
|
||||
this.currentExec = exec;
|
||||
|
||||
const stream = await exec.start({ hijack: true, stdin: true });
|
||||
|
||||
this.execStream = stream;
|
||||
|
||||
// Handle output from container
|
||||
stream.on('data', (chunk: Buffer) => {
|
||||
ws.send(JSON.stringify({ type: 'output', data: chunk.toString() }));
|
||||
});
|
||||
|
||||
stream.on('error', (err: Error) => {
|
||||
ws.send(JSON.stringify({ type: 'error', message: err.message }));
|
||||
});
|
||||
|
||||
stream.on('end', () => {
|
||||
ws.send(JSON.stringify({ type: 'exit' }));
|
||||
this.execStream = null;
|
||||
this.currentExec = null;
|
||||
});
|
||||
|
||||
ws.send(JSON.stringify({ type: 'connected' }));
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
ws.send(JSON.stringify({ type: 'error', message: err.message }));
|
||||
}
|
||||
}
|
||||
|
||||
public sendExecInput(data: string) {
|
||||
if (this.execStream) {
|
||||
this.execStream.write(data);
|
||||
}
|
||||
}
|
||||
|
||||
public async resizeExec(cols: number, rows: number) {
|
||||
if (this.currentExec) {
|
||||
try {
|
||||
await this.currentExec.resize({ w: cols, h: rows });
|
||||
} catch {
|
||||
// Ignore resize errors
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default DockerController;
|
||||
@@ -0,0 +1,116 @@
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
export class FileSystemService {
|
||||
private baseDir: string;
|
||||
|
||||
constructor() {
|
||||
this.baseDir = process.env.COMPOSE_DIR || path.join(process.cwd(), '..', 'mock_data', 'docker', 'compose');
|
||||
}
|
||||
|
||||
async getStackFiles(): Promise<string[]> {
|
||||
try {
|
||||
const files = await fs.readdir(this.baseDir);
|
||||
return files.filter(file => file.endsWith('.yml') || file.endsWith('.yaml'));
|
||||
} catch (error) {
|
||||
console.error('Error reading stack files:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async getStackContent(filename: string): Promise<string> {
|
||||
const filePath = path.join(this.baseDir, filename);
|
||||
try {
|
||||
return await fs.readFile(filePath, 'utf-8');
|
||||
} catch (error) {
|
||||
console.error('Error reading file:', error);
|
||||
throw new Error(`Failed to read file: ${filename}`);
|
||||
}
|
||||
}
|
||||
|
||||
async saveStackContent(filename: string, content: string): Promise<void> {
|
||||
const filePath = path.join(this.baseDir, filename);
|
||||
console.log('Saving to path:', filePath);
|
||||
try {
|
||||
await fs.writeFile(filePath, content, 'utf-8');
|
||||
console.log('File written successfully');
|
||||
} catch (error) {
|
||||
console.error('Error writing file:', error);
|
||||
throw new Error(`Failed to save file: ${filename}`);
|
||||
}
|
||||
}
|
||||
|
||||
async envExists(filename: string): Promise<boolean> {
|
||||
const envFilename = filename.replace(/\.yml$/, '.env');
|
||||
const envPath = path.join(this.baseDir, envFilename);
|
||||
try {
|
||||
await fs.access(envPath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async getEnvContent(filename: string): Promise<string> {
|
||||
const envFilename = filename.replace(/\.yml$/, '.env');
|
||||
const envPath = path.join(this.baseDir, envFilename);
|
||||
try {
|
||||
return await fs.readFile(envPath, 'utf-8');
|
||||
} catch (error) {
|
||||
console.error('Error reading env file:', error);
|
||||
throw new Error(`Failed to read env file for: ${filename}`);
|
||||
}
|
||||
}
|
||||
|
||||
async saveEnvContent(filename: string, content: string): Promise<void> {
|
||||
const envFilename = filename.replace(/\.yml$/, '.env');
|
||||
const envPath = path.join(this.baseDir, envFilename);
|
||||
console.log('Saving env to path:', envPath);
|
||||
try {
|
||||
await fs.writeFile(envPath, content, 'utf-8');
|
||||
console.log('Env file written successfully');
|
||||
} catch (error) {
|
||||
console.error('Error writing env file:', error);
|
||||
throw new Error(`Failed to save env file for: ${filename}`);
|
||||
}
|
||||
}
|
||||
|
||||
async createStack(filename: string): Promise<void> {
|
||||
if (!filename.endsWith('.yml')) {
|
||||
throw new Error('Filename must end with .yml');
|
||||
}
|
||||
const filePath = path.join(this.baseDir, filename);
|
||||
const boilerplate = `version: '3.8'
|
||||
|
||||
services:
|
||||
# Add your services here
|
||||
`;
|
||||
try {
|
||||
await fs.writeFile(filePath, boilerplate, 'utf-8');
|
||||
console.log('Stack file created successfully:', filename);
|
||||
} catch (error) {
|
||||
console.error('Error creating stack file:', error);
|
||||
throw new Error(`Failed to create stack file: ${filename}`);
|
||||
}
|
||||
}
|
||||
|
||||
async deleteStack(filename: string): Promise<void> {
|
||||
const filePath = path.join(this.baseDir, filename);
|
||||
const envFilename = filename.replace(/\.yml$/, '.env');
|
||||
const envPath = path.join(this.baseDir, envFilename);
|
||||
try {
|
||||
await fs.unlink(filePath);
|
||||
console.log('Stack file deleted successfully:', filename);
|
||||
// Try to delete env file if it exists
|
||||
try {
|
||||
await fs.unlink(envPath);
|
||||
console.log('Associated env file deleted:', envFilename);
|
||||
} catch {
|
||||
// Env file doesn't exist, ignore
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting stack file:', error);
|
||||
throw new Error(`Failed to delete stack file: ${filename}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
declare module 'composerize' {
|
||||
export default function composerize(dockerRun: string): string;
|
||||
}
|
||||
Reference in New Issue
Block a user