Clean commit without large files

This commit is contained in:
LukeGus
2025-08-07 02:20:27 -05:00
commit d0b139e388
186 changed files with 22902 additions and 0 deletions

View File

@@ -0,0 +1,439 @@
import express from 'express';
import cors from 'cors';
import {Client as SSHClient} from 'ssh2';
import chalk from "chalk";
const app = express();
app.use(cors({
origin: '*',
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization']
}));
app.use(express.json());
const sshIconSymbol = '📁';
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')(`[${sshIconSymbol}]`)} ${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));
}
}
};
interface SSHSession {
client: SSHClient;
isConnected: boolean;
lastActive: number;
timeout?: NodeJS.Timeout;
}
const sshSessions: Record<string, SSHSession> = {};
const SESSION_TIMEOUT_MS = 10 * 60 * 1000;
function cleanupSession(sessionId: string) {
const session = sshSessions[sessionId];
if (session) {
try {
session.client.end();
} catch {
}
clearTimeout(session.timeout);
delete sshSessions[sessionId];
}
}
function scheduleSessionCleanup(sessionId: string) {
const session = sshSessions[sessionId];
if (session) {
if (session.timeout) clearTimeout(session.timeout);
session.timeout = setTimeout(() => cleanupSession(sessionId), SESSION_TIMEOUT_MS);
}
}
app.post('/ssh/config_editor/ssh/connect', (req, res) => {
const {sessionId, ip, port, username, password, sshKey, keyPassword} = req.body;
if (!sessionId || !ip || !username || !port) {
return res.status(400).json({error: 'Missing SSH connection parameters'});
}
if (sshSessions[sessionId]?.isConnected) cleanupSession(sessionId);
const client = new SSHClient();
const config: any = {
host: ip,
port: port || 22,
username,
readyTimeout: 20000,
keepaliveInterval: 10000,
keepaliveCountMax: 3,
algorithms: {
kex: [
'diffie-hellman-group14-sha256',
'diffie-hellman-group14-sha1',
'diffie-hellman-group1-sha1',
'diffie-hellman-group-exchange-sha256',
'diffie-hellman-group-exchange-sha1',
'ecdh-sha2-nistp256',
'ecdh-sha2-nistp384',
'ecdh-sha2-nistp521'
],
cipher: [
'aes128-ctr',
'aes192-ctr',
'aes256-ctr',
'aes128-gcm@openssh.com',
'aes256-gcm@openssh.com',
'aes128-cbc',
'aes192-cbc',
'aes256-cbc',
'3des-cbc'
],
hmac: [
'hmac-sha2-256',
'hmac-sha2-512',
'hmac-sha1',
'hmac-md5'
],
compress: [
'none',
'zlib@openssh.com',
'zlib'
]
}
};
if (sshKey && sshKey.trim()) {
config.privateKey = sshKey;
if (keyPassword) config.passphrase = keyPassword;
} else if (password && password.trim()) {
config.password = password;
} else {
return res.status(400).json({error: 'Either password or SSH key must be provided'});
}
let responseSent = false;
client.on('ready', () => {
if (responseSent) return;
responseSent = true;
sshSessions[sessionId] = {client, isConnected: true, lastActive: Date.now()};
scheduleSessionCleanup(sessionId);
res.json({status: 'success', message: 'SSH connection established'});
});
client.on('error', (err) => {
if (responseSent) return;
responseSent = true;
logger.error(`SSH connection error for session ${sessionId}:`, err.message);
res.status(500).json({status: 'error', message: err.message});
});
client.on('close', () => {
if (sshSessions[sessionId]) sshSessions[sessionId].isConnected = false;
cleanupSession(sessionId);
});
client.connect(config);
});
app.post('/ssh/config_editor/ssh/disconnect', (req, res) => {
const {sessionId} = req.body;
cleanupSession(sessionId);
res.json({status: 'success', message: 'SSH connection disconnected'});
});
app.get('/ssh/config_editor/ssh/status', (req, res) => {
const sessionId = req.query.sessionId as string;
const isConnected = !!sshSessions[sessionId]?.isConnected;
res.json({status: 'success', connected: isConnected});
});
app.get('/ssh/config_editor/ssh/listFiles', (req, res) => {
const sessionId = req.query.sessionId as string;
const sshConn = sshSessions[sessionId];
const sshPath = decodeURIComponent((req.query.path as string) || '/');
if (!sessionId) {
return res.status(400).json({error: 'Session ID is required'});
}
if (!sshConn?.isConnected) {
return res.status(400).json({error: 'SSH connection not established'});
}
sshConn.lastActive = Date.now();
scheduleSessionCleanup(sessionId);
const escapedPath = sshPath.replace(/'/g, "'\"'\"'");
sshConn.client.exec(`ls -la '${escapedPath}'`, (err, stream) => {
if (err) {
logger.error('SSH listFiles error:', err);
return res.status(500).json({error: err.message});
}
let data = '';
let errorData = '';
stream.on('data', (chunk: Buffer) => {
data += chunk.toString();
});
stream.stderr.on('data', (chunk: Buffer) => {
errorData += chunk.toString();
});
stream.on('close', (code) => {
if (code !== 0) {
logger.error(`SSH listFiles command failed with code ${code}: ${errorData.replace(/\n/g, ' ').trim()}`);
return res.status(500).json({error: `Command failed: ${errorData}`});
}
const lines = data.split('\n').filter(line => line.trim());
const files = [];
for (let i = 1; i < lines.length; i++) {
const line = lines[i];
const parts = line.split(/\s+/);
if (parts.length >= 9) {
const permissions = parts[0];
const name = parts.slice(8).join(' ');
const isDirectory = permissions.startsWith('d');
const isLink = permissions.startsWith('l');
if (name === '.' || name === '..') continue;
files.push({
name,
type: isDirectory ? 'directory' : (isLink ? 'link' : 'file')
});
}
}
res.json(files);
});
});
});
app.get('/ssh/config_editor/ssh/readFile', (req, res) => {
const sessionId = req.query.sessionId as string;
const sshConn = sshSessions[sessionId];
const filePath = decodeURIComponent(req.query.path as string);
if (!sessionId) {
return res.status(400).json({error: 'Session ID is required'});
}
if (!sshConn?.isConnected) {
return res.status(400).json({error: 'SSH connection not established'});
}
if (!filePath) {
return res.status(400).json({error: 'File path is required'});
}
sshConn.lastActive = Date.now();
scheduleSessionCleanup(sessionId);
const escapedPath = filePath.replace(/'/g, "'\"'\"'");
sshConn.client.exec(`cat '${escapedPath}'`, (err, stream) => {
if (err) {
logger.error('SSH readFile error:', err);
return res.status(500).json({error: err.message});
}
let data = '';
let errorData = '';
stream.on('data', (chunk: Buffer) => {
data += chunk.toString();
});
stream.stderr.on('data', (chunk: Buffer) => {
errorData += chunk.toString();
});
stream.on('close', (code) => {
if (code !== 0) {
logger.error(`SSH readFile command failed with code ${code}: ${errorData.replace(/\n/g, ' ').trim()}`);
return res.status(500).json({error: `Command failed: ${errorData}`});
}
res.json({content: data, path: filePath});
});
});
});
app.post('/ssh/config_editor/ssh/writeFile', (req, res) => {
const {sessionId, path: filePath, content} = req.body;
const sshConn = sshSessions[sessionId];
if (!sessionId) {
return res.status(400).json({error: 'Session ID is required'});
}
if (!sshConn?.isConnected) {
return res.status(400).json({error: 'SSH connection not established'});
}
if (!filePath) {
return res.status(400).json({error: 'File path is required'});
}
if (content === undefined) {
return res.status(400).json({error: 'File content is required'});
}
sshConn.lastActive = Date.now();
scheduleSessionCleanup(sessionId);
const tempFile = `/tmp/temp_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
const escapedTempFile = tempFile.replace(/'/g, "'\"'\"'");
const escapedFilePath = filePath.replace(/'/g, "'\"'\"'");
const base64Content = Buffer.from(content, 'utf8').toString('base64');
const commandTimeout = setTimeout(() => {
logger.error(`SSH writeFile command timed out for session: ${sessionId}`);
if (!res.headersSent) {
res.status(500).json({error: 'SSH command timed out'});
}
}, 15000);
const checkCommand = `ls -la '${escapedFilePath}' 2>/dev/null || echo "File does not exist"`;
sshConn.client.exec(checkCommand, (checkErr, checkStream) => {
if (checkErr) {
return res.status(500).json({error: `File check failed: ${checkErr.message}`});
}
let checkResult = '';
checkStream.on('data', (chunk: Buffer) => {
checkResult += chunk.toString();
});
checkStream.on('close', (checkCode) => {
const writeCommand = `echo '${base64Content}' > '${escapedTempFile}' && base64 -d '${escapedTempFile}' > '${escapedFilePath}' && rm -f '${escapedTempFile}' && echo "SUCCESS" && exit 0`;
sshConn.client.exec(writeCommand, (err, stream) => {
if (err) {
clearTimeout(commandTimeout);
logger.error('SSH writeFile error:', err);
if (!res.headersSent) {
return res.status(500).json({error: err.message});
}
return;
}
let outputData = '';
let errorData = '';
stream.on('data', (chunk: Buffer) => {
outputData += chunk.toString();
});
stream.stderr.on('data', (chunk: Buffer) => {
errorData += chunk.toString();
if (chunk.toString().includes('Permission denied')) {
clearTimeout(commandTimeout);
logger.error(`Permission denied writing to file: ${filePath}`);
if (!res.headersSent) {
return res.status(403).json({
error: `Permission denied: Cannot write to ${filePath}. Check file ownership and permissions. Use 'ls -la ${filePath}' to verify.`
});
}
return;
}
});
stream.on('close', (code) => {
clearTimeout(commandTimeout);
if (outputData.includes('SUCCESS')) {
const verifyCommand = `ls -la '${escapedFilePath}' 2>/dev/null | awk '{print $5}'`;
sshConn.client.exec(verifyCommand, (verifyErr, verifyStream) => {
if (verifyErr) {
if (!res.headersSent) {
res.json({message: 'File written successfully', path: filePath});
}
return;
}
let verifyResult = '';
verifyStream.on('data', (chunk: Buffer) => {
verifyResult += chunk.toString();
});
verifyStream.on('close', (verifyCode) => {
const fileSize = Number(verifyResult.trim());
if (fileSize > 0) {
if (!res.headersSent) {
res.json({message: 'File written successfully', path: filePath});
}
} else {
if (!res.headersSent) {
res.status(500).json({error: 'File write operation may have failed - file appears empty'});
}
}
});
});
return;
}
if (code !== 0) {
logger.error(`SSH writeFile command failed with code ${code}: ${errorData.replace(/\n/g, ' ').trim()}`);
if (!res.headersSent) {
return res.status(500).json({error: `Command failed: ${errorData}`});
}
return;
}
if (!res.headersSent) {
res.json({message: 'File written successfully', path: filePath});
}
});
stream.on('error', (streamErr) => {
clearTimeout(commandTimeout);
logger.error('SSH writeFile stream error:', streamErr);
if (!res.headersSent) {
res.status(500).json({error: `Stream error: ${streamErr.message}`});
}
});
});
});
});
});
process.on('SIGINT', () => {
Object.keys(sshSessions).forEach(cleanupSession);
process.exit(0);
});
process.on('SIGTERM', () => {
Object.keys(sshSessions).forEach(cleanupSession);
process.exit(0);
});
const PORT = 8084;
app.listen(PORT, () => {
});

View File

@@ -0,0 +1,243 @@
import express from 'express';
import bodyParser from 'body-parser';
import userRoutes from './routes/users.js';
import sshRoutes from './routes/ssh.js';
import chalk from 'chalk';
import cors from 'cors';
import fetch from 'node-fetch';
import 'dotenv/config';
const app = express();
app.use(cors({
origin: '*',
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization']
}));
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));
}
}
};
interface CacheEntry {
data: any;
timestamp: number;
expiresAt: number;
}
class GitHubCache {
private cache: Map<string, CacheEntry> = new Map();
private readonly CACHE_DURATION = 30 * 60 * 1000;
set(key: string, data: any): void {
const now = Date.now();
this.cache.set(key, {
data,
timestamp: now,
expiresAt: now + this.CACHE_DURATION
});
}
get(key: string): any | null {
const entry = this.cache.get(key);
if (!entry) {
return null;
}
if (Date.now() > entry.expiresAt) {
this.cache.delete(key);
return null;
}
return entry.data;
}
}
const githubCache = new GitHubCache();
const GITHUB_API_BASE = 'https://api.github.com';
const REPO_OWNER = 'LukeGus';
const REPO_NAME = 'Termix';
interface GitHubRelease {
id: number;
tag_name: string;
name: string;
body: string;
published_at: string;
html_url: string;
assets: Array<{
id: number;
name: string;
size: number;
download_count: number;
browser_download_url: string;
}>;
prerelease: boolean;
draft: boolean;
}
async function fetchGitHubAPI(endpoint: string, cacheKey: string): Promise<any> {
const cachedData = githubCache.get(cacheKey);
if (cachedData) {
return {
data: cachedData,
cached: true,
cache_age: Date.now() - cachedData.timestamp
};
}
try {
const response = await fetch(`${GITHUB_API_BASE}${endpoint}`, {
headers: {
'Accept': 'application/vnd.github+json',
'User-Agent': 'TermixUpdateChecker/1.0',
'X-GitHub-Api-Version': '2022-11-28'
}
});
if (!response.ok) {
throw new Error(`GitHub API error: ${response.status} ${response.statusText}`);
}
const data = await response.json();
githubCache.set(cacheKey, data);
return {
data: data,
cached: false
};
} catch (error) {
logger.error(`Failed to fetch from GitHub API: ${endpoint}`, error);
throw error;
}
}
app.use(bodyParser.json());
app.get('/health', (req, res) => {
res.json({status: 'ok'});
});
app.get('/version', async (req, res) => {
const localVersion = process.env.VERSION;
if (!localVersion) {
return res.status(401).send('Local Version Not Set');
}
try {
const cacheKey = 'latest_release';
const releaseData = await fetchGitHubAPI(
`/repos/${REPO_OWNER}/${REPO_NAME}/releases/latest`,
cacheKey
);
const rawTag = releaseData.data.tag_name || releaseData.data.name || '';
const remoteVersionMatch = rawTag.match(/(\d+\.\d+(\.\d+)?)/);
const remoteVersion = remoteVersionMatch ? remoteVersionMatch[1] : null;
if (!remoteVersion) {
return res.status(401).send('Remote Version Not Found');
}
const response = {
status: localVersion === remoteVersion ? 'up_to_date' : 'requires_update',
version: remoteVersion,
latest_release: {
tag_name: releaseData.data.tag_name,
name: releaseData.data.name,
published_at: releaseData.data.published_at,
html_url: releaseData.data.html_url
},
cached: releaseData.cached,
cache_age: releaseData.cache_age
};
res.json(response);
} catch (err) {
logger.error('Version check failed', err);
res.status(500).send('Fetch Error');
}
});
app.get('/releases/rss', async (req, res) => {
try {
const page = parseInt(req.query.page as string) || 1;
const per_page = Math.min(parseInt(req.query.per_page as string) || 20, 100);
const cacheKey = `releases_rss_${page}_${per_page}`;
const releasesData = await fetchGitHubAPI(
`/repos/${REPO_OWNER}/${REPO_NAME}/releases?page=${page}&per_page=${per_page}`,
cacheKey
);
const rssItems = releasesData.data.map((release: GitHubRelease) => ({
id: release.id,
title: release.name || release.tag_name,
description: release.body,
link: release.html_url,
pubDate: release.published_at,
version: release.tag_name,
isPrerelease: release.prerelease,
isDraft: release.draft,
assets: release.assets.map(asset => ({
name: asset.name,
size: asset.size,
download_count: asset.download_count,
download_url: asset.browser_download_url
}))
}));
const response = {
feed: {
title: `${REPO_NAME} Releases`,
description: `Latest releases from ${REPO_NAME} repository`,
link: `https://github.com/${REPO_OWNER}/${REPO_NAME}/releases`,
updated: new Date().toISOString()
},
items: rssItems,
total_count: rssItems.length,
cached: releasesData.cached,
cache_age: releasesData.cache_age
};
res.json(response);
} catch (error) {
logger.error('Failed to generate RSS format', error)
res.status(500).json({ error: 'Failed to generate RSS format', details: error instanceof Error ? error.message : 'Unknown error' });
}
});
app.use('/users', userRoutes);
app.use('/ssh', sshRoutes);
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'});
});
const PORT = 8081;
app.listen(PORT, () => {});

View File

@@ -0,0 +1,197 @@
import {drizzle} from 'drizzle-orm/better-sqlite3';
import Database from 'better-sqlite3';
import * as schema from './schema.js';
import chalk from 'chalk';
import fs from 'fs';
import path from 'path';
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 dataDir = process.env.DATA_DIR || './db/data';
const dbDir = path.resolve(dataDir);
if (!fs.existsSync(dbDir)) {
fs.mkdirSync(dbDir, {recursive: true});
}
const dbPath = path.join(dataDir, 'db.sqlite');
const sqlite = new Database(dbPath);
sqlite.exec(`
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL,
password_hash TEXT NOT NULL,
is_admin INTEGER NOT NULL DEFAULT 0,
is_oidc INTEGER NOT NULL DEFAULT 0,
client_id TEXT NOT NULL,
client_secret TEXT NOT NULL,
issuer_url TEXT NOT NULL,
authorization_url TEXT NOT NULL,
token_url TEXT NOT NULL,
redirect_uri TEXT,
identifier_path TEXT NOT NULL,
name_path TEXT NOT NULL,
scopes TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS ssh_data (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
name TEXT,
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)
);
CREATE TABLE IF NOT EXISTS config_editor_recent (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
host_id INTEGER NOT NULL,
name TEXT NOT NULL,
path TEXT NOT NULL,
last_opened TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id),
FOREIGN KEY (host_id) REFERENCES ssh_data(id)
);
CREATE TABLE IF NOT EXISTS config_editor_pinned (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
host_id INTEGER NOT NULL,
name TEXT NOT NULL,
path TEXT NOT NULL,
pinned_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id),
FOREIGN KEY (host_id) REFERENCES ssh_data(id)
);
CREATE TABLE IF NOT EXISTS config_editor_shortcuts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
host_id INTEGER NOT NULL,
name TEXT NOT NULL,
path TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id),
FOREIGN KEY (host_id) REFERENCES ssh_data(id)
);
`);
const addColumnIfNotExists = (table: string, column: string, definition: string) => {
try {
sqlite.prepare(`SELECT ${column}
FROM ${table} LIMIT 1`).get();
} catch (e) {
try {
sqlite.exec(`ALTER TABLE ${table}
ADD COLUMN ${column} ${definition};`);
} catch (alterError) {
logger.warn(`Failed to add column ${column} to ${table}: ${alterError}`);
}
}
};
const migrateSchema = () => {
logger.info('Checking for schema updates...');
addColumnIfNotExists('users', 'is_admin', 'INTEGER NOT NULL DEFAULT 0');
addColumnIfNotExists('users', 'is_oidc', 'INTEGER NOT NULL DEFAULT 0');
addColumnIfNotExists('users', 'oidc_identifier', 'TEXT');
addColumnIfNotExists('users', 'client_id', 'TEXT');
addColumnIfNotExists('users', 'client_secret', 'TEXT');
addColumnIfNotExists('users', 'issuer_url', 'TEXT');
addColumnIfNotExists('users', 'authorization_url', 'TEXT');
addColumnIfNotExists('users', 'token_url', 'TEXT');
try {
sqlite.prepare(`ALTER TABLE users DROP COLUMN redirect_uri`).run();
logger.info('Removed redirect_uri column from users table');
} catch (e) {
}
addColumnIfNotExists('users', 'identifier_path', 'TEXT');
addColumnIfNotExists('users', 'name_path', 'TEXT');
addColumnIfNotExists('users', 'scopes', 'TEXT');
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');
addColumnIfNotExists('config_editor_recent', 'host_id', 'INTEGER NOT NULL');
addColumnIfNotExists('config_editor_pinned', 'host_id', 'INTEGER NOT NULL');
addColumnIfNotExists('config_editor_shortcuts', 'host_id', 'INTEGER NOT NULL');
logger.success('Schema migration completed');
};
migrateSchema();
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

@@ -0,0 +1,76 @@
import {sqliteTable, text, integer} from 'drizzle-orm/sqlite-core';
import {sql} from 'drizzle-orm';
export const users = sqliteTable('users', {
id: text('id').primaryKey(),
username: text('username').notNull(),
password_hash: text('password_hash').notNull(),
is_admin: integer('is_admin', {mode: 'boolean'}).notNull().default(false),
is_oidc: integer('is_oidc', {mode: 'boolean'}).notNull().default(false),
oidc_identifier: text('oidc_identifier'),
client_id: text('client_id'),
client_secret: text('client_secret'),
issuer_url: text('issuer_url'),
authorization_url: text('authorization_url'),
token_url: text('token_url'),
identifier_path: text('identifier_path'),
name_path: text('name_path'),
scopes: text().default("openid email profile"),
});
export const settings = sqliteTable('settings', {
key: text('key').primaryKey(),
value: text('value').notNull(),
});
export const sshData = sqliteTable('ssh_data', {
id: integer('id').primaryKey({autoIncrement: true}),
userId: text('user_id').notNull().references(() => users.id),
name: text('name'),
ip: text('ip').notNull(),
port: integer('port').notNull(),
username: text('username').notNull(),
folder: text('folder'),
tags: text('tags'),
pin: integer('pin', {mode: 'boolean'}).notNull().default(false),
authType: text('auth_type').notNull(),
password: text('password'),
key: text('key', {length: 8192}),
keyPassword: text('key_password'),
keyType: text('key_type'),
enableTerminal: integer('enable_terminal', {mode: 'boolean'}).notNull().default(true),
enableTunnel: integer('enable_tunnel', {mode: 'boolean'}).notNull().default(true),
tunnelConnections: text('tunnel_connections'),
enableConfigEditor: integer('enable_config_editor', {mode: 'boolean'}).notNull().default(true),
defaultPath: text('default_path'),
createdAt: text('created_at').notNull().default(sql`CURRENT_TIMESTAMP`),
updatedAt: text('updated_at').notNull().default(sql`CURRENT_TIMESTAMP`),
});
export const configEditorRecent = sqliteTable('config_editor_recent', {
id: integer('id').primaryKey({autoIncrement: true}),
userId: text('user_id').notNull().references(() => users.id),
hostId: integer('host_id').notNull().references(() => sshData.id),
name: text('name').notNull(),
path: text('path').notNull(),
lastOpened: text('last_opened').notNull().default(sql`CURRENT_TIMESTAMP`),
});
export const configEditorPinned = sqliteTable('config_editor_pinned', {
id: integer('id').primaryKey({autoIncrement: true}),
userId: text('user_id').notNull().references(() => users.id),
hostId: integer('host_id').notNull().references(() => sshData.id),
name: text('name').notNull(),
path: text('path').notNull(),
pinnedAt: text('pinned_at').notNull().default(sql`CURRENT_TIMESTAMP`),
});
export const configEditorShortcuts = sqliteTable('config_editor_shortcuts', {
id: integer('id').primaryKey({autoIncrement: true}),
userId: text('user_id').notNull().references(() => users.id),
hostId: integer('host_id').notNull().references(() => sshData.id),
name: text('name').notNull(),
path: text('path').notNull(),
createdAt: text('created_at').notNull().default(sql`CURRENT_TIMESTAMP`),
});

View File

@@ -0,0 +1,700 @@
import express from 'express';
import {db} from '../db/index.js';
import {sshData, configEditorRecent, configEditorPinned, configEditorShortcuts} from '../db/schema.js';
import {eq, and, desc} from 'drizzle-orm';
import chalk from 'chalk';
import jwt from 'jsonwebtoken';
import multer from 'multer';
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;
}
const upload = multer({
storage: multer.memoryStorage(),
limits: {
fileSize: 10 * 1024 * 1024,
},
fileFilter: (req, file, cb) => {
if (file.fieldname === 'key') {
cb(null, true);
} else {
cb(new Error('Invalid file type'));
}
}
});
function authenticateJWT(req: Request, res: Response, next: NextFunction) {
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'});
}
}
function isLocalhost(req: Request) {
const ip = req.ip || req.connection?.remoteAddress;
return ip === '127.0.0.1' || ip === '::1' || ip === '::ffff:127.0.0.1';
}
// Internal-only endpoint for autostart (no JWT)
router.get('/db/host/internal', async (req: Request, res: Response) => {
if (!isLocalhost(req) && req.headers['x-internal-request'] !== '1') {
logger.warn('Unauthorized attempt to access internal SSH host endpoint');
return res.status(403).json({error: 'Forbidden'});
}
try {
const data = await db.select().from(sshData);
// 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 (internal)', err);
res.status(500).json({error: 'Failed to fetch SSH data'});
}
});
// Route: Create SSH data (requires JWT)
// POST /ssh/host
router.post('/db/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');
return res.status(400).json({error: 'Invalid SSH data'});
}
const sshDataObj: any = {
userId: userId,
name,
folder,
tags: Array.isArray(tags) ? tags.join(',') : (tags || ''),
ip,
port,
username,
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 (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 {
await db.insert(sshData).values(sshDataObj);
res.json({message: 'SSH data created'});
} catch (err) {
logger.error('Failed to save SSH data', err);
res.status(500).json({error: 'Failed to save SSH data'});
}
});
// Route: Update SSH data (requires JWT)
// PUT /ssh/host/:id
router.put('/db/host/:id', authenticateJWT, upload.single('key'), async (req: Request, res: Response) => {
let hostData: any;
if (req.headers['content-type']?.includes('multipart/form-data')) {
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'});
}
if (req.file) {
hostData.key = req.file.buffer.toString('utf8');
}
} else {
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'});
}
const sshDataObj: any = {
name,
folder,
tags: Array.isArray(tags) ? tags.join(',') : (tags || ''),
ip,
port,
username,
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 (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 {
await db.update(sshData)
.set(sshDataObj)
.where(and(eq(sshData.id, Number(id)), eq(sshData.userId, userId)));
res.json({message: 'SSH data updated'});
} catch (err) {
logger.error('Failed to update SSH data', err);
res.status(500).json({error: 'Failed to update SSH data'});
}
});
// Route: Get SSH data for the authenticated user (requires JWT)
// GET /ssh/host
router.get('/db/host', authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as any).userId;
if (!isNonEmptyString(userId)) {
logger.warn('Invalid userId for SSH data fetch');
return res.status(400).json({error: 'Invalid userId'});
}
try {
const data = await db
.select()
.from(sshData)
.where(eq(sshData.userId, userId));
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('/db/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('/db/folders', authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as any).userId;
if (!isNonEmptyString(userId)) {
logger.warn('Invalid userId for SSH folder fetch');
return res.status(400).json({error: 'Invalid userId'});
}
try {
const data = await db
.select({folder: sshData.folder})
.from(sshData)
.where(eq(sshData.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 folders', err);
res.status(500).json({error: 'Failed to fetch SSH folders'});
}
});
// Route: Delete SSH host by id (requires JWT)
// DELETE /ssh/host/:id
router.delete('/db/host/: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 host delete');
return res.status(400).json({error: 'Invalid userId or id'});
}
try {
const result = await db.delete(sshData)
.where(and(eq(sshData.id, Number(id)), eq(sshData.userId, userId)));
res.json({message: 'SSH host deleted'});
} catch (err) {
logger.error('Failed to delete SSH host', err);
res.status(500).json({error: 'Failed to delete SSH host'});
}
});
// Route: Get recent files (requires JWT)
// GET /ssh/config_editor/recent
router.get('/config_editor/recent', authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as any).userId;
const hostId = req.query.hostId ? parseInt(req.query.hostId as string) : null;
if (!isNonEmptyString(userId)) {
logger.warn('Invalid userId for recent files fetch');
return res.status(400).json({error: 'Invalid userId'});
}
if (!hostId) {
logger.warn('Host ID is required for recent files fetch');
return res.status(400).json({error: 'Host ID is required'});
}
try {
const recentFiles = await db
.select()
.from(configEditorRecent)
.where(and(
eq(configEditorRecent.userId, userId),
eq(configEditorRecent.hostId, hostId)
))
.orderBy(desc(configEditorRecent.lastOpened));
res.json(recentFiles);
} catch (err) {
logger.error('Failed to fetch recent files', err);
res.status(500).json({error: 'Failed to fetch recent files'});
}
});
// Route: Add file to recent (requires JWT)
// POST /ssh/config_editor/recent
router.post('/config_editor/recent', authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as any).userId;
const {name, path, hostId} = req.body;
if (!isNonEmptyString(userId) || !name || !path || !hostId) {
logger.warn('Invalid request for adding recent file');
return res.status(400).json({error: 'Invalid request - userId, name, path, and hostId are required'});
}
try {
const conditions = [
eq(configEditorRecent.userId, userId),
eq(configEditorRecent.path, path),
eq(configEditorRecent.hostId, hostId)
];
const existing = await db
.select()
.from(configEditorRecent)
.where(and(...conditions));
if (existing.length > 0) {
await db
.update(configEditorRecent)
.set({lastOpened: new Date().toISOString()})
.where(and(...conditions));
} else {
// Add new recent file
await db.insert(configEditorRecent).values({
userId,
hostId,
name,
path,
lastOpened: new Date().toISOString()
});
}
res.json({message: 'File added to recent'});
} catch (err) {
logger.error('Failed to add recent file', err);
res.status(500).json({error: 'Failed to add recent file'});
}
});
// Route: Remove file from recent (requires JWT)
// DELETE /ssh/config_editor/recent
router.delete('/config_editor/recent', authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as any).userId;
const {name, path, hostId} = req.body;
if (!isNonEmptyString(userId) || !name || !path || !hostId) {
logger.warn('Invalid request for removing recent file');
return res.status(400).json({error: 'Invalid request - userId, name, path, and hostId are required'});
}
try {
const conditions = [
eq(configEditorRecent.userId, userId),
eq(configEditorRecent.path, path),
eq(configEditorRecent.hostId, hostId)
];
const result = await db
.delete(configEditorRecent)
.where(and(...conditions));
res.json({message: 'File removed from recent'});
} catch (err) {
logger.error('Failed to remove recent file', err);
res.status(500).json({error: 'Failed to remove recent file'});
}
});
// Route: Get pinned files (requires JWT)
// GET /ssh/config_editor/pinned
router.get('/config_editor/pinned', authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as any).userId;
const hostId = req.query.hostId ? parseInt(req.query.hostId as string) : null;
if (!isNonEmptyString(userId)) {
logger.warn('Invalid userId for pinned files fetch');
return res.status(400).json({error: 'Invalid userId'});
}
if (!hostId) {
logger.warn('Host ID is required for pinned files fetch');
return res.status(400).json({error: 'Host ID is required'});
}
try {
const pinnedFiles = await db
.select()
.from(configEditorPinned)
.where(and(
eq(configEditorPinned.userId, userId),
eq(configEditorPinned.hostId, hostId)
))
.orderBy(configEditorPinned.pinnedAt);
res.json(pinnedFiles);
} catch (err) {
logger.error('Failed to fetch pinned files', err);
res.status(500).json({error: 'Failed to fetch pinned files'});
}
});
// Route: Add file to pinned (requires JWT)
// POST /ssh/config_editor/pinned
router.post('/config_editor/pinned', authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as any).userId;
const {name, path, hostId} = req.body;
if (!isNonEmptyString(userId) || !name || !path || !hostId) {
logger.warn('Invalid request for adding pinned file');
return res.status(400).json({error: 'Invalid request - userId, name, path, and hostId are required'});
}
try {
const conditions = [
eq(configEditorPinned.userId, userId),
eq(configEditorPinned.path, path),
eq(configEditorPinned.hostId, hostId)
];
const existing = await db
.select()
.from(configEditorPinned)
.where(and(...conditions));
if (existing.length === 0) {
await db.insert(configEditorPinned).values({
userId,
hostId,
name,
path,
pinnedAt: new Date().toISOString()
});
}
res.json({message: 'File pinned successfully'});
} catch (err) {
logger.error('Failed to pin file', err);
res.status(500).json({error: 'Failed to pin file'});
}
});
// Route: Remove file from pinned (requires JWT)
// DELETE /ssh/config_editor/pinned
router.delete('/config_editor/pinned', authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as any).userId;
const {name, path, hostId} = req.body;
if (!isNonEmptyString(userId) || !name || !path || !hostId) {
logger.warn('Invalid request for removing pinned file');
return res.status(400).json({error: 'Invalid request - userId, name, path, and hostId are required'});
}
try {
const conditions = [
eq(configEditorPinned.userId, userId),
eq(configEditorPinned.path, path),
eq(configEditorPinned.hostId, hostId)
];
const result = await db
.delete(configEditorPinned)
.where(and(...conditions));
res.json({message: 'File unpinned successfully'});
} catch (err) {
logger.error('Failed to unpin file', err);
res.status(500).json({error: 'Failed to unpin file'});
}
});
// Route: Get folder shortcuts (requires JWT)
// GET /ssh/config_editor/shortcuts
router.get('/config_editor/shortcuts', authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as any).userId;
const hostId = req.query.hostId ? parseInt(req.query.hostId as string) : null;
if (!isNonEmptyString(userId)) {
return res.status(400).json({error: 'Invalid userId'});
}
if (!hostId) {
return res.status(400).json({error: 'Host ID is required'});
}
try {
const shortcuts = await db
.select()
.from(configEditorShortcuts)
.where(and(
eq(configEditorShortcuts.userId, userId),
eq(configEditorShortcuts.hostId, hostId)
))
.orderBy(configEditorShortcuts.createdAt);
res.json(shortcuts);
} catch (err) {
logger.error('Failed to fetch shortcuts', err);
res.status(500).json({error: 'Failed to fetch shortcuts'});
}
});
// Route: Add folder shortcut (requires JWT)
// POST /ssh/config_editor/shortcuts
router.post('/config_editor/shortcuts', authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as any).userId;
const {name, path, hostId} = req.body;
if (!isNonEmptyString(userId) || !name || !path || !hostId) {
return res.status(400).json({error: 'Invalid request - userId, name, path, and hostId are required'});
}
try {
const conditions = [
eq(configEditorShortcuts.userId, userId),
eq(configEditorShortcuts.path, path),
eq(configEditorShortcuts.hostId, hostId)
];
const existing = await db
.select()
.from(configEditorShortcuts)
.where(and(...conditions));
if (existing.length === 0) {
await db.insert(configEditorShortcuts).values({
userId,
hostId,
name,
path,
createdAt: new Date().toISOString()
});
}
res.json({message: 'Shortcut added successfully'});
} catch (err) {
logger.error('Failed to add shortcut', err);
res.status(500).json({error: 'Failed to add shortcut'});
}
});
// Route: Remove folder shortcut (requires JWT)
// DELETE /ssh/config_editor/shortcuts
router.delete('/config_editor/shortcuts', authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as any).userId;
const {name, path, hostId} = req.body;
if (!isNonEmptyString(userId) || !name || !path || !hostId) {
return res.status(400).json({error: 'Invalid request - userId, name, path, and hostId are required'});
}
try {
const conditions = [
eq(configEditorShortcuts.userId, userId),
eq(configEditorShortcuts.path, path),
eq(configEditorShortcuts.hostId, hostId)
];
const result = await db
.delete(configEditorShortcuts)
.where(and(...conditions));
res.json({message: 'Shortcut removed successfully'});
} catch (err) {
logger.error('Failed to remove shortcut', err);
res.status(500).json({error: 'Failed to remove shortcut'});
}
});
export default router;

View File

@@ -0,0 +1,642 @@
import express from 'express';
import {db} from '../db/index.js';
import {users, settings} from '../db/schema.js';
import {eq, and} from 'drizzle-orm';
import chalk from 'chalk';
import bcrypt from 'bcryptjs';
import {nanoid} from 'nanoid';
import jwt from 'jsonwebtoken';
import type {Request, Response, NextFunction} from 'express';
async function verifyOIDCToken(idToken: string, issuerUrl: string, clientId: string): Promise<any> {
try {
let jwksUrl: string | null = null;
const normalizedIssuerUrl = issuerUrl.endsWith('/') ? issuerUrl.slice(0, -1) : issuerUrl;
try {
const discoveryUrl = `${normalizedIssuerUrl}/.well-known/openid-configuration`;
const discoveryResponse = await fetch(discoveryUrl);
if (discoveryResponse.ok) {
const discovery = await discoveryResponse.json() as any;
if (discovery.jwks_uri) {
jwksUrl = discovery.jwks_uri;
} else {
logger.warn('OIDC discovery document does not contain jwks_uri');
}
} else {
logger.warn(`OIDC discovery failed with status: ${discoveryResponse.status}`);
}
} catch (discoveryError) {
logger.warn(`OIDC discovery failed: ${discoveryError}`);
}
if (!jwksUrl) {
jwksUrl = `${normalizedIssuerUrl}/.well-known/jwks.json`;
}
if (!jwksUrl) {
const authentikJwksUrl = `${normalizedIssuerUrl}/jwks/`;
try {
const jwksTestResponse = await fetch(authentikJwksUrl);
if (jwksTestResponse.ok) {
jwksUrl = authentikJwksUrl;
}
} catch (error) {
logger.warn(`Authentik JWKS URL also failed: ${error}`);
}
}
if (!jwksUrl) {
const baseUrl = normalizedIssuerUrl.replace(/\/application\/o\/[^\/]+$/, '');
const rootJwksUrl = `${baseUrl}/.well-known/jwks.json`;
try {
const jwksTestResponse = await fetch(rootJwksUrl);
if (jwksTestResponse.ok) {
jwksUrl = rootJwksUrl;
}
} catch (error) {
logger.warn(`Authentik root JWKS URL also failed: ${error}`);
}
}
const jwksResponse = await fetch(jwksUrl);
if (!jwksResponse.ok) {
throw new Error(`Failed to fetch JWKS from ${jwksUrl}: ${jwksResponse.status}`);
}
const jwks = await jwksResponse.json() as any;
const header = JSON.parse(Buffer.from(idToken.split('.')[0], 'base64').toString());
const keyId = header.kid;
const publicKey = jwks.keys.find((key: any) => key.kid === keyId);
if (!publicKey) {
throw new Error(`No matching public key found for key ID: ${keyId}`);
}
const { importJWK, jwtVerify } = await import('jose');
const key = await importJWK(publicKey);
const { payload } = await jwtVerify(idToken, key, {
issuer: issuerUrl,
audience: clientId,
});
return payload;
} catch (error) {
logger.error('OIDC token verification failed:', error);
throw error;
}
}
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;
}
interface JWTPayload {
userId: string;
iat?: number;
exp?: number;
}
// JWT authentication middleware
function authenticateJWT(req: Request, res: Response, next: NextFunction) {
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 traditional user (username/password)
// POST /users/create
router.post('/create', async (req, res) => {
try {
const row = db.$client.prepare("SELECT value FROM settings WHERE key = 'allow_registration'").get();
if (row && (row as any).value !== 'true') {
return res.status(403).json({error: 'Registration is currently disabled'});
}
} catch (e) {
}
const {username, password} = req.body;
if (!isNonEmptyString(username) || !isNonEmptyString(password)) {
logger.warn('Invalid user creation attempt - missing username or password');
return res.status(400).json({ error: 'Username and password are required' });
}
try {
const existing = await db
.select()
.from(users)
.where(eq(users.username, username));
if (existing && existing.length > 0) {
logger.warn(`Attempt to create duplicate username: ${username}`);
return res.status(409).json({error: 'Username already exists'});
}
let isFirstUser = false;
try {
const countResult = db.$client.prepare('SELECT COUNT(*) as count FROM users').get();
isFirstUser = ((countResult as any)?.count || 0) === 0;
} catch (e) {
isFirstUser = true;
}
const saltRounds = parseInt(process.env.SALT || '10', 10);
const password_hash = await bcrypt.hash(password, saltRounds);
const id = nanoid();
await db.insert(users).values({
id,
username,
password_hash,
is_admin: isFirstUser,
is_oidc: false,
client_id: '',
client_secret: '',
issuer_url: '',
authorization_url: '',
token_url: '',
identifier_path: '',
name_path: '',
scopes: 'openid email profile',
});
logger.success(`Traditional user created: ${username} (is_admin: ${isFirstUser})`);
res.json({message: 'User created', is_admin: isFirstUser});
} catch (err) {
logger.error('Failed to create user', err);
res.status(500).json({error: 'Failed to create user'});
}
});
// Route: Create OIDC provider configuration (admin only)
// POST /users/oidc-config
router.post('/oidc-config', authenticateJWT, async (req, res) => {
const userId = (req as any).userId;
try {
const user = await db.select().from(users).where(eq(users.id, userId));
if (!user || user.length === 0 || !user[0].is_admin) {
return res.status(403).json({error: 'Not authorized'});
}
const {
client_id,
client_secret,
issuer_url,
authorization_url,
token_url,
identifier_path,
name_path,
scopes
} = req.body;
if (!isNonEmptyString(client_id) || !isNonEmptyString(client_secret) ||
!isNonEmptyString(issuer_url) || !isNonEmptyString(authorization_url) ||
!isNonEmptyString(token_url) || !isNonEmptyString(identifier_path) ||
!isNonEmptyString(name_path)) {
return res.status(400).json({error: 'All OIDC configuration fields are required'});
}
const config = {
client_id,
client_secret,
issuer_url,
authorization_url,
token_url,
identifier_path,
name_path,
scopes: scopes || 'openid email profile'
};
db.$client.prepare("INSERT OR REPLACE INTO settings (key, value) VALUES ('oidc_config', ?)").run(JSON.stringify(config));
res.json({message: 'OIDC configuration updated'});
} catch (err) {
logger.error('Failed to update OIDC config', err);
res.status(500).json({error: 'Failed to update OIDC config'});
}
});
// Route: Get OIDC configuration
// GET /users/oidc-config
router.get('/oidc-config', async (req, res) => {
try {
const row = db.$client.prepare("SELECT value FROM settings WHERE key = 'oidc_config'").get();
if (!row) {
return res.status(404).json({error: 'OIDC not configured'});
}
res.json(JSON.parse((row as any).value));
} catch (err) {
logger.error('Failed to get OIDC config', err);
res.status(500).json({error: 'Failed to get OIDC config'});
}
});
// Route: Get OIDC authorization URL
// GET /users/oidc/authorize
router.get('/oidc/authorize', async (req, res) => {
try {
const row = db.$client.prepare("SELECT value FROM settings WHERE key = 'oidc_config'").get();
if (!row) {
return res.status(404).json({error: 'OIDC not configured'});
}
const config = JSON.parse((row as any).value);
const state = nanoid();
const nonce = nanoid();
let origin = req.get('Origin') || req.get('Referer')?.replace(/\/[^\/]*$/, '') || 'http://localhost:5173';
if (origin.includes('localhost')) {
origin = 'http://localhost:8081';
}
const redirectUri = `${origin}/users/oidc/callback`;
db.$client.prepare("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)").run(`oidc_state_${state}`, nonce);
db.$client.prepare("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)").run(`oidc_redirect_${state}`, redirectUri);
const authUrl = new URL(config.authorization_url);
authUrl.searchParams.set('client_id', config.client_id);
authUrl.searchParams.set('redirect_uri', redirectUri);
authUrl.searchParams.set('response_type', 'code');
authUrl.searchParams.set('scope', config.scopes);
authUrl.searchParams.set('state', state);
authUrl.searchParams.set('nonce', nonce);
res.json({auth_url: authUrl.toString(), state, nonce});
} catch (err) {
logger.error('Failed to generate OIDC auth URL', err);
res.status(500).json({error: 'Failed to generate authorization URL'});
}
});
// Route: OIDC callback - exchange code for token and create/login user
// GET /users/oidc/callback
router.get('/oidc/callback', async (req, res) => {
const {code, state} = req.query;
if (!isNonEmptyString(code) || !isNonEmptyString(state)) {
return res.status(400).json({error: 'Code and state are required'});
}
const storedRedirectRow = db.$client.prepare("SELECT value FROM settings WHERE key = ?").get(`oidc_redirect_${state}`);
if (!storedRedirectRow) {
return res.status(400).json({error: 'Invalid state parameter - redirect URI not found'});
}
const redirectUri = (storedRedirectRow as any).value;
try {
const storedNonce = db.$client.prepare("SELECT value FROM settings WHERE key = ?").get(`oidc_state_${state}`);
if (!storedNonce) {
return res.status(400).json({error: 'Invalid state parameter'});
}
db.$client.prepare("DELETE FROM settings WHERE key = ?").run(`oidc_state_${state}`);
db.$client.prepare("DELETE FROM settings WHERE key = ?").run(`oidc_redirect_${state}`);
const configRow = db.$client.prepare("SELECT value FROM settings WHERE key = 'oidc_config'").get();
if (!configRow) {
return res.status(500).json({error: 'OIDC not configured'});
}
const config = JSON.parse((configRow as any).value);
const tokenResponse = await fetch(config.token_url, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
grant_type: 'authorization_code',
client_id: config.client_id,
client_secret: config.client_secret,
code: code,
redirect_uri: redirectUri,
}),
});
if (!tokenResponse.ok) {
logger.error('OIDC token exchange failed', await tokenResponse.text());
return res.status(400).json({error: 'Failed to exchange authorization code'});
}
const tokenData = await tokenResponse.json() as any;
let userInfo;
if (tokenData.id_token) {
try {
userInfo = await verifyOIDCToken(tokenData.id_token, config.issuer_url, config.client_id);
} catch (error) {
logger.error('OIDC token verification failed, falling back to userinfo endpoint', error);
if (tokenData.access_token) {
const normalizedIssuerUrl = config.issuer_url.endsWith('/') ? config.issuer_url.slice(0, -1) : config.issuer_url;
const baseUrl = normalizedIssuerUrl.replace(/\/application\/o\/[^\/]+$/, '');
const userInfoUrl = `${baseUrl}/userinfo/`;
const userInfoResponse = await fetch(userInfoUrl, {
headers: {
'Authorization': `Bearer ${tokenData.access_token}`,
},
});
if (userInfoResponse.ok) {
userInfo = await userInfoResponse.json();
} else {
logger.error(`Userinfo endpoint failed with status: ${userInfoResponse.status}`);
}
}
}
} else if (tokenData.access_token) {
const normalizedIssuerUrl = config.issuer_url.endsWith('/') ? config.issuer_url.slice(0, -1) : config.issuer_url;
const baseUrl = normalizedIssuerUrl.replace(/\/application\/o\/[^\/]+$/, '');
const userInfoUrl = `${baseUrl}/userinfo/`;
const userInfoResponse = await fetch(userInfoUrl, {
headers: {
'Authorization': `Bearer ${tokenData.access_token}`,
},
});
if (userInfoResponse.ok) {
userInfo = await userInfoResponse.json();
} else {
logger.error(`Userinfo endpoint failed with status: ${userInfoResponse.status}`);
}
}
if (!userInfo) {
return res.status(400).json({error: 'Failed to get user information'});
}
const identifier = userInfo[config.identifier_path];
const name = userInfo[config.name_path] || identifier;
if (!identifier) {
logger.error(`Identifier not found at path: ${config.identifier_path}`);
logger.error(`Available fields: ${Object.keys(userInfo).join(', ')}`);
return res.status(400).json({error: `User identifier not found at path: ${config.identifier_path}. Available fields: ${Object.keys(userInfo).join(', ')}`});
}
let user = await db
.select()
.from(users)
.where(and(eq(users.is_oidc, true), eq(users.oidc_identifier, identifier)));
let isFirstUser = false;
if (!user || user.length === 0) {
try {
const countResult = db.$client.prepare('SELECT COUNT(*) as count FROM users').get();
isFirstUser = ((countResult as any)?.count || 0) === 0;
} catch (e) {
isFirstUser = true;
}
const id = nanoid();
await db.insert(users).values({
id,
username: name,
password_hash: '',
is_admin: isFirstUser,
is_oidc: true,
oidc_identifier: identifier,
client_id: config.client_id,
client_secret: config.client_secret,
issuer_url: config.issuer_url,
authorization_url: config.authorization_url,
token_url: config.token_url,
identifier_path: config.identifier_path,
name_path: config.name_path,
scopes: config.scopes,
});
user = await db
.select()
.from(users)
.where(eq(users.id, id));
} else {
await db.update(users)
.set({ username: name })
.where(eq(users.id, user[0].id));
user = await db
.select()
.from(users)
.where(eq(users.id, user[0].id));
}
const userRecord = user[0];
const jwtSecret = process.env.JWT_SECRET || 'secret';
const token = jwt.sign({ userId: userRecord.id }, jwtSecret, {
expiresIn: '50d',
});
let frontendUrl = redirectUri.replace('/users/oidc/callback', '');
if (frontendUrl.includes('localhost')) {
frontendUrl = 'http://localhost:5173';
}
const redirectUrl = new URL(frontendUrl);
redirectUrl.searchParams.set('success', 'true');
redirectUrl.searchParams.set('token', token);
res.redirect(redirectUrl.toString());
} catch (err) {
logger.error('OIDC callback failed', err);
let frontendUrl = redirectUri.replace('/users/oidc/callback', '');
if (frontendUrl.includes('localhost')) {
frontendUrl = 'http://localhost:5173';
}
const redirectUrl = new URL(frontendUrl);
redirectUrl.searchParams.set('error', 'OIDC authentication failed');
res.redirect(redirectUrl.toString());
}
});
// Route: Get user JWT by username and password (traditional login)
// POST /users/login
router.post('/login', async (req, res) => {
const {username, password} = req.body;
if (!isNonEmptyString(username) || !isNonEmptyString(password)) {
logger.warn('Invalid traditional login attempt');
return res.status(400).json({ error: 'Invalid username or password' });
}
try {
const user = await db
.select()
.from(users)
.where(eq(users.username, username));
if (!user || user.length === 0) {
logger.warn(`User not found: ${username}`);
return res.status(404).json({ error: 'User not found' });
}
const userRecord = user[0];
if (userRecord.is_oidc) {
return res.status(403).json({ error: 'This user uses external authentication' });
}
const isMatch = await bcrypt.compare(password, userRecord.password_hash);
if (!isMatch) {
logger.warn(`Incorrect password for user: ${username}`);
return res.status(401).json({ error: 'Incorrect password' });
}
const jwtSecret = process.env.JWT_SECRET || 'secret';
const token = jwt.sign({ userId: userRecord.id }, jwtSecret, {
expiresIn: '50d',
});
return res.json({
token,
is_admin: !!userRecord.is_admin,
username: userRecord.username
});
} catch (err) {
logger.error('Failed to log in user', err);
return res.status(500).json({ error: 'Login failed' });
}
});
// Route: Get current user's info using JWT
// GET /users/me
router.get('/me', authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as any).userId;
if (!isNonEmptyString(userId)) {
logger.warn('Invalid userId in JWT for /users/me');
return res.status(401).json({error: 'Invalid userId'});
}
try {
const user = await db
.select()
.from(users)
.where(eq(users.id, userId));
if (!user || user.length === 0) {
logger.warn(`User not found for /users/me: ${userId}`);
return res.status(401).json({error: 'User not found'});
}
res.json({
username: user[0].username,
is_admin: !!user[0].is_admin,
is_oidc: !!user[0].is_oidc
});
} catch (err) {
logger.error('Failed to get username', err);
res.status(500).json({error: 'Failed to get username'});
}
});
// Route: Count users
// GET /users/count
router.get('/count', async (req, res) => {
try {
const countResult = db.$client.prepare('SELECT COUNT(*) as count FROM users').get();
const count = (countResult as any)?.count || 0;
res.json({count});
} catch (err) {
logger.error('Failed to count users', err);
res.status(500).json({error: 'Failed to count users'});
}
});
// Route: DB health check (actually queries DB)
// GET /users/db-health
router.get('/db-health', async (req, res) => {
try {
db.$client.prepare('SELECT 1').get();
res.json({status: 'ok'});
} catch (err) {
logger.error('DB health check failed', err);
res.status(500).json({error: 'Database not accessible'});
}
});
// Route: Get registration allowed status
// GET /users/registration-allowed
router.get('/registration-allowed', async (req, res) => {
try {
const row = db.$client.prepare("SELECT value FROM settings WHERE key = 'allow_registration'").get();
res.json({allowed: row ? (row as any).value === 'true' : true});
} catch (err) {
logger.error('Failed to get registration allowed', err);
res.status(500).json({error: 'Failed to get registration allowed'});
}
});
// Route: Set registration allowed status (admin only)
// PATCH /users/registration-allowed
router.patch('/registration-allowed', authenticateJWT, async (req, res) => {
const userId = (req as any).userId;
try {
const user = await db.select().from(users).where(eq(users.id, userId));
if (!user || user.length === 0 || !user[0].is_admin) {
return res.status(403).json({error: 'Not authorized'});
}
const {allowed} = req.body;
if (typeof allowed !== 'boolean') {
return res.status(400).json({error: 'Invalid value for allowed'});
}
db.$client.prepare("UPDATE settings SET value = ? WHERE key = 'allow_registration'").run(allowed ? 'true' : 'false');
res.json({allowed});
} catch (err) {
logger.error('Failed to set registration allowed', err);
res.status(500).json({error: 'Failed to set registration allowed'});
}
});
export default router;

363
src/backend/ssh/ssh.ts Normal file
View File

@@ -0,0 +1,363 @@
import {WebSocketServer, WebSocket, type RawData} from 'ws';
import {Client, type ClientChannel, type PseudoTtyOptions} from 'ssh2';
import chalk from 'chalk';
const wss = new WebSocketServer({port: 8082});
const sshIconSymbol = '🖥️';
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')(`[${sshIconSymbol}]`)} ${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));
}
}
};
wss.on('connection', (ws: WebSocket) => {
let sshConn: Client | null = null;
let sshStream: ClientChannel | null = null;
let pingInterval: NodeJS.Timeout | null = null;
ws.on('close', () => {
cleanupSSH();
});
ws.on('message', (msg: RawData) => {
let parsed: any;
try {
parsed = JSON.parse(msg.toString());
} catch (e) {
logger.error('Invalid JSON received: ' + msg.toString());
ws.send(JSON.stringify({type: 'error', message: 'Invalid JSON'}));
return;
}
const {type, data} = parsed;
switch (type) {
case 'connectToHost':
handleConnectToHost(data);
break;
case 'resize':
handleResize(data);
break;
case 'disconnect':
cleanupSSH();
break;
case 'input':
if (sshStream) {
if (data === '\t') {
sshStream.write(data);
} else if (data.startsWith('\x1b')) {
sshStream.write(data);
} else {
sshStream.write(Buffer.from(data, 'utf8'));
}
}
break;
case 'ping':
ws.send(JSON.stringify({type: 'pong'}));
break;
default:
logger.warn('Unknown message type: ' + type);
}
});
function handleConnectToHost(data: {
cols: number;
rows: number;
hostConfig: {
ip: string;
port: number;
username: string;
password?: string;
key?: string;
keyPassword?: string;
keyType?: string;
authType?: string;
};
}) {
const {cols, rows, hostConfig} = data;
const {ip, port, username, password, key, keyPassword, keyType, authType} = hostConfig;
if (!username || typeof username !== 'string' || username.trim() === '') {
logger.error('Invalid username provided');
ws.send(JSON.stringify({type: 'error', message: 'Invalid username provided'}));
return;
}
if (!ip || typeof ip !== 'string' || ip.trim() === '') {
logger.error('Invalid IP provided');
ws.send(JSON.stringify({type: 'error', message: 'Invalid IP provided'}));
return;
}
if (!port || typeof port !== 'number' || port <= 0) {
logger.error('Invalid port provided');
ws.send(JSON.stringify({type: 'error', message: 'Invalid port provided'}));
return;
}
sshConn = new Client();
const connectionTimeout = setTimeout(() => {
if (sshConn) {
logger.error('SSH connection timeout');
ws.send(JSON.stringify({type: 'error', message: 'SSH connection timeout'}));
cleanupSSH(connectionTimeout);
}
}, 15000);
sshConn.on('ready', () => {
clearTimeout(connectionTimeout);
const pseudoTtyOpts: PseudoTtyOptions = {
term: 'xterm-256color',
cols,
rows,
modes: {
ECHO: 1,
ECHOCTL: 0,
ICANON: 1,
ISIG: 1,
ICRNL: 1,
IXON: 1,
IXOFF: 0,
ISTRIP: 0,
OPOST: 1,
ONLCR: 1,
OCRNL: 0,
ONOCR: 0,
ONLRET: 0,
CS7: 0,
CS8: 1,
PARENB: 0,
PARODD: 0,
TTY_OP_ISPEED: 38400,
TTY_OP_OSPEED: 38400,
}
};
sshConn!.shell(pseudoTtyOpts, (err, stream) => {
if (err) {
logger.error('Shell error: ' + err.message);
ws.send(JSON.stringify({type: 'error', message: 'Shell error: ' + err.message}));
return;
}
sshStream = stream;
stream.on('data', (chunk: Buffer) => {
let data: string;
try {
data = chunk.toString('utf8');
} catch (e) {
data = chunk.toString('binary');
}
ws.send(JSON.stringify({type: 'data', data}));
});
stream.on('close', () => {
cleanupSSH(connectionTimeout);
});
stream.on('error', (err: Error) => {
logger.error('SSH stream error: ' + err.message);
const isConnectionError = err.message.includes('ECONNRESET') ||
err.message.includes('EPIPE') ||
err.message.includes('ENOTCONN') ||
err.message.includes('ETIMEDOUT');
if (isConnectionError) {
ws.send(JSON.stringify({type: 'disconnected', message: 'Connection lost'}));
} else {
ws.send(JSON.stringify({type: 'error', message: 'SSH stream error: ' + err.message}));
}
});
setupPingInterval();
ws.send(JSON.stringify({type: 'connected', message: 'SSH connected'}));
});
});
sshConn.on('error', (err: Error) => {
clearTimeout(connectionTimeout);
logger.error('SSH connection error: ' + err.message);
let errorMessage = 'SSH error: ' + err.message;
if (err.message.includes('No matching key exchange algorithm')) {
errorMessage = 'SSH error: No compatible key exchange algorithm found. This may be due to an older SSH server or network device.';
} else if (err.message.includes('No matching cipher')) {
errorMessage = 'SSH error: No compatible cipher found. This may be due to an older SSH server or network device.';
} else if (err.message.includes('No matching MAC')) {
errorMessage = 'SSH error: No compatible MAC algorithm found. This may be due to an older SSH server or network device.';
} else if (err.message.includes('ENOTFOUND') || err.message.includes('ENOENT')) {
errorMessage = 'SSH error: Could not resolve hostname or connect to server.';
} else if (err.message.includes('ECONNREFUSED')) {
errorMessage = 'SSH error: Connection refused. The server may not be running or the port may be incorrect.';
} else if (err.message.includes('ETIMEDOUT')) {
errorMessage = 'SSH error: Connection timed out. Check your network connection and server availability.';
} else if (err.message.includes('ECONNRESET') || err.message.includes('EPIPE')) {
errorMessage = 'SSH error: Connection was reset. This may be due to network issues or server timeout.';
} else if (err.message.includes('authentication failed') || err.message.includes('Permission denied')) {
errorMessage = 'SSH error: Authentication failed. Please check your username and password/key.';
}
ws.send(JSON.stringify({type: 'error', message: errorMessage}));
cleanupSSH(connectionTimeout);
});
sshConn.on('close', () => {
clearTimeout(connectionTimeout);
cleanupSSH(connectionTimeout);
});
const connectConfig: any = {
host: ip,
port,
username,
keepaliveInterval: 30000,
keepaliveCountMax: 3,
readyTimeout: 10000,
tcpKeepAlive: true,
tcpKeepAliveInitialDelay: 30000,
env: {
TERM: 'xterm-256color',
LANG: 'en_US.UTF-8',
LC_ALL: 'en_US.UTF-8',
LC_CTYPE: 'en_US.UTF-8',
LC_MESSAGES: 'en_US.UTF-8',
LC_MONETARY: 'en_US.UTF-8',
LC_NUMERIC: 'en_US.UTF-8',
LC_TIME: 'en_US.UTF-8',
LC_COLLATE: 'en_US.UTF-8',
COLORTERM: 'truecolor',
},
algorithms: {
kex: [
'diffie-hellman-group14-sha256',
'diffie-hellman-group14-sha1',
'diffie-hellman-group1-sha1',
'diffie-hellman-group-exchange-sha256',
'diffie-hellman-group-exchange-sha1',
'ecdh-sha2-nistp256',
'ecdh-sha2-nistp384',
'ecdh-sha2-nistp521'
],
cipher: [
'aes128-ctr',
'aes192-ctr',
'aes256-ctr',
'aes128-gcm@openssh.com',
'aes256-gcm@openssh.com',
'aes128-cbc',
'aes192-cbc',
'aes256-cbc',
'3des-cbc'
],
hmac: [
'hmac-sha2-256',
'hmac-sha2-512',
'hmac-sha1',
'hmac-md5'
],
compress: [
'none',
'zlib@openssh.com',
'zlib'
]
}
};
if (authType === 'key' && key) {
connectConfig.privateKey = key;
if (keyPassword) {
connectConfig.passphrase = keyPassword;
}
if (keyType && keyType !== 'auto') {
connectConfig.privateKeyType = keyType;
}
} else if (authType === 'key') {
logger.error('SSH key authentication requested but no key provided');
ws.send(JSON.stringify({type: 'error', message: 'SSH key authentication requested but no key provided'}));
return;
} else {
connectConfig.password = password;
}
sshConn.connect(connectConfig);
}
function handleResize(data: { cols: number; rows: number }) {
if (sshStream && sshStream.setWindow) {
sshStream.setWindow(data.rows, data.cols, data.rows, data.cols);
ws.send(JSON.stringify({type: 'resized', cols: data.cols, rows: data.rows}));
}
}
function cleanupSSH(timeoutId?: NodeJS.Timeout) {
if (timeoutId) {
clearTimeout(timeoutId);
}
if (pingInterval) {
clearInterval(pingInterval);
pingInterval = null;
}
if (sshStream) {
try {
sshStream.end();
} catch (e: any) {
logger.error('Error closing stream: ' + e.message);
}
sshStream = null;
}
if (sshConn) {
try {
sshConn.end();
} catch (e: any) {
logger.error('Error closing connection: ' + e.message);
}
sshConn = null;
}
}
function setupPingInterval() {
pingInterval = setInterval(() => {
if (sshConn && sshStream) {
try {
sshStream.write('\x00');
} catch (e: any) {
logger.error('SSH keepalive failed: ' + e.message);
cleanupSSH();
}
}
}, 60000);
}
});

File diff suppressed because it is too large Load Diff

55
src/backend/starter.ts Normal file
View File

@@ -0,0 +1,55 @@
// npx tsc -p tsconfig.node.json
// node ./dist/backend/starter.js
import './database/database.js'
import './ssh/ssh.js';
import './ssh_tunnel/ssh_tunnel.js';
import './config_editor/config_editor.js';
import chalk from 'chalk';
const fixedIconSymbol = '🚀';
const getTimeStamp = (): string => {
return chalk.gray(`[${new Date().toLocaleTimeString()}]`);
};
const formatMessage = (level: string, colorFn: chalk.Chalk, message: string): string => {
return `${getTimeStamp()} ${colorFn(`[${level.toUpperCase()}]`)} ${chalk.hex('#1e3a8a')(`[${fixedIconSymbol}]`)} ${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));
}
}
};
(async () => {
try {
logger.info("Starting all backend servers...");
logger.success("All servers started successfully");
process.on('SIGINT', () => {
logger.info("Shutting down servers...");
process.exit(0);
});
} catch (error) {
logger.error("Failed to start servers:", error);
process.exit(1);
}
})();