Started config editor, migrated to one ssh manager for adding hosts.

This commit is contained in:
LukeGus
2025-07-26 15:42:15 -05:00
parent 608111c37b
commit 2e62dee798
36 changed files with 3064 additions and 1240 deletions

View File

@@ -2,21 +2,16 @@ import express from 'express';
import bodyParser from 'body-parser';
import userRoutes from './routes/users.js';
import sshRoutes from './routes/ssh.js';
import sshTunnelRoutes from './routes/ssh_tunnel.js';
import configEditorRoutes from './routes/config_editor.js';
import chalk from 'chalk';
import cors from 'cors';
// CORS for local dev
const app = express();
app.use(cors({
origin: 'http://localhost:5173',
credentials: true,
origin: '*',
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization']
}));
// Custom logger (adapted from starter.ts, with a database icon)
const dbIconSymbol = '🗄️';
const getTimeStamp = (): string => chalk.gray(`[${new Date().toLocaleTimeString()}]`);
const formatMessage = (level: string, colorFn: chalk.Chalk, message: string): string => {
@@ -51,14 +46,16 @@ app.get('/health', (req, res) => {
app.use('/users', userRoutes);
app.use('/ssh', sshRoutes);
app.use('/ssh_tunnel', sshTunnelRoutes);
app.use('/config_editor', configEditorRoutes);
app.use((err: unknown, req: express.Request, res: express.Response, next: express.NextFunction) => {
logger.error('Unhandled error:', err);
res.status(500).json({ error: 'Internal Server Error' });
});
// Start server
const PORT = 8081;
app.listen(PORT);
app.listen(PORT, () => {
logger.success(`Database server started on port ${PORT}`);
}).on('error', (err) => {
logger.error(`Failed to start database server:`, err);
process.exit(1);
});

View File

@@ -38,6 +38,7 @@ if (!fs.existsSync(dbDir)) {
const sqlite = new Database('./db/data/db.sqlite');
// Create tables using Drizzle schema
sqlite.exec(`
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
@@ -45,102 +46,92 @@ CREATE TABLE IF NOT EXISTS users (
password_hash TEXT NOT NULL,
is_admin INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS ssh_data (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
name TEXT,
folder TEXT,
tags TEXT,
ip TEXT NOT NULL,
port INTEGER NOT NULL,
username TEXT,
password TEXT,
auth_method TEXT,
key TEXT,
key_password TEXT,
key_type TEXT,
save_auth_method INTEGER,
is_pinned INTEGER,
default_path TEXT,
FOREIGN KEY(user_id) REFERENCES users(id)
);
CREATE TABLE IF NOT EXISTS config_ssh_data (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
name TEXT,
folder TEXT,
tags TEXT,
ip TEXT NOT NULL,
port INTEGER NOT NULL,
username TEXT,
password TEXT,
auth_method TEXT,
key TEXT,
key_password TEXT,
key_type TEXT,
save_auth_method INTEGER,
is_pinned INTEGER,
default_path TEXT,
FOREIGN KEY(user_id) REFERENCES users(id)
);
CREATE TABLE IF NOT EXISTS ssh_tunnel_data (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
name TEXT,
folder TEXT,
source_port INTEGER NOT NULL,
endpoint_port INTEGER NOT NULL,
source_ip TEXT NOT NULL,
source_ssh_port INTEGER NOT NULL,
source_username TEXT,
source_password TEXT,
source_auth_method TEXT,
source_ssh_key TEXT,
source_key_password TEXT,
source_key_type TEXT,
endpoint_ip TEXT NOT NULL,
endpoint_ssh_port INTEGER NOT NULL,
endpoint_username TEXT,
endpoint_password TEXT,
endpoint_auth_method TEXT,
endpoint_ssh_key TEXT,
endpoint_key_password TEXT,
endpoint_key_type TEXT,
max_retries INTEGER NOT NULL DEFAULT 3,
retry_interval INTEGER NOT NULL DEFAULT 5000,
connection_state TEXT NOT NULL DEFAULT 'DISCONNECTED',
auto_start INTEGER NOT NULL DEFAULT 0,
is_pinned INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY(user_id) REFERENCES users(id)
);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS config_editor_data (
CREATE TABLE IF NOT EXISTS ssh_data (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
type TEXT NOT NULL,
name TEXT,
path TEXT NOT NULL,
server TEXT,
last_opened TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
ip TEXT NOT NULL,
port INTEGER NOT NULL,
username TEXT NOT NULL,
folder TEXT,
tags TEXT,
pin INTEGER NOT NULL DEFAULT 0,
auth_type TEXT NOT NULL,
password TEXT,
key TEXT,
key_password TEXT,
key_type TEXT,
enable_terminal INTEGER NOT NULL DEFAULT 1,
enable_tunnel INTEGER NOT NULL DEFAULT 1,
tunnel_connections TEXT,
enable_config_editor INTEGER NOT NULL DEFAULT 1,
default_path TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(user_id) REFERENCES users(id)
);
`);
try {
sqlite.prepare('SELECT is_admin FROM users LIMIT 1').get();
} catch (e) {
sqlite.exec('ALTER TABLE users ADD COLUMN is_admin INTEGER NOT NULL DEFAULT 0;');
}
// Function to safely add a column if it doesn't exist
const addColumnIfNotExists = (table: string, column: string, definition: string) => {
try {
// Try to select the column to see if it exists
sqlite.prepare(`SELECT ${column} FROM ${table} LIMIT 1`).get();
} catch (e) {
// Column doesn't exist, add it
try {
sqlite.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition};`);
} catch (alterError) {
logger.warn(`Failed to add column ${column} to ${table}: ${alterError}`);
}
}
};
// Auto-migrate: Add any missing columns based on current schema
const migrateSchema = () => {
logger.info('Checking for schema updates...');
// Add missing columns to users table
addColumnIfNotExists('users', 'is_admin', 'INTEGER NOT NULL DEFAULT 0');
// Add missing columns to ssh_data table
addColumnIfNotExists('ssh_data', 'name', 'TEXT');
addColumnIfNotExists('ssh_data', 'folder', 'TEXT');
addColumnIfNotExists('ssh_data', 'tags', 'TEXT');
addColumnIfNotExists('ssh_data', 'pin', 'INTEGER NOT NULL DEFAULT 0');
addColumnIfNotExists('ssh_data', 'auth_type', 'TEXT NOT NULL DEFAULT "password"');
addColumnIfNotExists('ssh_data', 'password', 'TEXT');
addColumnIfNotExists('ssh_data', 'key', 'TEXT');
addColumnIfNotExists('ssh_data', 'key_password', 'TEXT');
addColumnIfNotExists('ssh_data', 'key_type', 'TEXT');
addColumnIfNotExists('ssh_data', 'enable_terminal', 'INTEGER NOT NULL DEFAULT 1');
addColumnIfNotExists('ssh_data', 'enable_tunnel', 'INTEGER NOT NULL DEFAULT 1');
addColumnIfNotExists('ssh_data', 'tunnel_connections', 'TEXT');
addColumnIfNotExists('ssh_data', 'enable_config_editor', 'INTEGER NOT NULL DEFAULT 1');
addColumnIfNotExists('ssh_data', 'default_path', 'TEXT');
addColumnIfNotExists('ssh_data', 'created_at', 'TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP');
addColumnIfNotExists('ssh_data', 'updated_at', 'TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP');
logger.success('Schema migration completed');
};
// Run auto-migration
migrateSchema();
// Initialize default settings
try {
const row = sqlite.prepare("SELECT value FROM settings WHERE key = 'allow_registration'").get();
if (!row) {
sqlite.prepare("INSERT INTO settings (key, value) VALUES ('allow_registration', 'true')").run();
}
} catch (e) {
logger.warn('Could not initialize default settings');
}
export const db = drizzle(sqlite, { schema });

View File

@@ -1,4 +1,5 @@
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';
import { sql } from 'drizzle-orm';
export const users = sqliteTable('users', {
id: text('id').primaryKey(), // Unique user ID (nanoid)
@@ -7,87 +8,31 @@ export const users = sqliteTable('users', {
is_admin: integer('is_admin', { mode: 'boolean' }).notNull().default(false), // Admin flag
});
export const sshData = sqliteTable('ssh_data', {
id: integer('id').primaryKey({ autoIncrement: true }),
userId: text('user_id').notNull().references(() => users.id),
name: text('name'),
folder: text('folder'),
tags: text('tags'),
ip: text('ip').notNull(),
port: integer('port').notNull(),
username: text('username'),
password: text('password'),
authMethod: text('auth_method'),
key: text('key', { length: 8192 }), // Increased for larger keys
keyPassword: text('key_password'), // Password for protected keys
keyType: text('key_type'), // Type of SSH key (RSA, ED25519, etc.)
saveAuthMethod: integer('save_auth_method', { mode: 'boolean' }),
isPinned: integer('is_pinned', { mode: 'boolean' }),
defaultPath: text('default_path'), // Default path for SSH connection
});
export const sshTunnelData = sqliteTable('ssh_tunnel_data', {
id: integer('id').primaryKey({ autoIncrement: true }),
userId: text('user_id').notNull().references(() => users.id),
name: text('name'),
folder: text('folder'),
sourcePort: integer('source_port').notNull(),
endpointPort: integer('endpoint_port').notNull(),
sourceIP: text('source_ip').notNull(),
sourceSSHPort: integer('source_ssh_port').notNull(),
sourceUsername: text('source_username'),
sourcePassword: text('source_password'),
sourceAuthMethod: text('source_auth_method'),
sourceSSHKey: text('source_ssh_key', { length: 8192 }),
sourceKeyPassword: text('source_key_password'),
sourceKeyType: text('source_key_type'),
endpointIP: text('endpoint_ip').notNull(),
endpointSSHPort: integer('endpoint_ssh_port').notNull(),
endpointUsername: text('endpoint_username'),
endpointPassword: text('endpoint_password'),
endpointAuthMethod: text('endpoint_auth_method'),
endpointSSHKey: text('endpoint_ssh_key', { length: 8192 }),
endpointKeyPassword: text('endpoint_key_password'),
endpointKeyType: text('endpoint_key_type'),
maxRetries: integer('max_retries').notNull().default(3),
retryInterval: integer('retry_interval').notNull().default(5000),
connectionState: text('connection_state').notNull().default('DISCONNECTED'),
autoStart: integer('auto_start', { mode: 'boolean' }).notNull().default(false),
isPinned: integer('is_pinned', { mode: 'boolean' }).notNull().default(false),
});
export const settings = sqliteTable('settings', {
key: text('key').primaryKey(),
value: text('value').notNull(),
});
export const configEditorData = sqliteTable('config_editor_data', {
export const sshData = sqliteTable('ssh_data', {
id: integer('id').primaryKey({ autoIncrement: true }),
userId: text('user_id').notNull().references(() => users.id),
type: text('type').notNull(), // 'recent' | 'pinned' | 'shortcut'
name: text('name'),
path: text('path').notNull(),
server: text('server', { length: 2048 }), // JSON stringified server info (if SSH)
lastOpened: text('last_opened'), // ISO string (for recent)
createdAt: text('created_at').notNull(),
updatedAt: text('updated_at').notNull(),
});
export const configSshData = sqliteTable('config_ssh_data', {
id: integer('id').primaryKey({ autoIncrement: true }),
userId: text('user_id').notNull().references(() => users.id),
name: text('name'),
folder: text('folder'),
tags: text('tags'),
name: text('name'), // Host name
ip: text('ip').notNull(),
port: integer('port').notNull(),
username: text('username'),
username: text('username').notNull(),
folder: text('folder'),
tags: text('tags'), // JSON stringified array
pin: integer('pin', { mode: 'boolean' }).notNull().default(false),
authType: text('auth_type').notNull(), // 'password' | 'key'
password: text('password'),
authMethod: text('auth_method'),
key: text('key', { length: 8192 }),
keyPassword: text('key_password'),
keyType: text('key_type'),
saveAuthMethod: integer('save_auth_method', { mode: 'boolean' }),
isPinned: integer('is_pinned', { mode: 'boolean' }),
defaultPath: text('default_path'),
key: text('key', { length: 8192 }), // Increased for larger keys
keyPassword: text('key_password'), // Password for protected keys
keyType: text('key_type'), // Type of SSH key (RSA, ED25519, etc.)
enableTerminal: integer('enable_terminal', { mode: 'boolean' }).notNull().default(true),
enableTunnel: integer('enable_tunnel', { mode: 'boolean' }).notNull().default(true),
tunnelConnections: text('tunnel_connections'), // JSON stringified array of tunnel connections
enableConfigEditor: integer('enable_config_editor', { mode: 'boolean' }).notNull().default(true),
defaultPath: text('default_path'), // Default path for SSH connection
createdAt: text('created_at').notNull().default(sql`CURRENT_TIMESTAMP`),
updatedAt: text('updated_at').notNull().default(sql`CURRENT_TIMESTAMP`),
});

View File

@@ -1,317 +0,0 @@
import express from 'express';
import { db } from '../db/index.js';
import { configEditorData, configSshData } from '../db/schema.js';
import { eq, and } from 'drizzle-orm';
import type { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
const router = express.Router();
// --- JWT Auth Middleware ---
interface JWTPayload {
userId: string;
iat?: number;
exp?: number;
}
function authenticateJWT(req: Request, res: Response, next: NextFunction) {
const authHeader = req.headers['authorization'];
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Missing or invalid Authorization header' });
}
const token = authHeader.split(' ')[1];
const jwtSecret = process.env.JWT_SECRET || 'secret';
try {
const payload = jwt.verify(token, jwtSecret) as JWTPayload;
(req as any).userId = payload.userId;
next();
} catch (err) {
return res.status(401).json({ error: 'Invalid or expired token' });
}
}
// --- Config Data Endpoints (DB-backed, per user) ---
router.get('/recent', authenticateJWT, async (req, res) => {
const userId = (req as any).userId;
try {
const data = await db.select().from(configEditorData).where(and(eq(configEditorData.userId, userId), eq(configEditorData.type, 'recent')));
res.json(data);
} catch (err) {
res.status(500).json({ error: 'Failed to fetch recent files' });
}
});
router.post('/recent', authenticateJWT, async (req, res) => {
const userId = (req as any).userId;
const { name, path: filePath, server, lastOpened } = req.body;
if (!filePath) return res.status(400).json({ error: 'Missing path' });
try {
const now = new Date().toISOString();
await db.insert(configEditorData).values({
userId,
type: 'recent',
name,
path: filePath,
server: server ? JSON.stringify(server) : null,
lastOpened: lastOpened || now,
createdAt: now,
updatedAt: now,
});
res.json({ message: 'Added to recent' });
} catch (err) {
res.status(500).json({ error: 'Failed to add to recent' });
}
});
router.get('/pinned', authenticateJWT, async (req, res) => {
const userId = (req as any).userId;
try {
const data = await db.select().from(configEditorData).where(and(eq(configEditorData.userId, userId), eq(configEditorData.type, 'pinned')));
res.json(data);
} catch (err) {
res.status(500).json({ error: 'Failed to fetch pinned files' });
}
});
router.post('/pinned', authenticateJWT, async (req, res) => {
const userId = (req as any).userId;
const { name, path: filePath, server } = req.body;
if (!filePath) return res.status(400).json({ error: 'Missing path' });
try {
const now = new Date().toISOString();
await db.insert(configEditorData).values({
userId,
type: 'pinned',
name,
path: filePath,
server: server ? JSON.stringify(server) : null,
createdAt: now,
updatedAt: now,
});
res.json({ message: 'Added to pinned' });
} catch (err) {
res.status(500).json({ error: 'Failed to add to pinned' });
}
});
router.get('/shortcuts', authenticateJWT, async (req, res) => {
const userId = (req as any).userId;
try {
const data = await db.select().from(configEditorData).where(and(eq(configEditorData.userId, userId), eq(configEditorData.type, 'shortcut')));
res.json(data);
} catch (err) {
res.status(500).json({ error: 'Failed to fetch shortcuts' });
}
});
router.post('/shortcuts', authenticateJWT, async (req, res) => {
const userId = (req as any).userId;
const { name, path: folderPath, server } = req.body;
if (!folderPath) return res.status(400).json({ error: 'Missing path' });
try {
const now = new Date().toISOString();
await db.insert(configEditorData).values({
userId,
type: 'shortcut',
name,
path: folderPath,
server: server ? JSON.stringify(server) : null,
createdAt: now,
updatedAt: now,
});
res.json({ message: 'Added to shortcuts' });
} catch (err) {
res.status(500).json({ error: 'Failed to add to shortcuts' });
}
});
// DELETE /config_editor/shortcuts
router.delete('/shortcuts', authenticateJWT, async (req, res) => {
const userId = (req as any).userId;
const { path } = req.body;
if (!path) return res.status(400).json({ error: 'Missing path' });
try {
await db.delete(configEditorData)
.where(and(eq(configEditorData.userId, userId), eq(configEditorData.type, 'shortcut'), eq(configEditorData.path, path)));
res.json({ message: 'Shortcut removed' });
} catch (err) {
res.status(500).json({ error: 'Failed to remove shortcut' });
}
});
// POST /config_editor/shortcuts/delete (for compatibility)
router.post('/shortcuts/delete', authenticateJWT, async (req, res) => {
const userId = (req as any).userId;
const { path } = req.body;
if (!path) return res.status(400).json({ error: 'Missing path' });
try {
await db.delete(configEditorData)
.where(and(eq(configEditorData.userId, userId), eq(configEditorData.type, 'shortcut'), eq(configEditorData.path, path)));
res.json({ message: 'Shortcut removed' });
} catch (err) {
res.status(500).json({ error: 'Failed to remove shortcut' });
}
});
// --- Local Default Path Endpoints ---
// GET /config_editor/local_default_path
router.get('/local_default_path', authenticateJWT, async (req, res) => {
const userId = (req as any).userId;
try {
const row = await db.select().from(configEditorData)
.where(and(eq(configEditorData.userId, userId), eq(configEditorData.type, 'local_default_path')))
.then(rows => rows[0]);
res.json({ defaultPath: row?.path || '/' });
} catch (err) {
res.status(500).json({ error: 'Failed to fetch local default path' });
}
});
// POST /config_editor/local_default_path
router.post('/local_default_path', authenticateJWT, async (req, res) => {
const userId = (req as any).userId;
const { defaultPath } = req.body;
if (!defaultPath) return res.status(400).json({ error: 'Missing defaultPath' });
try {
const now = new Date().toISOString();
// Upsert: delete old, insert new
await db.delete(configEditorData)
.where(and(eq(configEditorData.userId, userId), eq(configEditorData.type, 'local_default_path')));
await db.insert(configEditorData).values({
userId,
type: 'local_default_path',
name: 'Local Files',
path: defaultPath,
createdAt: now,
updatedAt: now,
});
res.json({ message: 'Local default path saved' });
} catch (err) {
res.status(500).json({ error: 'Failed to save local default path' });
}
});
// --- SSH Connection CRUD for Config Editor ---
// GET /config_editor/ssh/host
router.get('/ssh/host', authenticateJWT, async (req, res) => {
const userId = (req as any).userId;
if (!userId) {
return res.status(400).json({ error: 'Invalid userId' });
}
try {
const data = await db.select().from(configSshData).where(eq(configSshData.userId, userId));
res.json(data);
} catch (err) {
res.status(500).json({ error: 'Failed to fetch SSH hosts' });
}
});
// POST /config_editor/ssh/host
router.post('/ssh/host', authenticateJWT, async (req, res) => {
const userId = (req as any).userId;
const { name, folder, tags, ip, port, username, password, sshKey, keyPassword, keyType, isPinned, defaultPath, authMethod } = req.body;
if (!userId || !ip || !port) {
return res.status(400).json({ error: 'Invalid SSH data' });
}
const sshDataObj: any = {
userId,
name,
folder,
tags: Array.isArray(tags) ? tags.join(',') : tags,
ip,
port,
username,
authMethod,
isPinned: isPinned ? 1 : 0,
defaultPath: defaultPath || null,
};
if (authMethod === 'password') {
sshDataObj.password = password;
sshDataObj.key = null;
sshDataObj.keyPassword = null;
sshDataObj.keyType = null;
} else if (authMethod === 'key') {
sshDataObj.key = sshKey;
sshDataObj.keyPassword = keyPassword;
sshDataObj.keyType = keyType;
sshDataObj.password = null;
}
try {
await db.insert(configSshData).values(sshDataObj);
res.json({ message: 'SSH host created' });
} catch (err) {
res.status(500).json({ error: 'Failed to create SSH host' });
}
});
// PUT /config_editor/ssh/host/:id
router.put('/ssh/host/:id', authenticateJWT, async (req, res) => {
const userId = (req as any).userId;
const { id } = req.params;
const { name, folder, tags, ip, port, username, password, sshKey, keyPassword, keyType, isPinned, defaultPath, authMethod } = req.body;
if (!userId || !ip || !port || !id) {
return res.status(400).json({ error: 'Invalid SSH data' });
}
const sshDataObj: any = {
name,
folder,
tags: Array.isArray(tags) ? tags.join(',') : tags,
ip,
port,
username,
authMethod,
isPinned: isPinned ? 1 : 0,
defaultPath: defaultPath || null,
};
if (authMethod === 'password') {
sshDataObj.password = password;
sshDataObj.key = null;
sshDataObj.keyPassword = null;
sshDataObj.keyType = null;
} else if (authMethod === 'key') {
sshDataObj.key = sshKey;
sshDataObj.keyPassword = keyPassword;
sshDataObj.keyType = keyType;
sshDataObj.password = null;
}
try {
await db.update(configSshData)
.set(sshDataObj)
.where(and(eq(configSshData.id, Number(id)), eq(configSshData.userId, userId)));
res.json({ message: 'SSH host updated' });
} catch (err) {
res.status(500).json({ error: 'Failed to update SSH host' });
}
});
// --- SSH Connection CRUD (reuse /ssh/host endpoints, or proxy) ---
router.delete('/ssh/host/:id', authenticateJWT, async (req, res) => {
const userId = (req as any).userId;
const { id } = req.params;
if (!userId || !id) {
return res.status(400).json({ error: 'Invalid userId or id' });
}
try {
await db.delete(configSshData)
.where(and(eq(configSshData.id, Number(id)), eq(configSshData.userId, userId)));
res.json({ message: 'SSH host deleted' });
} catch (err) {
res.status(500).json({ error: 'Failed to delete SSH host' });
}
});
// GET /config_editor/ssh/folders
router.get('/ssh/folders', authenticateJWT, async (req, res) => {
const userId = (req as any).userId;
if (!userId) {
return res.status(400).json({ error: 'Invalid userId' });
}
try {
const data = await db
.select({ folder: configSshData.folder })
.from(configSshData)
.where(eq(configSshData.userId, userId));
const folderCounts: Record<string, number> = {};
data.forEach(d => {
if (d.folder && d.folder.trim() !== '') {
folderCounts[d.folder] = (folderCounts[d.folder] || 0) + 1;
}
});
const folders = Object.keys(folderCounts).filter(folder => folderCounts[folder] > 0);
res.json(folders);
} catch (err) {
res.status(500).json({ error: 'Failed to fetch SSH folders' });
}
});
export default router;

View File

@@ -4,6 +4,7 @@ import { sshData } from '../db/schema.js';
import { eq, and } from 'drizzle-orm';
import chalk from 'chalk';
import jwt from 'jsonwebtoken';
import multer from 'multer';
import type { Request, Response, NextFunction } from 'express';
const dbIconSymbol = '🗄️';
@@ -47,6 +48,22 @@ interface JWTPayload {
exp?: number;
}
// Configure multer for file uploads
const upload = multer({
storage: multer.memoryStorage(),
limits: {
fileSize: 10 * 1024 * 1024, // 10MB limit
},
fileFilter: (req, file, cb) => {
// Only allow specific file types for SSH keys
if (file.fieldname === 'key') {
cb(null, true);
} else {
cb(new Error('Invalid file type'));
}
}
});
// JWT authentication middleware
function authenticateJWT(req: Request, res: Response, next: NextFunction) {
const authHeader = req.headers['authorization'];
@@ -68,8 +85,34 @@ function authenticateJWT(req: Request, res: Response, next: NextFunction) {
// Route: Create SSH data (requires JWT)
// POST /ssh/host
router.post('/host', authenticateJWT, async (req: Request, res: Response) => {
const { name, folder, tags, ip, port, username, password, authMethod, key, keyPassword, keyType, saveAuthMethod, isPinned, defaultPath } = req.body;
router.post('/host', authenticateJWT, upload.single('key'), async (req: Request, res: Response) => {
let hostData: any;
// Check if this is a multipart form data request (file upload)
if (req.headers['content-type']?.includes('multipart/form-data')) {
// Parse the JSON data from the 'data' field
if (req.body.data) {
try {
hostData = JSON.parse(req.body.data);
} catch (err) {
logger.warn('Invalid JSON data in multipart request');
return res.status(400).json({ error: 'Invalid JSON data' });
}
} else {
logger.warn('Missing data field in multipart request');
return res.status(400).json({ error: 'Missing data field' });
}
// Add the file data if present
if (req.file) {
hostData.key = req.file.buffer.toString('utf8');
}
} else {
// Regular JSON request
hostData = req.body;
}
const { name, folder, tags, ip, port, username, password, authMethod, key, keyPassword, keyType, pin, enableTerminal, enableTunnel, enableConfigEditor, defaultPath, tunnelConnections } = hostData;
const userId = (req as any).userId;
if (!isNonEmptyString(userId) || !isNonEmptyString(ip) || !isValidPort(port)) {
logger.warn('Invalid SSH data input');
@@ -80,33 +123,30 @@ router.post('/host', authenticateJWT, async (req: Request, res: Response) => {
userId: userId,
name,
folder,
tags: Array.isArray(tags) ? tags.join(',') : tags,
tags: Array.isArray(tags) ? tags.join(',') : (tags || ''),
ip,
port,
username,
authMethod,
saveAuthMethod: saveAuthMethod ? 1 : 0,
isPinned: isPinned ? 1 : 0,
authType: authMethod,
pin: !!pin ? 1 : 0,
enableTerminal: !!enableTerminal ? 1 : 0,
enableTunnel: !!enableTunnel ? 1 : 0,
tunnelConnections: Array.isArray(tunnelConnections) ? JSON.stringify(tunnelConnections) : null,
enableConfigEditor: !!enableConfigEditor ? 1 : 0,
defaultPath: defaultPath || null,
};
if (saveAuthMethod) {
if (authMethod === 'password') {
sshDataObj.password = password;
sshDataObj.key = null;
sshDataObj.keyPassword = null;
sshDataObj.keyType = null;
} else if (authMethod === 'key') {
sshDataObj.key = key;
sshDataObj.keyPassword = keyPassword;
sshDataObj.keyType = keyType;
sshDataObj.password = null;
}
} else {
sshDataObj.password = null;
// Handle authentication data based on authMethod
if (authMethod === 'password') {
sshDataObj.password = password;
sshDataObj.key = null;
sshDataObj.keyPassword = null;
sshDataObj.keyType = null;
} else if (authMethod === 'key') {
sshDataObj.key = key;
sshDataObj.keyPassword = keyPassword;
sshDataObj.keyType = keyType;
sshDataObj.password = null;
}
try {
@@ -120,11 +160,36 @@ router.post('/host', authenticateJWT, async (req: Request, res: Response) => {
// Route: Update SSH data (requires JWT)
// PUT /ssh/host/:id
router.put('/host/:id', authenticateJWT, async (req: Request, res: Response) => {
const { name, folder, tags, ip, port, username, password, authMethod, key, keyPassword, keyType, saveAuthMethod, isPinned, defaultPath } = req.body;
router.put('/host/:id', authenticateJWT, upload.single('key'), async (req: Request, res: Response) => {
let hostData: any;
// Check if this is a multipart form data request (file upload)
if (req.headers['content-type']?.includes('multipart/form-data')) {
// Parse the JSON data from the 'data' field
if (req.body.data) {
try {
hostData = JSON.parse(req.body.data);
} catch (err) {
logger.warn('Invalid JSON data in multipart request');
return res.status(400).json({ error: 'Invalid JSON data' });
}
} else {
logger.warn('Missing data field in multipart request');
return res.status(400).json({ error: 'Missing data field' });
}
// Add the file data if present
if (req.file) {
hostData.key = req.file.buffer.toString('utf8');
}
} else {
// Regular JSON request
hostData = req.body;
}
const { name, folder, tags, ip, port, username, password, authMethod, key, keyPassword, keyType, pin, enableTerminal, enableTunnel, enableConfigEditor, defaultPath, tunnelConnections } = hostData;
const { id } = req.params;
const userId = (req as any).userId;
if (!isNonEmptyString(userId) || !isNonEmptyString(ip) || !isValidPort(port) || !id) {
logger.warn('Invalid SSH data input for update');
return res.status(400).json({ error: 'Invalid SSH data' });
@@ -133,37 +198,34 @@ router.put('/host/:id', authenticateJWT, async (req: Request, res: Response) =>
const sshDataObj: any = {
name,
folder,
tags: Array.isArray(tags) ? tags.join(',') : tags,
tags: Array.isArray(tags) ? tags.join(',') : (tags || ''),
ip,
port,
username,
authMethod,
saveAuthMethod: saveAuthMethod ? 1 : 0,
isPinned: isPinned ? 1 : 0,
authType: authMethod,
pin: !!pin ? 1 : 0,
enableTerminal: !!enableTerminal ? 1 : 0,
enableTunnel: !!enableTunnel ? 1 : 0,
tunnelConnections: Array.isArray(tunnelConnections) ? JSON.stringify(tunnelConnections) : null,
enableConfigEditor: !!enableConfigEditor ? 1 : 0,
defaultPath: defaultPath || null,
};
if (saveAuthMethod) {
if (authMethod === 'password') {
sshDataObj.password = password;
sshDataObj.key = null;
sshDataObj.keyPassword = null;
sshDataObj.keyType = null;
} else if (authMethod === 'key') {
sshDataObj.key = key;
sshDataObj.keyPassword = keyPassword;
sshDataObj.keyType = keyType;
sshDataObj.password = null;
}
} else {
sshDataObj.password = null;
// Handle authentication data based on authMethod
if (authMethod === 'password') {
sshDataObj.password = password;
sshDataObj.key = null;
sshDataObj.keyPassword = null;
sshDataObj.keyType = null;
} else if (authMethod === 'key') {
sshDataObj.key = key;
sshDataObj.keyPassword = keyPassword;
sshDataObj.keyType = keyType;
sshDataObj.password = null;
}
try {
const result = await db.update(sshData)
await db.update(sshData)
.set(sshDataObj)
.where(and(eq(sshData.id, Number(id)), eq(sshData.userId, userId)));
res.json({ message: 'SSH data updated' });
@@ -186,13 +248,62 @@ router.get('/host', authenticateJWT, async (req: Request, res: Response) => {
.select()
.from(sshData)
.where(eq(sshData.userId, userId));
res.json(data);
// Convert tags to array, booleans to bool, tunnelConnections to array
const result = data.map((row: any) => ({
...row,
tags: typeof row.tags === 'string' ? (row.tags ? row.tags.split(',').filter(Boolean) : []) : [],
pin: !!row.pin,
enableTerminal: !!row.enableTerminal,
enableTunnel: !!row.enableTunnel,
tunnelConnections: row.tunnelConnections ? JSON.parse(row.tunnelConnections) : [],
enableConfigEditor: !!row.enableConfigEditor,
}));
res.json(result);
} catch (err) {
logger.error('Failed to fetch SSH data', err);
res.status(500).json({ error: 'Failed to fetch SSH data' });
}
});
// Route: Get SSH host by ID (requires JWT)
// GET /ssh/host/:id
router.get('/host/:id', authenticateJWT, async (req: Request, res: Response) => {
const { id } = req.params;
const userId = (req as any).userId;
if (!isNonEmptyString(userId) || !id) {
logger.warn('Invalid request for SSH host fetch');
return res.status(400).json({ error: 'Invalid request' });
}
try {
const data = await db
.select()
.from(sshData)
.where(and(eq(sshData.id, Number(id)), eq(sshData.userId, userId)));
if (data.length === 0) {
return res.status(404).json({ error: 'SSH host not found' });
}
const host = data[0];
const result = {
...host,
tags: typeof host.tags === 'string' ? (host.tags ? host.tags.split(',').filter(Boolean) : []) : [],
pin: !!host.pin,
enableTerminal: !!host.enableTerminal,
enableTunnel: !!host.enableTunnel,
tunnelConnections: host.tunnelConnections ? JSON.parse(host.tunnelConnections) : [],
enableConfigEditor: !!host.enableConfigEditor,
};
res.json(result);
} catch (err) {
logger.error('Failed to fetch SSH host', err);
res.status(500).json({ error: 'Failed to fetch SSH host' });
}
});
// Route: Get all unique folders for the authenticated user (requires JWT)
// GET /ssh/folders
router.get('/folders', authenticateJWT, async (req: Request, res: Response) => {

View File

@@ -1,306 +0,0 @@
import express from 'express';
import { db } from '../db/index.js';
import { sshTunnelData } from '../db/schema.js';
import { eq, and } from 'drizzle-orm';
import chalk from 'chalk';
import jwt from 'jsonwebtoken';
import type { Request, Response, NextFunction } from 'express';
const dbIconSymbol = '🗄️';
const getTimeStamp = (): string => chalk.gray(`[${new Date().toLocaleTimeString()}]`);
const formatMessage = (level: string, colorFn: chalk.Chalk, message: string): string => {
return `${getTimeStamp()} ${colorFn(`[${level.toUpperCase()}]`)} ${chalk.hex('#1e3a8a')(`[${dbIconSymbol}]`)} ${message}`;
};
const logger = {
info: (msg: string): void => {
console.log(formatMessage('info', chalk.cyan, msg));
},
warn: (msg: string): void => {
console.warn(formatMessage('warn', chalk.yellow, msg));
},
error: (msg: string, err?: unknown): void => {
console.error(formatMessage('error', chalk.redBright, msg));
if (err) console.error(err);
},
success: (msg: string): void => {
console.log(formatMessage('success', chalk.greenBright, msg));
},
debug: (msg: string): void => {
if (process.env.NODE_ENV !== 'production') {
console.debug(formatMessage('debug', chalk.magenta, msg));
}
}
};
const router = express.Router();
function isNonEmptyString(val: any): val is string {
return typeof val === 'string' && val.trim().length > 0;
}
function isValidPort(val: any): val is number {
return typeof val === 'number' && val > 0 && val < 65536;
}
interface JWTPayload {
userId: string;
iat?: number;
exp?: number;
}
// JWT authentication middleware
function authenticateJWT(req: Request, res: Response, next: NextFunction) {
// Only allow bypass if X-Internal-Request header is set
if (req.headers['x-internal-request'] === '1') {
(req as any).userId = 'internal_service';
return next();
}
const authHeader = req.headers['authorization'];
if (!authHeader || !authHeader.startsWith('Bearer ')) {
logger.warn('Missing or invalid Authorization header');
return res.status(401).json({ error: 'Missing or invalid Authorization header' });
}
const token = authHeader.split(' ')[1];
const jwtSecret = process.env.JWT_SECRET || 'secret';
try {
const payload = jwt.verify(token, jwtSecret) as JWTPayload;
(req as any).userId = payload.userId;
next();
} catch (err) {
logger.warn('Invalid or expired token');
return res.status(401).json({ error: 'Invalid or expired token' });
}
}
// Route: Create SSH tunnel data (requires JWT)
// POST /ssh_tunnel/tunnel
router.post('/tunnel', authenticateJWT, async (req: Request, res: Response) => {
const {
name, folder, sourcePort, endpointPort, sourceIP, sourceSSHPort, sourceUsername,
sourcePassword, sourceAuthMethod, sourceSSHKey, sourceKeyPassword, sourceKeyType,
endpointIP, endpointSSHPort, endpointUsername, endpointPassword, endpointAuthMethod,
endpointSSHKey, endpointKeyPassword, endpointKeyType, maxRetries, retryInterval, autoStart, isPinned
} = req.body;
const userId = (req as any).userId;
if (!isNonEmptyString(userId) || !isNonEmptyString(sourceIP) || !isValidPort(sourcePort) ||
!isValidPort(endpointPort) || !isValidPort(sourceSSHPort) || !isNonEmptyString(endpointIP) ||
!isValidPort(endpointSSHPort)) {
logger.warn('Invalid SSH tunnel data input');
return res.status(400).json({ error: 'Invalid SSH tunnel data' });
}
const sshTunnelDataObj: any = {
userId: userId,
name,
folder,
sourcePort,
endpointPort,
sourceIP,
sourceSSHPort,
sourceUsername,
sourceAuthMethod,
endpointIP,
endpointSSHPort,
endpointUsername,
endpointAuthMethod,
maxRetries: maxRetries || 3,
retryInterval: retryInterval || 5000,
connectionState: 'DISCONNECTED',
autoStart: autoStart || false,
isPinned: isPinned || false
};
// Handle source authentication
if (sourceAuthMethod === 'password') {
sshTunnelDataObj.sourcePassword = sourcePassword;
sshTunnelDataObj.sourceSSHKey = null;
sshTunnelDataObj.sourceKeyPassword = null;
sshTunnelDataObj.sourceKeyType = null;
} else if (sourceAuthMethod === 'key') {
sshTunnelDataObj.sourceSSHKey = sourceSSHKey;
sshTunnelDataObj.sourceKeyPassword = sourceKeyPassword;
sshTunnelDataObj.sourceKeyType = sourceKeyType;
sshTunnelDataObj.sourcePassword = null;
}
// Handle endpoint authentication
if (endpointAuthMethod === 'password') {
sshTunnelDataObj.endpointPassword = endpointPassword;
sshTunnelDataObj.endpointSSHKey = null;
sshTunnelDataObj.endpointKeyPassword = null;
sshTunnelDataObj.endpointKeyType = null;
} else if (endpointAuthMethod === 'key') {
sshTunnelDataObj.endpointSSHKey = endpointSSHKey;
sshTunnelDataObj.endpointKeyPassword = endpointKeyPassword;
sshTunnelDataObj.endpointKeyType = endpointKeyType;
sshTunnelDataObj.endpointPassword = null;
}
try {
await db.insert(sshTunnelData).values(sshTunnelDataObj);
res.json({ message: 'SSH tunnel data created' });
} catch (err) {
logger.error('Failed to save SSH tunnel data', err);
res.status(500).json({ error: 'Failed to save SSH tunnel data' });
}
});
// Route: Update SSH tunnel data (requires JWT)
// PUT /ssh_tunnel/tunnel/:id
router.put('/tunnel/:id', authenticateJWT, async (req: Request, res: Response) => {
const {
name, folder, sourcePort, endpointPort, sourceIP, sourceSSHPort, sourceUsername,
sourcePassword, sourceAuthMethod, sourceSSHKey, sourceKeyPassword, sourceKeyType,
endpointIP, endpointSSHPort, endpointUsername, endpointPassword, endpointAuthMethod,
endpointSSHKey, endpointKeyPassword, endpointKeyType, maxRetries, retryInterval, autoStart, isPinned
} = req.body;
const { id } = req.params;
const userId = (req as any).userId;
if (!isNonEmptyString(userId) || !isNonEmptyString(sourceIP) || !isValidPort(sourcePort) ||
!isValidPort(endpointPort) || !isValidPort(sourceSSHPort) || !isNonEmptyString(endpointIP) ||
!isValidPort(endpointSSHPort) || !id) {
logger.warn('Invalid SSH tunnel data input for update');
return res.status(400).json({ error: 'Invalid SSH tunnel data' });
}
const sshTunnelDataObj: any = {
name,
folder,
sourcePort,
endpointPort,
sourceIP,
sourceSSHPort,
sourceUsername,
sourceAuthMethod,
endpointIP,
endpointSSHPort,
endpointUsername,
endpointAuthMethod,
maxRetries: maxRetries || 3,
retryInterval: retryInterval || 5000,
autoStart: autoStart || false,
isPinned: isPinned || false
};
// Handle source authentication
if (sourceAuthMethod === 'password') {
sshTunnelDataObj.sourcePassword = sourcePassword;
sshTunnelDataObj.sourceSSHKey = null;
sshTunnelDataObj.sourceKeyPassword = null;
sshTunnelDataObj.sourceKeyType = null;
} else if (sourceAuthMethod === 'key') {
sshTunnelDataObj.sourceSSHKey = sourceSSHKey;
sshTunnelDataObj.sourceKeyPassword = sourceKeyPassword;
sshTunnelDataObj.sourceKeyType = sourceKeyType;
sshTunnelDataObj.sourcePassword = null;
}
// Handle endpoint authentication
if (endpointAuthMethod === 'password') {
sshTunnelDataObj.endpointPassword = endpointPassword;
sshTunnelDataObj.endpointSSHKey = null;
sshTunnelDataObj.endpointKeyPassword = null;
sshTunnelDataObj.endpointKeyType = null;
} else if (endpointAuthMethod === 'key') {
sshTunnelDataObj.endpointSSHKey = endpointSSHKey;
sshTunnelDataObj.endpointKeyPassword = endpointKeyPassword;
sshTunnelDataObj.endpointKeyType = endpointKeyType;
sshTunnelDataObj.endpointPassword = null;
}
try {
const result = await db.update(sshTunnelData)
.set(sshTunnelDataObj)
.where(and(eq(sshTunnelData.id, Number(id)), eq(sshTunnelData.userId, userId)));
res.json({ message: 'SSH tunnel data updated' });
} catch (err) {
logger.error('Failed to update SSH tunnel data', err);
res.status(500).json({ error: 'Failed to update SSH tunnel data' });
}
});
// Route: Get SSH tunnel data for the authenticated user (requires JWT)
// GET /ssh_tunnel/tunnel
router.get('/tunnel', authenticateJWT, async (req: Request, res: Response) => {
// If internal request and allAutoStart=1, return all autoStart tunnels
if (req.headers['x-internal-request'] === '1' && req.query.allAutoStart === '1') {
try {
const data = await db
.select()
.from(sshTunnelData)
.where(eq(sshTunnelData.autoStart, true));
return res.json(data);
} catch (err) {
logger.error('Failed to fetch all auto-start SSH tunnel data', err);
return res.status(500).json({ error: 'Failed to fetch auto-start SSH tunnel data' });
}
}
// Default: filter by userId
const userId = (req as any).userId;
if (!isNonEmptyString(userId)) {
logger.warn('Invalid userId for SSH tunnel data fetch');
return res.status(400).json({ error: 'Invalid userId' });
}
try {
const data = await db
.select()
.from(sshTunnelData)
.where(eq(sshTunnelData.userId, userId));
res.json(data);
} catch (err) {
logger.error('Failed to fetch SSH tunnel data', err);
res.status(500).json({ error: 'Failed to fetch SSH tunnel data' });
}
});
// Route: Get all unique folders for the authenticated user (requires JWT)
// GET /ssh_tunnel/folders
router.get('/folders', authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as any).userId;
if (!isNonEmptyString(userId)) {
logger.warn('Invalid userId for SSH tunnel folder fetch');
return res.status(400).json({ error: 'Invalid userId' });
}
try {
const data = await db
.select({ folder: sshTunnelData.folder })
.from(sshTunnelData)
.where(eq(sshTunnelData.userId, userId));
const folderCounts: Record<string, number> = {};
data.forEach(d => {
if (d.folder && d.folder.trim() !== '') {
folderCounts[d.folder] = (folderCounts[d.folder] || 0) + 1;
}
});
const folders = Object.keys(folderCounts).filter(folder => folderCounts[folder] > 0);
res.json(folders);
} catch (err) {
logger.error('Failed to fetch SSH tunnel folders', err);
res.status(500).json({ error: 'Failed to fetch SSH tunnel folders' });
}
});
// Route: Delete SSH tunnel by id (requires JWT)
// DELETE /ssh_tunnel/tunnel/:id
router.delete('/tunnel/:id', authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as any).userId;
const { id } = req.params;
if (!isNonEmptyString(userId) || !id) {
logger.warn('Invalid userId or id for SSH tunnel delete');
return res.status(400).json({ error: 'Invalid userId or id' });
}
try {
const result = await db.delete(sshTunnelData)
.where(and(eq(sshTunnelData.id, Number(id)), eq(sshTunnelData.userId, userId)));
res.json({ message: 'SSH tunnel deleted' });
} catch (err) {
logger.error('Failed to delete SSH tunnel', err);
res.status(500).json({ error: 'Failed to delete SSH tunnel' });
}
});
export default router;