v1.6.0 #221

Merged
LukeGus merged 74 commits from dev-1.6.0 into main 2025-09-12 19:42:00 +00:00
44 changed files with 2341 additions and 3387 deletions
Showing only changes of commit aa6947ad58 - Show all commits

View File

@@ -310,7 +310,9 @@
"allowNewAccountRegistration": "Allow new account registration",
"missingRequiredFields": "Missing required fields: {{fields}}",
"oidcConfigurationUpdated": "OIDC configuration updated successfully!",
"oidcConfigurationDisabled": "OIDC configuration disabled successfully!",
"failedToUpdateOidcConfig": "Failed to update OIDC configuration",
"failedToDisableOidcConfig": "Failed to disable OIDC configuration",
"enterUsernameToMakeAdmin": "Enter username to make admin",
"userIsNowAdmin": "User {{username}} is now an admin",
"failedToMakeUserAdmin": "Failed to make user admin",

View File

@@ -309,7 +309,9 @@
"allowNewAccountRegistration": "允许新账户注册",
"missingRequiredFields": "缺少必填字段:{{fields}}",
"oidcConfigurationUpdated": "OIDC 配置更新成功!",
"oidcConfigurationDisabled": "OIDC 配置禁用成功!",
"failedToUpdateOidcConfig": "更新 OIDC 配置失败",
"failedToDisableOidcConfig": "禁用 OIDC 配置失败",
"enterUsernameToMakeAdmin": "输入用户名以设为管理员",
"userIsNowAdmin": "用户 {{username}} 现在是管理员",
"failedToMakeUserAdmin": "设为管理员失败",

View File

@@ -4,12 +4,12 @@ import userRoutes from './routes/users.js';
import sshRoutes from './routes/ssh.js';
import alertRoutes from './routes/alerts.js';
import credentialsRoutes from './routes/credentials.js';
import chalk from 'chalk';
import cors from 'cors';
import fetch from 'node-fetch';
import fs from 'fs';
import path from 'path';
import 'dotenv/config';
import { databaseLogger, apiLogger } from '../utils/logger.js';
const app = express();
app.use(cors({
@@ -18,32 +18,6 @@ app.use(cors({
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;
@@ -61,19 +35,23 @@ class GitHubCache {
timestamp: now,
expiresAt: now + this.CACHE_DURATION
});
databaseLogger.debug(`Cache entry set`, { operation: 'cache_set', key, expiresIn: this.CACHE_DURATION });
}
get(key: string): any | null {
const entry = this.cache.get(key);
if (!entry) {
databaseLogger.debug(`Cache miss`, { operation: 'cache_get', key });
return null;
}
if (Date.now() > entry.expiresAt) {
this.cache.delete(key);
databaseLogger.debug(`Cache entry expired`, { operation: 'cache_get', key, expired: true });
return null;
}
databaseLogger.debug(`Cache hit`, { operation: 'cache_get', key, age: Date.now() - entry.timestamp });
return entry.data;
}
}
@@ -105,6 +83,7 @@ interface GitHubRelease {
async function fetchGitHubAPI(endpoint: string, cacheKey: string): Promise<any> {
const cachedData = githubCache.get(cacheKey);
if (cachedData) {
databaseLogger.debug(`Using cached GitHub API data`, { operation: 'github_api', endpoint, cached: true });
return {
data: cachedData,
cached: true,
@@ -113,6 +92,7 @@ async function fetchGitHubAPI(endpoint: string, cacheKey: string): Promise<any>
}
try {
databaseLogger.info(`Fetching from GitHub API`, { operation: 'github_api', endpoint });
const response = await fetch(`${GITHUB_API_BASE}${endpoint}`, {
headers: {
'Accept': 'application/vnd.github+json',
@@ -126,15 +106,15 @@ async function fetchGitHubAPI(endpoint: string, cacheKey: string): Promise<any>
}
const data = await response.json();
githubCache.set(cacheKey, data);
databaseLogger.success(`GitHub API data fetched successfully`, { operation: 'github_api', endpoint, dataSize: JSON.stringify(data).length });
return {
data: data,
cached: false
};
} catch (error) {
logger.error(`Failed to fetch from GitHub API: ${endpoint}`, error);
databaseLogger.error(`Failed to fetch from GitHub API`, error, { operation: 'github_api', endpoint });
throw error;
}
}
@@ -142,10 +122,12 @@ async function fetchGitHubAPI(endpoint: string, cacheKey: string): Promise<any>
app.use(bodyParser.json());
app.get('/health', (req, res) => {
apiLogger.info(`Health check requested`, { operation: 'health_check' });
res.json({status: 'ok'});
});
app.get('/version', async (req, res) => {
apiLogger.info(`Version check requested`, { operation: 'version_check' });
let localVersion = process.env.VERSION;
if (!localVersion) {
@@ -153,13 +135,14 @@ app.get('/version', async (req, res) => {
const packagePath = path.resolve(process.cwd(), 'package.json');
const packageJson = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
localVersion = packageJson.version;
databaseLogger.debug(`Version read from package.json`, { operation: 'version_check', localVersion });
} catch (error) {
logger.error('Failed to read version from package.json:', error);
databaseLogger.error('Failed to read version from package.json', error, { operation: 'version_check' });
}
}
if (!localVersion) {
logger.error('No version information available');
databaseLogger.error('No version information available', undefined, { operation: 'version_check' });
return res.status(404).send('Local Version Not Set');
}
@@ -175,11 +158,21 @@ app.get('/version', async (req, res) => {
const remoteVersion = remoteVersionMatch ? remoteVersionMatch[1] : null;
if (!remoteVersion) {
databaseLogger.warn('Remote version not found in GitHub response', { operation: 'version_check', rawTag });
return res.status(401).send('Remote Version Not Found');
}
const isUpToDate = localVersion === remoteVersion;
databaseLogger.info(`Version comparison completed`, {
operation: 'version_check',
localVersion,
remoteVersion,
isUpToDate,
cached: releaseData.cached
});
const response = {
status: localVersion === remoteVersion ? 'up_to_date' : 'requires_update',
status: isUpToDate ? 'up_to_date' : 'requires_update',
localVersion: localVersion,
version: remoteVersion,
latest_release: {
@@ -194,7 +187,7 @@ app.get('/version', async (req, res) => {
res.json(response);
} catch (err) {
logger.error('Version check failed', err);
databaseLogger.error('Version check failed', err, { operation: 'version_check' });
res.status(500).send('Fetch Error');
}
});
@@ -205,6 +198,8 @@ app.get('/releases/rss', async (req, res) => {
const per_page = Math.min(parseInt(req.query.per_page as string) || 20, 100);
const cacheKey = `releases_rss_${page}_${per_page}`;
apiLogger.info(`RSS releases requested`, { operation: 'rss_releases', page, per_page });
const releasesData = await fetchGitHubAPI(
`/repos/${REPO_OWNER}/${REPO_NAME}/releases?page=${page}&per_page=${per_page}`,
cacheKey
@@ -240,9 +235,17 @@ app.get('/releases/rss', async (req, res) => {
cache_age: releasesData.cache_age
};
databaseLogger.success(`RSS releases generated successfully`, {
operation: 'rss_releases',
itemCount: rssItems.length,
page,
per_page,
cached: releasesData.cached
});
res.json(response);
} catch (error) {
logger.error('Failed to generate RSS format', error)
databaseLogger.error('Failed to generate RSS format', error, { operation: 'rss_releases' });
res.status(500).json({
error: 'Failed to generate RSS format',
details: error instanceof Error ? error.message : 'Unknown error'
@@ -257,10 +260,20 @@ app.use('/alerts', alertRoutes);
app.use('/credentials', credentialsRoutes);
app.use((err: unknown, req: express.Request, res: express.Response, next: express.NextFunction) => {
logger.error('Unhandled error:', err);
apiLogger.error('Unhandled error in request', err, {
operation: 'error_handler',
method: req.method,
url: req.url,
userAgent: req.get('User-Agent')
});
res.status(500).json({error: 'Internal Server Error'});
});
const PORT = 8081;
app.listen(PORT, () => {
databaseLogger.success(`Database API server started on port ${PORT}`, {
operation: 'server_start',
port: PORT,
routes: ['/users', '/ssh', '/alerts', '/credentials', '/health', '/version', '/releases/rss']
});
});

View File

@@ -1,381 +1,140 @@
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';
import { MigrationManager } from '../migrations/migrator.js';
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));
}
}
};
import { databaseLogger } from '../../utils/logger.js';
const dataDir = process.env.DATA_DIR || './db/data';
const dbDir = path.resolve(dataDir);
if (!fs.existsSync(dbDir)) {
databaseLogger.info(`Creating database directory`, { operation: 'db_init', path: dbDir });
fs.mkdirSync(dbDir, {recursive: true});
}
const dbPath = path.join(dataDir, 'db.sqlite');
databaseLogger.info(`Initializing SQLite database`, { operation: 'db_init', path: dbPath });
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 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 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_file_manager
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 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_file_manager 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 file_manager_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 file_manager_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 file_manager_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 file_manager_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 file_manager_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
)
);
CREATE TABLE IF NOT EXISTS file_manager_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)
);
CREATE TABLE IF NOT EXISTS dismissed_alerts
(
id
INTEGER
PRIMARY
KEY
AUTOINCREMENT,
user_id
TEXT
NOT
NULL,
alert_id
TEXT
NOT
NULL,
dismissed_at
TEXT
NOT
NULL
DEFAULT
CURRENT_TIMESTAMP,
FOREIGN
KEY
(
user_id
) REFERENCES users
(
id
)
);
CREATE TABLE IF NOT EXISTS dismissed_alerts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
alert_id TEXT NOT NULL,
dismissed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users (id)
);
CREATE TABLE IF NOT EXISTS ssh_credentials (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
name TEXT NOT NULL,
description TEXT,
folder TEXT,
tags TEXT,
auth_type TEXT NOT NULL,
username TEXT NOT NULL,
password TEXT,
key TEXT,
key_password TEXT,
key_type TEXT,
usage_count INTEGER NOT NULL DEFAULT 0,
last_used 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 ssh_credential_usage (
id INTEGER PRIMARY KEY AUTOINCREMENT,
credential_id INTEGER NOT NULL,
host_id INTEGER NOT NULL,
user_id TEXT NOT NULL,
used_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (credential_id) REFERENCES ssh_credentials (id),
FOREIGN KEY (host_id) REFERENCES ssh_data (id),
FOREIGN KEY (user_id) REFERENCES users (id)
);
`);
const addColumnIfNotExists = (table: string, column: string, definition: string) => {
@@ -384,16 +143,18 @@ const addColumnIfNotExists = (table: string, column: string, definition: string)
FROM ${table} LIMIT 1`).get();
} catch (e) {
try {
databaseLogger.debug(`Adding column ${column} to ${table}`, { operation: 'schema_migration', table, column });
sqlite.exec(`ALTER TABLE ${table}
ADD COLUMN ${column} ${definition};`);
databaseLogger.success(`Column ${column} added to ${table}`, { operation: 'schema_migration', table, column });
} catch (alterError) {
logger.warn(`Failed to add column ${column} to ${table}: ${alterError}`);
databaseLogger.warn(`Failed to add column ${column} to ${table}`, { operation: 'schema_migration', table, column, error: alterError });
}
}
};
const migrateSchema = () => {
logger.info('Checking for schema updates...');
databaseLogger.info('Checking for schema updates...', { operation: 'schema_migration' });
addColumnIfNotExists('users', 'is_admin', 'INTEGER NOT NULL DEFAULT 0');
@@ -405,8 +166,11 @@ const migrateSchema = () => {
addColumnIfNotExists('users', 'authorization_url', 'TEXT');
addColumnIfNotExists('users', 'token_url', 'TEXT');
try {
databaseLogger.debug('Attempting to drop redirect_uri column', { operation: 'schema_migration', table: 'users' });
sqlite.prepare(`ALTER TABLE users DROP COLUMN redirect_uri`).run();
databaseLogger.success('redirect_uri column dropped', { operation: 'schema_migration', table: 'users' });
} catch (e) {
databaseLogger.debug('redirect_uri column does not exist or could not be dropped', { operation: 'schema_migration', table: 'users' });
}
addColumnIfNotExists('users', 'identifier_path', 'TEXT');
@@ -434,37 +198,36 @@ const migrateSchema = () => {
addColumnIfNotExists('ssh_data', 'created_at', 'TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP');
addColumnIfNotExists('ssh_data', 'updated_at', 'TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP');
// Add credential_id column for SSH credentials management
addColumnIfNotExists('ssh_data', 'credential_id', 'INTEGER REFERENCES ssh_credentials(id)');
addColumnIfNotExists('file_manager_recent', 'host_id', 'INTEGER NOT NULL');
addColumnIfNotExists('file_manager_pinned', 'host_id', 'INTEGER NOT NULL');
addColumnIfNotExists('file_manager_shortcuts', 'host_id', 'INTEGER NOT NULL');
logger.success('Schema migration completed');
databaseLogger.success('Schema migration completed', { operation: 'schema_migration' });
};
const initializeDatabase = async () => {
migrateSchema();
// Run new migration system
const migrationManager = new MigrationManager(sqlite);
await migrationManager.runMigrations();
try {
const row = sqlite.prepare("SELECT value FROM settings WHERE key = 'allow_registration'").get();
if (!row) {
databaseLogger.info('Initializing default settings', { operation: 'db_init', setting: 'allow_registration' });
sqlite.prepare("INSERT INTO settings (key, value) VALUES ('allow_registration', 'true')").run();
databaseLogger.success('Default settings initialized', { operation: 'db_init' });
} else {
databaseLogger.debug('Default settings already exist', { operation: 'db_init' });
}
} catch (e) {
logger.warn('Could not initialize default settings');
databaseLogger.warn('Could not initialize default settings', { operation: 'db_init', error: e });
}
};
// Initialize database (async)
initializeDatabase().catch(error => {
logger.error('Failed to initialize database:', error);
databaseLogger.error('Failed to initialize database', error, { operation: 'db_init' });
process.exit(1);
});
databaseLogger.success('Database connection established', { operation: 'db_init', path: dbPath });
export const db = drizzle(sqlite, {schema});

View File

@@ -1,5 +1,6 @@
import {sqliteTable, text, integer} from 'drizzle-orm/sqlite-core';
import {sql} from 'drizzle-orm';
import { databaseLogger } from '../../utils/logger.js';
export const users = sqliteTable('users', {
id: text('id').primaryKey(),
@@ -97,12 +98,12 @@ export const sshCredentials = sqliteTable('ssh_credentials', {
description: text('description'),
folder: text('folder'),
tags: text('tags'),
authType: text('auth_type').notNull(), // 'password' | 'key'
authType: text('auth_type').notNull(),
username: text('username').notNull(),
encryptedPassword: text('encrypted_password'), // AES encrypted
encryptedKey: text('encrypted_key', {length: 16384}), // AES encrypted SSH key
encryptedKeyPassword: text('encrypted_key_password'), // AES encrypted key passphrase
keyType: text('key_type'), // 'rsa' | 'ecdsa' | 'ed25519'
password: text('password'),
key: text('key', {length: 16384}),
keyPassword: text('key_password'),
keyType: text('key_type'),
usageCount: integer('usage_count').notNull().default(0),
lastUsed: text('last_used'),
createdAt: text('created_at').notNull().default(sql`CURRENT_TIMESTAMP`),

View File

@@ -1,76 +0,0 @@
import type { Database } from 'better-sqlite3';
export const up = (db: Database) => {
// Create SSH credentials table
db.exec(`
CREATE TABLE IF NOT EXISTS ssh_credentials (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL REFERENCES users(id),
name TEXT NOT NULL,
description TEXT,
folder TEXT,
tags TEXT,
auth_type TEXT NOT NULL,
username TEXT NOT NULL,
encrypted_password TEXT,
encrypted_key TEXT,
encrypted_key_password TEXT,
key_type TEXT,
usage_count INTEGER NOT NULL DEFAULT 0,
last_used TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
)
`);
// Create credential usage tracking table
db.exec(`
CREATE TABLE IF NOT EXISTS ssh_credential_usage (
id INTEGER PRIMARY KEY AUTOINCREMENT,
credential_id INTEGER NOT NULL REFERENCES ssh_credentials(id),
host_id INTEGER NOT NULL REFERENCES ssh_data(id),
user_id TEXT NOT NULL REFERENCES users(id),
used_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
)
`);
// Add credential_id column to ssh_data table if it doesn't exist
const columns = db.prepare(`PRAGMA table_info(ssh_data)`).all();
const hasCredentialId = columns.some((col: any) => col.name === 'credential_id');
if (!hasCredentialId) {
db.exec(`
ALTER TABLE ssh_data
ADD COLUMN credential_id INTEGER REFERENCES ssh_credentials(id)
`);
}
// Create indexes for better performance
db.exec(`CREATE INDEX IF NOT EXISTS idx_ssh_credentials_user_id ON ssh_credentials(user_id)`);
db.exec(`CREATE INDEX IF NOT EXISTS idx_ssh_credentials_folder ON ssh_credentials(folder)`);
db.exec(`CREATE INDEX IF NOT EXISTS idx_ssh_credential_usage_credential_id ON ssh_credential_usage(credential_id)`);
db.exec(`CREATE INDEX IF NOT EXISTS idx_ssh_credential_usage_host_id ON ssh_credential_usage(host_id)`);
db.exec(`CREATE INDEX IF NOT EXISTS idx_ssh_data_credential_id ON ssh_data(credential_id)`);
console.log('✅ Added SSH credentials management tables');
};
export const down = (db: Database) => {
// Remove credential_id column from ssh_data table
db.exec(`
CREATE TABLE ssh_data_backup AS SELECT
id, user_id, name, ip, port, username, folder, tags, pin, auth_type,
password, key, key_password, key_type, enable_terminal, enable_tunnel,
tunnel_connections, enable_file_manager, default_path, created_at, updated_at
FROM ssh_data
`);
db.exec(`DROP TABLE ssh_data`);
db.exec(`ALTER TABLE ssh_data_backup RENAME TO ssh_data`);
// Drop credential tables
db.exec(`DROP TABLE IF EXISTS ssh_credential_usage`);
db.exec(`DROP TABLE IF EXISTS ssh_credentials`);
console.log('✅ Removed SSH credentials management tables');
};

View File

@@ -1,261 +0,0 @@
import type { Database } from 'better-sqlite3';
import chalk from 'chalk';
import { readFileSync, readdirSync } from 'fs';
import { join } from 'path';
import { fileURLToPath } from 'url';
import { dirname } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const logger = {
info: (msg: string): void => {
const timestamp = chalk.gray(`[${new Date().toLocaleTimeString()}]`);
console.log(`${timestamp} ${chalk.cyan('[MIGRATION]')} ${msg}`);
},
warn: (msg: string): void => {
const timestamp = chalk.gray(`[${new Date().toLocaleTimeString()}]`);
console.warn(`${timestamp} ${chalk.yellow('[MIGRATION]')} ${msg}`);
},
error: (msg: string, err?: unknown): void => {
const timestamp = chalk.gray(`[${new Date().toLocaleTimeString()}]`);
console.error(`${timestamp} ${chalk.redBright('[MIGRATION]')} ${msg}`);
if (err) console.error(err);
},
success: (msg: string): void => {
const timestamp = chalk.gray(`[${new Date().toLocaleTimeString()}]`);
console.log(`${timestamp} ${chalk.greenBright('[MIGRATION]')} ${msg}`);
}
};
interface Migration {
id: string;
name: string;
up: (db: Database) => void;
down: (db: Database) => void;
}
class MigrationManager {
private db: Database;
private migrationsPath: string;
constructor(db: Database) {
this.db = db;
this.migrationsPath = __dirname;
this.ensureMigrationsTable();
}
private ensureMigrationsTable() {
this.db.exec(`
CREATE TABLE IF NOT EXISTS migrations (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
applied_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
)
`);
}
private getAppliedMigrations(): Set<string> {
const applied = this.db.prepare('SELECT id FROM migrations').all() as { id: string }[];
return new Set(applied.map(m => m.id));
}
private async loadMigration(filename: string): Promise<Migration | null> {
try {
const migrationPath = join(this.migrationsPath, filename);
// Convert to file:// URL for Windows compatibility
const migrationUrl = process.platform === 'win32'
? `file:///${migrationPath.replace(/\\/g, '/')}`
: migrationPath;
const migration = await import(migrationUrl);
// Extract migration ID and name from filename
const matches = filename.match(/^(\d+)-(.+)\.(ts|js)$/);
if (!matches) {
logger.warn(`Skipping invalid migration filename: ${filename}`);
return null;
}
const [, id, name] = matches;
return {
id: id.padStart(3, '0'),
name: name.replace(/-/g, ' '),
up: migration.up,
down: migration.down
};
} catch (error) {
logger.error(`Failed to load migration ${filename}:`, error);
return null;
}
}
private getMigrationFiles(): string[] {
try {
return readdirSync(this.migrationsPath)
.filter(file => (file.endsWith('.ts') || file.endsWith('.js')) && !file.includes('migrator'))
.sort();
} catch (error) {
logger.error('Failed to read migrations directory:', error);
return [];
}
}
async runMigrations(): Promise<void> {
logger.info('Starting database migrations...');
const migrationFiles = this.getMigrationFiles();
if (migrationFiles.length === 0) {
logger.info('No migrations found');
return;
}
const appliedMigrations = this.getAppliedMigrations();
const migrations: Migration[] = [];
// Load all migrations
for (const filename of migrationFiles) {
const migration = await this.loadMigration(filename);
if (migration) {
migrations.push(migration);
}
}
// Filter out already applied migrations
const pendingMigrations = migrations.filter(m => !appliedMigrations.has(m.id));
if (pendingMigrations.length === 0) {
logger.info('All migrations are already applied');
return;
}
logger.info(`Found ${pendingMigrations.length} pending migration(s)`);
// Run pending migrations in transaction
const transaction = this.db.transaction(() => {
for (const migration of pendingMigrations) {
logger.info(`Applying migration ${migration.id}: ${migration.name}`);
try {
migration.up(this.db);
// Record the migration
this.db.prepare(`
INSERT INTO migrations (id, name)
VALUES (?, ?)
`).run(migration.id, migration.name);
logger.success(`Applied migration ${migration.id}: ${migration.name}`);
} catch (error) {
logger.error(`Failed to apply migration ${migration.id}:`, error);
throw error;
}
}
});
try {
transaction();
logger.success(`Successfully applied ${pendingMigrations.length} migration(s)`);
} catch (error) {
logger.error('Migration transaction failed, rolling back:', error);
throw error;
}
}
async rollbackMigration(targetId?: string): Promise<void> {
logger.warn('Starting migration rollback...');
const appliedMigrations = this.db.prepare(`
SELECT id, name FROM migrations
ORDER BY id DESC
`).all() as { id: string; name: string }[];
if (appliedMigrations.length === 0) {
logger.info('No migrations to rollback');
return;
}
const migrationsToRollback = targetId
? appliedMigrations.filter(m => m.id >= targetId)
: [appliedMigrations[0]]; // Only rollback the latest
const migrationFiles = this.getMigrationFiles();
const migrations: Migration[] = [];
// Load migrations that need to be rolled back
for (const filename of migrationFiles) {
const migration = await this.loadMigration(filename);
if (migration && migrationsToRollback.some(m => m.id === migration.id)) {
migrations.push(migration);
}
}
// Sort in reverse order for rollback
migrations.sort((a, b) => b.id.localeCompare(a.id));
const transaction = this.db.transaction(() => {
for (const migration of migrations) {
logger.info(`Rolling back migration ${migration.id}: ${migration.name}`);
try {
migration.down(this.db);
// Remove the migration record
this.db.prepare(`DELETE FROM migrations WHERE id = ?`).run(migration.id);
logger.success(`Rolled back migration ${migration.id}: ${migration.name}`);
} catch (error) {
logger.error(`Failed to rollback migration ${migration.id}:`, error);
throw error;
}
}
});
try {
transaction();
logger.success(`Successfully rolled back ${migrations.length} migration(s)`);
} catch (error) {
logger.error('Rollback transaction failed:', error);
throw error;
}
}
getMigrationStatus(): { id: string; name: string; applied: boolean }[] {
const migrationFiles = this.getMigrationFiles();
const appliedMigrations = this.getAppliedMigrations();
return migrationFiles.map(filename => {
const matches = filename.match(/^(\d+)-(.+)\.(ts|js)$/);
if (!matches) return null;
const [, id, name] = matches;
const migrationId = id.padStart(3, '0');
return {
id: migrationId,
name: name.replace(/-/g, ' '),
applied: appliedMigrations.has(migrationId)
};
}).filter(Boolean) as { id: string; name: string; applied: boolean }[];
}
printStatus(): void {
const status = this.getMigrationStatus();
logger.info('Migration Status:');
console.log(chalk.gray('─'.repeat(60)));
status.forEach(migration => {
const statusIcon = migration.applied ? chalk.green('✓') : chalk.yellow('○');
const statusText = migration.applied ? chalk.green('Applied') : chalk.yellow('Pending');
console.log(`${statusIcon} ${migration.id} - ${migration.name} [${statusText}]`);
});
console.log(chalk.gray('─'.repeat(60)));
const appliedCount = status.filter(m => m.applied).length;
console.log(`Total: ${status.length} migrations, ${appliedCount} applied, ${status.length - appliedCount} pending`);
}
}
export { MigrationManager };
export type { Migration };

View File

@@ -2,35 +2,10 @@ import express from 'express';
import {db} from '../db/index.js';
import {dismissedAlerts} from '../db/schema.js';
import {eq, and} from 'drizzle-orm';
import chalk from 'chalk';
import fetch from 'node-fetch';
import type {Request, Response, NextFunction} from 'express';
import { authLogger } from '../../utils/logger.js';
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('#dc2626')(`[${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;
@@ -88,9 +63,11 @@ async function fetchAlertsFromGitHub(): Promise<TermixAlert[]> {
const cacheKey = 'termix_alerts';
const cachedData = alertCache.get(cacheKey);
if (cachedData) {
authLogger.info('Returning cached alerts from GitHub', { operation: 'alerts_fetch', cacheKey, alertCount: cachedData.length });
return cachedData;
}
authLogger.info('Fetching alerts from GitHub', { operation: 'alerts_fetch', url: `${GITHUB_RAW_BASE}/${REPO_OWNER}/${REPO_NAME}/${ALERTS_FILE}` });
try {
const url = `${GITHUB_RAW_BASE}/${REPO_OWNER}/${REPO_NAME}/${ALERTS_FILE}`;
@@ -102,10 +79,12 @@ async function fetchAlertsFromGitHub(): Promise<TermixAlert[]> {
});
if (!response.ok) {
authLogger.warn('GitHub API returned error status', { operation: 'alerts_fetch', status: response.status, statusText: response.statusText });
throw new Error(`GitHub raw content error: ${response.status} ${response.statusText}`);
}
const alerts: TermixAlert[] = await response.json() as TermixAlert[];
authLogger.info('Successfully fetched alerts from GitHub', { operation: 'alerts_fetch', totalAlerts: alerts.length });
const now = new Date();
@@ -115,10 +94,12 @@ async function fetchAlertsFromGitHub(): Promise<TermixAlert[]> {
return isValid;
});
authLogger.info('Filtered alerts by expiry date', { operation: 'alerts_fetch', totalAlerts: alerts.length, validAlerts: validAlerts.length });
alertCache.set(cacheKey, validAlerts);
authLogger.success('Alerts cached successfully', { operation: 'alerts_fetch', alertCount: validAlerts.length });
return validAlerts;
} catch (error) {
logger.error('Failed to fetch alerts from GitHub', error);
authLogger.error('Failed to fetch alerts from GitHub', { operation: 'alerts_fetch', error: error instanceof Error ? error.message : 'Unknown error' });
return [];
}
}
@@ -136,7 +117,7 @@ router.get('/', async (req, res) => {
total_count: alerts.length
});
} catch (error) {
logger.error('Failed to get alerts', error);
authLogger.error('Failed to get alerts', error);
res.status(500).json({error: 'Failed to fetch alerts'});
}
});
@@ -168,7 +149,7 @@ router.get('/user/:userId', async (req, res) => {
dismissed_count: dismissedAlertIds.size
});
} catch (error) {
logger.error('Failed to get user alerts', error);
authLogger.error('Failed to get user alerts', error);
res.status(500).json({error: 'Failed to fetch user alerts'});
}
});
@@ -180,7 +161,7 @@ router.post('/dismiss', async (req, res) => {
const {userId, alertId} = req.body;
if (!userId || !alertId) {
logger.warn('Missing userId or alertId in dismiss request');
authLogger.warn('Missing userId or alertId in dismiss request');
return res.status(400).json({error: 'User ID and Alert ID are required'});
}
@@ -193,7 +174,7 @@ router.post('/dismiss', async (req, res) => {
));
if (existingDismissal.length > 0) {
logger.warn(`Alert ${alertId} already dismissed by user ${userId}`);
authLogger.warn(`Alert ${alertId} already dismissed by user ${userId}`);
return res.status(409).json({error: 'Alert already dismissed'});
}
@@ -202,10 +183,10 @@ router.post('/dismiss', async (req, res) => {
alertId
});
logger.success(`Alert ${alertId} dismissed by user ${userId}. Insert result: ${JSON.stringify(result)}`);
authLogger.success(`Alert ${alertId} dismissed by user ${userId}. Insert result: ${JSON.stringify(result)}`);
res.json({message: 'Alert dismissed successfully'});
} catch (error) {
logger.error('Failed to dismiss alert', error);
authLogger.error('Failed to dismiss alert', error);
res.status(500).json({error: 'Failed to dismiss alert'});
}
});
@@ -233,7 +214,7 @@ router.get('/dismissed/:userId', async (req, res) => {
total_count: dismissedAlertRecords.length
});
} catch (error) {
logger.error('Failed to get dismissed alerts', error);
authLogger.error('Failed to get dismissed alerts', error);
res.status(500).json({error: 'Failed to fetch dismissed alerts'});
}
});
@@ -259,10 +240,10 @@ router.delete('/dismiss', async (req, res) => {
return res.status(404).json({error: 'Dismissed alert not found'});
}
logger.success(`Alert ${alertId} undismissed by user ${userId}`);
authLogger.success(`Alert ${alertId} undismissed by user ${userId}`);
res.json({message: 'Alert undismissed successfully'});
} catch (error) {
logger.error('Failed to undismiss alert', error);
authLogger.error('Failed to undismiss alert', error);
res.status(500).json({error: 'Failed to undismiss alert'});
}
});

View File

@@ -1,29 +1,11 @@
import express from 'express';
import {credentialService} from '../../services/credentials.js';
import {db} from '../db/index.js';
import {sshCredentials, sshCredentialUsage, sshData} from '../db/schema.js';
import {eq, and, desc, sql} from 'drizzle-orm';
import type {Request, Response, NextFunction} from 'express';
import jwt from 'jsonwebtoken';
import chalk from 'chalk';
import { authLogger } from '../../utils/logger.js';
const credIconSymbol = '🔐';
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('#0f766e')(`[${credIconSymbol}]`)} ${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));
}
};
const router = express.Router();
@@ -40,7 +22,7 @@ function isNonEmptyString(val: any): val is string {
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');
authLogger.warn('Missing or invalid Authorization header');
return res.status(401).json({error: 'Missing or invalid Authorization header'});
}
const token = authHeader.split(' ')[1];
@@ -50,7 +32,7 @@ function authenticateJWT(req: Request, res: Response, next: NextFunction) {
(req as any).userId = payload.userId;
next();
} catch (err) {
logger.warn('Invalid or expired token');
authLogger.warn('Invalid or expired token');
return res.status(401).json({error: 'Invalid or expired token'});
}
}
@@ -72,34 +54,56 @@ router.post('/', authenticateJWT, async (req: Request, res: Response) => {
keyType
} = req.body;
authLogger.info('Credential creation request received', { operation: 'credential_create', userId, name, authType, username });
if (!isNonEmptyString(userId) || !isNonEmptyString(name) || !isNonEmptyString(username)) {
logger.warn('Invalid credential creation data');
authLogger.warn('Invalid credential creation data validation failed', { operation: 'credential_create', userId, hasName: !!name, hasUsername: !!username });
return res.status(400).json({error: 'Name and username are required'});
}
if (!['password', 'key'].includes(authType)) {
logger.warn('Invalid auth type');
authLogger.warn('Invalid auth type provided', { operation: 'credential_create', userId, name, authType });
return res.status(400).json({error: 'Auth type must be "password" or "key"'});
}
try {
const credential = await credentialService.createCredential(userId, {
name,
description,
folder,
tags,
authType,
username,
password,
key,
keyPassword,
keyType
});
if (authType === 'password' && !password) {
authLogger.warn('Password required for password authentication', { operation: 'credential_create', userId, name, authType });
return res.status(400).json({error: 'Password is required for password authentication'});
}
if (authType === 'key' && !key) {
authLogger.warn('SSH key required for key authentication', { operation: 'credential_create', userId, name, authType });
return res.status(400).json({error: 'SSH key is required for key authentication'});
}
logger.success(`Created credential: ${name}`);
res.status(201).json(credential);
authLogger.info('Preparing credential data for database insertion', { operation: 'credential_create', userId, name, authType, hasPassword: !!password, hasKey: !!key });
const plainPassword = (authType === 'password' && password) ? password : null;
const plainKey = (authType === 'key' && key) ? key : null;
const plainKeyPassword = (authType === 'key' && keyPassword) ? keyPassword : null;
const credentialData = {
userId,
name: name.trim(),
description: description?.trim() || null,
folder: folder?.trim() || null,
tags: Array.isArray(tags) ? tags.join(',') : (tags || ''),
authType,
username: username.trim(),
password: plainPassword,
key: plainKey,
keyPassword: plainKeyPassword,
keyType: keyType || null,
usageCount: 0,
lastUsed: null,
};
authLogger.info('Inserting credential into database', { operation: 'credential_create', userId, name, authType, username });
const result = await db.insert(sshCredentials).values(credentialData).returning();
const created = result[0];
authLogger.success('Credential created successfully', { operation: 'credential_create', userId, name, credentialId: created.id, authType, username });
res.status(201).json(formatCredentialOutput(created));
} catch (err) {
logger.error('Failed to create credential', err);
authLogger.error('Failed to create credential in database', err, { operation: 'credential_create', userId, name, authType, username });
res.status(500).json({
error: err instanceof Error ? err.message : 'Failed to create credential'
});
@@ -112,15 +116,20 @@ router.get('/', authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as any).userId;
if (!isNonEmptyString(userId)) {
logger.warn('Invalid userId for credential fetch');
authLogger.warn('Invalid userId for credential fetch');
return res.status(400).json({error: 'Invalid userId'});
}
try {
const credentials = await credentialService.getUserCredentials(userId);
res.json(credentials);
const credentials = await db
.select()
.from(sshCredentials)
.where(eq(sshCredentials.userId, userId))
.orderBy(desc(sshCredentials.updatedAt));
res.json(credentials.map(cred => formatCredentialOutput(cred)));
} catch (err) {
logger.error('Failed to fetch credentials', err);
authLogger.error('Failed to fetch credentials', err);
res.status(500).json({error: 'Failed to fetch credentials'});
}
});
@@ -131,40 +140,71 @@ router.get('/folders', authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as any).userId;
if (!isNonEmptyString(userId)) {
logger.warn('Invalid userId for credential folder fetch');
authLogger.warn('Invalid userId for credential folder fetch');
return res.status(400).json({error: 'Invalid userId'});
}
try {
const folders = await credentialService.getCredentialsFolders(userId);
const result = await db
.select({folder: sshCredentials.folder})
.from(sshCredentials)
.where(eq(sshCredentials.userId, userId));
const folderCounts: Record<string, number> = {};
result.forEach(r => {
if (r.folder && r.folder.trim() !== '') {
folderCounts[r.folder] = (folderCounts[r.folder] || 0) + 1;
}
});
const folders = Object.keys(folderCounts).filter(folder => folderCounts[folder] > 0);
res.json(folders);
} catch (err) {
logger.error('Failed to fetch credential folders', err);
authLogger.error('Failed to fetch credential folders', err);
res.status(500).json({error: 'Failed to fetch credential folders'});
}
});
// Get a specific credential by ID (with decrypted secrets)
// Get a specific credential by ID (with plain text secrets)
// GET /credentials/:id
router.get('/:id', authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as any).userId;
const {id} = req.params;
if (!isNonEmptyString(userId) || !id) {
logger.warn('Invalid request for credential fetch');
authLogger.warn('Invalid request for credential fetch');
return res.status(400).json({error: 'Invalid request'});
}
try {
const credential = await credentialService.getCredentialWithSecrets(userId, parseInt(id));
const credentials = await db
.select()
.from(sshCredentials)
.where(and(
eq(sshCredentials.id, parseInt(id)),
eq(sshCredentials.userId, userId)
));
if (!credential) {
if (credentials.length === 0) {
return res.status(404).json({error: 'Credential not found'});
}
res.json(credential);
const credential = credentials[0];
const output = formatCredentialOutput(credential);
if (credential.password) {
(output as any).password = credential.password;
}
if (credential.key) {
(output as any).key = credential.key;
}
if (credential.keyPassword) {
(output as any).keyPassword = credential.keyPassword;
}
res.json(output);
} catch (err) {
logger.error('Failed to fetch credential', err);
authLogger.error('Failed to fetch credential', err);
res.status(500).json({
error: err instanceof Error ? err.message : 'Failed to fetch credential'
});
@@ -179,16 +219,70 @@ router.put('/:id', authenticateJWT, async (req: Request, res: Response) => {
const updateData = req.body;
if (!isNonEmptyString(userId) || !id) {
logger.warn('Invalid request for credential update');
authLogger.warn('Invalid request for credential update');
return res.status(400).json({error: 'Invalid request'});
}
try {
const credential = await credentialService.updateCredential(userId, parseInt(id), updateData);
logger.success(`Updated credential ID ${id}`);
res.json(credential);
const existing = await db
.select()
.from(sshCredentials)
.where(and(
eq(sshCredentials.id, parseInt(id)),
eq(sshCredentials.userId, userId)
));
if (existing.length === 0) {
return res.status(404).json({error: 'Credential not found'});
}
const updateFields: any = {};
if (updateData.name !== undefined) updateFields.name = updateData.name.trim();
if (updateData.description !== undefined) updateFields.description = updateData.description?.trim() || null;
if (updateData.folder !== undefined) updateFields.folder = updateData.folder?.trim() || null;
if (updateData.tags !== undefined) {
updateFields.tags = Array.isArray(updateData.tags) ? updateData.tags.join(',') : (updateData.tags || '');
}
if (updateData.username !== undefined) updateFields.username = updateData.username.trim();
if (updateData.authType !== undefined) updateFields.authType = updateData.authType;
if (updateData.keyType !== undefined) updateFields.keyType = updateData.keyType;
if (updateData.password !== undefined) {
updateFields.password = updateData.password || null;
}
if (updateData.key !== undefined) {
updateFields.key = updateData.key || null;
}
if (updateData.keyPassword !== undefined) {
updateFields.keyPassword = updateData.keyPassword || null;
}
if (Object.keys(updateFields).length === 0) {
const existing = await db
.select()
.from(sshCredentials)
.where(eq(sshCredentials.id, parseInt(id)));
return res.json(formatCredentialOutput(existing[0]));
}
await db
.update(sshCredentials)
.set(updateFields)
.where(and(
eq(sshCredentials.id, parseInt(id)),
eq(sshCredentials.userId, userId)
));
const updated = await db
.select()
.from(sshCredentials)
.where(eq(sshCredentials.id, parseInt(id)));
res.json(formatCredentialOutput(updated[0]));
} catch (err) {
logger.error('Failed to update credential', err);
authLogger.error('Failed to update credential', err);
res.status(500).json({
error: err instanceof Error ? err.message : 'Failed to update credential'
});
@@ -202,16 +296,52 @@ router.delete('/:id', authenticateJWT, async (req: Request, res: Response) => {
const {id} = req.params;
if (!isNonEmptyString(userId) || !id) {
logger.warn('Invalid request for credential deletion');
authLogger.warn('Invalid request for credential deletion');
return res.status(400).json({error: 'Invalid request'});
}
try {
await credentialService.deleteCredential(userId, parseInt(id));
logger.success(`Deleted credential ID ${id}`);
const hostsUsingCredential = await db
.select()
.from(sshData)
.where(and(
eq(sshData.credentialId, parseInt(id)),
eq(sshData.userId, userId)
));
if (hostsUsingCredential.length > 0) {
await db
.update(sshData)
.set({
credentialId: null,
password: null,
key: null,
keyPassword: null,
authType: 'password'
})
.where(and(
eq(sshData.credentialId, parseInt(id)),
eq(sshData.userId, userId)
));
}
await db
.delete(sshCredentialUsage)
.where(and(
eq(sshCredentialUsage.credentialId, parseInt(id)),
eq(sshCredentialUsage.userId, userId)
));
await db
.delete(sshCredentials)
.where(and(
eq(sshCredentials.id, parseInt(id)),
eq(sshCredentials.userId, userId)
));
res.json({message: 'Credential deleted successfully'});
} catch (err) {
logger.error('Failed to delete credential', err);
authLogger.error('Failed to delete credential', err);
res.status(500).json({
error: err instanceof Error ? err.message : 'Failed to delete credential'
});
@@ -225,18 +355,62 @@ router.post('/:id/apply-to-host/:hostId', authenticateJWT, async (req: Request,
const {id: credentialId, hostId} = req.params;
if (!isNonEmptyString(userId) || !credentialId || !hostId) {
logger.warn('Invalid request for credential application');
authLogger.warn('Invalid request for credential application');
return res.status(400).json({error: 'Invalid request'});
}
try {
const {sshHostService} = await import('../../services/ssh-host.js');
await sshHostService.applyCredentialToHost(userId, parseInt(hostId), parseInt(credentialId));
const credentials = await db
.select()
.from(sshCredentials)
.where(and(
eq(sshCredentials.id, parseInt(credentialId)),
eq(sshCredentials.userId, userId)
));
logger.success(`Applied credential ${credentialId} to host ${hostId}`);
if (credentials.length === 0) {
return res.status(404).json({error: 'Credential not found'});
}
const credential = credentials[0];
await db
.update(sshData)
.set({
credentialId: parseInt(credentialId),
username: credential.username,
authType: credential.authType,
password: null,
key: null,
keyPassword: null,
keyType: null,
updatedAt: new Date().toISOString()
})
.where(and(
eq(sshData.id, parseInt(hostId)),
eq(sshData.userId, userId)
));
// Record credential usage
await db.insert(sshCredentialUsage).values({
credentialId: parseInt(credentialId),
hostId: parseInt(hostId),
userId,
});
// Update credential usage stats
await db
.update(sshCredentials)
.set({
usageCount: sql`${sshCredentials.usageCount}
+ 1`,
lastUsed: new Date().toISOString(),
updatedAt: new Date().toISOString()
})
.where(eq(sshCredentials.id, parseInt(credentialId)));
res.json({message: 'Credential applied to host successfully'});
} catch (err) {
logger.error('Failed to apply credential to host', err);
authLogger.error('Failed to apply credential to host', err);
res.status(500).json({
error: err instanceof Error ? err.message : 'Failed to apply credential to host'
});
@@ -250,21 +424,69 @@ router.get('/:id/hosts', authenticateJWT, async (req: Request, res: Response) =>
const {id: credentialId} = req.params;
if (!isNonEmptyString(userId) || !credentialId) {
logger.warn('Invalid request for credential hosts fetch');
authLogger.warn('Invalid request for credential hosts fetch');
return res.status(400).json({error: 'Invalid request'});
}
try {
const {sshHostService} = await import('../../services/ssh-host.js');
const hosts = await sshHostService.getHostsUsingCredential(userId, parseInt(credentialId));
const hosts = await db
.select()
.from(sshData)
.where(and(
eq(sshData.credentialId, parseInt(credentialId)),
eq(sshData.userId, userId)
));
res.json(hosts);
res.json(hosts.map(host => formatSSHHostOutput(host)));
} catch (err) {
logger.error('Failed to fetch hosts using credential', err);
authLogger.error('Failed to fetch hosts using credential', err);
res.status(500).json({
error: err instanceof Error ? err.message : 'Failed to fetch hosts using credential'
});
}
});
function formatCredentialOutput(credential: any): any {
return {
id: credential.id,
name: credential.name,
description: credential.description,
folder: credential.folder,
tags: typeof credential.tags === 'string'
? (credential.tags ? credential.tags.split(',').filter(Boolean) : [])
: [],
authType: credential.authType,
username: credential.username,
keyType: credential.keyType,
usageCount: credential.usageCount || 0,
lastUsed: credential.lastUsed,
createdAt: credential.createdAt,
updatedAt: credential.updatedAt,
};
}
function formatSSHHostOutput(host: any): any {
return {
id: host.id,
userId: host.userId,
name: host.name,
ip: host.ip,
port: host.port,
username: host.username,
folder: host.folder,
tags: typeof host.tags === 'string'
? (host.tags ? host.tags.split(',').filter(Boolean) : [])
: [],
pin: !!host.pin,
authType: host.authType,
enableTerminal: !!host.enableTerminal,
enableTunnel: !!host.enableTunnel,
tunnelConnections: host.tunnelConnections ? JSON.parse(host.tunnelConnections) : [],
enableFileManager: !!host.enableFileManager,
defaultPath: host.defaultPath,
createdAt: host.createdAt,
updatedAt: host.updatedAt,
};
}
export default router;

File diff suppressed because it is too large Load Diff

View File

@@ -2,13 +2,13 @@ import express from 'express';
import {db} from '../db/index.js';
import {users, settings, sshData, fileManagerRecent, fileManagerPinned, fileManagerShortcuts, dismissedAlerts} 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 speakeasy from 'speakeasy';
import QRCode from 'qrcode';
import type {Request, Response, NextFunction} from 'express';
import { authLogger, apiLogger } from '../../utils/logger.js';
async function verifyOIDCToken(idToken: string, issuerUrl: string, clientId: string): Promise<any> {
try {
@@ -36,7 +36,7 @@ async function verifyOIDCToken(idToken: string, issuerUrl: string, clientId: str
}
}
} catch (discoveryError) {
logger.error(`OIDC discovery failed: ${discoveryError}`);
authLogger.error(`OIDC discovery failed: ${discoveryError}`);
}
let jwks: any = null;
@@ -52,13 +52,13 @@ async function verifyOIDCToken(idToken: string, issuerUrl: string, clientId: str
jwksUrl = url;
break;
} else {
logger.error(`Invalid JWKS structure from ${url}: ${JSON.stringify(jwksData)}`);
authLogger.error(`Invalid JWKS structure from ${url}: ${JSON.stringify(jwksData)}`);
}
} else {
logger.error(`JWKS fetch failed from ${url}: ${response.status} ${response.statusText}`);
authLogger.error(`JWKS fetch failed from ${url}: ${response.status} ${response.statusText}`);
}
} catch (error) {
logger.error(`JWKS fetch error from ${url}:`, error);
authLogger.error(`JWKS fetch error from ${url}:`, error);
continue;
}
}
@@ -89,36 +89,11 @@ async function verifyOIDCToken(idToken: string, issuerUrl: string, clientId: str
return payload;
} catch (error) {
logger.error('OIDC token verification failed:', error);
authLogger.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();
@@ -136,7 +111,7 @@ interface JWTPayload {
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');
authLogger.warn('Missing or invalid Authorization header', { operation: 'auth', method: req.method, url: req.url });
return res.status(401).json({error: 'Missing or invalid Authorization header'});
}
const token = authHeader.split(' ')[1];
@@ -144,9 +119,10 @@ function authenticateJWT(req: Request, res: Response, next: NextFunction) {
try {
const payload = jwt.verify(token, jwtSecret) as JWTPayload;
(req as any).userId = payload.userId;
authLogger.debug('JWT authentication successful', { operation: 'auth', userId: payload.userId, method: req.method, url: req.url });
next();
} catch (err) {
logger.warn('Invalid or expired token');
authLogger.warn('Invalid or expired token', { operation: 'auth', method: req.method, url: req.url, error: err });
return res.status(401).json({error: 'Invalid or expired token'});
}
}
@@ -165,7 +141,7 @@ router.post('/create', async (req, res) => {
const {username, password} = req.body;
if (!isNonEmptyString(username) || !isNonEmptyString(password)) {
logger.warn('Invalid user creation attempt - missing username or password');
authLogger.warn('Invalid user creation attempt - missing username or password', { operation: 'user_create', hasUsername: !!username, hasPassword: !!password });
return res.status(400).json({error: 'Username and password are required'});
}
@@ -175,7 +151,7 @@ router.post('/create', async (req, res) => {
.from(users)
.where(eq(users.username, username));
if (existing && existing.length > 0) {
logger.warn(`Attempt to create duplicate username: ${username}`);
authLogger.warn(`Attempt to create duplicate username: ${username}`, { operation: 'user_create', username });
return res.status(409).json({error: 'Username already exists'});
}
@@ -183,14 +159,19 @@ router.post('/create', async (req, res) => {
try {
const countResult = db.$client.prepare('SELECT COUNT(*) as count FROM users').get();
isFirstUser = ((countResult as any)?.count || 0) === 0;
authLogger.info('Checked user count for admin status', { operation: 'user_create', username, isFirstUser });
} catch (e) {
isFirstUser = true;
authLogger.warn('Failed to check user count, assuming first user', { operation: 'user_create', username, error: e });
}
authLogger.info('Hashing password for new user', { operation: 'user_create', username, saltRounds: parseInt(process.env.SALT || '10', 10) });
const saltRounds = parseInt(process.env.SALT || '10', 10);
const password_hash = await bcrypt.hash(password, saltRounds);
const id = nanoid();
authLogger.info('Generated user ID and hashed password', { operation: 'user_create', username, userId: id });
authLogger.info('Inserting new user into database', { operation: 'user_create', username, userId: id, isAdmin: isFirstUser });
await db.insert(users).values({
id,
username,
@@ -210,10 +191,10 @@ router.post('/create', async (req, res) => {
totp_backup_codes: null,
});
logger.success(`Traditional user created: ${username} (is_admin: ${isFirstUser})`);
authLogger.success(`Traditional user created: ${username} (is_admin: ${isFirstUser})`, { operation: 'user_create', username, isAdmin: isFirstUser, userId: id });
res.json({message: 'User created', is_admin: isFirstUser});
} catch (err) {
logger.error('Failed to create user', err);
authLogger.error('Failed to create user', err);
res.status(500).json({error: 'Failed to create user'});
}
});
@@ -240,30 +221,96 @@ router.post('/oidc-config', authenticateJWT, async (req, res) => {
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)) {
authLogger.info('OIDC config update request received', {
operation: 'oidc_config_update',
userId,
hasClientId: !!client_id,
hasClientSecret: !!client_secret,
hasIssuerUrl: !!issuer_url,
hasAuthUrl: !!authorization_url,
hasTokenUrl: !!token_url,
hasIdentifierPath: !!identifier_path,
hasNamePath: !!name_path,
clientIdValue: `"${client_id}"`,
clientSecretValue: client_secret ? '[REDACTED]' : `"${client_secret}"`,
issuerUrlValue: `"${issuer_url}"`,
authUrlValue: `"${authorization_url}"`,
tokenUrlValue: `"${token_url}"`,
identifierPathValue: `"${identifier_path}"`,
namePathValue: `"${name_path}"`,
scopesValue: `"${scopes}"`,
userinfoUrlValue: `"${userinfo_url}"`
});
const isDisableRequest = (!client_id || client_id.trim() === '') &&
(!client_secret || client_secret.trim() === '') &&
(!issuer_url || issuer_url.trim() === '') &&
(!authorization_url || authorization_url.trim() === '') &&
(!token_url || token_url.trim() === '');
const isEnableRequest = isNonEmptyString(client_id) && isNonEmptyString(client_secret) &&
isNonEmptyString(issuer_url) && isNonEmptyString(authorization_url) &&
isNonEmptyString(token_url) && isNonEmptyString(identifier_path) &&
isNonEmptyString(name_path);
authLogger.info('OIDC validation results', {
operation: 'oidc_config_update',
userId,
isDisableRequest,
isEnableRequest,
disableChecks: {
clientIdEmpty: !client_id || client_id.trim() === '',
clientSecretEmpty: !client_secret || client_secret.trim() === '',
issuerUrlEmpty: !issuer_url || issuer_url.trim() === '',
authUrlEmpty: !authorization_url || authorization_url.trim() === '',
tokenUrlEmpty: !token_url || token_url.trim() === ''
},
enableChecks: {
clientIdPresent: isNonEmptyString(client_id),
clientSecretPresent: isNonEmptyString(client_secret),
issuerUrlPresent: isNonEmptyString(issuer_url),
authUrlPresent: isNonEmptyString(authorization_url),
tokenUrlPresent: isNonEmptyString(token_url),
identifierPathPresent: isNonEmptyString(identifier_path),
namePathPresent: isNonEmptyString(name_path)
}
});
if (!isDisableRequest && !isEnableRequest) {
authLogger.warn('OIDC validation failed - neither disable nor enable request', {
operation: 'oidc_config_update',
userId,
isDisableRequest,
isEnableRequest
});
return res.status(400).json({error: 'All OIDC configuration fields are required'});
}
const config = {
client_id,
client_secret,
issuer_url,
authorization_url,
token_url,
userinfo_url: userinfo_url || '',
identifier_path,
name_path,
scopes: scopes || 'openid email profile'
};
if (isDisableRequest) {
// Disable OIDC by removing the configuration
db.$client.prepare("DELETE FROM settings WHERE key = 'oidc_config'").run();
authLogger.info('OIDC configuration disabled', { operation: 'oidc_disable', userId });
res.json({message: 'OIDC configuration disabled'});
} else {
// Enable OIDC by storing the configuration
const config = {
client_id,
client_secret,
issuer_url,
authorization_url,
token_url,
userinfo_url: userinfo_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'});
db.$client.prepare("INSERT OR REPLACE INTO settings (key, value) VALUES ('oidc_config', ?)").run(JSON.stringify(config));
authLogger.info('OIDC configuration updated', { operation: 'oidc_update', userId, hasUserinfoUrl: !!userinfo_url });
res.json({message: 'OIDC configuration updated'});
}
} catch (err) {
logger.error('Failed to update OIDC config', err);
authLogger.error('Failed to update OIDC config', err);
res.status(500).json({error: 'Failed to update OIDC config'});
}
});
@@ -278,7 +325,7 @@ router.get('/oidc-config', async (req, res) => {
}
res.json(JSON.parse((row as any).value));
} catch (err) {
logger.error('Failed to get OIDC config', err);
authLogger.error('Failed to get OIDC config', err);
res.status(500).json({error: 'Failed to get OIDC config'});
}
});
@@ -318,7 +365,7 @@ router.get('/oidc/authorize', async (req, res) => {
res.json({auth_url: authUrl.toString(), state, nonce});
} catch (err) {
logger.error('Failed to generate OIDC auth URL', err);
authLogger.error('Failed to generate OIDC auth URL', err);
res.status(500).json({error: 'Failed to generate authorization URL'});
}
});
@@ -369,7 +416,7 @@ router.get('/oidc/callback', async (req, res) => {
});
if (!tokenResponse.ok) {
logger.error('OIDC token exchange failed', await tokenResponse.text());
authLogger.error('OIDC token exchange failed', await tokenResponse.text());
return res.status(400).json({error: 'Failed to exchange authorization code'});
}
@@ -391,7 +438,7 @@ router.get('/oidc/callback', async (req, res) => {
}
}
} catch (discoveryError) {
logger.error(`OIDC discovery failed: ${discoveryError}`);
authLogger.error(`OIDC discovery failed: ${discoveryError}`);
}
if (config.userinfo_url) {
@@ -412,18 +459,18 @@ router.get('/oidc/callback', async (req, res) => {
if (tokenData.id_token) {
try {
userInfo = await verifyOIDCToken(tokenData.id_token, config.issuer_url, config.client_id);
logger.info('Successfully verified ID token and extracted user info');
authLogger.info('Successfully verified ID token and extracted user info');
} catch (error) {
logger.error('OIDC token verification failed, trying userinfo endpoints', error);
authLogger.error('OIDC token verification failed, trying userinfo endpoints', error);
try {
const parts = tokenData.id_token.split('.');
if (parts.length === 3) {
const payload = JSON.parse(Buffer.from(parts[1], 'base64').toString());
userInfo = payload;
logger.info('Successfully decoded ID token payload without verification');
authLogger.info('Successfully decoded ID token payload without verification');
}
} catch (decodeError) {
logger.error('Failed to decode ID token payload:', decodeError);
authLogger.error('Failed to decode ID token payload:', decodeError);
}
}
}
@@ -441,21 +488,21 @@ router.get('/oidc/callback', async (req, res) => {
userInfo = await userInfoResponse.json();
break;
} else {
logger.error(`Userinfo endpoint ${userInfoUrl} failed with status: ${userInfoResponse.status}`);
authLogger.error(`Userinfo endpoint ${userInfoUrl} failed with status: ${userInfoResponse.status}`);
}
} catch (error) {
logger.error(`Userinfo endpoint ${userInfoUrl} failed:`, error);
authLogger.error(`Userinfo endpoint ${userInfoUrl} failed:`, error);
continue;
}
}
}
if (!userInfo) {
logger.error('Failed to get user information from all sources');
logger.error(`Tried userinfo URLs: ${userInfoUrls.join(', ')}`);
logger.error(`Token data keys: ${Object.keys(tokenData).join(', ')}`);
logger.error(`Has id_token: ${!!tokenData.id_token}`);
logger.error(`Has access_token: ${!!tokenData.access_token}`);
authLogger.error('Failed to get user information from all sources');
authLogger.error(`Tried userinfo URLs: ${userInfoUrls.join(', ')}`);
authLogger.error(`Token data keys: ${Object.keys(tokenData).join(', ')}`);
authLogger.error(`Has id_token: ${!!tokenData.id_token}`);
authLogger.error(`Has access_token: ${!!tokenData.access_token}`);
return res.status(400).json({error: 'Failed to get user information'});
}
@@ -477,8 +524,8 @@ router.get('/oidc/callback', async (req, res) => {
identifier;
if (!identifier) {
logger.error(`Identifier not found at path: ${config.identifier_path}`);
logger.error(`Available fields: ${Object.keys(userInfo).join(', ')}`);
authLogger.error(`Identifier not found at path: ${config.identifier_path}`);
authLogger.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(', ')}`});
}
@@ -549,7 +596,7 @@ router.get('/oidc/callback', async (req, res) => {
res.redirect(redirectUrl.toString());
} catch (err) {
logger.error('OIDC callback failed', err);
authLogger.error('OIDC callback failed', err);
let frontendUrl = redirectUri.replace('/users/oidc/callback', '');
@@ -570,7 +617,7 @@ router.post('/login', async (req, res) => {
const {username, password} = req.body;
if (!isNonEmptyString(username) || !isNonEmptyString(password)) {
logger.warn('Invalid traditional login attempt');
authLogger.warn('Invalid traditional login attempt', { operation: 'user_login', hasUsername: !!username, hasPassword: !!password });
return res.status(400).json({error: 'Invalid username or password'});
}
@@ -581,38 +628,45 @@ router.post('/login', async (req, res) => {
.where(eq(users.username, username));
if (!user || user.length === 0) {
logger.warn(`User not found: ${username}`);
authLogger.warn(`User not found: ${username}`, { operation: 'user_login', username });
return res.status(404).json({error: 'User not found'});
}
const userRecord = user[0];
if (userRecord.is_oidc) {
authLogger.warn('OIDC user attempted traditional login', { operation: 'user_login', username, userId: userRecord.id });
return res.status(403).json({error: 'This user uses external authentication'});
}
authLogger.info('Verifying password for user login', { operation: 'user_login', username, userId: userRecord.id });
const isMatch = await bcrypt.compare(password, userRecord.password_hash);
if (!isMatch) {
logger.warn(`Incorrect password for user: ${username}`);
authLogger.warn(`Incorrect password for user: ${username}`, { operation: 'user_login', username, userId: userRecord.id });
return res.status(401).json({error: 'Incorrect password'});
}
authLogger.info('Password verified, generating JWT token', { operation: 'user_login', username, userId: userRecord.id, totpEnabled: userRecord.totp_enabled });
const jwtSecret = process.env.JWT_SECRET || 'secret';
const token = jwt.sign({userId: userRecord.id}, jwtSecret, {
expiresIn: '50d',
});
if (userRecord.totp_enabled) {
authLogger.info('User has TOTP enabled, requiring additional verification', { operation: 'user_login', username, userId: userRecord.id });
const tempToken = jwt.sign(
{userId: userRecord.id, pending_totp: true},
jwtSecret,
{expiresIn: '10m'}
);
authLogger.success('TOTP verification required for login', { operation: 'user_login', username, userId: userRecord.id });
return res.json({
requires_totp: true,
temp_token: jwt.sign(
{userId: userRecord.id, pending_totp: true},
jwtSecret,
{expiresIn: '10m'}
)
temp_token: tempToken
});
}
authLogger.success('User login successful', { operation: 'user_login', username, userId: userRecord.id, isAdmin: !!userRecord.is_admin });
return res.json({
token,
is_admin: !!userRecord.is_admin,
@@ -620,7 +674,7 @@ router.post('/login', async (req, res) => {
});
} catch (err) {
logger.error('Failed to log in user', err);
authLogger.error('Failed to log in user', err);
return res.status(500).json({error: 'Login failed'});
}
});
@@ -630,7 +684,7 @@ router.post('/login', async (req, res) => {
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');
authLogger.warn('Invalid userId in JWT for /users/me');
return res.status(401).json({error: 'Invalid userId'});
}
try {
@@ -639,7 +693,7 @@ router.get('/me', authenticateJWT, async (req: Request, res: Response) => {
.from(users)
.where(eq(users.id, userId));
if (!user || user.length === 0) {
logger.warn(`User not found for /users/me: ${userId}`);
authLogger.warn(`User not found for /users/me: ${userId}`);
return res.status(401).json({error: 'User not found'});
}
res.json({
@@ -650,7 +704,7 @@ router.get('/me', authenticateJWT, async (req: Request, res: Response) => {
totp_enabled: !!user[0].totp_enabled
});
} catch (err) {
logger.error('Failed to get username', err);
authLogger.error('Failed to get username', err);
res.status(500).json({error: 'Failed to get username'});
}
});
@@ -663,7 +717,7 @@ router.get('/count', async (req, res) => {
const count = (countResult as any)?.count || 0;
res.json({count});
} catch (err) {
logger.error('Failed to count users', err);
authLogger.error('Failed to count users', err);
res.status(500).json({error: 'Failed to count users'});
}
});
@@ -675,7 +729,7 @@ router.get('/db-health', async (req, res) => {
db.$client.prepare('SELECT 1').get();
res.json({status: 'ok'});
} catch (err) {
logger.error('DB health check failed', err);
authLogger.error('DB health check failed', err);
res.status(500).json({error: 'Database not accessible'});
}
});
@@ -687,7 +741,7 @@ router.get('/registration-allowed', async (req, res) => {
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);
authLogger.error('Failed to get registration allowed', err);
res.status(500).json({error: 'Failed to get registration allowed'});
}
});
@@ -708,7 +762,7 @@ router.patch('/registration-allowed', authenticateJWT, async (req, res) => {
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);
authLogger.error('Failed to set registration allowed', err);
res.status(500).json({error: 'Failed to set registration allowed'});
}
});
@@ -737,7 +791,7 @@ router.delete('/delete-account', authenticateJWT, async (req, res) => {
const isMatch = await bcrypt.compare(password, userRecord.password_hash);
if (!isMatch) {
logger.warn(`Incorrect password provided for account deletion: ${userRecord.username}`);
authLogger.warn(`Incorrect password provided for account deletion: ${userRecord.username}`);
return res.status(401).json({error: 'Incorrect password'});
}
@@ -750,11 +804,11 @@ router.delete('/delete-account', authenticateJWT, async (req, res) => {
await db.delete(users).where(eq(users.id, userId));
logger.success(`User account deleted: ${userRecord.username}`);
authLogger.success(`User account deleted: ${userRecord.username}`);
res.json({message: 'Account deleted successfully'});
} catch (err) {
logger.error('Failed to delete user account', err);
authLogger.error('Failed to delete user account', err);
res.status(500).json({error: 'Failed to delete account'});
}
});
@@ -775,7 +829,7 @@ router.post('/initiate-reset', async (req, res) => {
.where(eq(users.username, username));
if (!user || user.length === 0) {
logger.warn(`Password reset attempted for non-existent user: ${username}`);
authLogger.warn(`Password reset attempted for non-existent user: ${username}`);
return res.status(404).json({error: 'User not found'});
}
@@ -791,12 +845,12 @@ router.post('/initiate-reset', async (req, res) => {
JSON.stringify({code: resetCode, expiresAt: expiresAt.toISOString()})
);
logger.info(`Password reset code for user ${username}: ${resetCode} (expires at ${expiresAt.toLocaleString()})`);
authLogger.info(`Password reset code for user ${username}: ${resetCode} (expires at ${expiresAt.toLocaleString()})`);
res.json({message: 'Password reset code has been generated and logged. Check docker logs for the code.'});
} catch (err) {
logger.error('Failed to initiate password reset', err);
authLogger.error('Failed to initiate password reset', err);
res.status(500).json({error: 'Failed to initiate password reset'});
}
});
@@ -840,7 +894,7 @@ router.post('/verify-reset-code', async (req, res) => {
res.json({message: 'Reset code verified', tempToken});
} catch (err) {
logger.error('Failed to verify reset code', err);
authLogger.error('Failed to verify reset code', err);
res.status(500).json({error: 'Failed to verify reset code'});
}
});
@@ -884,11 +938,11 @@ router.post('/complete-reset', async (req, res) => {
db.$client.prepare("DELETE FROM settings WHERE key = ?").run(`reset_code_${username}`);
db.$client.prepare("DELETE FROM settings WHERE key = ?").run(`temp_reset_token_${username}`);
logger.success(`Password successfully reset for user: ${username}`);
authLogger.success(`Password successfully reset for user: ${username}`);
res.json({message: 'Password has been successfully reset'});
} catch (err) {
logger.error('Failed to complete password reset', err);
authLogger.error('Failed to complete password reset', err);
res.status(500).json({error: 'Failed to complete password reset'});
}
});
@@ -912,7 +966,7 @@ router.get('/list', authenticateJWT, async (req, res) => {
res.json({users: allUsers});
} catch (err) {
logger.error('Failed to list users', err);
authLogger.error('Failed to list users', err);
res.status(500).json({error: 'Failed to list users'});
}
});
@@ -946,11 +1000,11 @@ router.post('/make-admin', authenticateJWT, async (req, res) => {
.set({is_admin: true})
.where(eq(users.username, username));
logger.success(`User ${username} made admin by ${adminUser[0].username}`);
authLogger.success(`User ${username} made admin by ${adminUser[0].username}`);
res.json({message: `User ${username} is now an admin`});
} catch (err) {
logger.error('Failed to make user admin', err);
authLogger.error('Failed to make user admin', err);
res.status(500).json({error: 'Failed to make user admin'});
}
});
@@ -988,11 +1042,11 @@ router.post('/remove-admin', authenticateJWT, async (req, res) => {
.set({is_admin: false})
.where(eq(users.username, username));
logger.success(`Admin status removed from ${username} by ${adminUser[0].username}`);
authLogger.success(`Admin status removed from ${username} by ${adminUser[0].username}`);
res.json({message: `Admin status removed from ${username}`});
} catch (err) {
logger.error('Failed to remove admin status', err);
authLogger.error('Failed to remove admin status', err);
res.status(500).json({error: 'Failed to remove admin status'});
}
});
@@ -1057,7 +1111,7 @@ router.post('/totp/verify-login', async (req, res) => {
});
} catch (err) {
logger.error('TOTP verification failed', err);
authLogger.error('TOTP verification failed', err);
return res.status(500).json({error: 'TOTP verification failed'});
}
});
@@ -1096,7 +1150,7 @@ router.post('/totp/setup', authenticateJWT, async (req, res) => {
});
} catch (err) {
logger.error('Failed to setup TOTP', err);
authLogger.error('Failed to setup TOTP', err);
res.status(500).json({error: 'Failed to setup TOTP'});
}
});
@@ -1155,7 +1209,7 @@ router.post('/totp/enable', authenticateJWT, async (req, res) => {
});
} catch (err) {
logger.error('Failed to enable TOTP', err);
authLogger.error('Failed to enable TOTP', err);
res.status(500).json({error: 'Failed to enable TOTP'});
}
});
@@ -1213,7 +1267,7 @@ router.post('/totp/disable', authenticateJWT, async (req, res) => {
res.json({message: 'TOTP disabled successfully'});
} catch (err) {
logger.error('Failed to disable TOTP', err);
authLogger.error('Failed to disable TOTP', err);
res.status(500).json({error: 'Failed to disable TOTP'});
}
});
@@ -1271,7 +1325,7 @@ router.post('/totp/backup-codes', authenticateJWT, async (req, res) => {
res.json({backup_codes: backupCodes});
} catch (err) {
logger.error('Failed to generate backup codes', err);
authLogger.error('Failed to generate backup codes', err);
res.status(500).json({error: 'Failed to generate backup codes'});
}
});
@@ -1318,20 +1372,18 @@ router.delete('/delete-user', authenticateJWT, async (req, res) => {
await db.delete(dismissedAlerts).where(eq(dismissedAlerts.userId, targetUserId));
await db.delete(sshData).where(eq(sshData.userId, targetUserId));
// Note: All user-related data has been deleted above
// The tables config_editor_* and shared_hosts don't exist in the current schema
} catch (cleanupError) {
logger.error(`Cleanup failed for user ${username}:`, cleanupError);
authLogger.error(`Cleanup failed for user ${username}:`, cleanupError);
throw cleanupError;
}
await db.delete(users).where(eq(users.id, targetUserId));
logger.success(`User ${username} deleted by admin ${adminUser[0].username}`);
authLogger.success(`User ${username} deleted by admin ${adminUser[0].username}`);
res.json({message: `User ${username} deleted successfully`});
} catch (err) {
logger.error('Failed to delete user', err);
authLogger.error('Failed to delete user', err);
if (err && typeof err === 'object' && 'code' in err) {
if (err.code === 'SQLITE_CONSTRAINT_FOREIGNKEY') {

View File

@@ -1,370 +0,0 @@
import {db} from '../database/db/index.js';
import {sshCredentials, sshCredentialUsage, sshData} from '../database/db/schema.js';
import {eq, and, desc, sql} from 'drizzle-orm';
import {encryptionService} from './encryption.js';
import chalk from 'chalk';
const logger = {
info: (msg: string): void => {
const timestamp = chalk.gray(`[${new Date().toLocaleTimeString()}]`);
console.log(`${timestamp} ${chalk.cyan('[INFO]')} ${chalk.hex('#0f766e')('[CRED]')} ${msg}`);
},
warn: (msg: string): void => {
const timestamp = chalk.gray(`[${new Date().toLocaleTimeString()}]`);
console.warn(`${timestamp} ${chalk.yellow('[WARN]')} ${chalk.hex('#0f766e')('[CRED]')} ${msg}`);
},
error: (msg: string, err?: unknown): void => {
const timestamp = chalk.gray(`[${new Date().toLocaleTimeString()}]`);
console.error(`${timestamp} ${chalk.redBright('[ERROR]')} ${chalk.hex('#0f766e')('[CRED]')} ${msg}`);
if (err) console.error(err);
},
success: (msg: string): void => {
const timestamp = chalk.gray(`[${new Date().toLocaleTimeString()}]`);
console.log(`${timestamp} ${chalk.greenBright('[SUCCESS]')} ${chalk.hex('#0f766e')('[CRED]')} ${msg}`);
}
};
export interface CredentialInput {
name: string;
description?: string;
folder?: string;
tags?: string[];
authType: 'password' | 'key';
username: string;
password?: string;
key?: string;
keyPassword?: string;
keyType?: string;
}
export interface CredentialOutput {
id: number;
name: string;
description?: string;
folder?: string;
tags: string[];
authType: 'password' | 'key';
username: string;
keyType?: string;
usageCount: number;
lastUsed?: string;
createdAt: string;
updatedAt: string;
}
export interface CredentialWithSecrets extends CredentialOutput {
password?: string;
key?: string;
keyPassword?: string;
}
class CredentialService {
/**
* Create a new credential
*/
async createCredential(userId: string, input: CredentialInput): Promise<CredentialOutput> {
try {
// Validate input
if (!input.name?.trim()) {
throw new Error('Credential name is required');
}
if (!input.username?.trim()) {
throw new Error('Username is required');
}
if (!['password', 'key'].includes(input.authType)) {
throw new Error('Invalid auth type');
}
if (input.authType === 'password' && !input.password) {
throw new Error('Password is required for password authentication');
}
if (input.authType === 'key' && !input.key) {
throw new Error('SSH key is required for key authentication');
}
// Encrypt sensitive data
let encryptedPassword: string | null = null;
let encryptedKey: string | null = null;
let encryptedKeyPassword: string | null = null;
if (input.authType === 'password' && input.password) {
encryptedPassword = encryptionService.encryptToString(input.password);
} else if (input.authType === 'key') {
if (input.key) {
encryptedKey = encryptionService.encryptToString(input.key);
}
if (input.keyPassword) {
encryptedKeyPassword = encryptionService.encryptToString(input.keyPassword);
}
}
const credentialData = {
userId,
name: input.name.trim(),
description: input.description?.trim() || null,
folder: input.folder?.trim() || null,
tags: Array.isArray(input.tags) ? input.tags.join(',') : (input.tags || ''),
authType: input.authType,
username: input.username.trim(),
encryptedPassword,
encryptedKey,
encryptedKeyPassword,
keyType: input.keyType || null,
usageCount: 0,
lastUsed: null,
};
const result = await db.insert(sshCredentials).values(credentialData).returning();
const created = result[0];
logger.success(`Created credential "${input.name}" (ID: ${created.id})`);
return this.formatCredentialOutput(created);
} catch (error) {
logger.error('Failed to create credential', error);
throw error;
}
}
/**
* Get all credentials for a user
*/
async getUserCredentials(userId: string): Promise<CredentialOutput[]> {
try {
const credentials = await db
.select()
.from(sshCredentials)
.where(eq(sshCredentials.userId, userId))
.orderBy(desc(sshCredentials.updatedAt));
return credentials.map(cred => this.formatCredentialOutput(cred));
} catch (error) {
logger.error('Failed to fetch user credentials', error);
throw error;
}
}
/**
* Get a credential by ID with decrypted secrets
*/
async getCredentialWithSecrets(userId: string, credentialId: number): Promise<CredentialWithSecrets | null> {
try {
const credentials = await db
.select()
.from(sshCredentials)
.where(and(
eq(sshCredentials.id, credentialId),
eq(sshCredentials.userId, userId)
));
if (credentials.length === 0) {
return null;
}
const credential = credentials[0];
const output: CredentialWithSecrets = {
...this.formatCredentialOutput(credential)
};
// Decrypt sensitive data
try {
if (credential.encryptedPassword) {
output.password = encryptionService.decryptFromString(credential.encryptedPassword);
}
if (credential.encryptedKey) {
output.key = encryptionService.decryptFromString(credential.encryptedKey);
}
if (credential.encryptedKeyPassword) {
output.keyPassword = encryptionService.decryptFromString(credential.encryptedKeyPassword);
}
} catch (decryptError) {
logger.error(`Failed to decrypt credential ${credentialId}`, decryptError);
throw new Error('Failed to decrypt credential data');
}
return output;
} catch (error) {
logger.error('Failed to get credential with secrets', error);
throw error;
}
}
/**
* Update a credential
*/
async updateCredential(userId: string, credentialId: number, input: Partial<CredentialInput>): Promise<CredentialOutput> {
try {
// Check if credential exists and belongs to user
const existing = await db
.select()
.from(sshCredentials)
.where(and(
eq(sshCredentials.id, credentialId),
eq(sshCredentials.userId, userId)
));
if (existing.length === 0) {
throw new Error('Credential not found');
}
const updateData: any = {
updatedAt: new Date().toISOString()
};
if (input.name !== undefined) updateData.name = input.name.trim();
if (input.description !== undefined) updateData.description = input.description?.trim() || null;
if (input.folder !== undefined) updateData.folder = input.folder?.trim() || null;
if (input.tags !== undefined) {
updateData.tags = Array.isArray(input.tags) ? input.tags.join(',') : (input.tags || '');
}
if (input.username !== undefined) updateData.username = input.username.trim();
if (input.authType !== undefined) updateData.authType = input.authType;
if (input.keyType !== undefined) updateData.keyType = input.keyType;
// Handle sensitive data updates
if (input.password !== undefined) {
updateData.encryptedPassword = input.password ? encryptionService.encryptToString(input.password) : null;
}
if (input.key !== undefined) {
updateData.encryptedKey = input.key ? encryptionService.encryptToString(input.key) : null;
}
if (input.keyPassword !== undefined) {
updateData.encryptedKeyPassword = input.keyPassword ? encryptionService.encryptToString(input.keyPassword) : null;
}
await db
.update(sshCredentials)
.set(updateData)
.where(and(
eq(sshCredentials.id, credentialId),
eq(sshCredentials.userId, userId)
));
// Fetch updated credential
const updated = await db
.select()
.from(sshCredentials)
.where(eq(sshCredentials.id, credentialId));
logger.success(`Updated credential ID ${credentialId}`);
return this.formatCredentialOutput(updated[0]);
} catch (error) {
logger.error('Failed to update credential', error);
throw error;
}
}
/**
* Delete a credential
*/
async deleteCredential(userId: string, credentialId: number): Promise<void> {
try {
// Check if credential is in use
const hostsUsingCredential = await db
.select()
.from(sshData)
.where(and(
eq(sshData.credentialId, credentialId),
eq(sshData.userId, userId)
));
if (hostsUsingCredential.length > 0) {
throw new Error(`Cannot delete credential: it is currently used by ${hostsUsingCredential.length} host(s)`);
}
// Delete usage records
await db
.delete(sshCredentialUsage)
.where(and(
eq(sshCredentialUsage.credentialId, credentialId),
eq(sshCredentialUsage.userId, userId)
));
// Delete credential
const result = await db
.delete(sshCredentials)
.where(and(
eq(sshCredentials.id, credentialId),
eq(sshCredentials.userId, userId)
));
logger.success(`Deleted credential ID ${credentialId}`);
} catch (error) {
logger.error('Failed to delete credential', error);
throw error;
}
}
/**
* Record credential usage
*/
async recordUsage(userId: string, credentialId: number, hostId: number): Promise<void> {
try {
// Record usage
await db.insert(sshCredentialUsage).values({
credentialId,
hostId,
userId,
});
// Update credential usage stats
await db
.update(sshCredentials)
.set({
usageCount: sql`${sshCredentials.usageCount} + 1`,
lastUsed: new Date().toISOString(),
updatedAt: new Date().toISOString()
})
.where(eq(sshCredentials.id, credentialId));
} catch (error) {
logger.error('Failed to record credential usage', error);
// Don't throw - this is not critical
}
}
/**
* Get credentials grouped by folder
*/
async getCredentialsFolders(userId: string): Promise<string[]> {
try {
const result = await db
.select({folder: sshCredentials.folder})
.from(sshCredentials)
.where(eq(sshCredentials.userId, userId));
const folderCounts: Record<string, number> = {};
result.forEach(r => {
if (r.folder && r.folder.trim() !== '') {
folderCounts[r.folder] = (folderCounts[r.folder] || 0) + 1;
}
});
return Object.keys(folderCounts).filter(folder => folderCounts[folder] > 0);
} catch (error) {
logger.error('Failed to get credential folders', error);
throw error;
}
}
private formatCredentialOutput(credential: any): CredentialOutput {
return {
id: credential.id,
name: credential.name,
description: credential.description,
folder: credential.folder,
tags: typeof credential.tags === 'string'
? (credential.tags ? credential.tags.split(',').filter(Boolean) : [])
: [],
authType: credential.authType,
username: credential.username,
keyType: credential.keyType,
usageCount: credential.usageCount || 0,
lastUsed: credential.lastUsed,
createdAt: credential.createdAt,
updatedAt: credential.updatedAt,
};
}
}
export const credentialService = new CredentialService();

View File

@@ -1,133 +0,0 @@
import crypto from 'crypto';
import chalk from 'chalk';
const ALGORITHM = 'aes-256-gcm';
const KEY_LENGTH = 32; // 256 bits
const IV_LENGTH = 16; // 128 bits
const TAG_LENGTH = 16; // 128 bits
interface EncryptionResult {
encrypted: string;
iv: string;
tag: string;
}
interface DecryptionInput {
encrypted: string;
iv: string;
tag: string;
}
class EncryptionService {
private key: Buffer;
constructor() {
// Get or generate encryption key
const keyEnv = process.env.CREDENTIAL_ENCRYPTION_KEY;
if (keyEnv) {
this.key = Buffer.from(keyEnv, 'hex');
if (this.key.length !== KEY_LENGTH) {
throw new Error(`Invalid encryption key length. Expected ${KEY_LENGTH} bytes, got ${this.key.length}`);
}
} else {
// Generate a new key - in production, this should be stored securely
this.key = crypto.randomBytes(KEY_LENGTH);
console.warn(chalk.yellow(`[SECURITY] Generated new encryption key. Store this in CREDENTIAL_ENCRYPTION_KEY: ${this.key.toString('hex')}`));
}
}
/**
* Encrypt sensitive data
* @param plaintext - The data to encrypt
* @returns Encryption result with encrypted data, IV, and tag
*/
encrypt(plaintext: string): EncryptionResult {
try {
const iv = crypto.randomBytes(IV_LENGTH);
const cipher = crypto.createCipheriv(ALGORITHM, this.key, iv);
let encrypted = cipher.update(plaintext, 'utf8', 'hex');
encrypted += cipher.final('hex');
const tag = cipher.getAuthTag();
return {
encrypted,
iv: iv.toString('hex'),
tag: tag.toString('hex')
};
} catch (error) {
throw new Error(`Encryption failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* Decrypt sensitive data
* @param input - Encrypted data with IV and tag
* @returns Decrypted plaintext
*/
decrypt(input: DecryptionInput): string {
try {
const iv = Buffer.from(input.iv, 'hex');
const tag = Buffer.from(input.tag, 'hex');
const decipher = crypto.createDecipheriv('aes-256-gcm', this.key, iv);
decipher.setAuthTag(tag);
let decrypted = decipher.update(input.encrypted, 'hex', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
} catch (error) {
throw new Error(`Decryption failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* Encrypt data and return as single base64-encoded string
* Format: iv:tag:encrypted
*/
encryptToString(plaintext: string): string {
const result = this.encrypt(plaintext);
const combined = `${result.iv}:${result.tag}:${result.encrypted}`;
return Buffer.from(combined).toString('base64');
}
/**
* Decrypt data from base64-encoded string
*/
decryptFromString(encryptedString: string): string {
try {
const combined = Buffer.from(encryptedString, 'base64').toString();
const parts = combined.split(':');
if (parts.length !== 3) {
throw new Error('Invalid encrypted string format');
}
return this.decrypt({
iv: parts[0],
tag: parts[1],
encrypted: parts[2]
});
} catch (error) {
throw new Error(`Failed to decrypt string: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* Validate that a string can be decrypted (useful for testing)
*/
canDecrypt(encryptedString: string): boolean {
try {
this.decryptFromString(encryptedString);
return true;
} catch {
return false;
}
}
}
// Singleton instance
export const encryptionService = new EncryptionService();
// Types for external use
export type { EncryptionResult, DecryptionInput };

View File

@@ -1,277 +0,0 @@
import {db} from '../database/db/index.js';
import {sshData, sshCredentials} from '../database/db/schema.js';
import {eq, and} from 'drizzle-orm';
import {credentialService} from './credentials.js';
import {encryptionService} from './encryption.js';
import chalk from 'chalk';
const logger = {
info: (msg: string): void => {
const timestamp = chalk.gray(`[${new Date().toLocaleTimeString()}]`);
console.log(`${timestamp} ${chalk.cyan('[INFO]')} ${chalk.hex('#7c3aed')('[SSH]')} ${msg}`);
},
warn: (msg: string): void => {
const timestamp = chalk.gray(`[${new Date().toLocaleTimeString()}]`);
console.warn(`${timestamp} ${chalk.yellow('[WARN]')} ${chalk.hex('#7c3aed')('[SSH]')} ${msg}`);
},
error: (msg: string, err?: unknown): void => {
const timestamp = chalk.gray(`[${new Date().toLocaleTimeString()}]`);
console.error(`${timestamp} ${chalk.redBright('[ERROR]')} ${chalk.hex('#7c3aed')('[SSH]')} ${msg}`);
if (err) console.error(err);
},
success: (msg: string): void => {
const timestamp = chalk.gray(`[${new Date().toLocaleTimeString()}]`);
console.log(`${timestamp} ${chalk.greenBright('[SUCCESS]')} ${chalk.hex('#7c3aed')('[SSH]')} ${msg}`);
}
};
export interface SSHHostWithCredentials {
id: number;
userId: string;
name?: string;
ip: string;
port: number;
username: string;
folder?: string;
tags: string[];
pin: boolean;
authType: string;
// Auth data - either from credential or legacy fields
password?: string;
key?: string;
keyPassword?: string;
keyType?: string;
credentialId?: number;
credentialName?: string;
// Other fields
enableTerminal: boolean;
enableTunnel: boolean;
tunnelConnections: any[];
enableFileManager: boolean;
defaultPath?: string;
createdAt: string;
updatedAt: string;
}
class SSHHostService {
/**
* Get SSH host with resolved credentials
*/
async getHostWithCredentials(userId: string, hostId: number): Promise<SSHHostWithCredentials | null> {
try {
const hosts = await db
.select()
.from(sshData)
.where(and(
eq(sshData.id, hostId),
eq(sshData.userId, userId)
));
if (hosts.length === 0) {
return null;
}
const host = hosts[0];
return await this.resolveHostCredentials(host);
} catch (error) {
logger.error(`Failed to get host ${hostId} with credentials`, error);
throw error;
}
}
/**
* Apply a credential to an SSH host
*/
async applyCredentialToHost(userId: string, hostId: number, credentialId: number): Promise<void> {
try {
// Verify credential exists and belongs to user
const credential = await credentialService.getCredentialWithSecrets(userId, credentialId);
if (!credential) {
throw new Error('Credential not found');
}
// Update host to reference the credential and clear legacy fields
await db
.update(sshData)
.set({
credentialId: credentialId,
username: credential.username,
authType: credential.authType,
// Clear legacy credential fields since we're using the credential reference
password: null,
key: null,
keyPassword: null,
keyType: null,
updatedAt: new Date().toISOString()
})
.where(and(
eq(sshData.id, hostId),
eq(sshData.userId, userId)
));
// Record credential usage
await credentialService.recordUsage(userId, credentialId, hostId);
logger.success(`Applied credential ${credentialId} to host ${hostId}`);
} catch (error) {
logger.error(`Failed to apply credential ${credentialId} to host ${hostId}`, error);
throw error;
}
}
/**
* Remove credential from host (revert to legacy mode)
*/
async removeCredentialFromHost(userId: string, hostId: number): Promise<void> {
try {
await db
.update(sshData)
.set({
credentialId: null,
updatedAt: new Date().toISOString()
})
.where(and(
eq(sshData.id, hostId),
eq(sshData.userId, userId)
));
logger.success(`Removed credential reference from host ${hostId}`);
} catch (error) {
logger.error(`Failed to remove credential from host ${hostId}`, error);
throw error;
}
}
/**
* Get all hosts using a specific credential
*/
async getHostsUsingCredential(userId: string, credentialId: number): Promise<SSHHostWithCredentials[]> {
try {
const hosts = await db
.select()
.from(sshData)
.where(and(
eq(sshData.credentialId, credentialId),
eq(sshData.userId, userId)
));
const result: SSHHostWithCredentials[] = [];
for (const host of hosts) {
const resolved = await this.resolveHostCredentials(host);
result.push(resolved);
}
return result;
} catch (error) {
logger.error(`Failed to get hosts using credential ${credentialId}`, error);
throw error;
}
}
/**
* Resolve host credentials from either credential reference or legacy fields
*/
private async resolveHostCredentials(host: any): Promise<SSHHostWithCredentials> {
const baseHost: SSHHostWithCredentials = {
id: host.id,
userId: host.userId,
name: host.name,
ip: host.ip,
port: host.port,
username: host.username,
folder: host.folder,
tags: typeof host.tags === 'string'
? (host.tags ? host.tags.split(',').filter(Boolean) : [])
: [],
pin: !!host.pin,
authType: host.authType,
enableTerminal: !!host.enableTerminal,
enableTunnel: !!host.enableTunnel,
tunnelConnections: host.tunnelConnections ? JSON.parse(host.tunnelConnections) : [],
enableFileManager: !!host.enableFileManager,
defaultPath: host.defaultPath,
createdAt: host.createdAt,
updatedAt: host.updatedAt,
};
// If host uses a credential reference, get credentials from there
if (host.credentialId) {
try {
const credential = await credentialService.getCredentialWithSecrets(host.userId, host.credentialId);
if (credential) {
baseHost.credentialId = credential.id;
baseHost.credentialName = credential.name;
baseHost.username = credential.username;
baseHost.authType = credential.authType;
baseHost.password = credential.password;
baseHost.key = credential.key;
baseHost.keyPassword = credential.keyPassword;
baseHost.keyType = credential.keyType;
} else {
logger.warn(`Credential ${host.credentialId} not found for host ${host.id}, using legacy data`);
// Fall back to legacy data
this.addLegacyCredentials(baseHost, host);
}
} catch (error) {
logger.error(`Failed to resolve credential ${host.credentialId} for host ${host.id}`, error);
// Fall back to legacy data
this.addLegacyCredentials(baseHost, host);
}
} else {
// Use legacy credential fields
this.addLegacyCredentials(baseHost, host);
}
return baseHost;
}
private addLegacyCredentials(baseHost: SSHHostWithCredentials, host: any): void {
baseHost.password = host.password;
baseHost.key = host.key;
baseHost.keyPassword = host.keyPassword;
baseHost.keyType = host.keyType;
}
/**
* Migrate a host from legacy credentials to a managed credential
*/
async migrateHostToCredential(userId: string, hostId: number, credentialName: string): Promise<number> {
try {
const host = await this.getHostWithCredentials(userId, hostId);
if (!host) {
throw new Error('Host not found');
}
if (host.credentialId) {
throw new Error('Host already uses managed credentials');
}
// Create a new credential from the host's legacy data
const credentialData = {
name: credentialName,
description: `Migrated from host ${host.name || host.ip}`,
folder: host.folder,
tags: host.tags,
authType: host.authType as 'password' | 'key',
username: host.username,
password: host.password,
key: host.key,
keyPassword: host.keyPassword,
keyType: host.keyType,
};
const credential = await credentialService.createCredential(userId, credentialData);
// Apply the new credential to the host
await this.applyCredentialToHost(userId, hostId, credential.id);
logger.success(`Migrated host ${hostId} to managed credential ${credential.id}`);
return credential.id;
} catch (error) {
logger.error(`Failed to migrate host ${hostId} to credential`, error);
throw error;
}
}
}
export const sshHostService = new SSHHostService();

View File

@@ -1,7 +1,10 @@
import express from 'express';
import cors from 'cors';
import {Client as SSHClient} from 'ssh2';
import chalk from "chalk";
import {db} from '../database/db/index.js';
import {sshData, sshCredentials} from '../database/db/schema.js';
import {eq, and} from 'drizzle-orm';
import { fileLogger } from '../utils/logger.js';
const app = express();
@@ -14,31 +17,6 @@ app.use(express.json({limit: '100mb'}));
app.use(express.urlencoded({limit: '100mb', extended: true}));
app.use(express.raw({limit: '200mb', type: 'application/octet-stream'}));
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;
@@ -69,15 +47,52 @@ function scheduleSessionCleanup(sessionId: string) {
}
app.post('/ssh/file_manager/ssh/connect', async (req, res) => {
const {sessionId, ip, port, username, password, sshKey, keyPassword} = req.body;
const {sessionId, hostId, ip, port, username, password, sshKey, keyPassword, authType, credentialId, userId} = req.body;
fileLogger.info('File manager SSH connection request received', { operation: 'file_connect', sessionId, hostId, ip, port, username, authType, hasCredentialId: !!credentialId });
if (!sessionId || !ip || !username || !port) {
fileLogger.warn('Missing SSH connection parameters for file manager', { operation: 'file_connect', sessionId, hasIp: !!ip, hasUsername: !!username, hasPort: !!port });
return res.status(400).json({error: 'Missing SSH connection parameters'});
}
if (sshSessions[sessionId]?.isConnected) {
fileLogger.info('Cleaning up existing SSH session', { operation: 'file_connect', sessionId });
cleanupSession(sessionId);
}
const client = new SSHClient();
let resolvedCredentials = {password, sshKey, keyPassword, authType};
if (credentialId && hostId && userId) {
fileLogger.info('Resolving credentials from database for file manager', { operation: 'file_connect', sessionId, hostId, credentialId, userId });
try {
const credentials = await db
.select()
.from(sshCredentials)
.where(and(
eq(sshCredentials.id, credentialId),
eq(sshCredentials.userId, userId)
));
if (credentials.length > 0) {
const credential = credentials[0];
resolvedCredentials = {
password: credential.password,
sshKey: credential.key,
keyPassword: credential.keyPassword,
authType: credential.authType
};
fileLogger.success('Credentials resolved successfully for file manager', { operation: 'file_connect', sessionId, hostId, credentialId, authType: credential.authType });
} else {
fileLogger.warn('No credentials found in database for file manager', { operation: 'file_connect', sessionId, hostId, credentialId });
}
} catch (error) {
fileLogger.warn('Failed to resolve credentials from database for file manager', { operation: 'file_connect', sessionId, hostId, credentialId, error: error instanceof Error ? error.message : 'Unknown error' });
}
} else {
fileLogger.info('Using direct credentials for file manager connection', { operation: 'file_connect', sessionId, hostId, authType });
}
const config: any = {
host: ip,
port: port || 22,
@@ -121,26 +136,29 @@ app.post('/ssh/file_manager/ssh/connect', async (req, res) => {
}
};
if (sshKey && sshKey.trim()) {
if (resolvedCredentials.sshKey && resolvedCredentials.sshKey.trim()) {
fileLogger.info('Configuring SSH key authentication for file manager', { operation: 'file_connect', sessionId, hostId, hasKeyPassword: !!resolvedCredentials.keyPassword });
try {
if (!sshKey.includes('-----BEGIN') || !sshKey.includes('-----END')) {
if (!resolvedCredentials.sshKey.includes('-----BEGIN') || !resolvedCredentials.sshKey.includes('-----END')) {
throw new Error('Invalid private key format');
}
const cleanKey = sshKey.trim().replace(/\r\n/g, '\n').replace(/\r/g, '\n');
const cleanKey = resolvedCredentials.sshKey.trim().replace(/\r\n/g, '\n').replace(/\r/g, '\n');
config.privateKey = Buffer.from(cleanKey, 'utf8');
if (keyPassword) config.passphrase = keyPassword;
if (resolvedCredentials.keyPassword) config.passphrase = resolvedCredentials.keyPassword;
logger.info('SSH key authentication configured successfully for file manager');
fileLogger.success('SSH key authentication configured successfully for file manager', { operation: 'file_connect', sessionId, hostId });
} catch (keyError) {
logger.error('SSH key format error: ' + keyError.message);
fileLogger.error('SSH key format error for file manager', { operation: 'file_connect', sessionId, hostId, error: keyError.message });
return res.status(400).json({error: 'Invalid SSH key format'});
}
} else if (password && password.trim()) {
config.password = password;
} else if (resolvedCredentials.password && resolvedCredentials.password.trim()) {
fileLogger.info('Configuring password authentication for file manager', { operation: 'file_connect', sessionId, hostId });
config.password = resolvedCredentials.password;
} else {
fileLogger.warn('No authentication method provided for file manager', { operation: 'file_connect', sessionId, hostId });
return res.status(400).json({error: 'Either password or SSH key must be provided'});
}
@@ -149,6 +167,7 @@ app.post('/ssh/file_manager/ssh/connect', async (req, res) => {
client.on('ready', () => {
if (responseSent) return;
responseSent = true;
fileLogger.success('SSH connection established for file manager', { operation: 'file_connect', sessionId, hostId, ip, port, username, authType: resolvedCredentials.authType });
sshSessions[sessionId] = {client, isConnected: true, lastActive: Date.now()};
res.json({status: 'success', message: 'SSH connection established'});
});
@@ -156,7 +175,7 @@ app.post('/ssh/file_manager/ssh/connect', async (req, res) => {
client.on('error', (err) => {
if (responseSent) return;
responseSent = true;
logger.error(`SSH connection error for session ${sessionId}:`, err.message);
fileLogger.error('SSH connection failed for file manager', { operation: 'file_connect', sessionId, hostId, ip, port, username, error: err.message });
res.status(500).json({status: 'error', message: err.message});
});
@@ -199,7 +218,7 @@ app.get('/ssh/file_manager/ssh/listFiles', (req, res) => {
const escapedPath = sshPath.replace(/'/g, "'\"'\"'");
sshConn.client.exec(`ls -la '${escapedPath}'`, (err, stream) => {
if (err) {
logger.error('SSH listFiles error:', err);
fileLogger.error('SSH listFiles error:', err);
return res.status(500).json({error: err.message});
}
@@ -216,7 +235,7 @@ app.get('/ssh/file_manager/ssh/listFiles', (req, res) => {
stream.on('close', (code) => {
if (code !== 0) {
logger.error(`SSH listFiles command failed with code ${code}: ${errorData.replace(/\n/g, ' ').trim()}`);
fileLogger.error(`SSH listFiles command failed with code ${code}: ${errorData.replace(/\n/g, ' ').trim()}`);
return res.status(500).json({error: `Command failed: ${errorData}`});
}
@@ -269,7 +288,7 @@ app.get('/ssh/file_manager/ssh/readFile', (req, res) => {
const escapedPath = filePath.replace(/'/g, "'\"'\"'");
sshConn.client.exec(`cat '${escapedPath}'`, (err, stream) => {
if (err) {
logger.error('SSH readFile error:', err);
fileLogger.error('SSH readFile error:', err);
return res.status(500).json({error: err.message});
}
@@ -286,7 +305,7 @@ app.get('/ssh/file_manager/ssh/readFile', (req, res) => {
stream.on('close', (code) => {
if (code !== 0) {
logger.error(`SSH readFile command failed with code ${code}: ${errorData.replace(/\n/g, ' ').trim()}`);
fileLogger.error(`SSH readFile command failed with code ${code}: ${errorData.replace(/\n/g, ' ').trim()}`);
return res.status(500).json({error: `Command failed: ${errorData}`});
}
@@ -321,7 +340,7 @@ app.post('/ssh/file_manager/ssh/writeFile', (req, res) => {
try {
sshConn.client.sftp((err, sftp) => {
if (err) {
logger.warn(`SFTP failed, trying fallback method: ${err.message}`);
fileLogger.warn(`SFTP failed, trying fallback method: ${err.message}`);
tryFallbackMethod();
return;
}
@@ -336,7 +355,7 @@ app.post('/ssh/file_manager/ssh/writeFile', (req, res) => {
fileBuffer = Buffer.from(content);
}
} catch (bufferErr) {
logger.error('Buffer conversion error:', bufferErr);
fileLogger.error('Buffer conversion error:', bufferErr);
if (!res.headersSent) {
return res.status(500).json({error: 'Invalid file content format'});
}
@@ -351,14 +370,14 @@ app.post('/ssh/file_manager/ssh/writeFile', (req, res) => {
writeStream.on('error', (streamErr) => {
if (hasError || hasFinished) return;
hasError = true;
logger.warn(`SFTP write failed, trying fallback method: ${streamErr.message}`);
fileLogger.warn(`SFTP write failed, trying fallback method: ${streamErr.message}`);
tryFallbackMethod();
});
writeStream.on('finish', () => {
if (hasError || hasFinished) return;
hasFinished = true;
logger.success(`File written successfully via SFTP: ${filePath}`);
fileLogger.success(`File written successfully via SFTP: ${filePath}`);
if (!res.headersSent) {
res.json({message: 'File written successfully', path: filePath});
}
@@ -367,7 +386,7 @@ app.post('/ssh/file_manager/ssh/writeFile', (req, res) => {
writeStream.on('close', () => {
if (hasError || hasFinished) return;
hasFinished = true;
logger.success(`File written successfully via SFTP: ${filePath}`);
fileLogger.success(`File written successfully via SFTP: ${filePath}`);
if (!res.headersSent) {
res.json({message: 'File written successfully', path: filePath});
}
@@ -379,12 +398,12 @@ app.post('/ssh/file_manager/ssh/writeFile', (req, res) => {
} catch (writeErr) {
if (hasError || hasFinished) return;
hasError = true;
logger.warn(`SFTP write operation failed, trying fallback method: ${writeErr.message}`);
fileLogger.warn(`SFTP write operation failed, trying fallback method: ${writeErr.message}`);
tryFallbackMethod();
}
});
} catch (sftpErr) {
logger.warn(`SFTP connection error, trying fallback method: ${sftpErr.message}`);
fileLogger.warn(`SFTP connection error, trying fallback method: ${sftpErr.message}`);
tryFallbackMethod();
}
};
@@ -399,7 +418,7 @@ app.post('/ssh/file_manager/ssh/writeFile', (req, res) => {
sshConn.client.exec(writeCommand, (err, stream) => {
if (err) {
logger.error('Fallback write command failed:', err);
fileLogger.error('Fallback write command failed:', err);
if (!res.headersSent) {
return res.status(500).json({error: `Write failed: ${err.message}`});
}
@@ -421,12 +440,12 @@ app.post('/ssh/file_manager/ssh/writeFile', (req, res) => {
if (outputData.includes('SUCCESS')) {
logger.success(`File written successfully via fallback: ${filePath}`);
fileLogger.success(`File written successfully via fallback: ${filePath}`);
if (!res.headersSent) {
res.json({message: 'File written successfully', path: filePath});
}
} else {
logger.error(`Fallback write failed with code ${code}: ${errorData}`);
fileLogger.error(`Fallback write failed with code ${code}: ${errorData}`);
if (!res.headersSent) {
res.status(500).json({error: `Write failed: ${errorData}`});
}
@@ -435,7 +454,7 @@ app.post('/ssh/file_manager/ssh/writeFile', (req, res) => {
stream.on('error', (streamErr) => {
logger.error('Fallback write stream error:', streamErr);
fileLogger.error('Fallback write stream error:', streamErr);
if (!res.headersSent) {
res.status(500).json({error: `Write stream error: ${streamErr.message}`});
}
@@ -443,7 +462,7 @@ app.post('/ssh/file_manager/ssh/writeFile', (req, res) => {
});
} catch (fallbackErr) {
logger.error('Fallback method failed:', fallbackErr);
fileLogger.error('Fallback method failed:', fallbackErr);
if (!res.headersSent) {
res.status(500).json({error: `All write methods failed: ${fallbackErr.message}`});
}
@@ -475,12 +494,11 @@ app.post('/ssh/file_manager/ssh/uploadFile', (req, res) => {
const fullPath = filePath.endsWith('/') ? filePath + fileName : filePath + '/' + fileName;
const trySFTP = () => {
try {
sshConn.client.sftp((err, sftp) => {
if (err) {
logger.warn(`SFTP failed, trying fallback method: ${err.message}`);
fileLogger.warn(`SFTP failed, trying fallback method: ${err.message}`);
tryFallbackMethod();
return;
}
@@ -496,7 +514,7 @@ app.post('/ssh/file_manager/ssh/uploadFile', (req, res) => {
}
} catch (bufferErr) {
logger.error('Buffer conversion error:', bufferErr);
fileLogger.error('Buffer conversion error:', bufferErr);
if (!res.headersSent) {
return res.status(500).json({error: 'Invalid file content format'});
}
@@ -511,7 +529,7 @@ app.post('/ssh/file_manager/ssh/uploadFile', (req, res) => {
writeStream.on('error', (streamErr) => {
if (hasError || hasFinished) return;
hasError = true;
logger.warn(`SFTP write failed, trying fallback method: ${streamErr.message}`);
fileLogger.warn(`SFTP write failed, trying fallback method: ${streamErr.message}`);
tryFallbackMethod();
});
@@ -519,7 +537,7 @@ app.post('/ssh/file_manager/ssh/uploadFile', (req, res) => {
if (hasError || hasFinished) return;
hasFinished = true;
logger.success(`File uploaded successfully via SFTP: ${fullPath}`);
fileLogger.success(`File uploaded successfully via SFTP: ${fullPath}`);
if (!res.headersSent) {
res.json({message: 'File uploaded successfully', path: fullPath});
}
@@ -529,7 +547,7 @@ app.post('/ssh/file_manager/ssh/uploadFile', (req, res) => {
if (hasError || hasFinished) return;
hasFinished = true;
logger.success(`File uploaded successfully via SFTP: ${fullPath}`);
fileLogger.success(`File uploaded successfully via SFTP: ${fullPath}`);
if (!res.headersSent) {
res.json({message: 'File uploaded successfully', path: fullPath});
}
@@ -541,12 +559,12 @@ app.post('/ssh/file_manager/ssh/uploadFile', (req, res) => {
} catch (writeErr) {
if (hasError || hasFinished) return;
hasError = true;
logger.warn(`SFTP write operation failed, trying fallback method: ${writeErr.message}`);
fileLogger.warn(`SFTP write operation failed, trying fallback method: ${writeErr.message}`);
tryFallbackMethod();
}
});
} catch (sftpErr) {
logger.warn(`SFTP connection error, trying fallback method: ${sftpErr.message}`);
fileLogger.warn(`SFTP connection error, trying fallback method: ${sftpErr.message}`);
tryFallbackMethod();
}
};
@@ -571,7 +589,7 @@ app.post('/ssh/file_manager/ssh/uploadFile', (req, res) => {
sshConn.client.exec(writeCommand, (err, stream) => {
if (err) {
logger.error('Fallback upload command failed:', err);
fileLogger.error('Fallback upload command failed:', err);
if (!res.headersSent) {
return res.status(500).json({error: `Upload failed: ${err.message}`});
}
@@ -593,12 +611,12 @@ app.post('/ssh/file_manager/ssh/uploadFile', (req, res) => {
if (outputData.includes('SUCCESS')) {
logger.success(`File uploaded successfully via fallback: ${fullPath}`);
fileLogger.success(`File uploaded successfully via fallback: ${fullPath}`);
if (!res.headersSent) {
res.json({message: 'File uploaded successfully', path: fullPath});
}
} else {
logger.error(`Fallback upload failed with code ${code}: ${errorData}`);
fileLogger.error(`Fallback upload failed with code ${code}: ${errorData}`);
if (!res.headersSent) {
res.status(500).json({error: `Upload failed: ${errorData}`});
}
@@ -607,7 +625,7 @@ app.post('/ssh/file_manager/ssh/uploadFile', (req, res) => {
stream.on('error', (streamErr) => {
logger.error('Fallback upload stream error:', streamErr);
fileLogger.error('Fallback upload stream error:', streamErr);
if (!res.headersSent) {
res.status(500).json({error: `Upload stream error: ${streamErr.message}`});
}
@@ -629,7 +647,7 @@ app.post('/ssh/file_manager/ssh/uploadFile', (req, res) => {
sshConn.client.exec(writeCommand, (err, stream) => {
if (err) {
logger.error('Chunked fallback upload failed:', err);
fileLogger.error('Chunked fallback upload failed:', err);
if (!res.headersSent) {
return res.status(500).json({error: `Chunked upload failed: ${err.message}`});
}
@@ -651,12 +669,12 @@ app.post('/ssh/file_manager/ssh/uploadFile', (req, res) => {
if (outputData.includes('SUCCESS')) {
logger.success(`File uploaded successfully via chunked fallback: ${fullPath}`);
fileLogger.success(`File uploaded successfully via chunked fallback: ${fullPath}`);
if (!res.headersSent) {
res.json({message: 'File uploaded successfully', path: fullPath});
}
} else {
logger.error(`Chunked fallback upload failed with code ${code}: ${errorData}`);
fileLogger.error(`Chunked fallback upload failed with code ${code}: ${errorData}`);
if (!res.headersSent) {
res.status(500).json({error: `Chunked upload failed: ${errorData}`});
}
@@ -664,7 +682,7 @@ app.post('/ssh/file_manager/ssh/uploadFile', (req, res) => {
});
stream.on('error', (streamErr) => {
logger.error('Chunked fallback upload stream error:', streamErr);
fileLogger.error('Chunked fallback upload stream error:', streamErr);
if (!res.headersSent) {
res.status(500).json({error: `Chunked upload stream error: ${streamErr.message}`});
}
@@ -672,7 +690,7 @@ app.post('/ssh/file_manager/ssh/uploadFile', (req, res) => {
});
}
} catch (fallbackErr) {
logger.error('Fallback method failed:', fallbackErr);
fileLogger.error('Fallback method failed:', fallbackErr);
if (!res.headersSent) {
res.status(500).json({error: `All upload methods failed: ${fallbackErr.message}`});
}
@@ -707,7 +725,7 @@ app.post('/ssh/file_manager/ssh/createFile', (req, res) => {
sshConn.client.exec(createCommand, (err, stream) => {
if (err) {
logger.error('SSH createFile error:', err);
fileLogger.error('SSH createFile error:', err);
if (!res.headersSent) {
return res.status(500).json({error: err.message});
}
@@ -725,7 +743,7 @@ app.post('/ssh/file_manager/ssh/createFile', (req, res) => {
errorData += chunk.toString();
if (chunk.toString().includes('Permission denied')) {
logger.error(`Permission denied creating file: ${fullPath}`);
fileLogger.error(`Permission denied creating file: ${fullPath}`);
if (!res.headersSent) {
return res.status(403).json({
error: `Permission denied: Cannot create file ${fullPath}. Check directory permissions.`
@@ -744,7 +762,7 @@ app.post('/ssh/file_manager/ssh/createFile', (req, res) => {
}
if (code !== 0) {
logger.error(`SSH createFile command failed with code ${code}: ${errorData.replace(/\n/g, ' ').trim()}`);
fileLogger.error(`SSH createFile command failed with code ${code}: ${errorData.replace(/\n/g, ' ').trim()}`);
if (!res.headersSent) {
return res.status(500).json({error: `Command failed: ${errorData}`});
}
@@ -757,7 +775,7 @@ app.post('/ssh/file_manager/ssh/createFile', (req, res) => {
});
stream.on('error', (streamErr) => {
logger.error('SSH createFile stream error:', streamErr);
fileLogger.error('SSH createFile stream error:', streamErr);
if (!res.headersSent) {
res.status(500).json({error: `Stream error: ${streamErr.message}`});
}
@@ -791,7 +809,7 @@ app.post('/ssh/file_manager/ssh/createFolder', (req, res) => {
sshConn.client.exec(createCommand, (err, stream) => {
if (err) {
logger.error('SSH createFolder error:', err);
fileLogger.error('SSH createFolder error:', err);
if (!res.headersSent) {
return res.status(500).json({error: err.message});
}
@@ -809,7 +827,7 @@ app.post('/ssh/file_manager/ssh/createFolder', (req, res) => {
errorData += chunk.toString();
if (chunk.toString().includes('Permission denied')) {
logger.error(`Permission denied creating folder: ${fullPath}`);
fileLogger.error(`Permission denied creating folder: ${fullPath}`);
if (!res.headersSent) {
return res.status(403).json({
error: `Permission denied: Cannot create folder ${fullPath}. Check directory permissions.`
@@ -828,7 +846,7 @@ app.post('/ssh/file_manager/ssh/createFolder', (req, res) => {
}
if (code !== 0) {
logger.error(`SSH createFolder command failed with code ${code}: ${errorData.replace(/\n/g, ' ').trim()}`);
fileLogger.error(`SSH createFolder command failed with code ${code}: ${errorData.replace(/\n/g, ' ').trim()}`);
if (!res.headersSent) {
return res.status(500).json({error: `Command failed: ${errorData}`});
}
@@ -841,7 +859,7 @@ app.post('/ssh/file_manager/ssh/createFolder', (req, res) => {
});
stream.on('error', (streamErr) => {
logger.error('SSH createFolder stream error:', streamErr);
fileLogger.error('SSH createFolder stream error:', streamErr);
if (!res.headersSent) {
res.status(500).json({error: `Stream error: ${streamErr.message}`});
}
@@ -874,7 +892,7 @@ app.delete('/ssh/file_manager/ssh/deleteItem', (req, res) => {
sshConn.client.exec(deleteCommand, (err, stream) => {
if (err) {
logger.error('SSH deleteItem error:', err);
fileLogger.error('SSH deleteItem error:', err);
if (!res.headersSent) {
return res.status(500).json({error: err.message});
}
@@ -892,7 +910,7 @@ app.delete('/ssh/file_manager/ssh/deleteItem', (req, res) => {
errorData += chunk.toString();
if (chunk.toString().includes('Permission denied')) {
logger.error(`Permission denied deleting: ${itemPath}`);
fileLogger.error(`Permission denied deleting: ${itemPath}`);
if (!res.headersSent) {
return res.status(403).json({
error: `Permission denied: Cannot delete ${itemPath}. Check file permissions.`
@@ -911,7 +929,7 @@ app.delete('/ssh/file_manager/ssh/deleteItem', (req, res) => {
}
if (code !== 0) {
logger.error(`SSH deleteItem command failed with code ${code}: ${errorData.replace(/\n/g, ' ').trim()}`);
fileLogger.error(`SSH deleteItem command failed with code ${code}: ${errorData.replace(/\n/g, ' ').trim()}`);
if (!res.headersSent) {
return res.status(500).json({error: `Command failed: ${errorData}`});
}
@@ -924,7 +942,7 @@ app.delete('/ssh/file_manager/ssh/deleteItem', (req, res) => {
});
stream.on('error', (streamErr) => {
logger.error('SSH deleteItem stream error:', streamErr);
fileLogger.error('SSH deleteItem stream error:', streamErr);
if (!res.headersSent) {
res.status(500).json({error: `Stream error: ${streamErr.message}`});
}
@@ -959,7 +977,7 @@ app.put('/ssh/file_manager/ssh/renameItem', (req, res) => {
sshConn.client.exec(renameCommand, (err, stream) => {
if (err) {
logger.error('SSH renameItem error:', err);
fileLogger.error('SSH renameItem error:', err);
if (!res.headersSent) {
return res.status(500).json({error: err.message});
}
@@ -977,7 +995,7 @@ app.put('/ssh/file_manager/ssh/renameItem', (req, res) => {
errorData += chunk.toString();
if (chunk.toString().includes('Permission denied')) {
logger.error(`Permission denied renaming: ${oldPath}`);
fileLogger.error(`Permission denied renaming: ${oldPath}`);
if (!res.headersSent) {
return res.status(403).json({
error: `Permission denied: Cannot rename ${oldPath}. Check file permissions.`
@@ -996,7 +1014,7 @@ app.put('/ssh/file_manager/ssh/renameItem', (req, res) => {
}
if (code !== 0) {
logger.error(`SSH renameItem command failed with code ${code}: ${errorData.replace(/\n/g, ' ').trim()}`);
fileLogger.error(`SSH renameItem command failed with code ${code}: ${errorData.replace(/\n/g, ' ').trim()}`);
if (!res.headersSent) {
return res.status(500).json({error: `Command failed: ${errorData}`});
}
@@ -1009,7 +1027,7 @@ app.put('/ssh/file_manager/ssh/renameItem', (req, res) => {
});
stream.on('error', (streamErr) => {
logger.error('SSH renameItem stream error:', streamErr);
fileLogger.error('SSH renameItem stream error:', streamErr);
if (!res.headersSent) {
res.status(500).json({error: `Stream error: ${streamErr.message}`});
}
@@ -1029,4 +1047,5 @@ process.on('SIGTERM', () => {
const PORT = 8084;
app.listen(PORT, () => {
fileLogger.success('File Manager API server started', { operation: 'server_start', port: PORT });
});

View File

@@ -1,16 +1,40 @@
import express from 'express';
import chalk from 'chalk';
import fetch from 'node-fetch';
import net from 'net';
import cors from 'cors';
import {Client, type ConnectConfig} from 'ssh2';
import {sshHostService} from '../services/ssh-host.js';
import type {SSHHostWithCredentials} from '../services/ssh-host.js';
// Removed HostRecord - using SSHHostWithCredentials from ssh-host service instead
import {db} from '../database/db/index.js';
import {sshData, sshCredentials} from '../database/db/schema.js';
import {eq, and} from 'drizzle-orm';
import { statsLogger } from '../utils/logger.js';
type HostStatus = 'online' | 'offline';
interface SSHHostWithCredentials {
id: number;
name: string;
ip: string;
port: number;
username: string;
folder: string;
tags: string[];
pin: boolean;
authType: string;
password?: string;
key?: string;
keyPassword?: string;
keyType?: string;
credentialId?: number;
enableTerminal: boolean;
enableTunnel: boolean;
enableFileManager: boolean;
defaultPath: string;
tunnelConnections: any[];
createdAt: string;
updatedAt: string;
userId: string;
}
type StatusEntry = {
status: HostStatus;
lastChecked: string;
@@ -33,92 +57,127 @@ app.use((req, res, next) => {
});
app.use(express.json());
const statsIconSymbol = '📡';
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('#22c55e')(`[${statsIconSymbol}]`)} ${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 hostStatuses: Map<number, StatusEntry> = new Map();
async function fetchAllHosts(): Promise<SSHHostWithCredentials[]> {
const url = 'http://localhost:8081/ssh/db/host/internal';
try {
const resp = await fetch(url, {
headers: {'x-internal-request': '1'}
});
if (!resp.ok) {
throw new Error(`DB service error: ${resp.status} ${resp.statusText}`);
}
const data = await resp.json();
const rawHosts = Array.isArray(data) ? data : [];
const hosts = await db.select().from(sshData);
// Resolve credentials for each host using the same logic as main SSH connections
const hostsWithCredentials: SSHHostWithCredentials[] = [];
for (const rawHost of rawHosts) {
for (const host of hosts) {
try {
// Use the ssh-host service to properly resolve credentials
const host = await sshHostService.getHostWithCredentials(rawHost.userId, rawHost.id);
if (host) {
hostsWithCredentials.push(host);
const hostWithCreds = await resolveHostCredentials(host);
if (hostWithCreds) {
hostsWithCredentials.push(hostWithCreds);
}
} catch (err) {
logger.warn(`Failed to resolve credentials for host ${rawHost.id}: ${err instanceof Error ? err.message : 'Unknown error'}`);
statsLogger.warn(`Failed to resolve credentials for host ${host.id}: ${err instanceof Error ? err.message : 'Unknown error'}`);
}
}
return hostsWithCredentials.filter(h => !!h.id && !!h.ip && !!h.port);
} catch (err) {
logger.error('Failed to fetch hosts from database service', err);
statsLogger.error('Failed to fetch hosts from database', err);
return [];
}
}
async function fetchHostById(id: number): Promise<SSHHostWithCredentials | undefined> {
try {
// Get all users that might own this host
const url = 'http://localhost:8081/ssh/db/host/internal';
const resp = await fetch(url, {
headers: {'x-internal-request': '1'}
});
if (!resp.ok) {
throw new Error(`DB service error: ${resp.status} ${resp.statusText}`);
}
const data = await resp.json();
const rawHost = (Array.isArray(data) ? data : []).find((h: any) => h.id === id);
const hosts = await db.select().from(sshData).where(eq(sshData.id, id));
if (!rawHost) {
if (hosts.length === 0) {
return undefined;
}
// Use ssh-host service to properly resolve credentials
return await sshHostService.getHostWithCredentials(rawHost.userId, id);
const host = hosts[0];
return await resolveHostCredentials(host);
} catch (err) {
logger.error(`Failed to fetch host ${id}`, err);
statsLogger.error(`Failed to fetch host ${id}`, err);
return undefined;
}
}
async function resolveHostCredentials(host: any): Promise<SSHHostWithCredentials | undefined> {
try {
statsLogger.info('Resolving credentials for host', { operation: 'host_credential_resolve', hostId: host.id, hostName: host.name, hasCredentialId: !!host.credentialId });
const baseHost: any = {
id: host.id,
name: host.name,
ip: host.ip,
port: host.port,
username: host.username,
folder: host.folder || '',
tags: typeof host.tags === 'string' ? (host.tags ? host.tags.split(',').filter(Boolean) : []) : [],
pin: !!host.pin,
authType: host.authType,
enableTerminal: !!host.enableTerminal,
enableTunnel: !!host.enableTunnel,
enableFileManager: !!host.enableFileManager,
defaultPath: host.defaultPath || '/',
tunnelConnections: host.tunnelConnections ? JSON.parse(host.tunnelConnections) : [],
createdAt: host.createdAt,
updatedAt: host.updatedAt,
userId: host.userId
};
if (host.credentialId) {
statsLogger.info('Fetching credentials from database', { operation: 'host_credential_resolve', hostId: host.id, credentialId: host.credentialId, userId: host.userId });
try {
const credentials = await db
.select()
.from(sshCredentials)
.where(and(
eq(sshCredentials.id, host.credentialId),
eq(sshCredentials.userId, host.userId)
));
if (credentials.length > 0) {
const credential = credentials[0];
baseHost.credentialId = credential.id;
baseHost.username = credential.username;
baseHost.authType = credential.authType;
if (credential.password) {
baseHost.password = credential.password;
}
if (credential.key) {
baseHost.key = credential.key;
}
if (credential.keyPassword) {
baseHost.keyPassword = credential.keyPassword;
}
if (credential.keyType) {
baseHost.keyType = credential.keyType;
}
} else {
statsLogger.warn(`Credential ${host.credentialId} not found for host ${host.id}, using legacy data`);
addLegacyCredentials(baseHost, host);
}
} catch (error) {
statsLogger.warn(`Failed to resolve credential ${host.credentialId} for host ${host.id}: ${error instanceof Error ? error.message : 'Unknown error'}`);
addLegacyCredentials(baseHost, host);
}
} else {
addLegacyCredentials(baseHost, host);
}
return baseHost;
} catch (error) {
statsLogger.error(`Failed to resolve host credentials for host ${host.id}: ${error instanceof Error ? error.message : 'Unknown error'}`);
return undefined;
}
}
function addLegacyCredentials(baseHost: any, host: any): void {
baseHost.password = host.password || null;
baseHost.key = host.key || null;
baseHost.keyPassword = host.keyPassword || null;
baseHost.keyType = host.keyType;
}
function buildSshConfig(host: SSHHostWithCredentials): ConnectConfig {
const base: ConnectConfig = {
host: host.ip,
@@ -128,7 +187,6 @@ function buildSshConfig(host: SSHHostWithCredentials): ConnectConfig {
algorithms: {}
} as ConnectConfig;
// Use the same authentication logic as main SSH connections
if (host.authType === 'password') {
if (!host.password) {
throw new Error(`No password available for host ${host.ip}`);
@@ -152,7 +210,7 @@ function buildSshConfig(host: SSHHostWithCredentials): ConnectConfig {
(base as any).passphrase = host.keyPassword;
}
} catch (keyError) {
logger.error(`SSH key format error for host ${host.ip}: ${keyError instanceof Error ? keyError.message : 'Unknown error'}`);
statsLogger.error(`SSH key format error for host ${host.ip}: ${keyError instanceof Error ? keyError.message : 'Unknown error'}`);
throw new Error(`Invalid SSH key format for host ${host.ip}`);
}
} else {
@@ -316,7 +374,6 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{
let usedHuman: string | null = null;
let totalHuman: string | null = null;
try {
// Get both human-readable and bytes format for accurate calculation
const diskOutHuman = await execCommand(client, 'df -h -P / | tail -n +2');
const diskOutBytes = await execCommand(client, 'df -B1 -P / | tail -n +2');
@@ -330,7 +387,6 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{
totalHuman = humanParts[1] || null;
usedHuman = humanParts[2] || null;
// Calculate our own percentage using bytes for accuracy
const totalBytes = Number(bytesParts[1]);
const usedBytes = Number(bytesParts[2]);
@@ -381,25 +437,30 @@ function tcpPing(host: string, port: number, timeoutMs = 5000): Promise<boolean>
}
async function pollStatusesOnce(): Promise<void> {
statsLogger.info('Starting status polling for all hosts', { operation: 'status_poll' });
const hosts = await fetchAllHosts();
if (hosts.length === 0) {
logger.warn('No hosts retrieved for status polling');
statsLogger.warn('No hosts retrieved for status polling', { operation: 'status_poll' });
return;
}
statsLogger.info('Polling status for hosts', { operation: 'status_poll', hostCount: hosts.length, hostIds: hosts.map(h => h.id) });
const now = new Date().toISOString();
const checks = hosts.map(async (h) => {
statsLogger.info('Checking host status', { operation: 'status_poll', hostId: h.id, hostName: h.name, ip: h.ip, port: h.port });
const isOnline = await tcpPing(h.ip, h.port, 5000);
const now = new Date().toISOString();
const statusEntry: StatusEntry = {status: isOnline ? 'online' : 'offline', lastChecked: now};
hostStatuses.set(h.id, statusEntry);
statsLogger.info('Host status check completed', { operation: 'status_poll', hostId: h.id, hostName: h.name, status: isOnline ? 'online' : 'offline' });
return isOnline;
});
const results = await Promise.allSettled(checks);
const onlineCount = results.filter(r => r.status === 'fulfilled' && r.value === true).length;
const offlineCount = hosts.length - onlineCount;
statsLogger.success('Status polling completed', { operation: 'status_poll', totalHosts: hosts.length, onlineCount, offlineCount });
}
app.get('/status', async (req, res) => {
@@ -432,7 +493,7 @@ app.get('/status/:id', async (req, res) => {
hostStatuses.set(id, statusEntry);
res.json(statusEntry);
} catch (err) {
logger.error('Failed to check host status', err);
statsLogger.error('Failed to check host status', err);
res.status(500).json({error: 'Failed to check host status'});
}
});
@@ -455,7 +516,7 @@ app.get('/metrics/:id', async (req, res) => {
const metrics = await collectMetrics(host);
res.json({...metrics, lastChecked: new Date().toISOString()});
} catch (err) {
logger.error('Failed to collect metrics', err);
statsLogger.error('Failed to collect metrics', err);
return res.json({
cpu: {percent: null, cores: null, load: null},
memory: {percent: null, usedGiB: null, totalGiB: null},
@@ -467,9 +528,10 @@ app.get('/metrics/:id', async (req, res) => {
const PORT = 8085;
app.listen(PORT, async () => {
statsLogger.success('Server Stats API server started', { operation: 'server_start', port: PORT });
try {
await pollStatusesOnce();
} catch (err) {
logger.error('Initial poll failed', err);
statsLogger.error('Initial poll failed', err, { operation: 'initial_poll' });
}
});

View File

@@ -1,48 +1,27 @@
import {WebSocketServer, WebSocket, type RawData} from 'ws';
import {Client, type ClientChannel, type PseudoTtyOptions} from 'ssh2';
import chalk from 'chalk';
import {db} from '../database/db/index.js';
import {sshData, sshCredentials} from '../database/db/schema.js';
import {eq, and} from 'drizzle-orm';
import { sshLogger } from '../utils/logger.js';
const wss = new WebSocketServer({port: 8082});
sshLogger.success('SSH Terminal WebSocket server started', { operation: 'server_start', 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;
sshLogger.info('New WebSocket connection established', { operation: 'websocket_connect' });
ws.on('close', () => {
sshLogger.info('WebSocket connection closed', { operation: 'websocket_disconnect' });
cleanupSSH();
});
@@ -53,7 +32,7 @@ wss.on('connection', (ws: WebSocket) => {
try {
parsed = JSON.parse(msg.toString());
} catch (e) {
logger.error('Invalid JSON received: ' + msg.toString());
sshLogger.error('Invalid JSON received', e, { operation: 'websocket_message', messageLength: msg.toString().length });
ws.send(JSON.stringify({type: 'error', message: 'Invalid JSON'}));
return;
}
@@ -62,7 +41,11 @@ wss.on('connection', (ws: WebSocket) => {
switch (type) {
case 'connectToHost':
handleConnectToHost(data);
sshLogger.info('SSH connection request received', { operation: 'ssh_connect', hostId: data.hostConfig?.id, ip: data.hostConfig?.ip, port: data.hostConfig?.port });
handleConnectToHost(data).catch(error => {
sshLogger.error('Failed to connect to host', error, { operation: 'ssh_connect', hostId: data.hostConfig?.id, ip: data.hostConfig?.ip });
ws.send(JSON.stringify({type: 'error', message: 'Failed to connect to host: ' + (error instanceof Error ? error.message : 'Unknown error')}));
});
break;
case 'resize':
@@ -70,6 +53,7 @@ wss.on('connection', (ws: WebSocket) => {
break;
case 'disconnect':
sshLogger.info('SSH disconnect requested', { operation: 'ssh_disconnect' });
cleanupSSH();
break;
@@ -90,14 +74,15 @@ wss.on('connection', (ws: WebSocket) => {
break;
default:
logger.warn('Unknown message type: ' + type);
sshLogger.warn('Unknown message type received', { operation: 'websocket_message', messageType: type });
}
});
function handleConnectToHost(data: {
async function handleConnectToHost(data: {
cols: number;
rows: number;
hostConfig: {
id: number;
ip: string;
port: number;
username: string;
@@ -106,25 +91,27 @@ wss.on('connection', (ws: WebSocket) => {
keyPassword?: string;
keyType?: string;
authType?: string;
credentialId?: number;
userId?: string;
};
}) {
const {cols, rows, hostConfig} = data;
const {ip, port, username, password, key, keyPassword, keyType, authType} = hostConfig;
const {id, ip, port, username, password, key, keyPassword, keyType, authType, credentialId} = hostConfig;
if (!username || typeof username !== 'string' || username.trim() === '') {
logger.error('Invalid username provided');
sshLogger.error('Invalid username provided', undefined, { operation: 'ssh_connect', hostId: id, ip });
ws.send(JSON.stringify({type: 'error', message: 'Invalid username provided'}));
return;
}
if (!ip || typeof ip !== 'string' || ip.trim() === '') {
logger.error('Invalid IP provided');
sshLogger.error('Invalid IP provided', undefined, { operation: 'ssh_connect', hostId: id, username });
ws.send(JSON.stringify({type: 'error', message: 'Invalid IP provided'}));
return;
}
if (!port || typeof port !== 'number' || port <= 0) {
logger.error('Invalid port provided');
sshLogger.error('Invalid port provided', undefined, { operation: 'ssh_connect', hostId: id, ip, username, port });
ws.send(JSON.stringify({type: 'error', message: 'Invalid port provided'}));
return;
}
@@ -133,14 +120,41 @@ wss.on('connection', (ws: WebSocket) => {
const connectionTimeout = setTimeout(() => {
if (sshConn) {
logger.error('SSH connection timeout');
sshLogger.error('SSH connection timeout', undefined, { operation: 'ssh_connect', hostId: id, ip, port, username });
ws.send(JSON.stringify({type: 'error', message: 'SSH connection timeout'}));
cleanupSSH(connectionTimeout);
}
}, 60000);
let resolvedCredentials = {password, key, keyPassword, keyType, authType};
if (credentialId && id) {
try {
const credentials = await db
.select()
.from(sshCredentials)
.where(and(
eq(sshCredentials.id, credentialId),
eq(sshCredentials.userId, hostConfig.userId || '')
));
if (credentials.length > 0) {
const credential = credentials[0];
resolvedCredentials = {
password: credential.password,
key: credential.key,
keyPassword: credential.keyPassword,
keyType: credential.keyType,
authType: credential.authType
};
}
} catch (error) {
sshLogger.warn(`Failed to resolve credentials for host ${id}`, { operation: 'ssh_credentials', hostId: id, credentialId, error: error instanceof Error ? error.message : 'Unknown error' });
}
}
sshConn.on('ready', () => {
clearTimeout(connectionTimeout);
sshLogger.success('SSH connection established', { operation: 'ssh_connect', hostId: id, ip, port, username, authType: resolvedCredentials.authType });
sshConn!.shell({
@@ -149,7 +163,7 @@ wss.on('connection', (ws: WebSocket) => {
term: 'xterm-256color'
} as PseudoTtyOptions, (err, stream) => {
if (err) {
logger.error('Shell error: ' + err.message);
sshLogger.error('Shell error', err, { operation: 'ssh_shell', hostId: id, ip, port, username });
ws.send(JSON.stringify({type: 'error', message: 'Shell error: ' + err.message}));
return;
}
@@ -161,12 +175,12 @@ wss.on('connection', (ws: WebSocket) => {
});
stream.on('close', () => {
sshLogger.info('SSH stream closed', { operation: 'ssh_stream', hostId: id, ip, port, username });
ws.send(JSON.stringify({type: 'disconnected', message: 'Connection lost'}));
});
stream.on('error', (err: Error) => {
logger.error('SSH stream error: ' + err.message);
sshLogger.error('SSH stream error', err, { operation: 'ssh_stream', hostId: id, ip, port, username });
ws.send(JSON.stringify({type: 'error', message: 'SSH stream error: ' + err.message}));
});
@@ -178,7 +192,7 @@ wss.on('connection', (ws: WebSocket) => {
sshConn.on('error', (err: Error) => {
clearTimeout(connectionTimeout);
logger.error('SSH connection error: ' + err.message);
sshLogger.error('SSH connection error', err, { operation: 'ssh_connect', hostId: id, ip, port, username, authType: resolvedCredentials.authType });
let errorMessage = 'SSH error: ' + err.message;
if (err.message.includes('No matching key exchange algorithm')) {
@@ -210,7 +224,6 @@ wss.on('connection', (ws: WebSocket) => {
});
const connectConfig: any = {
host: ip,
port,
@@ -269,34 +282,34 @@ wss.on('connection', (ws: WebSocket) => {
]
}
};
if (authType === 'key' && key) {
if (resolvedCredentials.authType === 'key' && resolvedCredentials.key) {
try {
if (!key.includes('-----BEGIN') || !key.includes('-----END')) {
if (!resolvedCredentials.key.includes('-----BEGIN') || !resolvedCredentials.key.includes('-----END')) {
throw new Error('Invalid private key format');
}
const cleanKey = key.trim().replace(/\r\n/g, '\n').replace(/\r/g, '\n');
const cleanKey = resolvedCredentials.key.trim().replace(/\r\n/g, '\n').replace(/\r/g, '\n');
connectConfig.privateKey = Buffer.from(cleanKey, 'utf8');
if (keyPassword) {
connectConfig.passphrase = keyPassword;
if (resolvedCredentials.keyPassword) {
connectConfig.passphrase = resolvedCredentials.keyPassword;
}
if (keyType && keyType !== 'auto') {
connectConfig.privateKeyType = keyType;
if (resolvedCredentials.keyType && resolvedCredentials.keyType !== 'auto') {
connectConfig.privateKeyType = resolvedCredentials.keyType;
}
} catch (keyError) {
logger.error('SSH key format error: ' + keyError.message);
sshLogger.error('SSH key format error: ' + keyError.message);
ws.send(JSON.stringify({type: 'error', message: 'SSH key format error: Invalid private key format'}));
return;
}
} else if (authType === 'key') {
logger.error('SSH key authentication requested but no key provided');
} else if (resolvedCredentials.authType === 'key') {
sshLogger.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;
connectConfig.password = resolvedCredentials.password;
}
sshConn.connect(connectConfig);
@@ -323,7 +336,7 @@ wss.on('connection', (ws: WebSocket) => {
try {
sshStream.end();
} catch (e: any) {
logger.error('Error closing stream: ' + e.message);
sshLogger.error('Error closing stream: ' + e.message);
}
sshStream = null;
}
@@ -332,7 +345,7 @@ wss.on('connection', (ws: WebSocket) => {
try {
sshConn.end();
} catch (e: any) {
logger.error('Error closing connection: ' + e.message);
sshLogger.error('Error closing connection: ' + e.message);
}
sshConn = null;
}
@@ -344,7 +357,7 @@ wss.on('connection', (ws: WebSocket) => {
try {
sshStream.write('\x00');
} catch (e: any) {
logger.error('SSH keepalive failed: ' + e.message);
sshLogger.error('SSH keepalive failed: ' + e.message);
cleanupSSH();
}
}

View File

@@ -2,9 +2,23 @@ import express from 'express';
import cors from 'cors';
import {Client} from 'ssh2';
import {ChildProcess} from 'child_process';
import chalk from 'chalk';
import axios from 'axios';
import * as net from 'net';
import {db} from '../database/db/index.js';
import {sshData, sshCredentials} from '../database/db/schema.js';
import {eq, and} from 'drizzle-orm';
import type {
SSHHost,
TunnelConfig,
TunnelConnection,
TunnelStatus,
HostConfig,
VerificationData,
ConnectionState,
ErrorType
} from '../../types/index.js';
import { CONNECTION_STATES } from '../../types/index.js';
import { tunnelLogger } from '../utils/logger.js';
const app = express();
app.use(cors({
@@ -14,31 +28,6 @@ app.use(cors({
}));
app.use(express.json());
const tunnelIconSymbol = '📡';
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')(`[${tunnelIconSymbol}]`)} ${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 activeTunnels = new Map<string, Client>();
const retryCounters = new Map<string, number>();
@@ -53,109 +42,17 @@ const retryExhaustedTunnels = new Set<string>();
const tunnelConfigs = new Map<string, TunnelConfig>();
const activeTunnelProcesses = new Map<string, ChildProcess>();
interface TunnelConnection {
sourcePort: number;
endpointPort: number;
endpointHost: string;
maxRetries: number;
retryInterval: number;
autoStart: boolean;
}
interface SSHHost {
id: number;
name: string;
ip: string;
port: number;
username: string;
folder: string;
tags: string[];
pin: boolean;
authType: string;
password?: string;
key?: string;
keyPassword?: string;
keyType?: string;
enableTerminal: boolean;
enableTunnel: boolean;
enableFileManager: boolean;
defaultPath: string;
tunnelConnections: TunnelConnection[];
createdAt: string;
updatedAt: string;
}
interface TunnelConfig {
name: string;
hostName: string;
sourceIP: string;
sourceSSHPort: number;
sourceUsername: string;
sourcePassword?: string;
sourceAuthMethod: string;
sourceSSHKey?: string;
sourceKeyPassword?: string;
sourceKeyType?: string;
endpointIP: string;
endpointSSHPort: number;
endpointUsername: string;
endpointPassword?: string;
endpointAuthMethod: string;
endpointSSHKey?: string;
endpointKeyPassword?: string;
endpointKeyType?: string;
sourcePort: number;
endpointPort: number;
maxRetries: number;
retryInterval: number;
autoStart: boolean;
isPinned: boolean;
}
interface HostConfig {
host: SSHHost;
tunnels: TunnelConfig[];
}
interface TunnelStatus {
connected: boolean;
status: ConnectionState;
retryCount?: number;
maxRetries?: number;
nextRetryIn?: number;
reason?: string;
errorType?: ErrorType;
manualDisconnect?: boolean;
retryExhausted?: boolean;
}
interface VerificationData {
conn: Client;
timeout: NodeJS.Timeout;
}
const CONNECTION_STATES = {
DISCONNECTED: "disconnected",
CONNECTING: "connecting",
CONNECTED: "connected",
VERIFYING: "verifying",
FAILED: "failed",
UNSTABLE: "unstable",
RETRYING: "retrying",
WAITING: "waiting"
} as const;
const ERROR_TYPES = {
AUTH: "authentication",
NETWORK: "network",
PORT: "port_conflict",
PERMISSION: "permission",
TIMEOUT: "timeout",
UNKNOWN: "unknown"
AUTH: "AUTHENTICATION_FAILED",
NETWORK: "NETWORK_ERROR",
PORT: "CONNECTION_FAILED",
PERMISSION: "CONNECTION_FAILED",
TIMEOUT: "TIMEOUT",
UNKNOWN: "UNKNOWN"
} as const;
type ConnectionState = typeof CONNECTION_STATES[keyof typeof CONNECTION_STATES];
type ErrorType = typeof ERROR_TYPES[keyof typeof ERROR_TYPES];
function broadcastTunnelStatus(tunnelName: string, status: TunnelStatus): void {
if (status.status === CONNECTION_STATES.CONNECTED && activeRetryTimers.has(tunnelName)) {
@@ -178,7 +75,7 @@ function getAllTunnelStatus(): Record<string, TunnelStatus> {
}
function classifyError(errorMessage: string): ErrorType {
if (!errorMessage) return ERROR_TYPES.UNKNOWN;
if (!errorMessage) return 'UNKNOWN';
const message = errorMessage.toLowerCase();
@@ -186,34 +83,34 @@ function classifyError(errorMessage: string): ErrorType {
message.includes("connection reset by peer") ||
message.includes("connection refused") ||
message.includes("broken pipe")) {
return ERROR_TYPES.NETWORK;
return 'NETWORK_ERROR';
}
if (message.includes("authentication failed") ||
message.includes("permission denied") ||
message.includes("incorrect password")) {
return ERROR_TYPES.AUTH;
return 'AUTHENTICATION_FAILED';
}
if (message.includes("connect etimedout") ||
message.includes("timeout") ||
message.includes("timed out") ||
message.includes("keepalive timeout")) {
return ERROR_TYPES.TIMEOUT;
return 'TIMEOUT';
}
if (message.includes("bind: address already in use") ||
message.includes("failed for listen port") ||
message.includes("port forwarding failed")) {
return ERROR_TYPES.PORT;
return 'CONNECTION_FAILED';
}
if (message.includes("permission") ||
message.includes("access denied")) {
return ERROR_TYPES.PERMISSION;
return 'CONNECTION_FAILED';
}
return ERROR_TYPES.UNKNOWN;
return 'UNKNOWN';
}
function getTunnelMarker(tunnelName: string) {
@@ -225,7 +122,7 @@ function cleanupTunnelResources(tunnelName: string): void {
if (tunnelConfig) {
killRemoteTunnelByMarker(tunnelConfig, tunnelName, (err) => {
if (err) {
logger.error(`Failed to kill remote tunnel for '${tunnelName}': ${err.message}`);
tunnelLogger.error(`Failed to kill remote tunnel for '${tunnelName}': ${err.message}`);
}
});
}
@@ -237,7 +134,7 @@ function cleanupTunnelResources(tunnelName: string): void {
proc.kill('SIGTERM');
}
} catch (e) {
logger.error(`Error while killing local ssh process for tunnel '${tunnelName}'`, e);
tunnelLogger.error(`Error while killing local ssh process for tunnel '${tunnelName}'`, e);
}
activeTunnelProcesses.delete(tunnelName);
}
@@ -249,7 +146,7 @@ function cleanupTunnelResources(tunnelName: string): void {
conn.end();
}
} catch (e) {
logger.error(`Error while closing SSH2 Client for tunnel '${tunnelName}'`, e);
tunnelLogger.error(`Error while closing SSH2 Client for tunnel '${tunnelName}'`, e);
}
activeTunnels.delete(tunnelName);
}
@@ -359,7 +256,7 @@ function handleDisconnect(tunnelName: string, tunnelConfig: TunnelConfig | null,
retryCount = retryCount + 1;
if (retryCount > maxRetries) {
logger.error(`All ${maxRetries} retries failed for ${tunnelName}`);
tunnelLogger.error(`All ${maxRetries} retries failed for ${tunnelName}`);
retryExhaustedTunnels.add(tunnelName);
activeTunnels.delete(tunnelName);
@@ -423,7 +320,9 @@ function handleDisconnect(tunnelName: string, tunnelConfig: TunnelConfig | null,
if (!manualDisconnects.has(tunnelName)) {
activeTunnels.delete(tunnelName);
connectSSHTunnel(tunnelConfig, retryCount);
connectSSHTunnel(tunnelConfig, retryCount).catch(error => {
tunnelLogger.error(`Failed to connect tunnel ${tunnelConfig.name}: ${error instanceof Error ? error.message : 'Unknown error'}`);
});
}
}, retryInterval);
@@ -479,11 +378,14 @@ function setupPingInterval(tunnelName: string, tunnelConfig: TunnelConfig): void
verificationTimers.set(pingKey, pingInterval);
}
function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
async function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): Promise<void> {
const tunnelName = tunnelConfig.name;
const tunnelMarker = getTunnelMarker(tunnelName);
tunnelLogger.info('SSH tunnel connection attempt started', { operation: 'tunnel_connect', tunnelName, retryAttempt, sourceIP: tunnelConfig.sourceIP, sourcePort: tunnelConfig.sourceSSHPort });
if (manualDisconnects.has(tunnelName)) {
tunnelLogger.info('Tunnel connection cancelled due to manual disconnect', { operation: 'tunnel_connect', tunnelName });
return;
}
@@ -492,10 +394,14 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
if (retryAttempt === 0) {
retryExhaustedTunnels.delete(tunnelName);
retryCounters.delete(tunnelName);
tunnelLogger.info('Reset retry state for tunnel', { operation: 'tunnel_connect', tunnelName });
} else {
tunnelLogger.warn('Tunnel connection retry attempt', { operation: 'tunnel_connect', tunnelName, retryAttempt });
}
const currentStatus = connectionStatus.get(tunnelName);
if (!currentStatus || currentStatus.status !== CONNECTION_STATES.WAITING) {
tunnelLogger.info('Broadcasting tunnel connecting status', { operation: 'tunnel_connect', tunnelName, retryAttempt });
broadcastTunnelStatus(tunnelName, {
connected: false,
status: CONNECTION_STATES.CONNECTING,
@@ -504,7 +410,7 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
}
if (!tunnelConfig || !tunnelConfig.sourceIP || !tunnelConfig.sourceUsername || !tunnelConfig.sourceSSHPort) {
logger.error(`Invalid connection details for '${tunnelName}'`);
tunnelLogger.error('Invalid tunnel connection details', { operation: 'tunnel_connect', tunnelName, hasSourceIP: !!tunnelConfig?.sourceIP, hasSourceUsername: !!tunnelConfig?.sourceUsername, hasSourceSSHPort: !!tunnelConfig?.sourceSSHPort });
broadcastTunnelStatus(tunnelName, {
connected: false,
status: CONNECTION_STATES.FAILED,
@@ -513,6 +419,79 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
return;
}
let resolvedSourceCredentials = {
password: tunnelConfig.sourcePassword,
sshKey: tunnelConfig.sourceSSHKey,
keyPassword: tunnelConfig.sourceKeyPassword,
keyType: tunnelConfig.sourceKeyType,
authMethod: tunnelConfig.sourceAuthMethod
};
if (tunnelConfig.sourceCredentialId && tunnelConfig.sourceUserId) {
tunnelLogger.info('Resolving source credentials from database', { operation: 'tunnel_connect', tunnelName, credentialId: tunnelConfig.sourceCredentialId, userId: tunnelConfig.sourceUserId });
try {
const credentials = await db
.select()
.from(sshCredentials)
.where(and(
eq(sshCredentials.id, tunnelConfig.sourceCredentialId),
eq(sshCredentials.userId, tunnelConfig.sourceUserId)
));
if (credentials.length > 0) {
const credential = credentials[0];
resolvedSourceCredentials = {
password: credential.password,
sshKey: credential.key,
keyPassword: credential.keyPassword,
keyType: credential.keyType,
authMethod: credential.authType
};
tunnelLogger.success('Source credentials resolved successfully', { operation: 'tunnel_connect', tunnelName, credentialId: credential.id, authType: credential.authType });
} else {
tunnelLogger.warn('No source credentials found in database', { operation: 'tunnel_connect', tunnelName, credentialId: tunnelConfig.sourceCredentialId });
}
} catch (error) {
tunnelLogger.warn('Failed to resolve source credentials from database', { operation: 'tunnel_connect', tunnelName, credentialId: tunnelConfig.sourceCredentialId, error: error instanceof Error ? error.message : 'Unknown error' });
}
} else {
tunnelLogger.info('Using direct source credentials from tunnel config', { operation: 'tunnel_connect', tunnelName, authMethod: tunnelConfig.sourceAuthMethod });
}
// Resolve endpoint credentials if tunnel config has endpointCredentialId
let resolvedEndpointCredentials = {
password: tunnelConfig.endpointPassword,
sshKey: tunnelConfig.endpointSSHKey,
keyPassword: tunnelConfig.endpointKeyPassword,
keyType: tunnelConfig.endpointKeyType,
authMethod: tunnelConfig.endpointAuthMethod
};
if (tunnelConfig.endpointCredentialId && tunnelConfig.endpointUserId) {
try {
const credentials = await db
.select()
.from(sshCredentials)
.where(and(
eq(sshCredentials.id, tunnelConfig.endpointCredentialId),
eq(sshCredentials.userId, tunnelConfig.endpointUserId)
));
if (credentials.length > 0) {
const credential = credentials[0];
resolvedEndpointCredentials = {
password: credential.password,
sshKey: credential.key,
keyPassword: credential.keyPassword,
keyType: credential.keyType,
authMethod: credential.authType
};
}
} catch (error) {
tunnelLogger.warn(`Failed to resolve endpoint credentials for tunnel ${tunnelName}: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
const conn = new Client();
const connectionTimeout = setTimeout(() => {
@@ -536,7 +515,7 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
conn.on("error", (err) => {
clearTimeout(connectionTimeout);
logger.error(`SSH error for '${tunnelName}': ${err.message}`);
tunnelLogger.error(`SSH error for '${tunnelName}': ${err.message}`);
if (activeRetryTimers.has(tunnelName)) {
return;
@@ -555,13 +534,11 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
activeTunnels.delete(tunnelName);
const shouldNotRetry = errorType === ERROR_TYPES.AUTH ||
errorType === ERROR_TYPES.PORT ||
errorType === ERROR_TYPES.PERMISSION ||
const shouldNotRetry = errorType === 'AUTHENTICATION_FAILED' ||
errorType === 'CONNECTION_FAILED' ||
manualDisconnects.has(tunnelName);
handleDisconnect(tunnelName, tunnelConfig, !shouldNotRetry);
});
@@ -596,25 +573,24 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
}
let tunnelCmd: string;
if (tunnelConfig.endpointAuthMethod === "key" && tunnelConfig.endpointSSHKey) {
if (resolvedEndpointCredentials.authMethod === "key" && resolvedEndpointCredentials.sshKey) {
const keyFilePath = `/tmp/tunnel_key_${tunnelName.replace(/[^a-zA-Z0-9]/g, '_')}`;
tunnelCmd = `echo '${tunnelConfig.endpointSSHKey}' > ${keyFilePath} && chmod 600 ${keyFilePath} && ssh -i ${keyFilePath} -N -o StrictHostKeyChecking=no -o ExitOnForwardFailure=yes -o ServerAliveInterval=30 -o ServerAliveCountMax=3 -R ${tunnelConfig.endpointPort}:localhost:${tunnelConfig.sourcePort} ${tunnelConfig.endpointUsername}@${tunnelConfig.endpointIP} ${tunnelMarker} && rm -f ${keyFilePath}`;
tunnelCmd = `echo '${resolvedEndpointCredentials.sshKey}' > ${keyFilePath} && chmod 600 ${keyFilePath} && ssh -i ${keyFilePath} -N -o StrictHostKeyChecking=no -o ExitOnForwardFailure=yes -o ServerAliveInterval=30 -o ServerAliveCountMax=3 -R ${tunnelConfig.endpointPort}:localhost:${tunnelConfig.sourcePort} ${tunnelConfig.endpointUsername}@${tunnelConfig.endpointIP} ${tunnelMarker} && rm -f ${keyFilePath}`;
} else {
tunnelCmd = `sshpass -p '${tunnelConfig.endpointPassword || ''}' ssh -N -o StrictHostKeyChecking=no -o ExitOnForwardFailure=yes -o ServerAliveInterval=30 -o ServerAliveCountMax=3 -R ${tunnelConfig.endpointPort}:localhost:${tunnelConfig.sourcePort} ${tunnelConfig.endpointUsername}@${tunnelConfig.endpointIP} ${tunnelMarker}`;
tunnelCmd = `sshpass -p '${resolvedEndpointCredentials.password || ''}' ssh -N -o StrictHostKeyChecking=no -o ExitOnForwardFailure=yes -o ServerAliveInterval=30 -o ServerAliveCountMax=3 -R ${tunnelConfig.endpointPort}:localhost:${tunnelConfig.sourcePort} ${tunnelConfig.endpointUsername}@${tunnelConfig.endpointIP} ${tunnelMarker}`;
}
conn.exec(tunnelCmd, (err, stream) => {
if (err) {
logger.error(`Connection error for '${tunnelName}': ${err.message}`);
tunnelLogger.error(`Connection error for '${tunnelName}': ${err.message}`);
conn.end();
activeTunnels.delete(tunnelName);
const errorType = classifyError(err.message);
const shouldNotRetry = errorType === ERROR_TYPES.AUTH ||
errorType === ERROR_TYPES.PORT ||
errorType === ERROR_TYPES.PERMISSION;
const shouldNotRetry = errorType === 'AUTHENTICATION_FAILED' ||
errorType === 'CONNECTION_FAILED';
handleDisconnect(tunnelName, tunnelConfig, !shouldNotRetry);
return;
@@ -737,9 +713,9 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
}
};
if (tunnelConfig.sourceAuthMethod === "key" && tunnelConfig.sourceSSHKey) {
if (!tunnelConfig.sourceSSHKey.includes('-----BEGIN') || !tunnelConfig.sourceSSHKey.includes('-----END')) {
logger.error(`Invalid SSH key format for tunnel '${tunnelName}'. Key should contain both BEGIN and END markers`);
if (resolvedSourceCredentials.authMethod === "key" && resolvedSourceCredentials.sshKey) {
if (!resolvedSourceCredentials.sshKey.includes('-----BEGIN') || !resolvedSourceCredentials.sshKey.includes('-----END')) {
tunnelLogger.error(`Invalid SSH key format for tunnel '${tunnelName}'. Key should contain both BEGIN and END markers`);
broadcastTunnelStatus(tunnelName, {
connected: false,
status: CONNECTION_STATES.FAILED,
@@ -748,16 +724,16 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
return;
}
const cleanKey = tunnelConfig.sourceSSHKey.trim().replace(/\r\n/g, '\n').replace(/\r/g, '\n');
const cleanKey = resolvedSourceCredentials.sshKey.trim().replace(/\r\n/g, '\n').replace(/\r/g, '\n');
connOptions.privateKey = Buffer.from(cleanKey, 'utf8');
if (tunnelConfig.sourceKeyPassword) {
connOptions.passphrase = tunnelConfig.sourceKeyPassword;
if (resolvedSourceCredentials.keyPassword) {
connOptions.passphrase = resolvedSourceCredentials.keyPassword;
}
if (tunnelConfig.sourceKeyType && tunnelConfig.sourceKeyType !== 'auto') {
connOptions.privateKeyType = tunnelConfig.sourceKeyType;
if (resolvedSourceCredentials.keyType && resolvedSourceCredentials.keyType !== 'auto') {
connOptions.privateKeyType = resolvedSourceCredentials.keyType;
}
} else if (tunnelConfig.sourceAuthMethod === "key") {
logger.error(`SSH key authentication requested but no key provided for tunnel '${tunnelName}'`);
} else if (resolvedSourceCredentials.authMethod === "key") {
tunnelLogger.error(`SSH key authentication requested but no key provided for tunnel '${tunnelName}'`);
broadcastTunnelStatus(tunnelName, {
connected: false,
status: CONNECTION_STATES.FAILED,
@@ -765,7 +741,7 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
});
return;
} else {
connOptions.password = tunnelConfig.sourcePassword;
connOptions.password = resolvedSourceCredentials.password;
}
const finalStatus = connectionStatus.get(tunnelName);
@@ -898,7 +874,9 @@ app.post('/ssh/tunnel/connect', (req, res) => {
tunnelConfigs.set(tunnelName, tunnelConfig);
connectSSHTunnel(tunnelConfig, 0);
connectSSHTunnel(tunnelConfig, 0).catch(error => {
tunnelLogger.error(`Failed to connect tunnel ${tunnelConfig.name}: ${error instanceof Error ? error.message : 'Unknown error'}`);
});
res.json({message: 'Connection request received', tunnelName});
});
@@ -1027,22 +1005,25 @@ async function initializeAutoStartTunnels(): Promise<void> {
}
}
logger.info(`Found ${autoStartTunnels.length} auto-start tunnels`);
tunnelLogger.info(`Found ${autoStartTunnels.length} auto-start tunnels`);
for (const tunnelConfig of autoStartTunnels) {
tunnelConfigs.set(tunnelConfig.name, tunnelConfig);
setTimeout(() => {
connectSSHTunnel(tunnelConfig, 0);
connectSSHTunnel(tunnelConfig, 0).catch(error => {
tunnelLogger.error(`Failed to connect tunnel ${tunnelConfig.name}: ${error instanceof Error ? error.message : 'Unknown error'}`);
});
}, 1000);
}
} catch (error: any) {
logger.error('Failed to initialize auto-start tunnels:', error.message);
tunnelLogger.error('Failed to initialize auto-start tunnels:', error.message);
}
}
const PORT = 8083;
app.listen(PORT, () => {
tunnelLogger.success('SSH Tunnel API server started', { operation: 'server_start', port: PORT });
setTimeout(() => {
initializeAutoStartTunnels();
}, 2000);

View File

@@ -6,51 +6,47 @@ import './ssh/terminal.js';
import './ssh/tunnel.js';
import './ssh/file-manager.js';
import './ssh/server-stats.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));
}
}
};
import { systemLogger } from './utils/logger.js';
(async () => {
try {
logger.info("Starting all backend servers...");
systemLogger.info("Initializing backend services...", { operation: 'startup' });
logger.success("All servers started successfully");
systemLogger.info("Loading database service...", { operation: 'database_init' });
systemLogger.info("Loading SSH terminal service...", { operation: 'terminal_init' });
systemLogger.info("Loading SSH tunnel service...", { operation: 'tunnel_init' });
systemLogger.info("Loading file manager service...", { operation: 'file_manager_init' });
systemLogger.info("Loading server stats service...", { operation: 'stats_init' });
systemLogger.success("All backend services initialized successfully", {
operation: 'startup_complete',
services: ['database', 'terminal', 'tunnel', 'file_manager', 'stats']
});
process.on('SIGINT', () => {
logger.info("Shutting down servers...");
systemLogger.info("Received SIGINT signal, initiating graceful shutdown...", { operation: 'shutdown' });
systemLogger.info("Shutting down all services...", { operation: 'shutdown' });
process.exit(0);
});
process.on('SIGTERM', () => {
systemLogger.info("Received SIGTERM signal, initiating graceful shutdown...", { operation: 'shutdown' });
systemLogger.info("Shutting down all services...", { operation: 'shutdown' });
process.exit(0);
});
process.on('uncaughtException', (error) => {
systemLogger.error("Uncaught exception occurred", error, { operation: 'error_handling' });
process.exit(1);
});
process.on('unhandledRejection', (reason, promise) => {
systemLogger.error("Unhandled promise rejection", reason, { operation: 'error_handling' });
process.exit(1);
});
} catch (error) {
logger.error("Failed to start servers:", error);
systemLogger.error("Failed to initialize backend services", error, { operation: 'startup_failed' });
process.exit(1);
}
})();

172
src/backend/utils/logger.ts Normal file
View File

@@ -0,0 +1,172 @@
import chalk from 'chalk';
export type LogLevel = 'debug' | 'info' | 'warn' | 'error' | 'success';
export interface LogContext {
service?: string;
operation?: string;
userId?: string;
hostId?: number;
tunnelName?: string;
sessionId?: string;
requestId?: string;
duration?: number;
[key: string]: any;
}
class Logger {
private serviceName: string;
private serviceIcon: string;
private serviceColor: string;
constructor(serviceName: string, serviceIcon: string, serviceColor: string) {
this.serviceName = serviceName;
this.serviceIcon = serviceIcon;
this.serviceColor = serviceColor;
}
private getTimeStamp(): string {
return chalk.gray(`[${new Date().toLocaleTimeString()}]`);
}
private formatMessage(level: LogLevel, message: string, context?: LogContext): string {
const timestamp = this.getTimeStamp();
const levelColor = this.getLevelColor(level);
const serviceTag = chalk.hex(this.serviceColor)(`[${this.serviceIcon}]`);
const levelTag = levelColor(`[${level.toUpperCase()}]`);
let contextStr = '';
if (context) {
const contextParts = [];
if (context.operation) contextParts.push(`op:${context.operation}`);
if (context.userId) contextParts.push(`user:${context.userId}`);
if (context.hostId) contextParts.push(`host:${context.hostId}`);
if (context.tunnelName) contextParts.push(`tunnel:${context.tunnelName}`);
if (context.sessionId) contextParts.push(`session:${context.sessionId}`);
if (context.requestId) contextParts.push(`req:${context.requestId}`);
if (context.duration) contextParts.push(`duration:${context.duration}ms`);
if (contextParts.length > 0) {
contextStr = chalk.gray(` [${contextParts.join(',')}]`);
}
}
return `${timestamp} ${levelTag} ${serviceTag} ${message}${contextStr}`;
}
private getLevelColor(level: LogLevel): chalk.Chalk {
switch (level) {
case 'debug': return chalk.magenta;
case 'info': return chalk.cyan;
case 'warn': return chalk.yellow;
case 'error': return chalk.redBright;
case 'success': return chalk.greenBright;
default: return chalk.white;
}
}
private shouldLog(level: LogLevel): boolean {
if (level === 'debug' && process.env.NODE_ENV === 'production') {
return false;
}
return true;
}
debug(message: string, context?: LogContext): void {
if (!this.shouldLog('debug')) return;
console.debug(this.formatMessage('debug', message, context));
}
info(message: string, context?: LogContext): void {
if (!this.shouldLog('info')) return;
console.log(this.formatMessage('info', message, context));
}
warn(message: string, context?: LogContext): void {
if (!this.shouldLog('warn')) return;
console.warn(this.formatMessage('warn', message, context));
}
error(message: string, error?: unknown, context?: LogContext): void {
if (!this.shouldLog('error')) return;
console.error(this.formatMessage('error', message, context));
if (error) {
console.error(error);
}
}
success(message: string, context?: LogContext): void {
if (!this.shouldLog('success')) return;
console.log(this.formatMessage('success', message, context));
}
// Convenience methods for common operations
auth(message: string, context?: LogContext): void {
this.info(`AUTH: ${message}`, { ...context, operation: 'auth' });
}
db(message: string, context?: LogContext): void {
this.info(`DB: ${message}`, { ...context, operation: 'database' });
}
ssh(message: string, context?: LogContext): void {
this.info(`SSH: ${message}`, { ...context, operation: 'ssh' });
}
tunnel(message: string, context?: LogContext): void {
this.info(`TUNNEL: ${message}`, { ...context, operation: 'tunnel' });
}
file(message: string, context?: LogContext): void {
this.info(`FILE: ${message}`, { ...context, operation: 'file' });
}
api(message: string, context?: LogContext): void {
this.info(`API: ${message}`, { ...context, operation: 'api' });
}
request(message: string, context?: LogContext): void {
this.info(`REQUEST: ${message}`, { ...context, operation: 'request' });
}
response(message: string, context?: LogContext): void {
this.info(`RESPONSE: ${message}`, { ...context, operation: 'response' });
}
connection(message: string, context?: LogContext): void {
this.info(`CONNECTION: ${message}`, { ...context, operation: 'connection' });
}
disconnect(message: string, context?: LogContext): void {
this.info(`DISCONNECT: ${message}`, { ...context, operation: 'disconnect' });
}
retry(message: string, context?: LogContext): void {
this.warn(`RETRY: ${message}`, { ...context, operation: 'retry' });
}
cleanup(message: string, context?: LogContext): void {
this.info(`CLEANUP: ${message}`, { ...context, operation: 'cleanup' });
}
metrics(message: string, context?: LogContext): void {
this.info(`METRICS: ${message}`, { ...context, operation: 'metrics' });
}
security(message: string, context?: LogContext): void {
this.warn(`SECURITY: ${message}`, { ...context, operation: 'security' });
}
}
// Service-specific loggers
export const databaseLogger = new Logger('DATABASE', '🗄️', '#1e3a8a');
export const sshLogger = new Logger('SSH', '🖥️', '#1e3a8a');
export const tunnelLogger = new Logger('TUNNEL', '📡', '#1e3a8a');
export const fileLogger = new Logger('FILE', '📁', '#1e3a8a');
export const statsLogger = new Logger('STATS', '📊', '#22c55e');
export const apiLogger = new Logger('API', '🌐', '#3b82f6');
export const authLogger = new Logger('AUTH', '🔐', '#dc2626');
export const systemLogger = new Logger('SYSTEM', '🚀', '#1e3a8a');
// Default logger for general use
export const logger = systemLogger;

View File

@@ -4,15 +4,7 @@ import { Input } from "@/components/ui/input";
import { FormControl, FormItem, FormLabel } from "@/components/ui/form";
import { getCredentials } from '@/ui/main-axios';
import { useTranslation } from "react-i18next";
interface Credential {
id: number;
name: string;
description?: string;
username: string;
authType: 'password' | 'key';
folder?: string;
}
import type { Credential } from '../types/index.js';
interface CredentialSelectorProps {
value?: number | null;

416
src/types/index.ts Normal file
View File

@@ -0,0 +1,416 @@
// ============================================================================
// CENTRAL TYPE DEFINITIONS
// ============================================================================
// This file contains all shared interfaces and types used across the application
// to avoid duplication and ensure consistency.
import type { Client } from 'ssh2';
// ============================================================================
// SSH HOST TYPES
// ============================================================================
export interface SSHHost {
id: number;
name: string;
ip: string;
port: number;
username: string;
folder: string;
tags: string[];
pin: boolean;
authType: 'password' | 'key' | 'credential';
password?: string;
key?: string;
keyPassword?: string;
keyType?: string;
credentialId?: number;
userId?: string;
enableTerminal: boolean;
enableTunnel: boolean;
enableFileManager: boolean;
defaultPath: string;
tunnelConnections: TunnelConnection[];
createdAt: string;
updatedAt: string;
}
export interface SSHHostData {
name?: string;
ip: string;
port: number;
username: string;
folder?: string;
tags?: string[];
pin?: boolean;
authType: 'password' | 'key' | 'credential';
password?: string;
key?: File | null;
keyPassword?: string;
keyType?: string;
credentialId?: number | null;
enableTerminal?: boolean;
enableTunnel?: boolean;
enableFileManager?: boolean;
defaultPath?: string;
tunnelConnections?: any[];
}
// ============================================================================
// CREDENTIAL TYPES
// ============================================================================
export interface Credential {
id: number;
name: string;
description?: string;
folder?: string;
tags: string[];
authType: 'password' | 'key';
username: string;
password?: string;
key?: string;
keyPassword?: string;
keyType?: string;
usageCount: number;
lastUsed?: string;
createdAt: string;
updatedAt: string;
}
export interface CredentialData {
name: string;
description?: string;
folder?: string;
tags: string[];
authType: 'password' | 'key';
username: string;
password?: string;
key?: string;
keyPassword?: string;
keyType?: string;
}
// ============================================================================
// TUNNEL TYPES
// ============================================================================
export interface TunnelConnection {
sourcePort: number;
endpointPort: number;
endpointHost: string;
maxRetries: number;
retryInterval: number;
autoStart: boolean;
}
export interface TunnelConfig {
name: string;
hostName: string;
sourceIP: string;
sourceSSHPort: number;
sourceUsername: string;
sourcePassword?: string;
sourceAuthMethod: string;
sourceSSHKey?: string;
sourceKeyPassword?: string;
sourceKeyType?: string;
sourceCredentialId?: number;
sourceUserId?: string;
endpointIP: string;
endpointSSHPort: number;
endpointUsername: string;
endpointPassword?: string;
endpointAuthMethod: string;
endpointSSHKey?: string;
endpointKeyPassword?: string;
endpointKeyType?: string;
endpointCredentialId?: number;
endpointUserId?: string;
sourcePort: number;
endpointPort: number;
maxRetries: number;
retryInterval: number;
autoStart: boolean;
isPinned: boolean;
}
export interface TunnelStatus {
connected: boolean;
status: ConnectionState;
retryCount?: number;
maxRetries?: number;
nextRetryIn?: number;
reason?: string;
errorType?: ErrorType;
manualDisconnect?: boolean;
retryExhausted?: boolean;
}
// ============================================================================
// FILE MANAGER TYPES
// ============================================================================
export interface Tab {
id: string | number;
title: string;
fileName: string;
content: string;
isSSH?: boolean;
sshSessionId?: string;
filePath?: string;
loading?: boolean;
dirty?: boolean;
}
export interface FileManagerFile {
name: string;
path: string;
type?: 'file' | 'directory';
isSSH?: boolean;
sshSessionId?: string;
}
export interface FileManagerShortcut {
name: string;
path: string;
}
export interface FileItem {
name: string;
path: string;
isPinned?: boolean;
type: 'file' | 'directory';
sshSessionId?: string;
}
export interface ShortcutItem {
name: string;
path: string;
}
export interface SSHConnection {
id: number;
name: string;
ip: string;
port: number;
username: string;
isPinned?: boolean;
}
// ============================================================================
// HOST INFO TYPES
// ============================================================================
export interface HostInfo {
id: number;
name?: string;
ip: string;
port: number;
createdAt: string;
}
// ============================================================================
// ALERT TYPES
// ============================================================================
export interface TermixAlert {
id: string;
title: string;
message: string;
expiresAt: string;
priority?: 'low' | 'medium' | 'high' | 'critical';
type?: 'info' | 'warning' | 'error' | 'success';
actionUrl?: string;
actionText?: string;
}
// ============================================================================
// TAB TYPES
// ============================================================================
export interface TabContextTab {
id: number;
type: 'home' | 'terminal' | 'ssh_manager' | 'server' | 'admin' | 'file_manager';
title: string;
hostConfig?: any;
terminalRef?: React.RefObject<any>;
}
// ============================================================================
// CONNECTION STATES
// ============================================================================
export const CONNECTION_STATES = {
DISCONNECTED: "disconnected",
CONNECTING: "connecting",
CONNECTED: "connected",
VERIFYING: "verifying",
FAILED: "failed",
UNSTABLE: "unstable",
RETRYING: "retrying",
WAITING: "waiting",
DISCONNECTING: "disconnecting"
} as const;
export type ConnectionState = typeof CONNECTION_STATES[keyof typeof CONNECTION_STATES];
export type ErrorType = 'CONNECTION_FAILED' | 'AUTHENTICATION_FAILED' | 'TIMEOUT' | 'NETWORK_ERROR' | 'UNKNOWN';
// ============================================================================
// AUTHENTICATION TYPES
// ============================================================================
export type AuthType = 'password' | 'key' | 'credential';
export type KeyType = 'rsa' | 'ecdsa' | 'ed25519';
// ============================================================================
// API RESPONSE TYPES
// ============================================================================
export interface ApiResponse<T = any> {
data?: T;
error?: string;
message?: string;
status?: number;
}
// ============================================================================
// COMPONENT PROP TYPES
// ============================================================================
export interface CredentialsManagerProps {
onEditCredential?: (credential: Credential) => void;
}
export interface CredentialEditorProps {
editingCredential?: Credential | null;
onFormSubmit?: () => void;
}
export interface CredentialViewerProps {
credential: Credential;
onClose: () => void;
onEdit: () => void;
}
export interface CredentialSelectorProps {
value?: number | null;
onChange: (value: number | null) => void;
}
export interface HostManagerProps {
onSelectView?: (view: string) => void;
isTopbarOpen?: boolean;
}
export interface SSHManagerHostEditorProps {
editingHost?: SSHHost | null;
onFormSubmit?: () => void;
}
export interface SSHManagerHostViewerProps {
onEditHost?: (host: SSHHost) => void;
}
export interface HostProps {
host: SSHHost;
onHostConnect?: () => void;
}
export interface SSHTunnelProps {
filterHostKey?: string;
}
export interface SSHTunnelViewerProps {
hosts?: SSHHost[];
tunnelStatuses?: Record<string, TunnelStatus>;
tunnelActions?: Record<string, (action: 'connect' | 'disconnect' | 'cancel', host: SSHHost, tunnelIndex: number) => Promise<any>>;
onTunnelAction?: (action: 'connect' | 'disconnect' | 'cancel', host: SSHHost, tunnelIndex: number) => Promise<any>;
}
export interface FileManagerProps {
onSelectView?: (view: string) => void;
embedded?: boolean;
initialHost?: SSHHost | null;
}
export interface FileManagerLeftSidebarProps {
onSelectView?: (view: string) => void;
onOpenFile: (file: any) => void;
tabs: Tab[];
host: SSHHost;
onOperationComplete?: () => void;
onError?: (error: string) => void;
onSuccess?: (message: string) => void;
onPathChange?: (path: string) => void;
onDeleteItem?: (item: any) => void;
}
export interface FileManagerOperationsProps {
currentPath: string;
sshSessionId: string | null;
onOperationComplete?: () => void;
onError?: (error: string) => void;
onSuccess?: (message: string) => void;
}
export interface AlertCardProps {
alert: TermixAlert;
onDismiss: (alertId: string) => void;
}
export interface AlertManagerProps {
alerts: TermixAlert[];
onDismiss: (alertId: string) => void;
loggedIn: boolean;
}
export interface SSHTunnelObjectProps {
host: SSHHost;
tunnelStatuses: Record<string, TunnelStatus>;
tunnelActions: Record<string, boolean>;
onTunnelAction: (action: 'connect' | 'disconnect' | 'cancel', host: SSHHost, tunnelIndex: number) => Promise<any>;
compact?: boolean;
bare?: boolean;
}
export interface FolderStats {
totalHosts: number;
hostsByType: Array<{
type: string;
count: number;
}>;
}
export interface FolderManagerProps {
onFolderChanged?: () => void;
}
// ============================================================================
// BACKEND TYPES
// ============================================================================
export interface HostConfig {
host: SSHHost;
tunnels: TunnelConfig[];
}
export interface VerificationData {
conn: Client;
timeout: NodeJS.Timeout;
startTime: number;
attempts: number;
maxAttempts: number;
}
// ============================================================================
// UTILITY TYPES
// ============================================================================
export type Optional<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;
export type RequiredFields<T, K extends keyof T> = T & Required<Pick<T, K>>;
export type PartialExcept<T, K extends keyof T> = Partial<T> & Pick<T, K>;

View File

@@ -313,17 +313,30 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
<div className="flex gap-2 pt-2">
<Button type="submit" className="flex-1"
disabled={oidcLoading}>{oidcLoading ? t('admin.saving') : t('admin.saveConfiguration')}</Button>
<Button type="button" variant="outline" onClick={() => setOidcConfig({
client_id: '',
client_secret: '',
issuer_url: '',
authorization_url: '',
token_url: '',
identifier_path: 'sub',
name_path: 'name',
scopes: 'openid email profile',
userinfo_url: ''
})}>{t('admin.reset')}</Button>
<Button type="button" variant="outline" onClick={async () => {
const emptyConfig = {
client_id: '',
client_secret: '',
issuer_url: '',
authorization_url: '',
token_url: '',
identifier_path: '',
name_path: '',
scopes: '',
userinfo_url: ''
};
setOidcConfig(emptyConfig);
setOidcError(null);
setOidcLoading(true);
try {
await updateOIDCConfig(emptyConfig);
toast.success(t('admin.oidcConfigurationDisabled'));
} catch (err: any) {
setOidcError(err?.response?.data?.error || t('admin.failedToDisableOidcConfig'));
} finally {
setOidcLoading(false);
}
}} disabled={oidcLoading}>{t('admin.reset')}</Button>
</div>
</form>
</div>

View File

@@ -19,34 +19,16 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import React, { useEffect, useRef, useState } from "react"
import { Alert, AlertDescription } from "@/components/ui/alert"
import { toast } from "sonner"
import { createCredential, updateCredential, getCredentials } from '@/ui/main-axios'
import { createCredential, updateCredential, getCredentials, getCredentialDetails } from '@/ui/main-axios'
import { useTranslation } from "react-i18next"
interface Credential {
id: number;
name: string;
description?: string;
folder?: string;
tags: string[];
authType: 'password' | 'key';
username: string;
keyType?: string;
usageCount: number;
lastUsed?: string;
createdAt: string;
updatedAt: string;
}
interface CredentialEditorProps {
editingCredential?: Credential | null;
onFormSubmit?: () => void;
}
import type { Credential, CredentialEditorProps } from '../../../types/index.js'
export function CredentialEditor({ editingCredential, onFormSubmit }: CredentialEditorProps) {
const { t } = useTranslation();
const [credentials, setCredentials] = useState<Credential[]>([]);
const [folders, setFolders] = useState<string[]>([]);
const [loading, setLoading] = useState(true);
const [fullCredentialDetails, setFullCredentialDetails] = useState<Credential | null>(null);
const [authTab, setAuthTab] = useState<'password' | 'key'>('password');
@@ -60,8 +42,8 @@ export function CredentialEditor({ editingCredential, onFormSubmit }: Credential
const uniqueFolders = [...new Set(
credentialsData
.filter(credential => credential.folder && credential.folder.trim() !== '')
.map(credential => credential.folder)
)].sort();
.map(credential => credential.folder!)
)].sort() as string[];
setFolders(uniqueFolders);
} catch (error) {
@@ -73,6 +55,24 @@ export function CredentialEditor({ editingCredential, onFormSubmit }: Credential
fetchData();
}, []);
useEffect(() => {
const fetchCredentialDetails = async () => {
if (editingCredential) {
try {
const fullDetails = await getCredentialDetails(editingCredential.id);
setFullCredentialDetails(fullDetails);
} catch (error) {
console.error('Failed to fetch credential details:', error);
toast.error(t('credentials.failedToFetchCredentialDetails'));
}
} else {
setFullCredentialDetails(null);
}
};
fetchCredentialDetails();
}, [editingCredential, t]);
const formSchema = z.object({
name: z.string().min(1),
description: z.string().optional(),
@@ -81,7 +81,7 @@ export function CredentialEditor({ editingCredential, onFormSubmit }: Credential
authType: z.enum(['password', 'key']),
username: z.string().min(1),
password: z.string().optional(),
key: z.instanceof(File).optional().nullable(),
key: z.any().optional().nullable(),
keyPassword: z.string().optional(),
keyType: z.enum([
'rsa',
@@ -127,24 +127,24 @@ export function CredentialEditor({ editingCredential, onFormSubmit }: Credential
});
useEffect(() => {
if (editingCredential) {
const defaultAuthType = editingCredential.key ? 'key' : 'password';
if (editingCredential && fullCredentialDetails) {
const defaultAuthType = fullCredentialDetails.authType;
setAuthTab(defaultAuthType);
form.reset({
name: editingCredential.name || "",
description: editingCredential.description || "",
folder: editingCredential.folder || "",
tags: editingCredential.tags || [],
name: fullCredentialDetails.name || "",
description: fullCredentialDetails.description || "",
folder: fullCredentialDetails.folder || "",
tags: fullCredentialDetails.tags || [],
authType: defaultAuthType as 'password' | 'key',
username: editingCredential.username || "",
password: "",
username: fullCredentialDetails.username || "",
password: fullCredentialDetails.password || "",
key: null,
keyPassword: "",
keyType: (editingCredential.keyType as any) || "rsa",
keyPassword: fullCredentialDetails.keyPassword || "",
keyType: (fullCredentialDetails.keyType as any) || "rsa",
});
} else {
} else if (!editingCredential) {
setAuthTab('password');
form.reset({
@@ -160,7 +160,7 @@ export function CredentialEditor({ editingCredential, onFormSubmit }: Credential
keyType: "rsa",
});
}
}, [editingCredential, form]);
}, [editingCredential, fullCredentialDetails, form]);
const onSubmit = async (data: any) => {
try {
@@ -170,11 +170,38 @@ export function CredentialEditor({ editingCredential, onFormSubmit }: Credential
formData.name = formData.username;
}
const submitData: any = {
name: formData.name,
description: formData.description,
folder: formData.folder,
tags: formData.tags,
authType: formData.authType,
username: formData.username,
keyType: formData.keyType
};
if (formData.password !== undefined) {
submitData.password = formData.password;
}
if (formData.key !== undefined) {
if (formData.key instanceof File) {
const keyContent = await formData.key.text();
submitData.key = keyContent;
} else {
submitData.key = formData.key;
}
}
if (formData.keyPassword !== undefined) {
submitData.keyPassword = formData.keyPassword;
}
if (editingCredential) {
await updateCredential(editingCredential.id, formData);
await updateCredential(editingCredential.id, submitData);
toast.success(t('credentials.credentialUpdatedSuccessfully', { name: formData.name }));
} else {
await createCredential(formData);
await createCredential(submitData);
toast.success(t('credentials.credentialAddedSuccessfully', { name: formData.name }));
}

View File

@@ -27,45 +27,11 @@ import {
import { getCredentialDetails, getCredentialHosts } from '@/ui/main-axios';
import { toast } from 'sonner';
import { useTranslation } from 'react-i18next';
interface Credential {
id: number;
name: string;
description?: string;
folder?: string;
tags: string[];
authType: 'password' | 'key';
username: string;
keyType?: string;
usageCount: number;
lastUsed?: string;
createdAt: string;
updatedAt: string;
}
interface CredentialWithSecrets extends Credential {
password?: string;
key?: string;
keyPassword?: string;
}
interface HostInfo {
id: number;
name?: string;
ip: string;
port: number;
createdAt: string;
}
interface CredentialViewerProps {
credential: Credential;
onClose: () => void;
onEdit: () => void;
}
import type { Credential, HostInfo, CredentialViewerProps } from '../../../types/index.js';
const CredentialViewer: React.FC<CredentialViewerProps> = ({ credential, onClose, onEdit }) => {
const { t } = useTranslation();
const [credentialDetails, setCredentialDetails] = useState<CredentialWithSecrets | null>(null);
const [credentialDetails, setCredentialDetails] = useState<Credential | null>(null);
const [hostsUsing, setHostsUsing] = useState<HostInfo[]>([]);
const [loading, setLoading] = useState(true);
const [showSensitive, setShowSensitive] = useState<Record<string, boolean>>({});

View File

@@ -21,25 +21,7 @@ import { toast } from 'sonner';
import { useTranslation } from 'react-i18next';
import {CredentialEditor} from './CredentialEditor';
import CredentialViewer from './CredentialViewer';
interface Credential {
id: number;
name: string;
description?: string;
folder?: string;
tags: string[];
authType: 'password' | 'key';
username: string;
keyType?: string;
usageCount: number;
lastUsed?: string;
createdAt: string;
updatedAt: string;
}
interface CredentialsManagerProps {
onEditCredential?: (credential: Credential) => void;
}
import type { Credential, CredentialsManagerProps } from '../../../types/index.js';
export function CredentialsManager({ onEditCredential }: CredentialsManagerProps) {
const { t } = useTranslation();
@@ -83,20 +65,16 @@ export function CredentialsManager({ onEditCredential }: CredentialsManagerProps
toast.success(t('credentials.credentialDeletedSuccessfully', { name: credentialName }));
await fetchCredentials();
window.dispatchEvent(new CustomEvent('credentials:changed'));
} catch (err) {
toast.error(t('credentials.failedToDeleteCredential'));
} catch (err: any) {
if (err.response?.data?.details) {
toast.error(`${err.response.data.error}\n${err.response.data.details}`);
} else {
toast.error(t('credentials.failedToDeleteCredential'));
}
}
}
};
const filteredAndSortedCredentials = useMemo(() => {
let filtered = credentials;

View File

@@ -26,41 +26,7 @@ import {
getSSHStatus,
connectSSH
} from '@/ui/main-axios.ts';
interface Tab {
id: string | number;
title: string;
fileName: string;
content: string;
isSSH?: boolean;
sshSessionId?: string;
filePath?: string;
loading?: boolean;
dirty?: boolean;
}
interface SSHHost {
id: number;
name: string;
ip: string;
port: number;
username: string;
folder: string;
tags: string[];
pin: boolean;
authType: string;
password?: string;
key?: string;
keyPassword?: string;
keyType?: string;
enableTerminal: boolean;
enableTunnel: boolean;
enableFileManager: boolean;
defaultPath: string;
tunnelConnections: any[];
createdAt: string;
updatedAt: string;
}
import type { SSHHost, Tab, FileManagerProps } from '../../../types/index.js';
export function FileManager({onSelectView, embedded = false, initialHost = null}: {
onSelectView?: (view: string) => void,
@@ -378,12 +344,16 @@ export function FileManager({onSelectView, embedded = false, initialHost = null}
if (!status.connected) {
const connectPromise = connectSSH(tab.sshSessionId, {
hostId: currentHost.id,
ip: currentHost.ip,
port: currentHost.port,
username: currentHost.username,
password: currentHost.password,
sshKey: currentHost.key,
keyPassword: currentHost.keyPassword
keyPassword: currentHost.keyPassword,
authType: currentHost.authType,
credentialId: currentHost.credentialId,
userId: currentHost.userId
});
const connectTimeoutPromise = new Promise((_, reject) =>
setTimeout(() => reject(new Error(t('fileManager.sshReconnectionTimeout'))), 15000)

View File

@@ -5,19 +5,7 @@ import {Tabs, TabsList, TabsTrigger, TabsContent} from '@/components/ui/tabs.tsx
import {Input} from '@/components/ui/input.tsx';
import {useState} from 'react';
import {useTranslation} from 'react-i18next';
interface FileItem {
name: string;
path: string;
isPinned?: boolean;
type: 'file' | 'directory';
sshSessionId?: string;
}
interface ShortcutItem {
name: string;
path: string;
}
import type { FileItem, ShortcutItem } from '../../../types/index.js';
interface FileManagerHomeViewProps {
recent: FileItem[];

View File

@@ -19,29 +19,7 @@ import {
getSSHStatus,
connectSSH
} from '@/ui/main-axios.ts';
interface SSHHost {
id: number;
name: string;
ip: string;
port: number;
username: string;
folder: string;
tags: string[];
pin: boolean;
authType: string;
password?: string;
key?: string;
keyPassword?: string;
keyType?: string;
enableTerminal: boolean;
enableTunnel: boolean;
enableFileManager: boolean;
defaultPath: string;
tunnelConnections: any[];
createdAt: string;
updatedAt: string;
}
import type { SSHHost, FileManagerLeftSidebarProps } from '../../../types/index.js';
const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
{onSelectView, onOpenFile, tabs, host, onOperationComplete, onError, onSuccess, onPathChange, onDeleteItem}: {
@@ -133,12 +111,16 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
}
const connectionConfig = {
hostId: server.id,
ip: server.ip,
port: server.port,
username: server.username,
password: server.password,
sshKey: server.key,
keyPassword: server.keyPassword,
authType: server.authType,
credentialId: server.credentialId,
userId: server.userId,
};
await connectSSH(sessionId, connectionConfig);

View File

@@ -17,14 +17,7 @@ import {
} from 'lucide-react';
import {cn} from '@/lib/utils.ts';
import {useTranslation} from 'react-i18next';
interface FileManagerOperationsProps {
currentPath: string;
sshSessionId: string | null;
onOperationComplete: () => void;
onError: (error: string) => void;
onSuccess: (message: string) => void;
}
import type { FileManagerOperationsProps } from '../../../types/index.js';
export function FileManagerOperations({
currentPath,

View File

@@ -13,6 +13,7 @@ import {
import { getFoldersWithStats, renameFolder } from '@/ui/main-axios';
import { toast } from 'sonner';
import { useTranslation } from 'react-i18next';
import type { FolderManagerProps } from '../../../types/index.js';
interface FolderStats {
name: string;
@@ -24,10 +25,6 @@ interface FolderStats {
}>;
}
interface FolderManagerProps {
onFolderChanged?: () => void;
}
export function FolderManager({ onFolderChanged }: FolderManagerProps) {
const { t } = useTranslation();
const [folders, setFolders] = useState<FolderStats[]>([]);

View File

@@ -7,34 +7,7 @@ import {CredentialsManager} from "@/ui/Desktop/Apps/Credentials/CredentialsManag
import {CredentialEditor} from "@/ui/Desktop/Apps/Credentials/CredentialEditor.tsx";
import {useSidebar} from "@/components/ui/sidebar.tsx";
import {useTranslation} from "react-i18next";
interface HostManagerProps {
onSelectView: (view: string) => void;
isTopbarOpen?: boolean;
}
interface SSHHost {
id: number;
name: string;
ip: string;
port: number;
username: string;
folder: string;
tags: string[];
pin: boolean;
authType: string;
password?: string;
key?: string;
keyPassword?: string;
keyType?: string;
enableTerminal: boolean;
enableTunnel: boolean;
enableFileManager: boolean;
defaultPath: string;
tunnelConnections: any[];
createdAt: string;
updatedAt: string;
}
import type { SSHHost, HostManagerProps } from '../../../types/index.js';
export function HostManager({onSelectView, isTopbarOpen}: HostManagerProps): React.ReactElement {
const {t} = useTranslation();

View File

@@ -106,7 +106,7 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
authType: z.enum(['password', 'key', 'credential']),
credentialId: z.number().optional().nullable(),
password: z.string().optional(),
key: z.instanceof(File).optional().nullable(),
key: z.any().optional().nullable(),
keyPassword: z.string().optional(),
keyType: z.enum([
'auto',
@@ -205,7 +205,6 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
useEffect(() => {
if (editingHost) {
const defaultAuthType = editingHost.credentialId ? 'credential' : (editingHost.key ? 'key' : 'password');
setAuthTab(defaultAuthType);
form.reset({
@@ -219,7 +218,7 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
authType: defaultAuthType as 'password' | 'key' | 'credential',
credentialId: editingHost.credentialId || null,
password: editingHost.password || "",
key: editingHost.key ? new File([editingHost.key], "key.pem") : null,
key: null,
keyPassword: editingHost.keyPassword || "",
keyType: (editingHost.keyType as any) || "auto",
enableTerminal: editingHost.enableTerminal !== false,
@@ -230,7 +229,6 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
});
} else {
setAuthTab('password');
form.reset({
name: "",
ip: "",
@@ -283,11 +281,52 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
formData.name = `${formData.username}@${formData.ip}`;
}
const submitData: any = {
name: formData.name,
ip: formData.ip,
port: formData.port,
username: formData.username,
folder: formData.folder,
tags: formData.tags,
pin: formData.pin,
authType: formData.authType,
enableTerminal: formData.enableTerminal,
enableTunnel: formData.enableTunnel,
enableFileManager: formData.enableFileManager,
defaultPath: formData.defaultPath,
tunnelConnections: formData.tunnelConnections
};
if (formData.authType === 'credential') {
submitData.credentialId = formData.credentialId;
submitData.password = null;
submitData.key = null;
submitData.keyPassword = null;
submitData.keyType = null;
} else if (formData.authType === 'password') {
submitData.credentialId = null;
submitData.password = formData.password;
submitData.key = null;
submitData.keyPassword = null;
submitData.keyType = null;
} else if (formData.authType === 'key') {
submitData.credentialId = null;
submitData.password = null;
if (formData.key instanceof File) {
const keyContent = await formData.key.text();
submitData.key = keyContent;
} else {
submitData.key = formData.key;
}
submitData.keyPassword = formData.keyPassword;
submitData.keyType = formData.keyType;
}
if (editingHost) {
await updateSSHHost(editingHost.id, formData);
await updateSSHHost(editingHost.id, submitData);
toast.success(t('hosts.hostUpdatedSuccessfully', { name: formData.name }));
} else {
await createSSHHost(formData);
await createSSHHost(submitData);
toast.success(t('hosts.hostAddedSuccessfully', { name: formData.name }));
}

View File

@@ -27,29 +27,7 @@ import {
Pencil
} from "lucide-react";
import {Separator} from "@/components/ui/separator.tsx";
interface SSHHost {
id: number;
name: string;
ip: string;
port: number;
username: string;
folder: string;
tags: string[];
pin: boolean;
authType: string;
enableTerminal: boolean;
enableTunnel: boolean;
enableFileManager: boolean;
defaultPath: string;
tunnelConnections: any[];
createdAt: string;
updatedAt: string;
}
interface SSHManagerHostViewerProps {
onEditHost?: (host: SSHHost) => void;
}
import type { SSHHost, SSHManagerHostViewerProps } from '../../../types/index.js';
export function HostManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) {
const {t} = useTranslation();

View File

@@ -1,52 +1,7 @@
import React, {useState, useEffect, useCallback} from "react";
import {TunnelViewer} from "@/ui/Desktop/Apps/Tunnel/TunnelViewer.tsx";
import {getSSHHosts, getTunnelStatuses, connectTunnel, disconnectTunnel, cancelTunnel} from "@/ui/main-axios.ts";
interface TunnelConnection {
sourcePort: number;
endpointPort: number;
endpointHost: string;
maxRetries: number;
retryInterval: number;
autoStart: boolean;
}
interface SSHHost {
id: number;
name: string;
ip: string;
port: number;
username: string;
folder: string;
tags: string[];
pin: boolean;
authType: string;
password?: string;
key?: string;
keyPassword?: string;
keyType?: string;
enableTerminal: boolean;
enableTunnel: boolean;
enableFileManager: boolean;
defaultPath: string;
tunnelConnections: TunnelConnection[];
createdAt: string;
updatedAt: string;
}
interface TunnelStatus {
status: string;
reason?: string;
errorType?: string;
retryCount?: number;
maxRetries?: number;
nextRetryIn?: number;
retryExhausted?: boolean;
}
interface SSHTunnelProps {
filterHostKey?: string;
}
import type { SSHHost, TunnelConnection, TunnelStatus, SSHTunnelProps } from '../../../types/index.js';
export function Tunnel({filterHostKey}: SSHTunnelProps): React.ReactElement {
const [allHosts, setAllHosts] = useState<SSHHost[]>([]);
@@ -163,6 +118,8 @@ export function Tunnel({filterHostKey}: SSHTunnelProps): React.ReactElement {
sourceSSHKey: host.authType === 'key' ? host.key : undefined,
sourceKeyPassword: host.authType === 'key' ? host.keyPassword : undefined,
sourceKeyType: host.authType === 'key' ? host.keyType : undefined,
sourceCredentialId: host.credentialId,
sourceUserId: host.userId,
endpointIP: endpointHost.ip,
endpointSSHPort: endpointHost.port,
endpointUsername: endpointHost.username,
@@ -171,6 +128,8 @@ export function Tunnel({filterHostKey}: SSHTunnelProps): React.ReactElement {
endpointSSHKey: endpointHost.authType === 'key' ? endpointHost.key : undefined,
endpointKeyPassword: endpointHost.authType === 'key' ? endpointHost.keyPassword : undefined,
endpointKeyType: endpointHost.authType === 'key' ? endpointHost.keyType : undefined,
endpointCredentialId: endpointHost.credentialId,
endpointUserId: endpointHost.userId,
sourcePort: tunnel.sourcePort,
endpointPort: tunnel.endpointPort,
maxRetries: tunnel.maxRetries,

View File

@@ -20,65 +20,7 @@ import {
X
} from "lucide-react";
import {Badge} from "@/components/ui/badge.tsx";
const CONNECTION_STATES = {
DISCONNECTED: "disconnected",
CONNECTING: "connecting",
CONNECTED: "connected",
VERIFYING: "verifying",
FAILED: "failed",
UNSTABLE: "unstable",
RETRYING: "retrying",
WAITING: "waiting",
DISCONNECTING: "disconnecting"
};
interface TunnelConnection {
sourcePort: number;
endpointPort: number;
endpointHost: string;
maxRetries: number;
retryInterval: number;
autoStart: boolean;
}
interface SSHHost {
id: number;
name: string;
ip: string;
port: number;
username: string;
folder: string;
tags: string[];
pin: boolean;
authType: string;
enableTerminal: boolean;
enableTunnel: boolean;
enableFileManager: boolean;
defaultPath: string;
tunnelConnections: TunnelConnection[];
createdAt: string;
updatedAt: string;
}
interface TunnelStatus {
status: string;
reason?: string;
errorType?: string;
retryCount?: number;
maxRetries?: number;
nextRetryIn?: number;
retryExhausted?: boolean;
}
interface SSHTunnelObjectProps {
host: SSHHost;
tunnelStatuses: Record<string, TunnelStatus>;
tunnelActions: Record<string, boolean>;
onTunnelAction: (action: 'connect' | 'disconnect' | 'cancel', host: SSHHost, tunnelIndex: number) => Promise<any>;
compact?: boolean;
bare?: boolean;
}
import type { SSHHost, TunnelConnection, TunnelStatus, CONNECTION_STATES, SSHTunnelObjectProps } from '../../../types/index.js';
export function TunnelObject({
host,

View File

@@ -1,44 +1,7 @@
import React from "react";
import {TunnelObject} from "./TunnelObject.tsx";
import {useTranslation} from 'react-i18next';
interface TunnelConnection {
sourcePort: number;
endpointPort: number;
endpointHost: string;
maxRetries: number;
retryInterval: number;
autoStart: boolean;
}
interface SSHHost {
id: number;
name: string;
ip: string;
port: number;
username: string;
folder: string;
tags: string[];
pin: boolean;
authType: string;
enableTerminal: boolean;
enableTunnel: boolean;
enableFileManager: boolean;
defaultPath: string;
tunnelConnections: TunnelConnection[];
createdAt: string;
updatedAt: string;
}
interface TunnelStatus {
status: string;
reason?: string;
errorType?: string;
retryCount?: number;
maxRetries?: number;
nextRetryIn?: number;
retryExhausted?: boolean;
}
import type { SSHHost, TunnelConnection, TunnelStatus } from '../../../types/index.js';
interface SSHTunnelViewerProps {
hosts: SSHHost[];

View File

@@ -4,17 +4,7 @@ import {Button} from "@/components/ui/button.tsx";
import {Badge} from "@/components/ui/badge.tsx";
import {X, ExternalLink, AlertTriangle, Info, CheckCircle, AlertCircle} from "lucide-react";
import {useTranslation} from "react-i18next";
interface TermixAlert {
id: string;
title: string;
message: string;
expiresAt: string;
priority?: 'low' | 'medium' | 'high' | 'critical';
type?: 'info' | 'warning' | 'error' | 'success';
actionUrl?: string;
actionText?: string;
}
import type { TermixAlert } from '../../../types/index.js';
interface AlertCardProps {
alert: TermixAlert;

View File

@@ -3,17 +3,7 @@ import {HomepageAlertCard} from "./HomepageAlertCard.tsx";
import {Button} from "@/components/ui/button.tsx";
import { getUserAlerts, dismissAlert } from "@/ui/main-axios.ts";
import {useTranslation} from "react-i18next";
interface TermixAlert {
id: string;
title: string;
message: string;
expiresAt: string;
priority?: 'low' | 'medium' | 'high' | 'critical';
type?: 'info' | 'warning' | 'error' | 'success';
actionUrl?: string;
actionText?: string;
}
import type { TermixAlert } from '../../../types/index.js';
interface AlertManagerProps {
userId: string | null;

View File

@@ -5,33 +5,7 @@ import {ButtonGroup} from "@/components/ui/button-group.tsx";
import {Server, Terminal} from "lucide-react";
import {useTabs} from "@/ui/Desktop/Navigation/Tabs/TabContext.tsx";
import {getServerStatusById} from "@/ui/main-axios.ts";
interface SSHHost {
id: number;
name: string;
ip: string;
port: number;
username: string;
folder: string;
tags: string[];
pin: boolean;
authType: string;
password?: string;
key?: string;
keyPassword?: string;
keyType?: string;
enableTerminal: boolean;
enableTunnel: boolean;
enableFileManager: boolean;
defaultPath: string;
tunnelConnections: any[];
createdAt: string;
updatedAt: string;
}
interface HostProps {
host: SSHHost;
}
import type { SSHHost, HostProps } from '../../../types/index.js';
export function Host({host}: HostProps): React.ReactElement {
const {addTab} = useTabs();

View File

@@ -1,13 +1,8 @@
import React, {createContext, useContext, useState, useRef, type ReactNode} from 'react';
import {useTranslation} from 'react-i18next';
import type { TabContextTab } from '../../../types/index.js';
export interface Tab {
id: number;
type: 'home' | 'terminal' | 'ssh_manager' | 'server' | 'admin' | 'file_manager';
title: string;
hostConfig?: any;
terminalRef?: React.RefObject<any>;
}
export type Tab = TabContextTab;
interface TabContextType {
tabs: Tab[];

View File

@@ -1,13 +1,8 @@
import React, {createContext, useContext, useState, useRef, type ReactNode} from 'react';
import {useTranslation} from 'react-i18next';
import type { TabContextTab } from '../../../../types/index.js';
export interface Tab {
id: number;
type: 'terminal';
title: string;
hostConfig?: any;
terminalRef?: React.RefObject<any>;
}
export type Tab = TabContextTab;
interface TabContextType {
tabs: Tab[];

View File

@@ -1,13 +1,8 @@
import React, {createContext, useContext, useState, useRef, type ReactNode} from 'react';
import {useTranslation} from 'react-i18next';
import type { TabContextTab } from '../../../types/index.js';
export interface Tab {
id: number;
type: 'terminal';
title: string;
hostConfig?: any;
terminalRef?: React.RefObject<any>;
}
export type Tab = TabContextTab;
interface TabContextType {
tabs: Tab[];

View File

@@ -1,103 +1,16 @@
import axios, { AxiosError, type AxiosInstance } from 'axios';
// ============================================================================
// TYPES & INTERFACES
// ============================================================================
interface SSHHostData {
name?: string;
ip: string;
port: number;
username: string;
folder?: string;
tags?: string[];
pin?: boolean;
authType: 'password' | 'key' | 'credential';
password?: string;
key?: File | null;
keyPassword?: string;
keyType?: string;
credentialId?: number | null;
enableTerminal?: boolean;
enableTunnel?: boolean;
enableFileManager?: boolean;
defaultPath?: string;
tunnelConnections?: any[];
}
interface SSHHost {
id: number;
name: string;
ip: string;
port: number;
username: string;
folder: string;
tags: string[];
pin: boolean;
authType: string;
password?: string;
key?: string;
keyPassword?: string;
keyType?: string;
credentialId?: number;
enableTerminal: boolean;
enableTunnel: boolean;
enableFileManager: boolean;
defaultPath: string;
tunnelConnections: any[];
createdAt: string;
updatedAt: string;
}
interface TunnelConfig {
name: string;
hostName: string;
sourceIP: string;
sourceSSHPort: number;
sourceUsername: string;
sourcePassword?: string;
sourceAuthMethod: string;
sourceSSHKey?: string;
sourceKeyPassword?: string;
sourceKeyType?: string;
endpointIP: string;
endpointSSHPort: number;
endpointUsername: string;
endpointPassword?: string;
endpointAuthMethod: string;
endpointSSHKey?: string;
endpointKeyPassword?: string;
endpointKeyType?: string;
sourcePort: number;
endpointPort: number;
maxRetries: number;
retryInterval: number;
autoStart: boolean;
isPinned: boolean;
}
interface TunnelStatus {
status: string;
reason?: string;
errorType?: string;
retryCount?: number;
maxRetries?: number;
nextRetryIn?: number;
retryExhausted?: boolean;
}
interface FileManagerFile {
name: string;
path: string;
type?: 'file' | 'directory';
isSSH?: boolean;
sshSessionId?: string;
}
interface FileManagerShortcut {
name: string;
path: string;
}
import type {
SSHHost,
SSHHostData,
TunnelConfig,
TunnelStatus,
Credential,
CredentialData,
HostInfo,
ApiResponse,
FileManagerFile,
FileManagerShortcut
} from '../types/index.js';
interface FileManagerOperation {
name: string;
@@ -203,10 +116,26 @@ function createApiInstance(baseURL: string): AxiosInstance {
});
instance.interceptors.response.use(
(response) => response,
(response) => {
// Log successful requests in development
if (process.env.NODE_ENV === 'development') {
console.log(`✅ API ${response.config.method?.toUpperCase()} ${response.config.url} - ${response.status}`);
}
return response;
},
(error: AxiosError) => {
// Improved error logging
const method = error.config?.method?.toUpperCase() || 'UNKNOWN';
const url = error.config?.url || 'UNKNOWN';
const status = error.response?.status || 'NETWORK_ERROR';
const message = error.response?.data?.error || (error as Error).message || 'Unknown error';
console.error(`❌ API ${method} ${url} - ${status}: ${message}`);
if (error.response?.status === 401) {
console.warn('🔐 Authentication failed, clearing token');
document.cookie = 'jwt=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
localStorage.removeItem('jwt');
}
return Promise.reject(error);
}
@@ -296,17 +225,33 @@ function handleApiError(error: unknown, operation: string): never {
if (axios.isAxiosError(error)) {
const status = error.response?.status;
const message = error.response?.data?.error || error.message;
const code = error.response?.data?.code;
// Enhanced error logging
console.error(`🚨 API Error in ${operation}:`, {
status,
message,
code,
url: error.config?.url,
method: error.config?.method
});
if (status === 401) {
throw new ApiError('Authentication required', 401);
throw new ApiError('Authentication required. Please log in again.', 401, 'AUTH_REQUIRED');
} else if (status === 403) {
throw new ApiError('Access denied', 403);
throw new ApiError('Access denied. You do not have permission to perform this action.', 403, 'ACCESS_DENIED');
} else if (status === 404) {
throw new ApiError('Resource not found', 404);
throw new ApiError('Resource not found. The requested item may have been deleted.', 404, 'NOT_FOUND');
} else if (status === 409) {
throw new ApiError('Conflict. The resource already exists or is in use.', 409, 'CONFLICT');
} else if (status === 422) {
throw new ApiError('Validation error. Please check your input and try again.', 422, 'VALIDATION_ERROR');
} else if (status && status >= 500) {
throw new ApiError('Server error occurred', status);
throw new ApiError('Server error occurred. Please try again later.', status, 'SERVER_ERROR');
} else if (status === 0) {
throw new ApiError('Network error. Please check your connection and try again.', 0, 'NETWORK_ERROR');
} else {
throw new ApiError(message || `Failed to ${operation}`, status);
throw new ApiError(message || `Failed to ${operation}`, status, code);
}
}
@@ -314,7 +259,9 @@ function handleApiError(error: unknown, operation: string): never {
throw error;
}
throw new ApiError(`Unexpected error during ${operation}: ${error instanceof Error ? error.message : 'Unknown error'}`);
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
console.error(`🚨 Unexpected error in ${operation}:`, error);
throw new ApiError(`Unexpected error during ${operation}: ${errorMessage}`, undefined, 'UNKNOWN_ERROR');
}
// ============================================================================
@@ -602,12 +549,16 @@ export async function removeFileManagerShortcut(shortcut: FileManagerOperation):
// ============================================================================
export async function connectSSH(sessionId: string, config: {
hostId?: number;
ip: string;
port: number;
username: string;
password?: string;
sshKey?: string;
keyPassword?: string;
authType?: string;
credentialId?: number;
userId?: string;
}): Promise<any> {
try {
const response = await fileManagerApi.post('/ssh/connect', {
@@ -1119,15 +1070,47 @@ export async function getCredentialFolders(): Promise<any> {
}
}
export async function applyCredentialToHost(credentialId: number, hostId: number): Promise<any> {
// Get SSH host with resolved credentials
export async function getSSHHostWithCredentials(hostId: number): Promise<any> {
try {
const response = await authApi.post(`/credentials/${credentialId}/apply-to-host/${hostId}`);
const response = await sshHostApi.get(`/db/host/${hostId}/with-credentials`);
return response.data;
} catch (error) {
handleApiError(error, 'fetch SSH host with credentials');
}
}
// Apply credential to SSH host
export async function applyCredentialToHost(hostId: number, credentialId: number): Promise<any> {
try {
const response = await sshHostApi.post(`/db/host/${hostId}/apply-credential`, { credentialId });
return response.data;
} catch (error) {
handleApiError(error, 'apply credential to host');
}
}
// Remove credential from SSH host
export async function removeCredentialFromHost(hostId: number): Promise<any> {
try {
const response = await sshHostApi.delete(`/db/host/${hostId}/credential`);
return response.data;
} catch (error) {
handleApiError(error, 'remove credential from host');
}
}
// Migrate host to managed credential
export async function migrateHostToCredential(hostId: number, credentialName: string): Promise<any> {
try {
const response = await sshHostApi.post(`/db/host/${hostId}/migrate-to-credential`, { credentialName });
return response.data;
} catch (error) {
handleApiError(error, 'migrate host to credential');
}
}
// ============================================================================
// SSH FOLDER MANAGEMENT
// ============================================================================