v1.6.0 #221
@@ -310,7 +310,9 @@
|
|||||||
"allowNewAccountRegistration": "Allow new account registration",
|
"allowNewAccountRegistration": "Allow new account registration",
|
||||||
"missingRequiredFields": "Missing required fields: {{fields}}",
|
"missingRequiredFields": "Missing required fields: {{fields}}",
|
||||||
"oidcConfigurationUpdated": "OIDC configuration updated successfully!",
|
"oidcConfigurationUpdated": "OIDC configuration updated successfully!",
|
||||||
|
"oidcConfigurationDisabled": "OIDC configuration disabled successfully!",
|
||||||
"failedToUpdateOidcConfig": "Failed to update OIDC configuration",
|
"failedToUpdateOidcConfig": "Failed to update OIDC configuration",
|
||||||
|
"failedToDisableOidcConfig": "Failed to disable OIDC configuration",
|
||||||
"enterUsernameToMakeAdmin": "Enter username to make admin",
|
"enterUsernameToMakeAdmin": "Enter username to make admin",
|
||||||
"userIsNowAdmin": "User {{username}} is now an admin",
|
"userIsNowAdmin": "User {{username}} is now an admin",
|
||||||
"failedToMakeUserAdmin": "Failed to make user admin",
|
"failedToMakeUserAdmin": "Failed to make user admin",
|
||||||
|
|||||||
@@ -309,7 +309,9 @@
|
|||||||
"allowNewAccountRegistration": "允许新账户注册",
|
"allowNewAccountRegistration": "允许新账户注册",
|
||||||
"missingRequiredFields": "缺少必填字段:{{fields}}",
|
"missingRequiredFields": "缺少必填字段:{{fields}}",
|
||||||
"oidcConfigurationUpdated": "OIDC 配置更新成功!",
|
"oidcConfigurationUpdated": "OIDC 配置更新成功!",
|
||||||
|
"oidcConfigurationDisabled": "OIDC 配置禁用成功!",
|
||||||
"failedToUpdateOidcConfig": "更新 OIDC 配置失败",
|
"failedToUpdateOidcConfig": "更新 OIDC 配置失败",
|
||||||
|
"failedToDisableOidcConfig": "禁用 OIDC 配置失败",
|
||||||
"enterUsernameToMakeAdmin": "输入用户名以设为管理员",
|
"enterUsernameToMakeAdmin": "输入用户名以设为管理员",
|
||||||
"userIsNowAdmin": "用户 {{username}} 现在是管理员",
|
"userIsNowAdmin": "用户 {{username}} 现在是管理员",
|
||||||
"failedToMakeUserAdmin": "设为管理员失败",
|
"failedToMakeUserAdmin": "设为管理员失败",
|
||||||
|
|||||||
@@ -4,12 +4,12 @@ import userRoutes from './routes/users.js';
|
|||||||
import sshRoutes from './routes/ssh.js';
|
import sshRoutes from './routes/ssh.js';
|
||||||
import alertRoutes from './routes/alerts.js';
|
import alertRoutes from './routes/alerts.js';
|
||||||
import credentialsRoutes from './routes/credentials.js';
|
import credentialsRoutes from './routes/credentials.js';
|
||||||
import chalk from 'chalk';
|
|
||||||
import cors from 'cors';
|
import cors from 'cors';
|
||||||
import fetch from 'node-fetch';
|
import fetch from 'node-fetch';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import 'dotenv/config';
|
import 'dotenv/config';
|
||||||
|
import { databaseLogger, apiLogger } from '../utils/logger.js';
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
app.use(cors({
|
app.use(cors({
|
||||||
@@ -18,32 +18,6 @@ app.use(cors({
|
|||||||
allowedHeaders: ['Content-Type', 'Authorization']
|
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 {
|
interface CacheEntry {
|
||||||
data: any;
|
data: any;
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
@@ -61,19 +35,23 @@ class GitHubCache {
|
|||||||
timestamp: now,
|
timestamp: now,
|
||||||
expiresAt: now + this.CACHE_DURATION
|
expiresAt: now + this.CACHE_DURATION
|
||||||
});
|
});
|
||||||
|
databaseLogger.debug(`Cache entry set`, { operation: 'cache_set', key, expiresIn: this.CACHE_DURATION });
|
||||||
}
|
}
|
||||||
|
|
||||||
get(key: string): any | null {
|
get(key: string): any | null {
|
||||||
const entry = this.cache.get(key);
|
const entry = this.cache.get(key);
|
||||||
if (!entry) {
|
if (!entry) {
|
||||||
|
databaseLogger.debug(`Cache miss`, { operation: 'cache_get', key });
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Date.now() > entry.expiresAt) {
|
if (Date.now() > entry.expiresAt) {
|
||||||
this.cache.delete(key);
|
this.cache.delete(key);
|
||||||
|
databaseLogger.debug(`Cache entry expired`, { operation: 'cache_get', key, expired: true });
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
databaseLogger.debug(`Cache hit`, { operation: 'cache_get', key, age: Date.now() - entry.timestamp });
|
||||||
return entry.data;
|
return entry.data;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -105,6 +83,7 @@ interface GitHubRelease {
|
|||||||
async function fetchGitHubAPI(endpoint: string, cacheKey: string): Promise<any> {
|
async function fetchGitHubAPI(endpoint: string, cacheKey: string): Promise<any> {
|
||||||
const cachedData = githubCache.get(cacheKey);
|
const cachedData = githubCache.get(cacheKey);
|
||||||
if (cachedData) {
|
if (cachedData) {
|
||||||
|
databaseLogger.debug(`Using cached GitHub API data`, { operation: 'github_api', endpoint, cached: true });
|
||||||
return {
|
return {
|
||||||
data: cachedData,
|
data: cachedData,
|
||||||
cached: true,
|
cached: true,
|
||||||
@@ -113,6 +92,7 @@ async function fetchGitHubAPI(endpoint: string, cacheKey: string): Promise<any>
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
databaseLogger.info(`Fetching from GitHub API`, { operation: 'github_api', endpoint });
|
||||||
const response = await fetch(`${GITHUB_API_BASE}${endpoint}`, {
|
const response = await fetch(`${GITHUB_API_BASE}${endpoint}`, {
|
||||||
headers: {
|
headers: {
|
||||||
'Accept': 'application/vnd.github+json',
|
'Accept': 'application/vnd.github+json',
|
||||||
@@ -126,15 +106,15 @@ async function fetchGitHubAPI(endpoint: string, cacheKey: string): Promise<any>
|
|||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
githubCache.set(cacheKey, data);
|
githubCache.set(cacheKey, data);
|
||||||
|
|
||||||
|
databaseLogger.success(`GitHub API data fetched successfully`, { operation: 'github_api', endpoint, dataSize: JSON.stringify(data).length });
|
||||||
return {
|
return {
|
||||||
data: data,
|
data: data,
|
||||||
cached: false
|
cached: false
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} 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;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -142,10 +122,12 @@ async function fetchGitHubAPI(endpoint: string, cacheKey: string): Promise<any>
|
|||||||
app.use(bodyParser.json());
|
app.use(bodyParser.json());
|
||||||
|
|
||||||
app.get('/health', (req, res) => {
|
app.get('/health', (req, res) => {
|
||||||
|
apiLogger.info(`Health check requested`, { operation: 'health_check' });
|
||||||
res.json({status: 'ok'});
|
res.json({status: 'ok'});
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/version', async (req, res) => {
|
app.get('/version', async (req, res) => {
|
||||||
|
apiLogger.info(`Version check requested`, { operation: 'version_check' });
|
||||||
let localVersion = process.env.VERSION;
|
let localVersion = process.env.VERSION;
|
||||||
|
|
||||||
if (!localVersion) {
|
if (!localVersion) {
|
||||||
@@ -153,13 +135,14 @@ app.get('/version', async (req, res) => {
|
|||||||
const packagePath = path.resolve(process.cwd(), 'package.json');
|
const packagePath = path.resolve(process.cwd(), 'package.json');
|
||||||
const packageJson = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
|
const packageJson = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
|
||||||
localVersion = packageJson.version;
|
localVersion = packageJson.version;
|
||||||
|
databaseLogger.debug(`Version read from package.json`, { operation: 'version_check', localVersion });
|
||||||
} catch (error) {
|
} 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) {
|
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');
|
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;
|
const remoteVersion = remoteVersionMatch ? remoteVersionMatch[1] : null;
|
||||||
|
|
||||||
if (!remoteVersion) {
|
if (!remoteVersion) {
|
||||||
|
databaseLogger.warn('Remote version not found in GitHub response', { operation: 'version_check', rawTag });
|
||||||
return res.status(401).send('Remote Version Not Found');
|
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 = {
|
const response = {
|
||||||
status: localVersion === remoteVersion ? 'up_to_date' : 'requires_update',
|
status: isUpToDate ? 'up_to_date' : 'requires_update',
|
||||||
localVersion: localVersion,
|
localVersion: localVersion,
|
||||||
version: remoteVersion,
|
version: remoteVersion,
|
||||||
latest_release: {
|
latest_release: {
|
||||||
@@ -194,7 +187,7 @@ app.get('/version', async (req, res) => {
|
|||||||
|
|
||||||
res.json(response);
|
res.json(response);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error('Version check failed', err);
|
databaseLogger.error('Version check failed', err, { operation: 'version_check' });
|
||||||
res.status(500).send('Fetch Error');
|
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 per_page = Math.min(parseInt(req.query.per_page as string) || 20, 100);
|
||||||
const cacheKey = `releases_rss_${page}_${per_page}`;
|
const cacheKey = `releases_rss_${page}_${per_page}`;
|
||||||
|
|
||||||
|
apiLogger.info(`RSS releases requested`, { operation: 'rss_releases', page, per_page });
|
||||||
|
|
||||||
const releasesData = await fetchGitHubAPI(
|
const releasesData = await fetchGitHubAPI(
|
||||||
`/repos/${REPO_OWNER}/${REPO_NAME}/releases?page=${page}&per_page=${per_page}`,
|
`/repos/${REPO_OWNER}/${REPO_NAME}/releases?page=${page}&per_page=${per_page}`,
|
||||||
cacheKey
|
cacheKey
|
||||||
@@ -240,9 +235,17 @@ app.get('/releases/rss', async (req, res) => {
|
|||||||
cache_age: releasesData.cache_age
|
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);
|
res.json(response);
|
||||||
} catch (error) {
|
} 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({
|
res.status(500).json({
|
||||||
error: 'Failed to generate RSS format',
|
error: 'Failed to generate RSS format',
|
||||||
details: error instanceof Error ? error.message : 'Unknown error'
|
details: error instanceof Error ? error.message : 'Unknown error'
|
||||||
@@ -257,10 +260,20 @@ app.use('/alerts', alertRoutes);
|
|||||||
app.use('/credentials', credentialsRoutes);
|
app.use('/credentials', credentialsRoutes);
|
||||||
|
|
||||||
app.use((err: unknown, req: express.Request, res: express.Response, next: express.NextFunction) => {
|
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'});
|
res.status(500).json({error: 'Internal Server Error'});
|
||||||
});
|
});
|
||||||
|
|
||||||
const PORT = 8081;
|
const PORT = 8081;
|
||||||
app.listen(PORT, () => {
|
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']
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -1,381 +1,140 @@
|
|||||||
import {drizzle} from 'drizzle-orm/better-sqlite3';
|
import {drizzle} from 'drizzle-orm/better-sqlite3';
|
||||||
import Database from 'better-sqlite3';
|
import Database from 'better-sqlite3';
|
||||||
import * as schema from './schema.js';
|
import * as schema from './schema.js';
|
||||||
import chalk from 'chalk';
|
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { MigrationManager } from '../migrations/migrator.js';
|
import { databaseLogger } 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('#1e3a8a')(`[${dbIconSymbol}]`)} ${message}`;
|
|
||||||
};
|
|
||||||
const logger = {
|
|
||||||
info: (msg: string): void => {
|
|
||||||
console.log(formatMessage('info', chalk.cyan, msg));
|
|
||||||
},
|
|
||||||
warn: (msg: string): void => {
|
|
||||||
console.warn(formatMessage('warn', chalk.yellow, msg));
|
|
||||||
},
|
|
||||||
error: (msg: string, err?: unknown): void => {
|
|
||||||
console.error(formatMessage('error', chalk.redBright, msg));
|
|
||||||
if (err) console.error(err);
|
|
||||||
},
|
|
||||||
success: (msg: string): void => {
|
|
||||||
console.log(formatMessage('success', chalk.greenBright, msg));
|
|
||||||
},
|
|
||||||
debug: (msg: string): void => {
|
|
||||||
if (process.env.NODE_ENV !== 'production') {
|
|
||||||
console.debug(formatMessage('debug', chalk.magenta, msg));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const dataDir = process.env.DATA_DIR || './db/data';
|
const dataDir = process.env.DATA_DIR || './db/data';
|
||||||
const dbDir = path.resolve(dataDir);
|
const dbDir = path.resolve(dataDir);
|
||||||
if (!fs.existsSync(dbDir)) {
|
if (!fs.existsSync(dbDir)) {
|
||||||
|
databaseLogger.info(`Creating database directory`, { operation: 'db_init', path: dbDir });
|
||||||
fs.mkdirSync(dbDir, {recursive: true});
|
fs.mkdirSync(dbDir, {recursive: true});
|
||||||
}
|
}
|
||||||
|
|
||||||
const dbPath = path.join(dataDir, 'db.sqlite');
|
const dbPath = path.join(dataDir, 'db.sqlite');
|
||||||
|
databaseLogger.info(`Initializing SQLite database`, { operation: 'db_init', path: dbPath });
|
||||||
const sqlite = new Database(dbPath);
|
const sqlite = new Database(dbPath);
|
||||||
|
|
||||||
sqlite.exec(`
|
sqlite.exec(`
|
||||||
CREATE TABLE IF NOT EXISTS users
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
(
|
id TEXT PRIMARY KEY,
|
||||||
id
|
username TEXT NOT NULL,
|
||||||
TEXT
|
password_hash TEXT NOT NULL,
|
||||||
PRIMARY
|
is_admin INTEGER NOT NULL DEFAULT 0,
|
||||||
KEY,
|
is_oidc INTEGER NOT NULL DEFAULT 0,
|
||||||
username
|
client_id TEXT NOT NULL,
|
||||||
TEXT
|
client_secret TEXT NOT NULL,
|
||||||
NOT
|
issuer_url TEXT NOT NULL,
|
||||||
NULL,
|
authorization_url TEXT NOT NULL,
|
||||||
password_hash
|
token_url TEXT NOT NULL,
|
||||||
TEXT
|
redirect_uri TEXT,
|
||||||
NOT
|
identifier_path TEXT NOT NULL,
|
||||||
NULL,
|
name_path TEXT NOT NULL,
|
||||||
is_admin
|
scopes TEXT NOT NULL
|
||||||
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
|
CREATE TABLE IF NOT EXISTS settings (
|
||||||
(
|
key TEXT PRIMARY KEY,
|
||||||
key
|
value TEXT NOT NULL
|
||||||
TEXT
|
|
||||||
PRIMARY
|
|
||||||
KEY,
|
|
||||||
value
|
|
||||||
TEXT
|
|
||||||
NOT
|
|
||||||
NULL
|
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS ssh_data
|
CREATE TABLE IF NOT EXISTS ssh_data (
|
||||||
(
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
id
|
user_id TEXT NOT NULL,
|
||||||
INTEGER
|
name TEXT,
|
||||||
PRIMARY
|
ip TEXT NOT NULL,
|
||||||
KEY
|
port INTEGER NOT NULL,
|
||||||
AUTOINCREMENT,
|
username TEXT NOT NULL,
|
||||||
user_id
|
folder TEXT,
|
||||||
TEXT
|
tags TEXT,
|
||||||
NOT
|
pin INTEGER NOT NULL DEFAULT 0,
|
||||||
NULL,
|
auth_type TEXT NOT NULL,
|
||||||
name
|
password TEXT,
|
||||||
TEXT,
|
key TEXT,
|
||||||
ip
|
key_password TEXT,
|
||||||
TEXT
|
key_type TEXT,
|
||||||
NOT
|
enable_terminal INTEGER NOT NULL DEFAULT 1,
|
||||||
NULL,
|
enable_tunnel INTEGER NOT NULL DEFAULT 1,
|
||||||
port
|
tunnel_connections TEXT,
|
||||||
INTEGER
|
enable_file_manager INTEGER NOT NULL DEFAULT 1,
|
||||||
NOT
|
default_path TEXT,
|
||||||
NULL,
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
username
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
TEXT
|
FOREIGN KEY (user_id) REFERENCES users (id)
|
||||||
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
|
CREATE TABLE IF NOT EXISTS file_manager_recent (
|
||||||
(
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
id
|
user_id TEXT NOT NULL,
|
||||||
INTEGER
|
host_id INTEGER NOT NULL,
|
||||||
PRIMARY
|
name TEXT NOT NULL,
|
||||||
KEY
|
path TEXT NOT NULL,
|
||||||
AUTOINCREMENT,
|
last_opened TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
user_id
|
FOREIGN KEY (user_id) REFERENCES users (id),
|
||||||
TEXT
|
FOREIGN KEY (host_id) REFERENCES ssh_data (id)
|
||||||
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
|
CREATE TABLE IF NOT EXISTS file_manager_pinned (
|
||||||
(
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
id
|
user_id TEXT NOT NULL,
|
||||||
INTEGER
|
host_id INTEGER NOT NULL,
|
||||||
PRIMARY
|
name TEXT NOT NULL,
|
||||||
KEY
|
path TEXT NOT NULL,
|
||||||
AUTOINCREMENT,
|
pinned_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
user_id
|
FOREIGN KEY (user_id) REFERENCES users (id),
|
||||||
TEXT
|
FOREIGN KEY (host_id) REFERENCES ssh_data (id)
|
||||||
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
|
CREATE TABLE IF NOT EXISTS file_manager_shortcuts (
|
||||||
(
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
id
|
user_id TEXT NOT NULL,
|
||||||
INTEGER
|
host_id INTEGER NOT NULL,
|
||||||
PRIMARY
|
name TEXT NOT NULL,
|
||||||
KEY
|
path TEXT NOT NULL,
|
||||||
AUTOINCREMENT,
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
user_id
|
FOREIGN KEY (user_id) REFERENCES users (id),
|
||||||
TEXT
|
FOREIGN KEY (host_id) REFERENCES ssh_data (id)
|
||||||
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
|
CREATE TABLE IF NOT EXISTS dismissed_alerts (
|
||||||
(
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
id
|
user_id TEXT NOT NULL,
|
||||||
INTEGER
|
alert_id TEXT NOT NULL,
|
||||||
PRIMARY
|
dismissed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
KEY
|
FOREIGN KEY (user_id) REFERENCES users (id)
|
||||||
AUTOINCREMENT,
|
);
|
||||||
user_id
|
|
||||||
TEXT
|
CREATE TABLE IF NOT EXISTS ssh_credentials (
|
||||||
NOT
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
NULL,
|
user_id TEXT NOT NULL,
|
||||||
alert_id
|
name TEXT NOT NULL,
|
||||||
TEXT
|
description TEXT,
|
||||||
NOT
|
folder TEXT,
|
||||||
NULL,
|
tags TEXT,
|
||||||
dismissed_at
|
auth_type TEXT NOT NULL,
|
||||||
TEXT
|
username TEXT NOT NULL,
|
||||||
NOT
|
password TEXT,
|
||||||
NULL
|
key TEXT,
|
||||||
DEFAULT
|
key_password TEXT,
|
||||||
CURRENT_TIMESTAMP,
|
key_type TEXT,
|
||||||
FOREIGN
|
usage_count INTEGER NOT NULL DEFAULT 0,
|
||||||
KEY
|
last_used TEXT,
|
||||||
(
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
user_id
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
) REFERENCES users
|
FOREIGN KEY (user_id) REFERENCES users (id)
|
||||||
(
|
);
|
||||||
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) => {
|
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();
|
FROM ${table} LIMIT 1`).get();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
try {
|
try {
|
||||||
|
databaseLogger.debug(`Adding column ${column} to ${table}`, { operation: 'schema_migration', table, column });
|
||||||
sqlite.exec(`ALTER TABLE ${table}
|
sqlite.exec(`ALTER TABLE ${table}
|
||||||
ADD COLUMN ${column} ${definition};`);
|
ADD COLUMN ${column} ${definition};`);
|
||||||
|
databaseLogger.success(`Column ${column} added to ${table}`, { operation: 'schema_migration', table, column });
|
||||||
} catch (alterError) {
|
} 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 = () => {
|
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');
|
addColumnIfNotExists('users', 'is_admin', 'INTEGER NOT NULL DEFAULT 0');
|
||||||
|
|
||||||
@@ -405,8 +166,11 @@ const migrateSchema = () => {
|
|||||||
addColumnIfNotExists('users', 'authorization_url', 'TEXT');
|
addColumnIfNotExists('users', 'authorization_url', 'TEXT');
|
||||||
addColumnIfNotExists('users', 'token_url', 'TEXT');
|
addColumnIfNotExists('users', 'token_url', 'TEXT');
|
||||||
try {
|
try {
|
||||||
|
databaseLogger.debug('Attempting to drop redirect_uri column', { operation: 'schema_migration', table: 'users' });
|
||||||
sqlite.prepare(`ALTER TABLE users DROP COLUMN redirect_uri`).run();
|
sqlite.prepare(`ALTER TABLE users DROP COLUMN redirect_uri`).run();
|
||||||
|
databaseLogger.success('redirect_uri column dropped', { operation: 'schema_migration', table: 'users' });
|
||||||
} catch (e) {
|
} 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');
|
addColumnIfNotExists('users', 'identifier_path', 'TEXT');
|
||||||
@@ -433,38 +197,37 @@ const migrateSchema = () => {
|
|||||||
addColumnIfNotExists('ssh_data', 'default_path', 'TEXT');
|
addColumnIfNotExists('ssh_data', 'default_path', 'TEXT');
|
||||||
addColumnIfNotExists('ssh_data', 'created_at', 'TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP');
|
addColumnIfNotExists('ssh_data', 'created_at', 'TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP');
|
||||||
addColumnIfNotExists('ssh_data', 'updated_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('ssh_data', 'credential_id', 'INTEGER REFERENCES ssh_credentials(id)');
|
||||||
|
|
||||||
addColumnIfNotExists('file_manager_recent', 'host_id', 'INTEGER NOT NULL');
|
addColumnIfNotExists('file_manager_recent', 'host_id', 'INTEGER NOT NULL');
|
||||||
addColumnIfNotExists('file_manager_pinned', 'host_id', 'INTEGER NOT NULL');
|
addColumnIfNotExists('file_manager_pinned', 'host_id', 'INTEGER NOT NULL');
|
||||||
addColumnIfNotExists('file_manager_shortcuts', '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 () => {
|
const initializeDatabase = async () => {
|
||||||
migrateSchema();
|
migrateSchema();
|
||||||
|
|
||||||
// Run new migration system
|
|
||||||
const migrationManager = new MigrationManager(sqlite);
|
|
||||||
await migrationManager.runMigrations();
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const row = sqlite.prepare("SELECT value FROM settings WHERE key = 'allow_registration'").get();
|
const row = sqlite.prepare("SELECT value FROM settings WHERE key = 'allow_registration'").get();
|
||||||
if (!row) {
|
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();
|
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) {
|
} 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 => {
|
initializeDatabase().catch(error => {
|
||||||
logger.error('Failed to initialize database:', error);
|
databaseLogger.error('Failed to initialize database', error, { operation: 'db_init' });
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
databaseLogger.success('Database connection established', { operation: 'db_init', path: dbPath });
|
||||||
export const db = drizzle(sqlite, {schema});
|
export const db = drizzle(sqlite, {schema});
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import {sqliteTable, text, integer} from 'drizzle-orm/sqlite-core';
|
import {sqliteTable, text, integer} from 'drizzle-orm/sqlite-core';
|
||||||
import {sql} from 'drizzle-orm';
|
import {sql} from 'drizzle-orm';
|
||||||
|
import { databaseLogger } from '../../utils/logger.js';
|
||||||
|
|
||||||
export const users = sqliteTable('users', {
|
export const users = sqliteTable('users', {
|
||||||
id: text('id').primaryKey(),
|
id: text('id').primaryKey(),
|
||||||
@@ -97,12 +98,12 @@ export const sshCredentials = sqliteTable('ssh_credentials', {
|
|||||||
description: text('description'),
|
description: text('description'),
|
||||||
folder: text('folder'),
|
folder: text('folder'),
|
||||||
tags: text('tags'),
|
tags: text('tags'),
|
||||||
authType: text('auth_type').notNull(), // 'password' | 'key'
|
authType: text('auth_type').notNull(),
|
||||||
username: text('username').notNull(),
|
username: text('username').notNull(),
|
||||||
encryptedPassword: text('encrypted_password'), // AES encrypted
|
password: text('password'),
|
||||||
encryptedKey: text('encrypted_key', {length: 16384}), // AES encrypted SSH key
|
key: text('key', {length: 16384}),
|
||||||
encryptedKeyPassword: text('encrypted_key_password'), // AES encrypted key passphrase
|
keyPassword: text('key_password'),
|
||||||
keyType: text('key_type'), // 'rsa' | 'ecdsa' | 'ed25519'
|
keyType: text('key_type'),
|
||||||
usageCount: integer('usage_count').notNull().default(0),
|
usageCount: integer('usage_count').notNull().default(0),
|
||||||
lastUsed: text('last_used'),
|
lastUsed: text('last_used'),
|
||||||
createdAt: text('created_at').notNull().default(sql`CURRENT_TIMESTAMP`),
|
createdAt: text('created_at').notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||||
|
|||||||
@@ -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');
|
|
||||||
};
|
|
||||||
@@ -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 };
|
|
||||||
@@ -2,35 +2,10 @@ import express from 'express';
|
|||||||
import {db} from '../db/index.js';
|
import {db} from '../db/index.js';
|
||||||
import {dismissedAlerts} from '../db/schema.js';
|
import {dismissedAlerts} from '../db/schema.js';
|
||||||
import {eq, and} from 'drizzle-orm';
|
import {eq, and} from 'drizzle-orm';
|
||||||
import chalk from 'chalk';
|
|
||||||
import fetch from 'node-fetch';
|
import fetch from 'node-fetch';
|
||||||
import type {Request, Response, NextFunction} from 'express';
|
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 {
|
interface CacheEntry {
|
||||||
data: any;
|
data: any;
|
||||||
@@ -88,9 +63,11 @@ async function fetchAlertsFromGitHub(): Promise<TermixAlert[]> {
|
|||||||
const cacheKey = 'termix_alerts';
|
const cacheKey = 'termix_alerts';
|
||||||
const cachedData = alertCache.get(cacheKey);
|
const cachedData = alertCache.get(cacheKey);
|
||||||
if (cachedData) {
|
if (cachedData) {
|
||||||
|
authLogger.info('Returning cached alerts from GitHub', { operation: 'alerts_fetch', cacheKey, alertCount: cachedData.length });
|
||||||
return cachedData;
|
return cachedData;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
authLogger.info('Fetching alerts from GitHub', { operation: 'alerts_fetch', url: `${GITHUB_RAW_BASE}/${REPO_OWNER}/${REPO_NAME}/${ALERTS_FILE}` });
|
||||||
try {
|
try {
|
||||||
const url = `${GITHUB_RAW_BASE}/${REPO_OWNER}/${REPO_NAME}/${ALERTS_FILE}`;
|
const url = `${GITHUB_RAW_BASE}/${REPO_OWNER}/${REPO_NAME}/${ALERTS_FILE}`;
|
||||||
|
|
||||||
@@ -102,10 +79,12 @@ async function fetchAlertsFromGitHub(): Promise<TermixAlert[]> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
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}`);
|
throw new Error(`GitHub raw content error: ${response.status} ${response.statusText}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const alerts: TermixAlert[] = await response.json() as TermixAlert[];
|
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();
|
const now = new Date();
|
||||||
|
|
||||||
@@ -115,10 +94,12 @@ async function fetchAlertsFromGitHub(): Promise<TermixAlert[]> {
|
|||||||
return isValid;
|
return isValid;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
authLogger.info('Filtered alerts by expiry date', { operation: 'alerts_fetch', totalAlerts: alerts.length, validAlerts: validAlerts.length });
|
||||||
alertCache.set(cacheKey, validAlerts);
|
alertCache.set(cacheKey, validAlerts);
|
||||||
|
authLogger.success('Alerts cached successfully', { operation: 'alerts_fetch', alertCount: validAlerts.length });
|
||||||
return validAlerts;
|
return validAlerts;
|
||||||
} catch (error) {
|
} 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 [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -136,7 +117,7 @@ router.get('/', async (req, res) => {
|
|||||||
total_count: alerts.length
|
total_count: alerts.length
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} 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'});
|
res.status(500).json({error: 'Failed to fetch alerts'});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -168,7 +149,7 @@ router.get('/user/:userId', async (req, res) => {
|
|||||||
dismissed_count: dismissedAlertIds.size
|
dismissed_count: dismissedAlertIds.size
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} 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'});
|
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;
|
const {userId, alertId} = req.body;
|
||||||
|
|
||||||
if (!userId || !alertId) {
|
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'});
|
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) {
|
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'});
|
return res.status(409).json({error: 'Alert already dismissed'});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,10 +183,10 @@ router.post('/dismiss', async (req, res) => {
|
|||||||
alertId
|
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'});
|
res.json({message: 'Alert dismissed successfully'});
|
||||||
} catch (error) {
|
} 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'});
|
res.status(500).json({error: 'Failed to dismiss alert'});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -233,7 +214,7 @@ router.get('/dismissed/:userId', async (req, res) => {
|
|||||||
total_count: dismissedAlertRecords.length
|
total_count: dismissedAlertRecords.length
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} 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'});
|
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'});
|
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'});
|
res.json({message: 'Alert undismissed successfully'});
|
||||||
} catch (error) {
|
} 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'});
|
res.status(500).json({error: 'Failed to undismiss alert'});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,29 +1,11 @@
|
|||||||
import express from 'express';
|
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 type {Request, Response, NextFunction} from 'express';
|
||||||
import jwt from 'jsonwebtoken';
|
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();
|
const router = express.Router();
|
||||||
|
|
||||||
@@ -40,7 +22,7 @@ function isNonEmptyString(val: any): val is string {
|
|||||||
function authenticateJWT(req: Request, res: Response, next: NextFunction) {
|
function authenticateJWT(req: Request, res: Response, next: NextFunction) {
|
||||||
const authHeader = req.headers['authorization'];
|
const authHeader = req.headers['authorization'];
|
||||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
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'});
|
return res.status(401).json({error: 'Missing or invalid Authorization header'});
|
||||||
}
|
}
|
||||||
const token = authHeader.split(' ')[1];
|
const token = authHeader.split(' ')[1];
|
||||||
@@ -50,7 +32,7 @@ function authenticateJWT(req: Request, res: Response, next: NextFunction) {
|
|||||||
(req as any).userId = payload.userId;
|
(req as any).userId = payload.userId;
|
||||||
next();
|
next();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.warn('Invalid or expired token');
|
authLogger.warn('Invalid or expired token');
|
||||||
return res.status(401).json({error: '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
|
keyType
|
||||||
} = req.body;
|
} = req.body;
|
||||||
|
|
||||||
|
authLogger.info('Credential creation request received', { operation: 'credential_create', userId, name, authType, username });
|
||||||
|
|
||||||
if (!isNonEmptyString(userId) || !isNonEmptyString(name) || !isNonEmptyString(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'});
|
return res.status(400).json({error: 'Name and username are required'});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!['password', 'key'].includes(authType)) {
|
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"'});
|
return res.status(400).json({error: 'Auth type must be "password" or "key"'});
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const credential = await credentialService.createCredential(userId, {
|
if (authType === 'password' && !password) {
|
||||||
name,
|
authLogger.warn('Password required for password authentication', { operation: 'credential_create', userId, name, authType });
|
||||||
description,
|
return res.status(400).json({error: 'Password is required for password authentication'});
|
||||||
folder,
|
}
|
||||||
tags,
|
if (authType === 'key' && !key) {
|
||||||
authType,
|
authLogger.warn('SSH key required for key authentication', { operation: 'credential_create', userId, name, authType });
|
||||||
username,
|
return res.status(400).json({error: 'SSH key is required for key authentication'});
|
||||||
password,
|
}
|
||||||
key,
|
|
||||||
keyPassword,
|
|
||||||
keyType
|
|
||||||
});
|
|
||||||
|
|
||||||
logger.success(`Created credential: ${name}`);
|
authLogger.info('Preparing credential data for database insertion', { operation: 'credential_create', userId, name, authType, hasPassword: !!password, hasKey: !!key });
|
||||||
res.status(201).json(credential);
|
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) {
|
} 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({
|
res.status(500).json({
|
||||||
error: err instanceof Error ? err.message : 'Failed to create credential'
|
error: err instanceof Error ? err.message : 'Failed to create credential'
|
||||||
});
|
});
|
||||||
@@ -110,61 +114,97 @@ router.post('/', authenticateJWT, async (req: Request, res: Response) => {
|
|||||||
// GET /credentials
|
// GET /credentials
|
||||||
router.get('/', authenticateJWT, async (req: Request, res: Response) => {
|
router.get('/', authenticateJWT, async (req: Request, res: Response) => {
|
||||||
const userId = (req as any).userId;
|
const userId = (req as any).userId;
|
||||||
|
|
||||||
if (!isNonEmptyString(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'});
|
return res.status(400).json({error: 'Invalid userId'});
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const credentials = await credentialService.getUserCredentials(userId);
|
const credentials = await db
|
||||||
res.json(credentials);
|
.select()
|
||||||
|
.from(sshCredentials)
|
||||||
|
.where(eq(sshCredentials.userId, userId))
|
||||||
|
.orderBy(desc(sshCredentials.updatedAt));
|
||||||
|
|
||||||
|
res.json(credentials.map(cred => formatCredentialOutput(cred)));
|
||||||
} catch (err) {
|
} 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'});
|
res.status(500).json({error: 'Failed to fetch credentials'});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get all unique credential folders for the authenticated user
|
// Get all unique credential folders for the authenticated user
|
||||||
// GET /credentials/folders
|
// GET /credentials/folders
|
||||||
router.get('/folders', authenticateJWT, async (req: Request, res: Response) => {
|
router.get('/folders', authenticateJWT, async (req: Request, res: Response) => {
|
||||||
const userId = (req as any).userId;
|
const userId = (req as any).userId;
|
||||||
|
|
||||||
if (!isNonEmptyString(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'});
|
return res.status(400).json({error: 'Invalid userId'});
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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);
|
res.json(folders);
|
||||||
} catch (err) {
|
} 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'});
|
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
|
// GET /credentials/:id
|
||||||
router.get('/:id', authenticateJWT, async (req: Request, res: Response) => {
|
router.get('/:id', authenticateJWT, async (req: Request, res: Response) => {
|
||||||
const userId = (req as any).userId;
|
const userId = (req as any).userId;
|
||||||
const {id} = req.params;
|
const {id} = req.params;
|
||||||
|
|
||||||
if (!isNonEmptyString(userId) || !id) {
|
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'});
|
return res.status(400).json({error: 'Invalid request'});
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const credential = await credentialService.getCredentialWithSecrets(userId, parseInt(id));
|
const credentials = await db
|
||||||
|
.select()
|
||||||
if (!credential) {
|
.from(sshCredentials)
|
||||||
|
.where(and(
|
||||||
|
eq(sshCredentials.id, parseInt(id)),
|
||||||
|
eq(sshCredentials.userId, userId)
|
||||||
|
));
|
||||||
|
|
||||||
|
if (credentials.length === 0) {
|
||||||
return res.status(404).json({error: 'Credential not found'});
|
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) {
|
} catch (err) {
|
||||||
logger.error('Failed to fetch credential', err);
|
authLogger.error('Failed to fetch credential', err);
|
||||||
res.status(500).json({
|
res.status(500).json({
|
||||||
error: err instanceof Error ? err.message : 'Failed to fetch credential'
|
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;
|
const updateData = req.body;
|
||||||
|
|
||||||
if (!isNonEmptyString(userId) || !id) {
|
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'});
|
return res.status(400).json({error: 'Invalid request'});
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const credential = await credentialService.updateCredential(userId, parseInt(id), updateData);
|
const existing = await db
|
||||||
logger.success(`Updated credential ID ${id}`);
|
.select()
|
||||||
res.json(credential);
|
.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) {
|
} catch (err) {
|
||||||
logger.error('Failed to update credential', err);
|
authLogger.error('Failed to update credential', err);
|
||||||
res.status(500).json({
|
res.status(500).json({
|
||||||
error: err instanceof Error ? err.message : 'Failed to update credential'
|
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;
|
const {id} = req.params;
|
||||||
|
|
||||||
if (!isNonEmptyString(userId) || !id) {
|
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'});
|
return res.status(400).json({error: 'Invalid request'});
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await credentialService.deleteCredential(userId, parseInt(id));
|
const hostsUsingCredential = await db
|
||||||
logger.success(`Deleted credential ID ${id}`);
|
.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'});
|
res.json({message: 'Credential deleted successfully'});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error('Failed to delete credential', err);
|
authLogger.error('Failed to delete credential', err);
|
||||||
res.status(500).json({
|
res.status(500).json({
|
||||||
error: err instanceof Error ? err.message : 'Failed to delete credential'
|
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;
|
const {id: credentialId, hostId} = req.params;
|
||||||
|
|
||||||
if (!isNonEmptyString(userId) || !credentialId || !hostId) {
|
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'});
|
return res.status(400).json({error: 'Invalid request'});
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const {sshHostService} = await import('../../services/ssh-host.js');
|
const credentials = await db
|
||||||
await sshHostService.applyCredentialToHost(userId, parseInt(hostId), parseInt(credentialId));
|
.select()
|
||||||
|
.from(sshCredentials)
|
||||||
logger.success(`Applied credential ${credentialId} to host ${hostId}`);
|
.where(and(
|
||||||
|
eq(sshCredentials.id, parseInt(credentialId)),
|
||||||
|
eq(sshCredentials.userId, userId)
|
||||||
|
));
|
||||||
|
|
||||||
|
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'});
|
res.json({message: 'Credential applied to host successfully'});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error('Failed to apply credential to host', err);
|
authLogger.error('Failed to apply credential to host', err);
|
||||||
res.status(500).json({
|
res.status(500).json({
|
||||||
error: err instanceof Error ? err.message : 'Failed to apply credential to host'
|
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;
|
const {id: credentialId} = req.params;
|
||||||
|
|
||||||
if (!isNonEmptyString(userId) || !credentialId) {
|
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'});
|
return res.status(400).json({error: 'Invalid request'});
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const {sshHostService} = await import('../../services/ssh-host.js');
|
const hosts = await db
|
||||||
const hosts = await sshHostService.getHostsUsingCredential(userId, parseInt(credentialId));
|
.select()
|
||||||
|
.from(sshData)
|
||||||
res.json(hosts);
|
.where(and(
|
||||||
|
eq(sshData.credentialId, parseInt(credentialId)),
|
||||||
|
eq(sshData.userId, userId)
|
||||||
|
));
|
||||||
|
|
||||||
|
res.json(hosts.map(host => formatSSHHostOutput(host)));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error('Failed to fetch hosts using credential', err);
|
authLogger.error('Failed to fetch hosts using credential', err);
|
||||||
res.status(500).json({
|
res.status(500).json({
|
||||||
error: err instanceof Error ? err.message : 'Failed to fetch hosts using credential'
|
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;
|
export default router;
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -2,13 +2,13 @@ import express from 'express';
|
|||||||
import {db} from '../db/index.js';
|
import {db} from '../db/index.js';
|
||||||
import {users, settings, sshData, fileManagerRecent, fileManagerPinned, fileManagerShortcuts, dismissedAlerts} from '../db/schema.js';
|
import {users, settings, sshData, fileManagerRecent, fileManagerPinned, fileManagerShortcuts, dismissedAlerts} from '../db/schema.js';
|
||||||
import {eq, and} from 'drizzle-orm';
|
import {eq, and} from 'drizzle-orm';
|
||||||
import chalk from 'chalk';
|
|
||||||
import bcrypt from 'bcryptjs';
|
import bcrypt from 'bcryptjs';
|
||||||
import {nanoid} from 'nanoid';
|
import {nanoid} from 'nanoid';
|
||||||
import jwt from 'jsonwebtoken';
|
import jwt from 'jsonwebtoken';
|
||||||
import speakeasy from 'speakeasy';
|
import speakeasy from 'speakeasy';
|
||||||
import QRCode from 'qrcode';
|
import QRCode from 'qrcode';
|
||||||
import type {Request, Response, NextFunction} from 'express';
|
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> {
|
async function verifyOIDCToken(idToken: string, issuerUrl: string, clientId: string): Promise<any> {
|
||||||
try {
|
try {
|
||||||
@@ -36,7 +36,7 @@ async function verifyOIDCToken(idToken: string, issuerUrl: string, clientId: str
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (discoveryError) {
|
} catch (discoveryError) {
|
||||||
logger.error(`OIDC discovery failed: ${discoveryError}`);
|
authLogger.error(`OIDC discovery failed: ${discoveryError}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
let jwks: any = null;
|
let jwks: any = null;
|
||||||
@@ -52,13 +52,13 @@ async function verifyOIDCToken(idToken: string, issuerUrl: string, clientId: str
|
|||||||
jwksUrl = url;
|
jwksUrl = url;
|
||||||
break;
|
break;
|
||||||
} else {
|
} else {
|
||||||
logger.error(`Invalid JWKS structure from ${url}: ${JSON.stringify(jwksData)}`);
|
authLogger.error(`Invalid JWKS structure from ${url}: ${JSON.stringify(jwksData)}`);
|
||||||
}
|
}
|
||||||
} else {
|
} 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) {
|
} catch (error) {
|
||||||
logger.error(`JWKS fetch error from ${url}:`, error);
|
authLogger.error(`JWKS fetch error from ${url}:`, error);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -89,36 +89,11 @@ async function verifyOIDCToken(idToken: string, issuerUrl: string, clientId: str
|
|||||||
|
|
||||||
return payload;
|
return payload;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('OIDC token verification failed:', error);
|
authLogger.error('OIDC token verification failed:', error);
|
||||||
throw 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();
|
const router = express.Router();
|
||||||
|
|
||||||
@@ -136,7 +111,7 @@ interface JWTPayload {
|
|||||||
function authenticateJWT(req: Request, res: Response, next: NextFunction) {
|
function authenticateJWT(req: Request, res: Response, next: NextFunction) {
|
||||||
const authHeader = req.headers['authorization'];
|
const authHeader = req.headers['authorization'];
|
||||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
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'});
|
return res.status(401).json({error: 'Missing or invalid Authorization header'});
|
||||||
}
|
}
|
||||||
const token = authHeader.split(' ')[1];
|
const token = authHeader.split(' ')[1];
|
||||||
@@ -144,9 +119,10 @@ function authenticateJWT(req: Request, res: Response, next: NextFunction) {
|
|||||||
try {
|
try {
|
||||||
const payload = jwt.verify(token, jwtSecret) as JWTPayload;
|
const payload = jwt.verify(token, jwtSecret) as JWTPayload;
|
||||||
(req as any).userId = payload.userId;
|
(req as any).userId = payload.userId;
|
||||||
|
authLogger.debug('JWT authentication successful', { operation: 'auth', userId: payload.userId, method: req.method, url: req.url });
|
||||||
next();
|
next();
|
||||||
} catch (err) {
|
} 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'});
|
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;
|
const {username, password} = req.body;
|
||||||
|
|
||||||
if (!isNonEmptyString(username) || !isNonEmptyString(password)) {
|
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'});
|
return res.status(400).json({error: 'Username and password are required'});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,7 +151,7 @@ router.post('/create', async (req, res) => {
|
|||||||
.from(users)
|
.from(users)
|
||||||
.where(eq(users.username, username));
|
.where(eq(users.username, username));
|
||||||
if (existing && existing.length > 0) {
|
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'});
|
return res.status(409).json({error: 'Username already exists'});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,14 +159,19 @@ router.post('/create', async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
const countResult = db.$client.prepare('SELECT COUNT(*) as count FROM users').get();
|
const countResult = db.$client.prepare('SELECT COUNT(*) as count FROM users').get();
|
||||||
isFirstUser = ((countResult as any)?.count || 0) === 0;
|
isFirstUser = ((countResult as any)?.count || 0) === 0;
|
||||||
|
authLogger.info('Checked user count for admin status', { operation: 'user_create', username, isFirstUser });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
isFirstUser = true;
|
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 saltRounds = parseInt(process.env.SALT || '10', 10);
|
||||||
const password_hash = await bcrypt.hash(password, saltRounds);
|
const password_hash = await bcrypt.hash(password, saltRounds);
|
||||||
const id = nanoid();
|
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({
|
await db.insert(users).values({
|
||||||
id,
|
id,
|
||||||
username,
|
username,
|
||||||
@@ -210,10 +191,10 @@ router.post('/create', async (req, res) => {
|
|||||||
totp_backup_codes: null,
|
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});
|
res.json({message: 'User created', is_admin: isFirstUser});
|
||||||
} catch (err) {
|
} 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'});
|
res.status(500).json({error: 'Failed to create user'});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -240,30 +221,96 @@ router.post('/oidc-config', authenticateJWT, async (req, res) => {
|
|||||||
scopes
|
scopes
|
||||||
} = req.body;
|
} = req.body;
|
||||||
|
|
||||||
if (!isNonEmptyString(client_id) || !isNonEmptyString(client_secret) ||
|
authLogger.info('OIDC config update request received', {
|
||||||
!isNonEmptyString(issuer_url) || !isNonEmptyString(authorization_url) ||
|
operation: 'oidc_config_update',
|
||||||
!isNonEmptyString(token_url) || !isNonEmptyString(identifier_path) ||
|
userId,
|
||||||
!isNonEmptyString(name_path)) {
|
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'});
|
return res.status(400).json({error: 'All OIDC configuration fields are required'});
|
||||||
}
|
}
|
||||||
|
|
||||||
const config = {
|
if (isDisableRequest) {
|
||||||
client_id,
|
// Disable OIDC by removing the configuration
|
||||||
client_secret,
|
db.$client.prepare("DELETE FROM settings WHERE key = 'oidc_config'").run();
|
||||||
issuer_url,
|
authLogger.info('OIDC configuration disabled', { operation: 'oidc_disable', userId });
|
||||||
authorization_url,
|
res.json({message: 'OIDC configuration disabled'});
|
||||||
token_url,
|
} else {
|
||||||
userinfo_url: userinfo_url || '',
|
// Enable OIDC by storing the configuration
|
||||||
identifier_path,
|
const config = {
|
||||||
name_path,
|
client_id,
|
||||||
scopes: scopes || 'openid email profile'
|
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));
|
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'});
|
res.json({message: 'OIDC configuration updated'});
|
||||||
|
}
|
||||||
} catch (err) {
|
} 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'});
|
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));
|
res.json(JSON.parse((row as any).value));
|
||||||
} catch (err) {
|
} 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'});
|
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});
|
res.json({auth_url: authUrl.toString(), state, nonce});
|
||||||
} catch (err) {
|
} 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'});
|
res.status(500).json({error: 'Failed to generate authorization URL'});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -369,7 +416,7 @@ router.get('/oidc/callback', async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!tokenResponse.ok) {
|
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'});
|
return res.status(400).json({error: 'Failed to exchange authorization code'});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -391,7 +438,7 @@ router.get('/oidc/callback', async (req, res) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (discoveryError) {
|
} catch (discoveryError) {
|
||||||
logger.error(`OIDC discovery failed: ${discoveryError}`);
|
authLogger.error(`OIDC discovery failed: ${discoveryError}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (config.userinfo_url) {
|
if (config.userinfo_url) {
|
||||||
@@ -412,18 +459,18 @@ router.get('/oidc/callback', async (req, res) => {
|
|||||||
if (tokenData.id_token) {
|
if (tokenData.id_token) {
|
||||||
try {
|
try {
|
||||||
userInfo = await verifyOIDCToken(tokenData.id_token, config.issuer_url, config.client_id);
|
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) {
|
} catch (error) {
|
||||||
logger.error('OIDC token verification failed, trying userinfo endpoints', error);
|
authLogger.error('OIDC token verification failed, trying userinfo endpoints', error);
|
||||||
try {
|
try {
|
||||||
const parts = tokenData.id_token.split('.');
|
const parts = tokenData.id_token.split('.');
|
||||||
if (parts.length === 3) {
|
if (parts.length === 3) {
|
||||||
const payload = JSON.parse(Buffer.from(parts[1], 'base64').toString());
|
const payload = JSON.parse(Buffer.from(parts[1], 'base64').toString());
|
||||||
userInfo = payload;
|
userInfo = payload;
|
||||||
logger.info('Successfully decoded ID token payload without verification');
|
authLogger.info('Successfully decoded ID token payload without verification');
|
||||||
}
|
}
|
||||||
} catch (decodeError) {
|
} 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();
|
userInfo = await userInfoResponse.json();
|
||||||
break;
|
break;
|
||||||
} else {
|
} else {
|
||||||
logger.error(`Userinfo endpoint ${userInfoUrl} failed with status: ${userInfoResponse.status}`);
|
authLogger.error(`Userinfo endpoint ${userInfoUrl} failed with status: ${userInfoResponse.status}`);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Userinfo endpoint ${userInfoUrl} failed:`, error);
|
authLogger.error(`Userinfo endpoint ${userInfoUrl} failed:`, error);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!userInfo) {
|
if (!userInfo) {
|
||||||
logger.error('Failed to get user information from all sources');
|
authLogger.error('Failed to get user information from all sources');
|
||||||
logger.error(`Tried userinfo URLs: ${userInfoUrls.join(', ')}`);
|
authLogger.error(`Tried userinfo URLs: ${userInfoUrls.join(', ')}`);
|
||||||
logger.error(`Token data keys: ${Object.keys(tokenData).join(', ')}`);
|
authLogger.error(`Token data keys: ${Object.keys(tokenData).join(', ')}`);
|
||||||
logger.error(`Has id_token: ${!!tokenData.id_token}`);
|
authLogger.error(`Has id_token: ${!!tokenData.id_token}`);
|
||||||
logger.error(`Has access_token: ${!!tokenData.access_token}`);
|
authLogger.error(`Has access_token: ${!!tokenData.access_token}`);
|
||||||
return res.status(400).json({error: 'Failed to get user information'});
|
return res.status(400).json({error: 'Failed to get user information'});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -477,8 +524,8 @@ router.get('/oidc/callback', async (req, res) => {
|
|||||||
identifier;
|
identifier;
|
||||||
|
|
||||||
if (!identifier) {
|
if (!identifier) {
|
||||||
logger.error(`Identifier not found at path: ${config.identifier_path}`);
|
authLogger.error(`Identifier not found at path: ${config.identifier_path}`);
|
||||||
logger.error(`Available fields: ${Object.keys(userInfo).join(', ')}`);
|
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(', ')}`});
|
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());
|
res.redirect(redirectUrl.toString());
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error('OIDC callback failed', err);
|
authLogger.error('OIDC callback failed', err);
|
||||||
|
|
||||||
let frontendUrl = redirectUri.replace('/users/oidc/callback', '');
|
let frontendUrl = redirectUri.replace('/users/oidc/callback', '');
|
||||||
|
|
||||||
@@ -570,7 +617,7 @@ router.post('/login', async (req, res) => {
|
|||||||
const {username, password} = req.body;
|
const {username, password} = req.body;
|
||||||
|
|
||||||
if (!isNonEmptyString(username) || !isNonEmptyString(password)) {
|
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'});
|
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));
|
.where(eq(users.username, username));
|
||||||
|
|
||||||
if (!user || user.length === 0) {
|
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'});
|
return res.status(404).json({error: 'User not found'});
|
||||||
}
|
}
|
||||||
|
|
||||||
const userRecord = user[0];
|
const userRecord = user[0];
|
||||||
|
|
||||||
if (userRecord.is_oidc) {
|
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'});
|
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);
|
const isMatch = await bcrypt.compare(password, userRecord.password_hash);
|
||||||
if (!isMatch) {
|
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'});
|
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 jwtSecret = process.env.JWT_SECRET || 'secret';
|
||||||
const token = jwt.sign({userId: userRecord.id}, jwtSecret, {
|
const token = jwt.sign({userId: userRecord.id}, jwtSecret, {
|
||||||
expiresIn: '50d',
|
expiresIn: '50d',
|
||||||
});
|
});
|
||||||
|
|
||||||
if (userRecord.totp_enabled) {
|
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({
|
return res.json({
|
||||||
requires_totp: true,
|
requires_totp: true,
|
||||||
temp_token: jwt.sign(
|
temp_token: tempToken
|
||||||
{userId: userRecord.id, pending_totp: true},
|
|
||||||
jwtSecret,
|
|
||||||
{expiresIn: '10m'}
|
|
||||||
)
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
authLogger.success('User login successful', { operation: 'user_login', username, userId: userRecord.id, isAdmin: !!userRecord.is_admin });
|
||||||
return res.json({
|
return res.json({
|
||||||
token,
|
token,
|
||||||
is_admin: !!userRecord.is_admin,
|
is_admin: !!userRecord.is_admin,
|
||||||
@@ -620,7 +674,7 @@ router.post('/login', async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
} catch (err) {
|
} 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'});
|
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) => {
|
router.get('/me', authenticateJWT, async (req: Request, res: Response) => {
|
||||||
const userId = (req as any).userId;
|
const userId = (req as any).userId;
|
||||||
if (!isNonEmptyString(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'});
|
return res.status(401).json({error: 'Invalid userId'});
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -639,7 +693,7 @@ router.get('/me', authenticateJWT, async (req: Request, res: Response) => {
|
|||||||
.from(users)
|
.from(users)
|
||||||
.where(eq(users.id, userId));
|
.where(eq(users.id, userId));
|
||||||
if (!user || user.length === 0) {
|
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'});
|
return res.status(401).json({error: 'User not found'});
|
||||||
}
|
}
|
||||||
res.json({
|
res.json({
|
||||||
@@ -650,7 +704,7 @@ router.get('/me', authenticateJWT, async (req: Request, res: Response) => {
|
|||||||
totp_enabled: !!user[0].totp_enabled
|
totp_enabled: !!user[0].totp_enabled
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} 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'});
|
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;
|
const count = (countResult as any)?.count || 0;
|
||||||
res.json({count});
|
res.json({count});
|
||||||
} catch (err) {
|
} 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'});
|
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();
|
db.$client.prepare('SELECT 1').get();
|
||||||
res.json({status: 'ok'});
|
res.json({status: 'ok'});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error('DB health check failed', err);
|
authLogger.error('DB health check failed', err);
|
||||||
res.status(500).json({error: 'Database not accessible'});
|
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();
|
const row = db.$client.prepare("SELECT value FROM settings WHERE key = 'allow_registration'").get();
|
||||||
res.json({allowed: row ? (row as any).value === 'true' : true});
|
res.json({allowed: row ? (row as any).value === 'true' : true});
|
||||||
} catch (err) {
|
} 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'});
|
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');
|
db.$client.prepare("UPDATE settings SET value = ? WHERE key = 'allow_registration'").run(allowed ? 'true' : 'false');
|
||||||
res.json({allowed});
|
res.json({allowed});
|
||||||
} catch (err) {
|
} 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'});
|
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);
|
const isMatch = await bcrypt.compare(password, userRecord.password_hash);
|
||||||
if (!isMatch) {
|
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'});
|
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));
|
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'});
|
res.json({message: 'Account deleted successfully'});
|
||||||
|
|
||||||
} catch (err) {
|
} 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'});
|
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));
|
.where(eq(users.username, username));
|
||||||
|
|
||||||
if (!user || user.length === 0) {
|
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'});
|
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()})
|
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.'});
|
res.json({message: 'Password reset code has been generated and logged. Check docker logs for the code.'});
|
||||||
|
|
||||||
} catch (err) {
|
} 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'});
|
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});
|
res.json({message: 'Reset code verified', tempToken});
|
||||||
|
|
||||||
} catch (err) {
|
} 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'});
|
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(`reset_code_${username}`);
|
||||||
db.$client.prepare("DELETE FROM settings WHERE key = ?").run(`temp_reset_token_${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'});
|
res.json({message: 'Password has been successfully reset'});
|
||||||
|
|
||||||
} catch (err) {
|
} 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'});
|
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});
|
res.json({users: allUsers});
|
||||||
} catch (err) {
|
} 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'});
|
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})
|
.set({is_admin: true})
|
||||||
.where(eq(users.username, username));
|
.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`});
|
res.json({message: `User ${username} is now an admin`});
|
||||||
|
|
||||||
} catch (err) {
|
} 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'});
|
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})
|
.set({is_admin: false})
|
||||||
.where(eq(users.username, username));
|
.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}`});
|
res.json({message: `Admin status removed from ${username}`});
|
||||||
|
|
||||||
} catch (err) {
|
} 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'});
|
res.status(500).json({error: 'Failed to remove admin status'});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -1057,7 +1111,7 @@ router.post('/totp/verify-login', async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error('TOTP verification failed', err);
|
authLogger.error('TOTP verification failed', err);
|
||||||
return res.status(500).json({error: 'TOTP verification failed'});
|
return res.status(500).json({error: 'TOTP verification failed'});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -1096,7 +1150,7 @@ router.post('/totp/setup', authenticateJWT, async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
} catch (err) {
|
} 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'});
|
res.status(500).json({error: 'Failed to setup TOTP'});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -1155,7 +1209,7 @@ router.post('/totp/enable', authenticateJWT, async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
} catch (err) {
|
} 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'});
|
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'});
|
res.json({message: 'TOTP disabled successfully'});
|
||||||
|
|
||||||
} catch (err) {
|
} 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'});
|
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});
|
res.json({backup_codes: backupCodes});
|
||||||
|
|
||||||
} catch (err) {
|
} 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'});
|
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(dismissedAlerts).where(eq(dismissedAlerts.userId, targetUserId));
|
||||||
|
|
||||||
await db.delete(sshData).where(eq(sshData.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) {
|
} catch (cleanupError) {
|
||||||
logger.error(`Cleanup failed for user ${username}:`, cleanupError);
|
authLogger.error(`Cleanup failed for user ${username}:`, cleanupError);
|
||||||
throw cleanupError;
|
throw cleanupError;
|
||||||
}
|
}
|
||||||
|
|
||||||
await db.delete(users).where(eq(users.id, targetUserId));
|
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`});
|
res.json({message: `User ${username} deleted successfully`});
|
||||||
|
|
||||||
} catch (err) {
|
} 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 && typeof err === 'object' && 'code' in err) {
|
||||||
if (err.code === 'SQLITE_CONSTRAINT_FOREIGNKEY') {
|
if (err.code === 'SQLITE_CONSTRAINT_FOREIGNKEY') {
|
||||||
|
|||||||
@@ -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();
|
|
||||||
@@ -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 };
|
|
||||||
@@ -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();
|
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
import express from 'express';
|
import express from 'express';
|
||||||
import cors from 'cors';
|
import cors from 'cors';
|
||||||
import {Client as SSHClient} from 'ssh2';
|
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();
|
const app = express();
|
||||||
|
|
||||||
@@ -14,31 +17,6 @@ app.use(express.json({limit: '100mb'}));
|
|||||||
app.use(express.urlencoded({limit: '100mb', extended: true}));
|
app.use(express.urlencoded({limit: '100mb', extended: true}));
|
||||||
app.use(express.raw({limit: '200mb', type: 'application/octet-stream'}));
|
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 {
|
interface SSHSession {
|
||||||
client: SSHClient;
|
client: SSHClient;
|
||||||
@@ -69,15 +47,52 @@ function scheduleSessionCleanup(sessionId: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
app.post('/ssh/file_manager/ssh/connect', async (req, res) => {
|
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) {
|
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'});
|
return res.status(400).json({error: 'Missing SSH connection parameters'});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sshSessions[sessionId]?.isConnected) {
|
if (sshSessions[sessionId]?.isConnected) {
|
||||||
|
fileLogger.info('Cleaning up existing SSH session', { operation: 'file_connect', sessionId });
|
||||||
cleanupSession(sessionId);
|
cleanupSession(sessionId);
|
||||||
}
|
}
|
||||||
const client = new SSHClient();
|
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 = {
|
const config: any = {
|
||||||
host: ip,
|
host: ip,
|
||||||
port: port || 22,
|
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 {
|
try {
|
||||||
if (!sshKey.includes('-----BEGIN') || !sshKey.includes('-----END')) {
|
if (!resolvedCredentials.sshKey.includes('-----BEGIN') || !resolvedCredentials.sshKey.includes('-----END')) {
|
||||||
throw new Error('Invalid private key format');
|
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');
|
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) {
|
} 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'});
|
return res.status(400).json({error: 'Invalid SSH key format'});
|
||||||
}
|
}
|
||||||
} else if (password && password.trim()) {
|
} else if (resolvedCredentials.password && resolvedCredentials.password.trim()) {
|
||||||
config.password = password;
|
fileLogger.info('Configuring password authentication for file manager', { operation: 'file_connect', sessionId, hostId });
|
||||||
|
config.password = resolvedCredentials.password;
|
||||||
} else {
|
} 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'});
|
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', () => {
|
client.on('ready', () => {
|
||||||
if (responseSent) return;
|
if (responseSent) return;
|
||||||
responseSent = true;
|
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()};
|
sshSessions[sessionId] = {client, isConnected: true, lastActive: Date.now()};
|
||||||
res.json({status: 'success', message: 'SSH connection established'});
|
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) => {
|
client.on('error', (err) => {
|
||||||
if (responseSent) return;
|
if (responseSent) return;
|
||||||
responseSent = true;
|
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});
|
res.status(500).json({status: 'error', message: err.message});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -194,12 +213,12 @@ app.get('/ssh/file_manager/ssh/listFiles', (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
sshConn.lastActive = Date.now();
|
sshConn.lastActive = Date.now();
|
||||||
|
|
||||||
|
|
||||||
const escapedPath = sshPath.replace(/'/g, "'\"'\"'");
|
const escapedPath = sshPath.replace(/'/g, "'\"'\"'");
|
||||||
sshConn.client.exec(`ls -la '${escapedPath}'`, (err, stream) => {
|
sshConn.client.exec(`ls -la '${escapedPath}'`, (err, stream) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
logger.error('SSH listFiles error:', err);
|
fileLogger.error('SSH listFiles error:', err);
|
||||||
return res.status(500).json({error: err.message});
|
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) => {
|
stream.on('close', (code) => {
|
||||||
if (code !== 0) {
|
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}`});
|
return res.status(500).json({error: `Command failed: ${errorData}`});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -264,12 +283,12 @@ app.get('/ssh/file_manager/ssh/readFile', (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
sshConn.lastActive = Date.now();
|
sshConn.lastActive = Date.now();
|
||||||
|
|
||||||
|
|
||||||
const escapedPath = filePath.replace(/'/g, "'\"'\"'");
|
const escapedPath = filePath.replace(/'/g, "'\"'\"'");
|
||||||
sshConn.client.exec(`cat '${escapedPath}'`, (err, stream) => {
|
sshConn.client.exec(`cat '${escapedPath}'`, (err, stream) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
logger.error('SSH readFile error:', err);
|
fileLogger.error('SSH readFile error:', err);
|
||||||
return res.status(500).json({error: err.message});
|
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) => {
|
stream.on('close', (code) => {
|
||||||
if (code !== 0) {
|
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}`});
|
return res.status(500).json({error: `Command failed: ${errorData}`});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -321,7 +340,7 @@ app.post('/ssh/file_manager/ssh/writeFile', (req, res) => {
|
|||||||
try {
|
try {
|
||||||
sshConn.client.sftp((err, sftp) => {
|
sshConn.client.sftp((err, sftp) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
logger.warn(`SFTP failed, trying fallback method: ${err.message}`);
|
fileLogger.warn(`SFTP failed, trying fallback method: ${err.message}`);
|
||||||
tryFallbackMethod();
|
tryFallbackMethod();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -336,7 +355,7 @@ app.post('/ssh/file_manager/ssh/writeFile', (req, res) => {
|
|||||||
fileBuffer = Buffer.from(content);
|
fileBuffer = Buffer.from(content);
|
||||||
}
|
}
|
||||||
} catch (bufferErr) {
|
} catch (bufferErr) {
|
||||||
logger.error('Buffer conversion error:', bufferErr);
|
fileLogger.error('Buffer conversion error:', bufferErr);
|
||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
return res.status(500).json({error: 'Invalid file content format'});
|
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) => {
|
writeStream.on('error', (streamErr) => {
|
||||||
if (hasError || hasFinished) return;
|
if (hasError || hasFinished) return;
|
||||||
hasError = true;
|
hasError = true;
|
||||||
logger.warn(`SFTP write failed, trying fallback method: ${streamErr.message}`);
|
fileLogger.warn(`SFTP write failed, trying fallback method: ${streamErr.message}`);
|
||||||
tryFallbackMethod();
|
tryFallbackMethod();
|
||||||
});
|
});
|
||||||
|
|
||||||
writeStream.on('finish', () => {
|
writeStream.on('finish', () => {
|
||||||
if (hasError || hasFinished) return;
|
if (hasError || hasFinished) return;
|
||||||
hasFinished = true;
|
hasFinished = true;
|
||||||
logger.success(`File written successfully via SFTP: ${filePath}`);
|
fileLogger.success(`File written successfully via SFTP: ${filePath}`);
|
||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
res.json({message: 'File written successfully', path: filePath});
|
res.json({message: 'File written successfully', path: filePath});
|
||||||
}
|
}
|
||||||
@@ -367,7 +386,7 @@ app.post('/ssh/file_manager/ssh/writeFile', (req, res) => {
|
|||||||
writeStream.on('close', () => {
|
writeStream.on('close', () => {
|
||||||
if (hasError || hasFinished) return;
|
if (hasError || hasFinished) return;
|
||||||
hasFinished = true;
|
hasFinished = true;
|
||||||
logger.success(`File written successfully via SFTP: ${filePath}`);
|
fileLogger.success(`File written successfully via SFTP: ${filePath}`);
|
||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
res.json({message: 'File written successfully', path: filePath});
|
res.json({message: 'File written successfully', path: filePath});
|
||||||
}
|
}
|
||||||
@@ -379,12 +398,12 @@ app.post('/ssh/file_manager/ssh/writeFile', (req, res) => {
|
|||||||
} catch (writeErr) {
|
} catch (writeErr) {
|
||||||
if (hasError || hasFinished) return;
|
if (hasError || hasFinished) return;
|
||||||
hasError = true;
|
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();
|
tryFallbackMethod();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} catch (sftpErr) {
|
} catch (sftpErr) {
|
||||||
logger.warn(`SFTP connection error, trying fallback method: ${sftpErr.message}`);
|
fileLogger.warn(`SFTP connection error, trying fallback method: ${sftpErr.message}`);
|
||||||
tryFallbackMethod();
|
tryFallbackMethod();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -399,7 +418,7 @@ app.post('/ssh/file_manager/ssh/writeFile', (req, res) => {
|
|||||||
sshConn.client.exec(writeCommand, (err, stream) => {
|
sshConn.client.exec(writeCommand, (err, stream) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
|
|
||||||
logger.error('Fallback write command failed:', err);
|
fileLogger.error('Fallback write command failed:', err);
|
||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
return res.status(500).json({error: `Write failed: ${err.message}`});
|
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')) {
|
if (outputData.includes('SUCCESS')) {
|
||||||
logger.success(`File written successfully via fallback: ${filePath}`);
|
fileLogger.success(`File written successfully via fallback: ${filePath}`);
|
||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
res.json({message: 'File written successfully', path: filePath});
|
res.json({message: 'File written successfully', path: filePath});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
logger.error(`Fallback write failed with code ${code}: ${errorData}`);
|
fileLogger.error(`Fallback write failed with code ${code}: ${errorData}`);
|
||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
res.status(500).json({error: `Write failed: ${errorData}`});
|
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) => {
|
stream.on('error', (streamErr) => {
|
||||||
|
|
||||||
logger.error('Fallback write stream error:', streamErr);
|
fileLogger.error('Fallback write stream error:', streamErr);
|
||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
res.status(500).json({error: `Write stream error: ${streamErr.message}`});
|
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) {
|
} catch (fallbackErr) {
|
||||||
|
|
||||||
logger.error('Fallback method failed:', fallbackErr);
|
fileLogger.error('Fallback method failed:', fallbackErr);
|
||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
res.status(500).json({error: `All write methods failed: ${fallbackErr.message}`});
|
res.status(500).json({error: `All write methods failed: ${fallbackErr.message}`});
|
||||||
}
|
}
|
||||||
@@ -470,17 +489,16 @@ app.post('/ssh/file_manager/ssh/uploadFile', (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
sshConn.lastActive = Date.now();
|
sshConn.lastActive = Date.now();
|
||||||
|
|
||||||
|
|
||||||
const fullPath = filePath.endsWith('/') ? filePath + fileName : filePath + '/' + fileName;
|
const fullPath = filePath.endsWith('/') ? filePath + fileName : filePath + '/' + fileName;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const trySFTP = () => {
|
const trySFTP = () => {
|
||||||
try {
|
try {
|
||||||
sshConn.client.sftp((err, sftp) => {
|
sshConn.client.sftp((err, sftp) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
logger.warn(`SFTP failed, trying fallback method: ${err.message}`);
|
fileLogger.warn(`SFTP failed, trying fallback method: ${err.message}`);
|
||||||
tryFallbackMethod();
|
tryFallbackMethod();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -496,7 +514,7 @@ app.post('/ssh/file_manager/ssh/uploadFile', (req, res) => {
|
|||||||
}
|
}
|
||||||
} catch (bufferErr) {
|
} catch (bufferErr) {
|
||||||
|
|
||||||
logger.error('Buffer conversion error:', bufferErr);
|
fileLogger.error('Buffer conversion error:', bufferErr);
|
||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
return res.status(500).json({error: 'Invalid file content format'});
|
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) => {
|
writeStream.on('error', (streamErr) => {
|
||||||
if (hasError || hasFinished) return;
|
if (hasError || hasFinished) return;
|
||||||
hasError = true;
|
hasError = true;
|
||||||
logger.warn(`SFTP write failed, trying fallback method: ${streamErr.message}`);
|
fileLogger.warn(`SFTP write failed, trying fallback method: ${streamErr.message}`);
|
||||||
tryFallbackMethod();
|
tryFallbackMethod();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -519,7 +537,7 @@ app.post('/ssh/file_manager/ssh/uploadFile', (req, res) => {
|
|||||||
if (hasError || hasFinished) return;
|
if (hasError || hasFinished) return;
|
||||||
hasFinished = true;
|
hasFinished = true;
|
||||||
|
|
||||||
logger.success(`File uploaded successfully via SFTP: ${fullPath}`);
|
fileLogger.success(`File uploaded successfully via SFTP: ${fullPath}`);
|
||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
res.json({message: 'File uploaded successfully', path: fullPath});
|
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;
|
if (hasError || hasFinished) return;
|
||||||
hasFinished = true;
|
hasFinished = true;
|
||||||
|
|
||||||
logger.success(`File uploaded successfully via SFTP: ${fullPath}`);
|
fileLogger.success(`File uploaded successfully via SFTP: ${fullPath}`);
|
||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
res.json({message: 'File uploaded successfully', path: fullPath});
|
res.json({message: 'File uploaded successfully', path: fullPath});
|
||||||
}
|
}
|
||||||
@@ -541,12 +559,12 @@ app.post('/ssh/file_manager/ssh/uploadFile', (req, res) => {
|
|||||||
} catch (writeErr) {
|
} catch (writeErr) {
|
||||||
if (hasError || hasFinished) return;
|
if (hasError || hasFinished) return;
|
||||||
hasError = true;
|
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();
|
tryFallbackMethod();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} catch (sftpErr) {
|
} catch (sftpErr) {
|
||||||
logger.warn(`SFTP connection error, trying fallback method: ${sftpErr.message}`);
|
fileLogger.warn(`SFTP connection error, trying fallback method: ${sftpErr.message}`);
|
||||||
tryFallbackMethod();
|
tryFallbackMethod();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -570,8 +588,8 @@ app.post('/ssh/file_manager/ssh/uploadFile', (req, res) => {
|
|||||||
|
|
||||||
sshConn.client.exec(writeCommand, (err, stream) => {
|
sshConn.client.exec(writeCommand, (err, stream) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
|
|
||||||
logger.error('Fallback upload command failed:', err);
|
fileLogger.error('Fallback upload command failed:', err);
|
||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
return res.status(500).json({error: `Upload failed: ${err.message}`});
|
return res.status(500).json({error: `Upload failed: ${err.message}`});
|
||||||
}
|
}
|
||||||
@@ -590,15 +608,15 @@ app.post('/ssh/file_manager/ssh/uploadFile', (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
stream.on('close', (code) => {
|
stream.on('close', (code) => {
|
||||||
|
|
||||||
|
|
||||||
if (outputData.includes('SUCCESS')) {
|
if (outputData.includes('SUCCESS')) {
|
||||||
logger.success(`File uploaded successfully via fallback: ${fullPath}`);
|
fileLogger.success(`File uploaded successfully via fallback: ${fullPath}`);
|
||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
res.json({message: 'File uploaded successfully', path: fullPath});
|
res.json({message: 'File uploaded successfully', path: fullPath});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
logger.error(`Fallback upload failed with code ${code}: ${errorData}`);
|
fileLogger.error(`Fallback upload failed with code ${code}: ${errorData}`);
|
||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
res.status(500).json({error: `Upload failed: ${errorData}`});
|
res.status(500).json({error: `Upload failed: ${errorData}`});
|
||||||
}
|
}
|
||||||
@@ -606,8 +624,8 @@ app.post('/ssh/file_manager/ssh/uploadFile', (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
stream.on('error', (streamErr) => {
|
stream.on('error', (streamErr) => {
|
||||||
|
|
||||||
logger.error('Fallback upload stream error:', streamErr);
|
fileLogger.error('Fallback upload stream error:', streamErr);
|
||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
res.status(500).json({error: `Upload stream error: ${streamErr.message}`});
|
res.status(500).json({error: `Upload stream error: ${streamErr.message}`});
|
||||||
}
|
}
|
||||||
@@ -628,8 +646,8 @@ app.post('/ssh/file_manager/ssh/uploadFile', (req, res) => {
|
|||||||
|
|
||||||
sshConn.client.exec(writeCommand, (err, stream) => {
|
sshConn.client.exec(writeCommand, (err, stream) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
|
|
||||||
logger.error('Chunked fallback upload failed:', err);
|
fileLogger.error('Chunked fallback upload failed:', err);
|
||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
return res.status(500).json({error: `Chunked upload failed: ${err.message}`});
|
return res.status(500).json({error: `Chunked upload failed: ${err.message}`});
|
||||||
}
|
}
|
||||||
@@ -648,15 +666,15 @@ app.post('/ssh/file_manager/ssh/uploadFile', (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
stream.on('close', (code) => {
|
stream.on('close', (code) => {
|
||||||
|
|
||||||
|
|
||||||
if (outputData.includes('SUCCESS')) {
|
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) {
|
if (!res.headersSent) {
|
||||||
res.json({message: 'File uploaded successfully', path: fullPath});
|
res.json({message: 'File uploaded successfully', path: fullPath});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
logger.error(`Chunked fallback upload failed with code ${code}: ${errorData}`);
|
fileLogger.error(`Chunked fallback upload failed with code ${code}: ${errorData}`);
|
||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
res.status(500).json({error: `Chunked upload failed: ${errorData}`});
|
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) => {
|
stream.on('error', (streamErr) => {
|
||||||
logger.error('Chunked fallback upload stream error:', streamErr);
|
fileLogger.error('Chunked fallback upload stream error:', streamErr);
|
||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
res.status(500).json({error: `Chunked upload stream error: ${streamErr.message}`});
|
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) {
|
} catch (fallbackErr) {
|
||||||
logger.error('Fallback method failed:', fallbackErr);
|
fileLogger.error('Fallback method failed:', fallbackErr);
|
||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
res.status(500).json({error: `All upload methods failed: ${fallbackErr.message}`});
|
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) => {
|
sshConn.client.exec(createCommand, (err, stream) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
logger.error('SSH createFile error:', err);
|
fileLogger.error('SSH createFile error:', err);
|
||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
return res.status(500).json({error: err.message});
|
return res.status(500).json({error: err.message});
|
||||||
}
|
}
|
||||||
@@ -725,7 +743,7 @@ app.post('/ssh/file_manager/ssh/createFile', (req, res) => {
|
|||||||
errorData += chunk.toString();
|
errorData += chunk.toString();
|
||||||
|
|
||||||
if (chunk.toString().includes('Permission denied')) {
|
if (chunk.toString().includes('Permission denied')) {
|
||||||
logger.error(`Permission denied creating file: ${fullPath}`);
|
fileLogger.error(`Permission denied creating file: ${fullPath}`);
|
||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
return res.status(403).json({
|
return res.status(403).json({
|
||||||
error: `Permission denied: Cannot create file ${fullPath}. Check directory permissions.`
|
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) {
|
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) {
|
if (!res.headersSent) {
|
||||||
return res.status(500).json({error: `Command failed: ${errorData}`});
|
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) => {
|
stream.on('error', (streamErr) => {
|
||||||
logger.error('SSH createFile stream error:', streamErr);
|
fileLogger.error('SSH createFile stream error:', streamErr);
|
||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
res.status(500).json({error: `Stream error: ${streamErr.message}`});
|
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) => {
|
sshConn.client.exec(createCommand, (err, stream) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
|
|
||||||
logger.error('SSH createFolder error:', err);
|
fileLogger.error('SSH createFolder error:', err);
|
||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
return res.status(500).json({error: err.message});
|
return res.status(500).json({error: err.message});
|
||||||
}
|
}
|
||||||
@@ -809,7 +827,7 @@ app.post('/ssh/file_manager/ssh/createFolder', (req, res) => {
|
|||||||
errorData += chunk.toString();
|
errorData += chunk.toString();
|
||||||
|
|
||||||
if (chunk.toString().includes('Permission denied')) {
|
if (chunk.toString().includes('Permission denied')) {
|
||||||
logger.error(`Permission denied creating folder: ${fullPath}`);
|
fileLogger.error(`Permission denied creating folder: ${fullPath}`);
|
||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
return res.status(403).json({
|
return res.status(403).json({
|
||||||
error: `Permission denied: Cannot create folder ${fullPath}. Check directory permissions.`
|
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) {
|
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) {
|
if (!res.headersSent) {
|
||||||
return res.status(500).json({error: `Command failed: ${errorData}`});
|
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) => {
|
stream.on('error', (streamErr) => {
|
||||||
logger.error('SSH createFolder stream error:', streamErr);
|
fileLogger.error('SSH createFolder stream error:', streamErr);
|
||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
res.status(500).json({error: `Stream error: ${streamErr.message}`});
|
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) => {
|
sshConn.client.exec(deleteCommand, (err, stream) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
logger.error('SSH deleteItem error:', err);
|
fileLogger.error('SSH deleteItem error:', err);
|
||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
return res.status(500).json({error: err.message});
|
return res.status(500).json({error: err.message});
|
||||||
}
|
}
|
||||||
@@ -892,7 +910,7 @@ app.delete('/ssh/file_manager/ssh/deleteItem', (req, res) => {
|
|||||||
errorData += chunk.toString();
|
errorData += chunk.toString();
|
||||||
|
|
||||||
if (chunk.toString().includes('Permission denied')) {
|
if (chunk.toString().includes('Permission denied')) {
|
||||||
logger.error(`Permission denied deleting: ${itemPath}`);
|
fileLogger.error(`Permission denied deleting: ${itemPath}`);
|
||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
return res.status(403).json({
|
return res.status(403).json({
|
||||||
error: `Permission denied: Cannot delete ${itemPath}. Check file permissions.`
|
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) {
|
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) {
|
if (!res.headersSent) {
|
||||||
return res.status(500).json({error: `Command failed: ${errorData}`});
|
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) => {
|
stream.on('error', (streamErr) => {
|
||||||
logger.error('SSH deleteItem stream error:', streamErr);
|
fileLogger.error('SSH deleteItem stream error:', streamErr);
|
||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
res.status(500).json({error: `Stream error: ${streamErr.message}`});
|
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) => {
|
sshConn.client.exec(renameCommand, (err, stream) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
logger.error('SSH renameItem error:', err);
|
fileLogger.error('SSH renameItem error:', err);
|
||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
return res.status(500).json({error: err.message});
|
return res.status(500).json({error: err.message});
|
||||||
}
|
}
|
||||||
@@ -977,7 +995,7 @@ app.put('/ssh/file_manager/ssh/renameItem', (req, res) => {
|
|||||||
errorData += chunk.toString();
|
errorData += chunk.toString();
|
||||||
|
|
||||||
if (chunk.toString().includes('Permission denied')) {
|
if (chunk.toString().includes('Permission denied')) {
|
||||||
logger.error(`Permission denied renaming: ${oldPath}`);
|
fileLogger.error(`Permission denied renaming: ${oldPath}`);
|
||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
return res.status(403).json({
|
return res.status(403).json({
|
||||||
error: `Permission denied: Cannot rename ${oldPath}. Check file permissions.`
|
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) {
|
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) {
|
if (!res.headersSent) {
|
||||||
return res.status(500).json({error: `Command failed: ${errorData}`});
|
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) => {
|
stream.on('error', (streamErr) => {
|
||||||
logger.error('SSH renameItem stream error:', streamErr);
|
fileLogger.error('SSH renameItem stream error:', streamErr);
|
||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
res.status(500).json({error: `Stream error: ${streamErr.message}`});
|
res.status(500).json({error: `Stream error: ${streamErr.message}`});
|
||||||
}
|
}
|
||||||
@@ -1029,4 +1047,5 @@ process.on('SIGTERM', () => {
|
|||||||
|
|
||||||
const PORT = 8084;
|
const PORT = 8084;
|
||||||
app.listen(PORT, () => {
|
app.listen(PORT, () => {
|
||||||
|
fileLogger.success('File Manager API server started', { operation: 'server_start', port: PORT });
|
||||||
});
|
});
|
||||||
@@ -1,16 +1,40 @@
|
|||||||
import express from 'express';
|
import express from 'express';
|
||||||
import chalk from 'chalk';
|
|
||||||
import fetch from 'node-fetch';
|
import fetch from 'node-fetch';
|
||||||
import net from 'net';
|
import net from 'net';
|
||||||
import cors from 'cors';
|
import cors from 'cors';
|
||||||
import {Client, type ConnectConfig} from 'ssh2';
|
import {Client, type ConnectConfig} from 'ssh2';
|
||||||
import {sshHostService} from '../services/ssh-host.js';
|
import {db} from '../database/db/index.js';
|
||||||
import type {SSHHostWithCredentials} from '../services/ssh-host.js';
|
import {sshData, sshCredentials} from '../database/db/schema.js';
|
||||||
|
import {eq, and} from 'drizzle-orm';
|
||||||
// Removed HostRecord - using SSHHostWithCredentials from ssh-host service instead
|
import { statsLogger } from '../utils/logger.js';
|
||||||
|
|
||||||
type HostStatus = 'online' | 'offline';
|
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 = {
|
type StatusEntry = {
|
||||||
status: HostStatus;
|
status: HostStatus;
|
||||||
lastChecked: string;
|
lastChecked: string;
|
||||||
@@ -33,92 +57,127 @@ app.use((req, res, next) => {
|
|||||||
});
|
});
|
||||||
app.use(express.json());
|
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();
|
const hostStatuses: Map<number, StatusEntry> = new Map();
|
||||||
|
|
||||||
async function fetchAllHosts(): Promise<SSHHostWithCredentials[]> {
|
async function fetchAllHosts(): Promise<SSHHostWithCredentials[]> {
|
||||||
const url = 'http://localhost:8081/ssh/db/host/internal';
|
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(url, {
|
const hosts = await db.select().from(sshData);
|
||||||
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 : [];
|
|
||||||
|
|
||||||
// Resolve credentials for each host using the same logic as main SSH connections
|
|
||||||
const hostsWithCredentials: SSHHostWithCredentials[] = [];
|
const hostsWithCredentials: SSHHostWithCredentials[] = [];
|
||||||
for (const rawHost of rawHosts) {
|
for (const host of hosts) {
|
||||||
try {
|
try {
|
||||||
// Use the ssh-host service to properly resolve credentials
|
const hostWithCreds = await resolveHostCredentials(host);
|
||||||
const host = await sshHostService.getHostWithCredentials(rawHost.userId, rawHost.id);
|
if (hostWithCreds) {
|
||||||
if (host) {
|
hostsWithCredentials.push(hostWithCreds);
|
||||||
hostsWithCredentials.push(host);
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} 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);
|
return hostsWithCredentials.filter(h => !!h.id && !!h.ip && !!h.port);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error('Failed to fetch hosts from database service', err);
|
statsLogger.error('Failed to fetch hosts from database', err);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchHostById(id: number): Promise<SSHHostWithCredentials | undefined> {
|
async function fetchHostById(id: number): Promise<SSHHostWithCredentials | undefined> {
|
||||||
try {
|
try {
|
||||||
// Get all users that might own this host
|
const hosts = await db.select().from(sshData).where(eq(sshData.id, id));
|
||||||
const url = 'http://localhost:8081/ssh/db/host/internal';
|
|
||||||
const resp = await fetch(url, {
|
if (hosts.length === 0) {
|
||||||
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);
|
|
||||||
|
|
||||||
if (!rawHost) {
|
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use ssh-host service to properly resolve credentials
|
const host = hosts[0];
|
||||||
return await sshHostService.getHostWithCredentials(rawHost.userId, id);
|
return await resolveHostCredentials(host);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error(`Failed to fetch host ${id}`, err);
|
statsLogger.error(`Failed to fetch host ${id}`, err);
|
||||||
return undefined;
|
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 {
|
function buildSshConfig(host: SSHHostWithCredentials): ConnectConfig {
|
||||||
const base: ConnectConfig = {
|
const base: ConnectConfig = {
|
||||||
host: host.ip,
|
host: host.ip,
|
||||||
@@ -128,7 +187,6 @@ function buildSshConfig(host: SSHHostWithCredentials): ConnectConfig {
|
|||||||
algorithms: {}
|
algorithms: {}
|
||||||
} as ConnectConfig;
|
} as ConnectConfig;
|
||||||
|
|
||||||
// Use the same authentication logic as main SSH connections
|
|
||||||
if (host.authType === 'password') {
|
if (host.authType === 'password') {
|
||||||
if (!host.password) {
|
if (!host.password) {
|
||||||
throw new Error(`No password available for host ${host.ip}`);
|
throw new Error(`No password available for host ${host.ip}`);
|
||||||
@@ -138,27 +196,27 @@ function buildSshConfig(host: SSHHostWithCredentials): ConnectConfig {
|
|||||||
if (!host.key) {
|
if (!host.key) {
|
||||||
throw new Error(`No SSH key available for host ${host.ip}`);
|
throw new Error(`No SSH key available for host ${host.ip}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (!host.key.includes('-----BEGIN') || !host.key.includes('-----END')) {
|
if (!host.key.includes('-----BEGIN') || !host.key.includes('-----END')) {
|
||||||
throw new Error('Invalid private key format');
|
throw new Error('Invalid private key format');
|
||||||
}
|
}
|
||||||
|
|
||||||
const cleanKey = host.key.trim().replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
const cleanKey = host.key.trim().replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
||||||
|
|
||||||
(base as any).privateKey = Buffer.from(cleanKey, 'utf8');
|
(base as any).privateKey = Buffer.from(cleanKey, 'utf8');
|
||||||
|
|
||||||
if (host.keyPassword) {
|
if (host.keyPassword) {
|
||||||
(base as any).passphrase = host.keyPassword;
|
(base as any).passphrase = host.keyPassword;
|
||||||
}
|
}
|
||||||
} catch (keyError) {
|
} 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}`);
|
throw new Error(`Invalid SSH key format for host ${host.ip}`);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
throw new Error(`Unsupported authentication type '${host.authType}' for host ${host.ip}`);
|
throw new Error(`Unsupported authentication type '${host.authType}' for host ${host.ip}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return base;
|
return base;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -316,24 +374,22 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{
|
|||||||
let usedHuman: string | null = null;
|
let usedHuman: string | null = null;
|
||||||
let totalHuman: string | null = null;
|
let totalHuman: string | null = null;
|
||||||
try {
|
try {
|
||||||
// Get both human-readable and bytes format for accurate calculation
|
|
||||||
const diskOutHuman = await execCommand(client, 'df -h -P / | tail -n +2');
|
const diskOutHuman = await execCommand(client, 'df -h -P / | tail -n +2');
|
||||||
const diskOutBytes = await execCommand(client, 'df -B1 -P / | tail -n +2');
|
const diskOutBytes = await execCommand(client, 'df -B1 -P / | tail -n +2');
|
||||||
|
|
||||||
const humanLine = diskOutHuman.stdout.split('\n').map(l => l.trim()).filter(Boolean)[0] || '';
|
const humanLine = diskOutHuman.stdout.split('\n').map(l => l.trim()).filter(Boolean)[0] || '';
|
||||||
const bytesLine = diskOutBytes.stdout.split('\n').map(l => l.trim()).filter(Boolean)[0] || '';
|
const bytesLine = diskOutBytes.stdout.split('\n').map(l => l.trim()).filter(Boolean)[0] || '';
|
||||||
|
|
||||||
const humanParts = humanLine.split(/\s+/);
|
const humanParts = humanLine.split(/\s+/);
|
||||||
const bytesParts = bytesLine.split(/\s+/);
|
const bytesParts = bytesLine.split(/\s+/);
|
||||||
|
|
||||||
if (humanParts.length >= 6 && bytesParts.length >= 6) {
|
if (humanParts.length >= 6 && bytesParts.length >= 6) {
|
||||||
totalHuman = humanParts[1] || null;
|
totalHuman = humanParts[1] || null;
|
||||||
usedHuman = humanParts[2] || null;
|
usedHuman = humanParts[2] || null;
|
||||||
|
|
||||||
// Calculate our own percentage using bytes for accuracy
|
|
||||||
const totalBytes = Number(bytesParts[1]);
|
const totalBytes = Number(bytesParts[1]);
|
||||||
const usedBytes = Number(bytesParts[2]);
|
const usedBytes = Number(bytesParts[2]);
|
||||||
|
|
||||||
if (Number.isFinite(totalBytes) && Number.isFinite(usedBytes) && totalBytes > 0) {
|
if (Number.isFinite(totalBytes) && Number.isFinite(usedBytes) && totalBytes > 0) {
|
||||||
diskPercent = Math.max(0, Math.min(100, (usedBytes / totalBytes) * 100));
|
diskPercent = Math.max(0, Math.min(100, (usedBytes / totalBytes) * 100));
|
||||||
}
|
}
|
||||||
@@ -381,25 +437,30 @@ function tcpPing(host: string, port: number, timeoutMs = 5000): Promise<boolean>
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function pollStatusesOnce(): Promise<void> {
|
async function pollStatusesOnce(): Promise<void> {
|
||||||
|
statsLogger.info('Starting status polling for all hosts', { operation: 'status_poll' });
|
||||||
const hosts = await fetchAllHosts();
|
const hosts = await fetchAllHosts();
|
||||||
if (hosts.length === 0) {
|
if (hosts.length === 0) {
|
||||||
logger.warn('No hosts retrieved for status polling');
|
statsLogger.warn('No hosts retrieved for status polling', { operation: 'status_poll' });
|
||||||
return;
|
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 now = new Date().toISOString();
|
||||||
|
|
||||||
const checks = hosts.map(async (h) => {
|
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 isOnline = await tcpPing(h.ip, h.port, 5000);
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const statusEntry: StatusEntry = {status: isOnline ? 'online' : 'offline', lastChecked: now};
|
const statusEntry: StatusEntry = {status: isOnline ? 'online' : 'offline', lastChecked: now};
|
||||||
hostStatuses.set(h.id, statusEntry);
|
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;
|
return isOnline;
|
||||||
});
|
});
|
||||||
|
|
||||||
const results = await Promise.allSettled(checks);
|
const results = await Promise.allSettled(checks);
|
||||||
const onlineCount = results.filter(r => r.status === 'fulfilled' && r.value === true).length;
|
const onlineCount = results.filter(r => r.status === 'fulfilled' && r.value === true).length;
|
||||||
const offlineCount = hosts.length - onlineCount;
|
const offlineCount = hosts.length - onlineCount;
|
||||||
|
statsLogger.success('Status polling completed', { operation: 'status_poll', totalHosts: hosts.length, onlineCount, offlineCount });
|
||||||
}
|
}
|
||||||
|
|
||||||
app.get('/status', async (req, res) => {
|
app.get('/status', async (req, res) => {
|
||||||
@@ -424,15 +485,15 @@ app.get('/status/:id', async (req, res) => {
|
|||||||
if (!host) {
|
if (!host) {
|
||||||
return res.status(404).json({error: 'Host not found'});
|
return res.status(404).json({error: 'Host not found'});
|
||||||
}
|
}
|
||||||
|
|
||||||
const isOnline = await tcpPing(host.ip, host.port, 5000);
|
const isOnline = await tcpPing(host.ip, host.port, 5000);
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const statusEntry: StatusEntry = {status: isOnline ? 'online' : 'offline', lastChecked: now};
|
const statusEntry: StatusEntry = {status: isOnline ? 'online' : 'offline', lastChecked: now};
|
||||||
|
|
||||||
hostStatuses.set(id, statusEntry);
|
hostStatuses.set(id, statusEntry);
|
||||||
res.json(statusEntry);
|
res.json(statusEntry);
|
||||||
} catch (err) {
|
} 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'});
|
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);
|
const metrics = await collectMetrics(host);
|
||||||
res.json({...metrics, lastChecked: new Date().toISOString()});
|
res.json({...metrics, lastChecked: new Date().toISOString()});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error('Failed to collect metrics', err);
|
statsLogger.error('Failed to collect metrics', err);
|
||||||
return res.json({
|
return res.json({
|
||||||
cpu: {percent: null, cores: null, load: null},
|
cpu: {percent: null, cores: null, load: null},
|
||||||
memory: {percent: null, usedGiB: null, totalGiB: null},
|
memory: {percent: null, usedGiB: null, totalGiB: null},
|
||||||
@@ -467,9 +528,10 @@ app.get('/metrics/:id', async (req, res) => {
|
|||||||
|
|
||||||
const PORT = 8085;
|
const PORT = 8085;
|
||||||
app.listen(PORT, async () => {
|
app.listen(PORT, async () => {
|
||||||
|
statsLogger.success('Server Stats API server started', { operation: 'server_start', port: PORT });
|
||||||
try {
|
try {
|
||||||
await pollStatusesOnce();
|
await pollStatusesOnce();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error('Initial poll failed', err);
|
statsLogger.error('Initial poll failed', err, { operation: 'initial_poll' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -1,48 +1,27 @@
|
|||||||
import {WebSocketServer, WebSocket, type RawData} from 'ws';
|
import {WebSocketServer, WebSocket, type RawData} from 'ws';
|
||||||
import {Client, type ClientChannel, type PseudoTtyOptions} from 'ssh2';
|
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});
|
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) => {
|
wss.on('connection', (ws: WebSocket) => {
|
||||||
let sshConn: Client | null = null;
|
let sshConn: Client | null = null;
|
||||||
let sshStream: ClientChannel | null = null;
|
let sshStream: ClientChannel | null = null;
|
||||||
let pingInterval: NodeJS.Timeout | null = null;
|
let pingInterval: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
|
sshLogger.info('New WebSocket connection established', { operation: 'websocket_connect' });
|
||||||
|
|
||||||
|
|
||||||
ws.on('close', () => {
|
ws.on('close', () => {
|
||||||
|
sshLogger.info('WebSocket connection closed', { operation: 'websocket_disconnect' });
|
||||||
cleanupSSH();
|
cleanupSSH();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -53,7 +32,7 @@ wss.on('connection', (ws: WebSocket) => {
|
|||||||
try {
|
try {
|
||||||
parsed = JSON.parse(msg.toString());
|
parsed = JSON.parse(msg.toString());
|
||||||
} catch (e) {
|
} 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'}));
|
ws.send(JSON.stringify({type: 'error', message: 'Invalid JSON'}));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -62,7 +41,11 @@ wss.on('connection', (ws: WebSocket) => {
|
|||||||
|
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'connectToHost':
|
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;
|
break;
|
||||||
|
|
||||||
case 'resize':
|
case 'resize':
|
||||||
@@ -70,6 +53,7 @@ wss.on('connection', (ws: WebSocket) => {
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case 'disconnect':
|
case 'disconnect':
|
||||||
|
sshLogger.info('SSH disconnect requested', { operation: 'ssh_disconnect' });
|
||||||
cleanupSSH();
|
cleanupSSH();
|
||||||
break;
|
break;
|
||||||
|
|
||||||
@@ -90,14 +74,15 @@ wss.on('connection', (ws: WebSocket) => {
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
default:
|
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;
|
cols: number;
|
||||||
rows: number;
|
rows: number;
|
||||||
hostConfig: {
|
hostConfig: {
|
||||||
|
id: number;
|
||||||
ip: string;
|
ip: string;
|
||||||
port: number;
|
port: number;
|
||||||
username: string;
|
username: string;
|
||||||
@@ -106,25 +91,27 @@ wss.on('connection', (ws: WebSocket) => {
|
|||||||
keyPassword?: string;
|
keyPassword?: string;
|
||||||
keyType?: string;
|
keyType?: string;
|
||||||
authType?: string;
|
authType?: string;
|
||||||
|
credentialId?: number;
|
||||||
|
userId?: string;
|
||||||
};
|
};
|
||||||
}) {
|
}) {
|
||||||
const {cols, rows, hostConfig} = data;
|
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() === '') {
|
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'}));
|
ws.send(JSON.stringify({type: 'error', message: 'Invalid username provided'}));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!ip || typeof ip !== 'string' || ip.trim() === '') {
|
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'}));
|
ws.send(JSON.stringify({type: 'error', message: 'Invalid IP provided'}));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!port || typeof port !== 'number' || port <= 0) {
|
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'}));
|
ws.send(JSON.stringify({type: 'error', message: 'Invalid port provided'}));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -133,14 +120,41 @@ wss.on('connection', (ws: WebSocket) => {
|
|||||||
|
|
||||||
const connectionTimeout = setTimeout(() => {
|
const connectionTimeout = setTimeout(() => {
|
||||||
if (sshConn) {
|
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'}));
|
ws.send(JSON.stringify({type: 'error', message: 'SSH connection timeout'}));
|
||||||
cleanupSSH(connectionTimeout);
|
cleanupSSH(connectionTimeout);
|
||||||
}
|
}
|
||||||
}, 60000);
|
}, 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', () => {
|
sshConn.on('ready', () => {
|
||||||
clearTimeout(connectionTimeout);
|
clearTimeout(connectionTimeout);
|
||||||
|
sshLogger.success('SSH connection established', { operation: 'ssh_connect', hostId: id, ip, port, username, authType: resolvedCredentials.authType });
|
||||||
|
|
||||||
|
|
||||||
sshConn!.shell({
|
sshConn!.shell({
|
||||||
@@ -149,7 +163,7 @@ wss.on('connection', (ws: WebSocket) => {
|
|||||||
term: 'xterm-256color'
|
term: 'xterm-256color'
|
||||||
} as PseudoTtyOptions, (err, stream) => {
|
} as PseudoTtyOptions, (err, stream) => {
|
||||||
if (err) {
|
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}));
|
ws.send(JSON.stringify({type: 'error', message: 'Shell error: ' + err.message}));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -161,12 +175,12 @@ wss.on('connection', (ws: WebSocket) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
stream.on('close', () => {
|
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'}));
|
ws.send(JSON.stringify({type: 'disconnected', message: 'Connection lost'}));
|
||||||
});
|
});
|
||||||
|
|
||||||
stream.on('error', (err: Error) => {
|
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}));
|
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) => {
|
sshConn.on('error', (err: Error) => {
|
||||||
clearTimeout(connectionTimeout);
|
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;
|
let errorMessage = 'SSH error: ' + err.message;
|
||||||
if (err.message.includes('No matching key exchange algorithm')) {
|
if (err.message.includes('No matching key exchange algorithm')) {
|
||||||
@@ -210,7 +224,6 @@ wss.on('connection', (ws: WebSocket) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const connectConfig: any = {
|
const connectConfig: any = {
|
||||||
host: ip,
|
host: ip,
|
||||||
port,
|
port,
|
||||||
@@ -269,34 +282,34 @@ wss.on('connection', (ws: WebSocket) => {
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
if (authType === 'key' && key) {
|
if (resolvedCredentials.authType === 'key' && resolvedCredentials.key) {
|
||||||
try {
|
try {
|
||||||
if (!key.includes('-----BEGIN') || !key.includes('-----END')) {
|
if (!resolvedCredentials.key.includes('-----BEGIN') || !resolvedCredentials.key.includes('-----END')) {
|
||||||
throw new Error('Invalid private key format');
|
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');
|
connectConfig.privateKey = Buffer.from(cleanKey, 'utf8');
|
||||||
|
|
||||||
if (keyPassword) {
|
if (resolvedCredentials.keyPassword) {
|
||||||
connectConfig.passphrase = keyPassword;
|
connectConfig.passphrase = resolvedCredentials.keyPassword;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (keyType && keyType !== 'auto') {
|
if (resolvedCredentials.keyType && resolvedCredentials.keyType !== 'auto') {
|
||||||
connectConfig.privateKeyType = keyType;
|
connectConfig.privateKeyType = resolvedCredentials.keyType;
|
||||||
}
|
}
|
||||||
} catch (keyError) {
|
} 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'}));
|
ws.send(JSON.stringify({type: 'error', message: 'SSH key format error: Invalid private key format'}));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} else if (authType === 'key') {
|
} else if (resolvedCredentials.authType === 'key') {
|
||||||
logger.error('SSH key authentication requested but no key provided');
|
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'}));
|
ws.send(JSON.stringify({type: 'error', message: 'SSH key authentication requested but no key provided'}));
|
||||||
return;
|
return;
|
||||||
} else {
|
} else {
|
||||||
connectConfig.password = password;
|
connectConfig.password = resolvedCredentials.password;
|
||||||
}
|
}
|
||||||
|
|
||||||
sshConn.connect(connectConfig);
|
sshConn.connect(connectConfig);
|
||||||
@@ -323,7 +336,7 @@ wss.on('connection', (ws: WebSocket) => {
|
|||||||
try {
|
try {
|
||||||
sshStream.end();
|
sshStream.end();
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
logger.error('Error closing stream: ' + e.message);
|
sshLogger.error('Error closing stream: ' + e.message);
|
||||||
}
|
}
|
||||||
sshStream = null;
|
sshStream = null;
|
||||||
}
|
}
|
||||||
@@ -332,7 +345,7 @@ wss.on('connection', (ws: WebSocket) => {
|
|||||||
try {
|
try {
|
||||||
sshConn.end();
|
sshConn.end();
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
logger.error('Error closing connection: ' + e.message);
|
sshLogger.error('Error closing connection: ' + e.message);
|
||||||
}
|
}
|
||||||
sshConn = null;
|
sshConn = null;
|
||||||
}
|
}
|
||||||
@@ -344,7 +357,7 @@ wss.on('connection', (ws: WebSocket) => {
|
|||||||
try {
|
try {
|
||||||
sshStream.write('\x00');
|
sshStream.write('\x00');
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
logger.error('SSH keepalive failed: ' + e.message);
|
sshLogger.error('SSH keepalive failed: ' + e.message);
|
||||||
cleanupSSH();
|
cleanupSSH();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,9 +2,23 @@ import express from 'express';
|
|||||||
import cors from 'cors';
|
import cors from 'cors';
|
||||||
import {Client} from 'ssh2';
|
import {Client} from 'ssh2';
|
||||||
import {ChildProcess} from 'child_process';
|
import {ChildProcess} from 'child_process';
|
||||||
import chalk from 'chalk';
|
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import * as net from 'net';
|
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();
|
const app = express();
|
||||||
app.use(cors({
|
app.use(cors({
|
||||||
@@ -14,31 +28,6 @@ app.use(cors({
|
|||||||
}));
|
}));
|
||||||
app.use(express.json());
|
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 activeTunnels = new Map<string, Client>();
|
||||||
const retryCounters = new Map<string, number>();
|
const retryCounters = new Map<string, number>();
|
||||||
@@ -53,109 +42,17 @@ const retryExhaustedTunnels = new Set<string>();
|
|||||||
const tunnelConfigs = new Map<string, TunnelConfig>();
|
const tunnelConfigs = new Map<string, TunnelConfig>();
|
||||||
const activeTunnelProcesses = new Map<string, ChildProcess>();
|
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 = {
|
const ERROR_TYPES = {
|
||||||
AUTH: "authentication",
|
AUTH: "AUTHENTICATION_FAILED",
|
||||||
NETWORK: "network",
|
NETWORK: "NETWORK_ERROR",
|
||||||
PORT: "port_conflict",
|
PORT: "CONNECTION_FAILED",
|
||||||
PERMISSION: "permission",
|
PERMISSION: "CONNECTION_FAILED",
|
||||||
TIMEOUT: "timeout",
|
TIMEOUT: "TIMEOUT",
|
||||||
UNKNOWN: "unknown"
|
UNKNOWN: "UNKNOWN"
|
||||||
} as const;
|
} 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 {
|
function broadcastTunnelStatus(tunnelName: string, status: TunnelStatus): void {
|
||||||
if (status.status === CONNECTION_STATES.CONNECTED && activeRetryTimers.has(tunnelName)) {
|
if (status.status === CONNECTION_STATES.CONNECTED && activeRetryTimers.has(tunnelName)) {
|
||||||
@@ -178,7 +75,7 @@ function getAllTunnelStatus(): Record<string, TunnelStatus> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function classifyError(errorMessage: string): ErrorType {
|
function classifyError(errorMessage: string): ErrorType {
|
||||||
if (!errorMessage) return ERROR_TYPES.UNKNOWN;
|
if (!errorMessage) return 'UNKNOWN';
|
||||||
|
|
||||||
const message = errorMessage.toLowerCase();
|
const message = errorMessage.toLowerCase();
|
||||||
|
|
||||||
@@ -186,34 +83,34 @@ function classifyError(errorMessage: string): ErrorType {
|
|||||||
message.includes("connection reset by peer") ||
|
message.includes("connection reset by peer") ||
|
||||||
message.includes("connection refused") ||
|
message.includes("connection refused") ||
|
||||||
message.includes("broken pipe")) {
|
message.includes("broken pipe")) {
|
||||||
return ERROR_TYPES.NETWORK;
|
return 'NETWORK_ERROR';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (message.includes("authentication failed") ||
|
if (message.includes("authentication failed") ||
|
||||||
message.includes("permission denied") ||
|
message.includes("permission denied") ||
|
||||||
message.includes("incorrect password")) {
|
message.includes("incorrect password")) {
|
||||||
return ERROR_TYPES.AUTH;
|
return 'AUTHENTICATION_FAILED';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (message.includes("connect etimedout") ||
|
if (message.includes("connect etimedout") ||
|
||||||
message.includes("timeout") ||
|
message.includes("timeout") ||
|
||||||
message.includes("timed out") ||
|
message.includes("timed out") ||
|
||||||
message.includes("keepalive timeout")) {
|
message.includes("keepalive timeout")) {
|
||||||
return ERROR_TYPES.TIMEOUT;
|
return 'TIMEOUT';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (message.includes("bind: address already in use") ||
|
if (message.includes("bind: address already in use") ||
|
||||||
message.includes("failed for listen port") ||
|
message.includes("failed for listen port") ||
|
||||||
message.includes("port forwarding failed")) {
|
message.includes("port forwarding failed")) {
|
||||||
return ERROR_TYPES.PORT;
|
return 'CONNECTION_FAILED';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (message.includes("permission") ||
|
if (message.includes("permission") ||
|
||||||
message.includes("access denied")) {
|
message.includes("access denied")) {
|
||||||
return ERROR_TYPES.PERMISSION;
|
return 'CONNECTION_FAILED';
|
||||||
}
|
}
|
||||||
|
|
||||||
return ERROR_TYPES.UNKNOWN;
|
return 'UNKNOWN';
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTunnelMarker(tunnelName: string) {
|
function getTunnelMarker(tunnelName: string) {
|
||||||
@@ -225,7 +122,7 @@ function cleanupTunnelResources(tunnelName: string): void {
|
|||||||
if (tunnelConfig) {
|
if (tunnelConfig) {
|
||||||
killRemoteTunnelByMarker(tunnelConfig, tunnelName, (err) => {
|
killRemoteTunnelByMarker(tunnelConfig, tunnelName, (err) => {
|
||||||
if (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');
|
proc.kill('SIGTERM');
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} 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);
|
activeTunnelProcesses.delete(tunnelName);
|
||||||
}
|
}
|
||||||
@@ -249,7 +146,7 @@ function cleanupTunnelResources(tunnelName: string): void {
|
|||||||
conn.end();
|
conn.end();
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} 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);
|
activeTunnels.delete(tunnelName);
|
||||||
}
|
}
|
||||||
@@ -359,7 +256,7 @@ function handleDisconnect(tunnelName: string, tunnelConfig: TunnelConfig | null,
|
|||||||
retryCount = retryCount + 1;
|
retryCount = retryCount + 1;
|
||||||
|
|
||||||
if (retryCount > maxRetries) {
|
if (retryCount > maxRetries) {
|
||||||
logger.error(`All ${maxRetries} retries failed for ${tunnelName}`);
|
tunnelLogger.error(`All ${maxRetries} retries failed for ${tunnelName}`);
|
||||||
|
|
||||||
retryExhaustedTunnels.add(tunnelName);
|
retryExhaustedTunnels.add(tunnelName);
|
||||||
activeTunnels.delete(tunnelName);
|
activeTunnels.delete(tunnelName);
|
||||||
@@ -423,7 +320,9 @@ function handleDisconnect(tunnelName: string, tunnelConfig: TunnelConfig | null,
|
|||||||
|
|
||||||
if (!manualDisconnects.has(tunnelName)) {
|
if (!manualDisconnects.has(tunnelName)) {
|
||||||
activeTunnels.delete(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);
|
}, retryInterval);
|
||||||
|
|
||||||
@@ -457,7 +356,7 @@ function setupPingInterval(tunnelName: string, tunnelConfig: TunnelConfig): void
|
|||||||
clearInterval(verificationTimers.get(pingKey)!);
|
clearInterval(verificationTimers.get(pingKey)!);
|
||||||
verificationTimers.delete(pingKey);
|
verificationTimers.delete(pingKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
const pingInterval = setInterval(() => {
|
const pingInterval = setInterval(() => {
|
||||||
const currentStatus = connectionStatus.get(tunnelName);
|
const currentStatus = connectionStatus.get(tunnelName);
|
||||||
if (currentStatus?.status === CONNECTION_STATES.CONNECTED) {
|
if (currentStatus?.status === CONNECTION_STATES.CONNECTED) {
|
||||||
@@ -475,15 +374,18 @@ function setupPingInterval(tunnelName: string, tunnelConfig: TunnelConfig): void
|
|||||||
verificationTimers.delete(pingKey);
|
verificationTimers.delete(pingKey);
|
||||||
}
|
}
|
||||||
}, 120000);
|
}, 120000);
|
||||||
|
|
||||||
verificationTimers.set(pingKey, pingInterval);
|
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 tunnelName = tunnelConfig.name;
|
||||||
const tunnelMarker = getTunnelMarker(tunnelName);
|
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)) {
|
if (manualDisconnects.has(tunnelName)) {
|
||||||
|
tunnelLogger.info('Tunnel connection cancelled due to manual disconnect', { operation: 'tunnel_connect', tunnelName });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -492,10 +394,14 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
|
|||||||
if (retryAttempt === 0) {
|
if (retryAttempt === 0) {
|
||||||
retryExhaustedTunnels.delete(tunnelName);
|
retryExhaustedTunnels.delete(tunnelName);
|
||||||
retryCounters.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);
|
const currentStatus = connectionStatus.get(tunnelName);
|
||||||
if (!currentStatus || currentStatus.status !== CONNECTION_STATES.WAITING) {
|
if (!currentStatus || currentStatus.status !== CONNECTION_STATES.WAITING) {
|
||||||
|
tunnelLogger.info('Broadcasting tunnel connecting status', { operation: 'tunnel_connect', tunnelName, retryAttempt });
|
||||||
broadcastTunnelStatus(tunnelName, {
|
broadcastTunnelStatus(tunnelName, {
|
||||||
connected: false,
|
connected: false,
|
||||||
status: CONNECTION_STATES.CONNECTING,
|
status: CONNECTION_STATES.CONNECTING,
|
||||||
@@ -504,7 +410,7 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!tunnelConfig || !tunnelConfig.sourceIP || !tunnelConfig.sourceUsername || !tunnelConfig.sourceSSHPort) {
|
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, {
|
broadcastTunnelStatus(tunnelName, {
|
||||||
connected: false,
|
connected: false,
|
||||||
status: CONNECTION_STATES.FAILED,
|
status: CONNECTION_STATES.FAILED,
|
||||||
@@ -513,6 +419,79 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
|
|||||||
return;
|
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 conn = new Client();
|
||||||
|
|
||||||
const connectionTimeout = setTimeout(() => {
|
const connectionTimeout = setTimeout(() => {
|
||||||
@@ -536,7 +515,7 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
|
|||||||
|
|
||||||
conn.on("error", (err) => {
|
conn.on("error", (err) => {
|
||||||
clearTimeout(connectionTimeout);
|
clearTimeout(connectionTimeout);
|
||||||
logger.error(`SSH error for '${tunnelName}': ${err.message}`);
|
tunnelLogger.error(`SSH error for '${tunnelName}': ${err.message}`);
|
||||||
|
|
||||||
if (activeRetryTimers.has(tunnelName)) {
|
if (activeRetryTimers.has(tunnelName)) {
|
||||||
return;
|
return;
|
||||||
@@ -555,11 +534,9 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
|
|||||||
|
|
||||||
activeTunnels.delete(tunnelName);
|
activeTunnels.delete(tunnelName);
|
||||||
|
|
||||||
const shouldNotRetry = errorType === ERROR_TYPES.AUTH ||
|
const shouldNotRetry = errorType === 'AUTHENTICATION_FAILED' ||
|
||||||
errorType === ERROR_TYPES.PORT ||
|
errorType === 'CONNECTION_FAILED' ||
|
||||||
errorType === ERROR_TYPES.PERMISSION ||
|
|
||||||
manualDisconnects.has(tunnelName);
|
manualDisconnects.has(tunnelName);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
handleDisconnect(tunnelName, tunnelConfig, !shouldNotRetry);
|
handleDisconnect(tunnelName, tunnelConfig, !shouldNotRetry);
|
||||||
@@ -596,25 +573,24 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let tunnelCmd: string;
|
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, '_')}`;
|
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 {
|
} 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) => {
|
conn.exec(tunnelCmd, (err, stream) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
logger.error(`Connection error for '${tunnelName}': ${err.message}`);
|
tunnelLogger.error(`Connection error for '${tunnelName}': ${err.message}`);
|
||||||
|
|
||||||
conn.end();
|
conn.end();
|
||||||
|
|
||||||
activeTunnels.delete(tunnelName);
|
activeTunnels.delete(tunnelName);
|
||||||
|
|
||||||
const errorType = classifyError(err.message);
|
const errorType = classifyError(err.message);
|
||||||
const shouldNotRetry = errorType === ERROR_TYPES.AUTH ||
|
const shouldNotRetry = errorType === 'AUTHENTICATION_FAILED' ||
|
||||||
errorType === ERROR_TYPES.PORT ||
|
errorType === 'CONNECTION_FAILED';
|
||||||
errorType === ERROR_TYPES.PERMISSION;
|
|
||||||
|
|
||||||
handleDisconnect(tunnelName, tunnelConfig, !shouldNotRetry);
|
handleDisconnect(tunnelName, tunnelConfig, !shouldNotRetry);
|
||||||
return;
|
return;
|
||||||
@@ -696,7 +672,7 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
|
|||||||
host: tunnelConfig.sourceIP,
|
host: tunnelConfig.sourceIP,
|
||||||
port: tunnelConfig.sourceSSHPort,
|
port: tunnelConfig.sourceSSHPort,
|
||||||
username: tunnelConfig.sourceUsername,
|
username: tunnelConfig.sourceUsername,
|
||||||
keepaliveInterval: 30000,
|
keepaliveInterval: 30000,
|
||||||
keepaliveCountMax: 3,
|
keepaliveCountMax: 3,
|
||||||
readyTimeout: 60000,
|
readyTimeout: 60000,
|
||||||
tcpKeepAlive: true,
|
tcpKeepAlive: true,
|
||||||
@@ -737,9 +713,9 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (tunnelConfig.sourceAuthMethod === "key" && tunnelConfig.sourceSSHKey) {
|
if (resolvedSourceCredentials.authMethod === "key" && resolvedSourceCredentials.sshKey) {
|
||||||
if (!tunnelConfig.sourceSSHKey.includes('-----BEGIN') || !tunnelConfig.sourceSSHKey.includes('-----END')) {
|
if (!resolvedSourceCredentials.sshKey.includes('-----BEGIN') || !resolvedSourceCredentials.sshKey.includes('-----END')) {
|
||||||
logger.error(`Invalid SSH key format for tunnel '${tunnelName}'. Key should contain both BEGIN and END markers`);
|
tunnelLogger.error(`Invalid SSH key format for tunnel '${tunnelName}'. Key should contain both BEGIN and END markers`);
|
||||||
broadcastTunnelStatus(tunnelName, {
|
broadcastTunnelStatus(tunnelName, {
|
||||||
connected: false,
|
connected: false,
|
||||||
status: CONNECTION_STATES.FAILED,
|
status: CONNECTION_STATES.FAILED,
|
||||||
@@ -748,16 +724,16 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
|
|||||||
return;
|
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');
|
connOptions.privateKey = Buffer.from(cleanKey, 'utf8');
|
||||||
if (tunnelConfig.sourceKeyPassword) {
|
if (resolvedSourceCredentials.keyPassword) {
|
||||||
connOptions.passphrase = tunnelConfig.sourceKeyPassword;
|
connOptions.passphrase = resolvedSourceCredentials.keyPassword;
|
||||||
}
|
}
|
||||||
if (tunnelConfig.sourceKeyType && tunnelConfig.sourceKeyType !== 'auto') {
|
if (resolvedSourceCredentials.keyType && resolvedSourceCredentials.keyType !== 'auto') {
|
||||||
connOptions.privateKeyType = tunnelConfig.sourceKeyType;
|
connOptions.privateKeyType = resolvedSourceCredentials.keyType;
|
||||||
}
|
}
|
||||||
} else if (tunnelConfig.sourceAuthMethod === "key") {
|
} else if (resolvedSourceCredentials.authMethod === "key") {
|
||||||
logger.error(`SSH key authentication requested but no key provided for tunnel '${tunnelName}'`);
|
tunnelLogger.error(`SSH key authentication requested but no key provided for tunnel '${tunnelName}'`);
|
||||||
broadcastTunnelStatus(tunnelName, {
|
broadcastTunnelStatus(tunnelName, {
|
||||||
connected: false,
|
connected: false,
|
||||||
status: CONNECTION_STATES.FAILED,
|
status: CONNECTION_STATES.FAILED,
|
||||||
@@ -765,7 +741,7 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
} else {
|
} else {
|
||||||
connOptions.password = tunnelConfig.sourcePassword;
|
connOptions.password = resolvedSourceCredentials.password;
|
||||||
}
|
}
|
||||||
|
|
||||||
const finalStatus = connectionStatus.get(tunnelName);
|
const finalStatus = connectionStatus.get(tunnelName);
|
||||||
@@ -832,7 +808,7 @@ function killRemoteTunnelByMarker(tunnelConfig: TunnelConfig, tunnelName: string
|
|||||||
callback(new Error('Invalid SSH key format'));
|
callback(new Error('Invalid SSH key format'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const cleanKey = tunnelConfig.sourceSSHKey.trim().replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
const cleanKey = tunnelConfig.sourceSSHKey.trim().replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
||||||
connOptions.privateKey = Buffer.from(cleanKey, 'utf8');
|
connOptions.privateKey = Buffer.from(cleanKey, 'utf8');
|
||||||
if (tunnelConfig.sourceKeyPassword) {
|
if (tunnelConfig.sourceKeyPassword) {
|
||||||
@@ -898,7 +874,9 @@ app.post('/ssh/tunnel/connect', (req, res) => {
|
|||||||
|
|
||||||
tunnelConfigs.set(tunnelName, tunnelConfig);
|
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});
|
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) {
|
for (const tunnelConfig of autoStartTunnels) {
|
||||||
tunnelConfigs.set(tunnelConfig.name, tunnelConfig);
|
tunnelConfigs.set(tunnelConfig.name, tunnelConfig);
|
||||||
|
|
||||||
setTimeout(() => {
|
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);
|
}, 1000);
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} 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;
|
const PORT = 8083;
|
||||||
app.listen(PORT, () => {
|
app.listen(PORT, () => {
|
||||||
|
tunnelLogger.success('SSH Tunnel API server started', { operation: 'server_start', port: PORT });
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
initializeAutoStartTunnels();
|
initializeAutoStartTunnels();
|
||||||
}, 2000);
|
}, 2000);
|
||||||
|
|||||||
@@ -6,51 +6,47 @@ import './ssh/terminal.js';
|
|||||||
import './ssh/tunnel.js';
|
import './ssh/tunnel.js';
|
||||||
import './ssh/file-manager.js';
|
import './ssh/file-manager.js';
|
||||||
import './ssh/server-stats.js';
|
import './ssh/server-stats.js';
|
||||||
import chalk from 'chalk';
|
import { systemLogger } from './utils/logger.js';
|
||||||
|
|
||||||
const fixedIconSymbol = '🚀';
|
|
||||||
|
|
||||||
const getTimeStamp = (): string => {
|
|
||||||
return chalk.gray(`[${new Date().toLocaleTimeString()}]`);
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatMessage = (level: string, colorFn: chalk.Chalk, message: string): string => {
|
|
||||||
return `${getTimeStamp()} ${colorFn(`[${level.toUpperCase()}]`)} ${chalk.hex('#1e3a8a')(`[${fixedIconSymbol}]`)} ${message}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const logger = {
|
|
||||||
info: (msg: string): void => {
|
|
||||||
console.log(formatMessage('info', chalk.cyan, msg));
|
|
||||||
},
|
|
||||||
warn: (msg: string): void => {
|
|
||||||
console.warn(formatMessage('warn', chalk.yellow, msg));
|
|
||||||
},
|
|
||||||
error: (msg: string, err?: unknown): void => {
|
|
||||||
console.error(formatMessage('error', chalk.redBright, msg));
|
|
||||||
if (err) console.error(err);
|
|
||||||
},
|
|
||||||
success: (msg: string): void => {
|
|
||||||
console.log(formatMessage('success', chalk.greenBright, msg));
|
|
||||||
},
|
|
||||||
debug: (msg: string): void => {
|
|
||||||
if (process.env.NODE_ENV !== 'production') {
|
|
||||||
console.debug(formatMessage('debug', chalk.magenta, msg));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
logger.info("Starting all backend servers...");
|
systemLogger.info("Initializing backend services...", { operation: 'startup' });
|
||||||
|
|
||||||
|
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' });
|
||||||
|
|
||||||
logger.success("All servers started successfully");
|
systemLogger.success("All backend services initialized successfully", {
|
||||||
|
operation: 'startup_complete',
|
||||||
|
services: ['database', 'terminal', 'tunnel', 'file_manager', 'stats']
|
||||||
|
});
|
||||||
|
|
||||||
process.on('SIGINT', () => {
|
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.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) {
|
} catch (error) {
|
||||||
logger.error("Failed to start servers:", error);
|
systemLogger.error("Failed to initialize backend services", error, { operation: 'startup_failed' });
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
172
src/backend/utils/logger.ts
Normal file
172
src/backend/utils/logger.ts
Normal 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;
|
||||||
@@ -4,15 +4,7 @@ import { Input } from "@/components/ui/input";
|
|||||||
import { FormControl, FormItem, FormLabel } from "@/components/ui/form";
|
import { FormControl, FormItem, FormLabel } from "@/components/ui/form";
|
||||||
import { getCredentials } from '@/ui/main-axios';
|
import { getCredentials } from '@/ui/main-axios';
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import type { Credential } from '../types/index.js';
|
||||||
interface Credential {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
description?: string;
|
|
||||||
username: string;
|
|
||||||
authType: 'password' | 'key';
|
|
||||||
folder?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CredentialSelectorProps {
|
interface CredentialSelectorProps {
|
||||||
value?: number | null;
|
value?: number | null;
|
||||||
|
|||||||
416
src/types/index.ts
Normal file
416
src/types/index.ts
Normal 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>;
|
||||||
@@ -313,17 +313,30 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
|
|||||||
<div className="flex gap-2 pt-2">
|
<div className="flex gap-2 pt-2">
|
||||||
<Button type="submit" className="flex-1"
|
<Button type="submit" className="flex-1"
|
||||||
disabled={oidcLoading}>{oidcLoading ? t('admin.saving') : t('admin.saveConfiguration')}</Button>
|
disabled={oidcLoading}>{oidcLoading ? t('admin.saving') : t('admin.saveConfiguration')}</Button>
|
||||||
<Button type="button" variant="outline" onClick={() => setOidcConfig({
|
<Button type="button" variant="outline" onClick={async () => {
|
||||||
client_id: '',
|
const emptyConfig = {
|
||||||
client_secret: '',
|
client_id: '',
|
||||||
issuer_url: '',
|
client_secret: '',
|
||||||
authorization_url: '',
|
issuer_url: '',
|
||||||
token_url: '',
|
authorization_url: '',
|
||||||
identifier_path: 'sub',
|
token_url: '',
|
||||||
name_path: 'name',
|
identifier_path: '',
|
||||||
scopes: 'openid email profile',
|
name_path: '',
|
||||||
userinfo_url: ''
|
scopes: '',
|
||||||
})}>{t('admin.reset')}</Button>
|
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>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -19,34 +19,16 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
|||||||
import React, { useEffect, useRef, useState } from "react"
|
import React, { useEffect, useRef, useState } from "react"
|
||||||
import { Alert, AlertDescription } from "@/components/ui/alert"
|
import { Alert, AlertDescription } from "@/components/ui/alert"
|
||||||
import { toast } from "sonner"
|
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"
|
import { useTranslation } from "react-i18next"
|
||||||
|
import type { Credential, CredentialEditorProps } from '../../../types/index.js'
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CredentialEditor({ editingCredential, onFormSubmit }: CredentialEditorProps) {
|
export function CredentialEditor({ editingCredential, onFormSubmit }: CredentialEditorProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [credentials, setCredentials] = useState<Credential[]>([]);
|
const [credentials, setCredentials] = useState<Credential[]>([]);
|
||||||
const [folders, setFolders] = useState<string[]>([]);
|
const [folders, setFolders] = useState<string[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [fullCredentialDetails, setFullCredentialDetails] = useState<Credential | null>(null);
|
||||||
|
|
||||||
const [authTab, setAuthTab] = useState<'password' | 'key'>('password');
|
const [authTab, setAuthTab] = useState<'password' | 'key'>('password');
|
||||||
|
|
||||||
@@ -60,8 +42,8 @@ export function CredentialEditor({ editingCredential, onFormSubmit }: Credential
|
|||||||
const uniqueFolders = [...new Set(
|
const uniqueFolders = [...new Set(
|
||||||
credentialsData
|
credentialsData
|
||||||
.filter(credential => credential.folder && credential.folder.trim() !== '')
|
.filter(credential => credential.folder && credential.folder.trim() !== '')
|
||||||
.map(credential => credential.folder)
|
.map(credential => credential.folder!)
|
||||||
)].sort();
|
)].sort() as string[];
|
||||||
|
|
||||||
setFolders(uniqueFolders);
|
setFolders(uniqueFolders);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -73,6 +55,24 @@ export function CredentialEditor({ editingCredential, onFormSubmit }: Credential
|
|||||||
fetchData();
|
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({
|
const formSchema = z.object({
|
||||||
name: z.string().min(1),
|
name: z.string().min(1),
|
||||||
description: z.string().optional(),
|
description: z.string().optional(),
|
||||||
@@ -81,7 +81,7 @@ export function CredentialEditor({ editingCredential, onFormSubmit }: Credential
|
|||||||
authType: z.enum(['password', 'key']),
|
authType: z.enum(['password', 'key']),
|
||||||
username: z.string().min(1),
|
username: z.string().min(1),
|
||||||
password: z.string().optional(),
|
password: z.string().optional(),
|
||||||
key: z.instanceof(File).optional().nullable(),
|
key: z.any().optional().nullable(),
|
||||||
keyPassword: z.string().optional(),
|
keyPassword: z.string().optional(),
|
||||||
keyType: z.enum([
|
keyType: z.enum([
|
||||||
'rsa',
|
'rsa',
|
||||||
@@ -127,24 +127,24 @@ export function CredentialEditor({ editingCredential, onFormSubmit }: Credential
|
|||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (editingCredential) {
|
if (editingCredential && fullCredentialDetails) {
|
||||||
const defaultAuthType = editingCredential.key ? 'key' : 'password';
|
const defaultAuthType = fullCredentialDetails.authType;
|
||||||
|
|
||||||
setAuthTab(defaultAuthType);
|
setAuthTab(defaultAuthType);
|
||||||
|
|
||||||
form.reset({
|
form.reset({
|
||||||
name: editingCredential.name || "",
|
name: fullCredentialDetails.name || "",
|
||||||
description: editingCredential.description || "",
|
description: fullCredentialDetails.description || "",
|
||||||
folder: editingCredential.folder || "",
|
folder: fullCredentialDetails.folder || "",
|
||||||
tags: editingCredential.tags || [],
|
tags: fullCredentialDetails.tags || [],
|
||||||
authType: defaultAuthType as 'password' | 'key',
|
authType: defaultAuthType as 'password' | 'key',
|
||||||
username: editingCredential.username || "",
|
username: fullCredentialDetails.username || "",
|
||||||
password: "",
|
password: fullCredentialDetails.password || "",
|
||||||
key: null,
|
key: null,
|
||||||
keyPassword: "",
|
keyPassword: fullCredentialDetails.keyPassword || "",
|
||||||
keyType: (editingCredential.keyType as any) || "rsa",
|
keyType: (fullCredentialDetails.keyType as any) || "rsa",
|
||||||
});
|
});
|
||||||
} else {
|
} else if (!editingCredential) {
|
||||||
setAuthTab('password');
|
setAuthTab('password');
|
||||||
|
|
||||||
form.reset({
|
form.reset({
|
||||||
@@ -160,7 +160,7 @@ export function CredentialEditor({ editingCredential, onFormSubmit }: Credential
|
|||||||
keyType: "rsa",
|
keyType: "rsa",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [editingCredential, form]);
|
}, [editingCredential, fullCredentialDetails, form]);
|
||||||
|
|
||||||
const onSubmit = async (data: any) => {
|
const onSubmit = async (data: any) => {
|
||||||
try {
|
try {
|
||||||
@@ -170,11 +170,38 @@ export function CredentialEditor({ editingCredential, onFormSubmit }: Credential
|
|||||||
formData.name = formData.username;
|
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) {
|
if (editingCredential) {
|
||||||
await updateCredential(editingCredential.id, formData);
|
await updateCredential(editingCredential.id, submitData);
|
||||||
toast.success(t('credentials.credentialUpdatedSuccessfully', { name: formData.name }));
|
toast.success(t('credentials.credentialUpdatedSuccessfully', { name: formData.name }));
|
||||||
} else {
|
} else {
|
||||||
await createCredential(formData);
|
await createCredential(submitData);
|
||||||
toast.success(t('credentials.credentialAddedSuccessfully', { name: formData.name }));
|
toast.success(t('credentials.credentialAddedSuccessfully', { name: formData.name }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,45 +27,11 @@ import {
|
|||||||
import { getCredentialDetails, getCredentialHosts } from '@/ui/main-axios';
|
import { getCredentialDetails, getCredentialHosts } from '@/ui/main-axios';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import type { Credential, HostInfo, CredentialViewerProps } from '../../../types/index.js';
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
const CredentialViewer: React.FC<CredentialViewerProps> = ({ credential, onClose, onEdit }) => {
|
const CredentialViewer: React.FC<CredentialViewerProps> = ({ credential, onClose, onEdit }) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [credentialDetails, setCredentialDetails] = useState<CredentialWithSecrets | null>(null);
|
const [credentialDetails, setCredentialDetails] = useState<Credential | null>(null);
|
||||||
const [hostsUsing, setHostsUsing] = useState<HostInfo[]>([]);
|
const [hostsUsing, setHostsUsing] = useState<HostInfo[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [showSensitive, setShowSensitive] = useState<Record<string, boolean>>({});
|
const [showSensitive, setShowSensitive] = useState<Record<string, boolean>>({});
|
||||||
|
|||||||
@@ -21,25 +21,7 @@ import { toast } from 'sonner';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import {CredentialEditor} from './CredentialEditor';
|
import {CredentialEditor} from './CredentialEditor';
|
||||||
import CredentialViewer from './CredentialViewer';
|
import CredentialViewer from './CredentialViewer';
|
||||||
|
import type { Credential, CredentialsManagerProps } from '../../../types/index.js';
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CredentialsManager({ onEditCredential }: CredentialsManagerProps) {
|
export function CredentialsManager({ onEditCredential }: CredentialsManagerProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -83,20 +65,16 @@ export function CredentialsManager({ onEditCredential }: CredentialsManagerProps
|
|||||||
toast.success(t('credentials.credentialDeletedSuccessfully', { name: credentialName }));
|
toast.success(t('credentials.credentialDeletedSuccessfully', { name: credentialName }));
|
||||||
await fetchCredentials();
|
await fetchCredentials();
|
||||||
window.dispatchEvent(new CustomEvent('credentials:changed'));
|
window.dispatchEvent(new CustomEvent('credentials:changed'));
|
||||||
} catch (err) {
|
} catch (err: any) {
|
||||||
toast.error(t('credentials.failedToDeleteCredential'));
|
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(() => {
|
const filteredAndSortedCredentials = useMemo(() => {
|
||||||
let filtered = credentials;
|
let filtered = credentials;
|
||||||
|
|
||||||
|
|||||||
@@ -26,41 +26,7 @@ import {
|
|||||||
getSSHStatus,
|
getSSHStatus,
|
||||||
connectSSH
|
connectSSH
|
||||||
} from '@/ui/main-axios.ts';
|
} from '@/ui/main-axios.ts';
|
||||||
|
import type { SSHHost, Tab, FileManagerProps } from '../../../types/index.js';
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function FileManager({onSelectView, embedded = false, initialHost = null}: {
|
export function FileManager({onSelectView, embedded = false, initialHost = null}: {
|
||||||
onSelectView?: (view: string) => void,
|
onSelectView?: (view: string) => void,
|
||||||
@@ -378,12 +344,16 @@ export function FileManager({onSelectView, embedded = false, initialHost = null}
|
|||||||
|
|
||||||
if (!status.connected) {
|
if (!status.connected) {
|
||||||
const connectPromise = connectSSH(tab.sshSessionId, {
|
const connectPromise = connectSSH(tab.sshSessionId, {
|
||||||
|
hostId: currentHost.id,
|
||||||
ip: currentHost.ip,
|
ip: currentHost.ip,
|
||||||
port: currentHost.port,
|
port: currentHost.port,
|
||||||
username: currentHost.username,
|
username: currentHost.username,
|
||||||
password: currentHost.password,
|
password: currentHost.password,
|
||||||
sshKey: currentHost.key,
|
sshKey: currentHost.key,
|
||||||
keyPassword: currentHost.keyPassword
|
keyPassword: currentHost.keyPassword,
|
||||||
|
authType: currentHost.authType,
|
||||||
|
credentialId: currentHost.credentialId,
|
||||||
|
userId: currentHost.userId
|
||||||
});
|
});
|
||||||
const connectTimeoutPromise = new Promise((_, reject) =>
|
const connectTimeoutPromise = new Promise((_, reject) =>
|
||||||
setTimeout(() => reject(new Error(t('fileManager.sshReconnectionTimeout'))), 15000)
|
setTimeout(() => reject(new Error(t('fileManager.sshReconnectionTimeout'))), 15000)
|
||||||
|
|||||||
@@ -5,19 +5,7 @@ import {Tabs, TabsList, TabsTrigger, TabsContent} from '@/components/ui/tabs.tsx
|
|||||||
import {Input} from '@/components/ui/input.tsx';
|
import {Input} from '@/components/ui/input.tsx';
|
||||||
import {useState} from 'react';
|
import {useState} from 'react';
|
||||||
import {useTranslation} from 'react-i18next';
|
import {useTranslation} from 'react-i18next';
|
||||||
|
import type { FileItem, ShortcutItem } from '../../../types/index.js';
|
||||||
interface FileItem {
|
|
||||||
name: string;
|
|
||||||
path: string;
|
|
||||||
isPinned?: boolean;
|
|
||||||
type: 'file' | 'directory';
|
|
||||||
sshSessionId?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ShortcutItem {
|
|
||||||
name: string;
|
|
||||||
path: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface FileManagerHomeViewProps {
|
interface FileManagerHomeViewProps {
|
||||||
recent: FileItem[];
|
recent: FileItem[];
|
||||||
|
|||||||
@@ -19,29 +19,7 @@ import {
|
|||||||
getSSHStatus,
|
getSSHStatus,
|
||||||
connectSSH
|
connectSSH
|
||||||
} from '@/ui/main-axios.ts';
|
} from '@/ui/main-axios.ts';
|
||||||
|
import type { SSHHost, FileManagerLeftSidebarProps } from '../../../types/index.js';
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
|
const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
|
||||||
{onSelectView, onOpenFile, tabs, host, onOperationComplete, onError, onSuccess, onPathChange, onDeleteItem}: {
|
{onSelectView, onOpenFile, tabs, host, onOperationComplete, onError, onSuccess, onPathChange, onDeleteItem}: {
|
||||||
@@ -133,12 +111,16 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const connectionConfig = {
|
const connectionConfig = {
|
||||||
|
hostId: server.id,
|
||||||
ip: server.ip,
|
ip: server.ip,
|
||||||
port: server.port,
|
port: server.port,
|
||||||
username: server.username,
|
username: server.username,
|
||||||
password: server.password,
|
password: server.password,
|
||||||
sshKey: server.key,
|
sshKey: server.key,
|
||||||
keyPassword: server.keyPassword,
|
keyPassword: server.keyPassword,
|
||||||
|
authType: server.authType,
|
||||||
|
credentialId: server.credentialId,
|
||||||
|
userId: server.userId,
|
||||||
};
|
};
|
||||||
|
|
||||||
await connectSSH(sessionId, connectionConfig);
|
await connectSSH(sessionId, connectionConfig);
|
||||||
|
|||||||
@@ -17,14 +17,7 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import {cn} from '@/lib/utils.ts';
|
import {cn} from '@/lib/utils.ts';
|
||||||
import {useTranslation} from 'react-i18next';
|
import {useTranslation} from 'react-i18next';
|
||||||
|
import type { FileManagerOperationsProps } from '../../../types/index.js';
|
||||||
interface FileManagerOperationsProps {
|
|
||||||
currentPath: string;
|
|
||||||
sshSessionId: string | null;
|
|
||||||
onOperationComplete: () => void;
|
|
||||||
onError: (error: string) => void;
|
|
||||||
onSuccess: (message: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function FileManagerOperations({
|
export function FileManagerOperations({
|
||||||
currentPath,
|
currentPath,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
import { getFoldersWithStats, renameFolder } from '@/ui/main-axios';
|
import { getFoldersWithStats, renameFolder } from '@/ui/main-axios';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import type { FolderManagerProps } from '../../../types/index.js';
|
||||||
|
|
||||||
interface FolderStats {
|
interface FolderStats {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -24,10 +25,6 @@ interface FolderStats {
|
|||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FolderManagerProps {
|
|
||||||
onFolderChanged?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function FolderManager({ onFolderChanged }: FolderManagerProps) {
|
export function FolderManager({ onFolderChanged }: FolderManagerProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [folders, setFolders] = useState<FolderStats[]>([]);
|
const [folders, setFolders] = useState<FolderStats[]>([]);
|
||||||
|
|||||||
@@ -7,34 +7,7 @@ import {CredentialsManager} from "@/ui/Desktop/Apps/Credentials/CredentialsManag
|
|||||||
import {CredentialEditor} from "@/ui/Desktop/Apps/Credentials/CredentialEditor.tsx";
|
import {CredentialEditor} from "@/ui/Desktop/Apps/Credentials/CredentialEditor.tsx";
|
||||||
import {useSidebar} from "@/components/ui/sidebar.tsx";
|
import {useSidebar} from "@/components/ui/sidebar.tsx";
|
||||||
import {useTranslation} from "react-i18next";
|
import {useTranslation} from "react-i18next";
|
||||||
|
import type { SSHHost, HostManagerProps } from '../../../types/index.js';
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function HostManager({onSelectView, isTopbarOpen}: HostManagerProps): React.ReactElement {
|
export function HostManager({onSelectView, isTopbarOpen}: HostManagerProps): React.ReactElement {
|
||||||
const {t} = useTranslation();
|
const {t} = useTranslation();
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
|
|||||||
authType: z.enum(['password', 'key', 'credential']),
|
authType: z.enum(['password', 'key', 'credential']),
|
||||||
credentialId: z.number().optional().nullable(),
|
credentialId: z.number().optional().nullable(),
|
||||||
password: z.string().optional(),
|
password: z.string().optional(),
|
||||||
key: z.instanceof(File).optional().nullable(),
|
key: z.any().optional().nullable(),
|
||||||
keyPassword: z.string().optional(),
|
keyPassword: z.string().optional(),
|
||||||
keyType: z.enum([
|
keyType: z.enum([
|
||||||
'auto',
|
'auto',
|
||||||
@@ -205,7 +205,6 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (editingHost) {
|
if (editingHost) {
|
||||||
const defaultAuthType = editingHost.credentialId ? 'credential' : (editingHost.key ? 'key' : 'password');
|
const defaultAuthType = editingHost.credentialId ? 'credential' : (editingHost.key ? 'key' : 'password');
|
||||||
|
|
||||||
setAuthTab(defaultAuthType);
|
setAuthTab(defaultAuthType);
|
||||||
|
|
||||||
form.reset({
|
form.reset({
|
||||||
@@ -219,7 +218,7 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
|
|||||||
authType: defaultAuthType as 'password' | 'key' | 'credential',
|
authType: defaultAuthType as 'password' | 'key' | 'credential',
|
||||||
credentialId: editingHost.credentialId || null,
|
credentialId: editingHost.credentialId || null,
|
||||||
password: editingHost.password || "",
|
password: editingHost.password || "",
|
||||||
key: editingHost.key ? new File([editingHost.key], "key.pem") : null,
|
key: null,
|
||||||
keyPassword: editingHost.keyPassword || "",
|
keyPassword: editingHost.keyPassword || "",
|
||||||
keyType: (editingHost.keyType as any) || "auto",
|
keyType: (editingHost.keyType as any) || "auto",
|
||||||
enableTerminal: editingHost.enableTerminal !== false,
|
enableTerminal: editingHost.enableTerminal !== false,
|
||||||
@@ -230,7 +229,6 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
|
|||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
setAuthTab('password');
|
setAuthTab('password');
|
||||||
|
|
||||||
form.reset({
|
form.reset({
|
||||||
name: "",
|
name: "",
|
||||||
ip: "",
|
ip: "",
|
||||||
@@ -283,11 +281,52 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
|
|||||||
formData.name = `${formData.username}@${formData.ip}`;
|
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) {
|
if (editingHost) {
|
||||||
await updateSSHHost(editingHost.id, formData);
|
await updateSSHHost(editingHost.id, submitData);
|
||||||
toast.success(t('hosts.hostUpdatedSuccessfully', { name: formData.name }));
|
toast.success(t('hosts.hostUpdatedSuccessfully', { name: formData.name }));
|
||||||
} else {
|
} else {
|
||||||
await createSSHHost(formData);
|
await createSSHHost(submitData);
|
||||||
toast.success(t('hosts.hostAddedSuccessfully', { name: formData.name }));
|
toast.success(t('hosts.hostAddedSuccessfully', { name: formData.name }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,29 +27,7 @@ import {
|
|||||||
Pencil
|
Pencil
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import {Separator} from "@/components/ui/separator.tsx";
|
import {Separator} from "@/components/ui/separator.tsx";
|
||||||
|
import type { SSHHost, SSHManagerHostViewerProps } from '../../../types/index.js';
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function HostManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) {
|
export function HostManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) {
|
||||||
const {t} = useTranslation();
|
const {t} = useTranslation();
|
||||||
|
|||||||
@@ -1,52 +1,7 @@
|
|||||||
import React, {useState, useEffect, useCallback} from "react";
|
import React, {useState, useEffect, useCallback} from "react";
|
||||||
import {TunnelViewer} from "@/ui/Desktop/Apps/Tunnel/TunnelViewer.tsx";
|
import {TunnelViewer} from "@/ui/Desktop/Apps/Tunnel/TunnelViewer.tsx";
|
||||||
import {getSSHHosts, getTunnelStatuses, connectTunnel, disconnectTunnel, cancelTunnel} from "@/ui/main-axios.ts";
|
import {getSSHHosts, getTunnelStatuses, connectTunnel, disconnectTunnel, cancelTunnel} from "@/ui/main-axios.ts";
|
||||||
|
import type { SSHHost, TunnelConnection, TunnelStatus, SSHTunnelProps } from '../../../types/index.js';
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Tunnel({filterHostKey}: SSHTunnelProps): React.ReactElement {
|
export function Tunnel({filterHostKey}: SSHTunnelProps): React.ReactElement {
|
||||||
const [allHosts, setAllHosts] = useState<SSHHost[]>([]);
|
const [allHosts, setAllHosts] = useState<SSHHost[]>([]);
|
||||||
@@ -163,6 +118,8 @@ export function Tunnel({filterHostKey}: SSHTunnelProps): React.ReactElement {
|
|||||||
sourceSSHKey: host.authType === 'key' ? host.key : undefined,
|
sourceSSHKey: host.authType === 'key' ? host.key : undefined,
|
||||||
sourceKeyPassword: host.authType === 'key' ? host.keyPassword : undefined,
|
sourceKeyPassword: host.authType === 'key' ? host.keyPassword : undefined,
|
||||||
sourceKeyType: host.authType === 'key' ? host.keyType : undefined,
|
sourceKeyType: host.authType === 'key' ? host.keyType : undefined,
|
||||||
|
sourceCredentialId: host.credentialId,
|
||||||
|
sourceUserId: host.userId,
|
||||||
endpointIP: endpointHost.ip,
|
endpointIP: endpointHost.ip,
|
||||||
endpointSSHPort: endpointHost.port,
|
endpointSSHPort: endpointHost.port,
|
||||||
endpointUsername: endpointHost.username,
|
endpointUsername: endpointHost.username,
|
||||||
@@ -171,6 +128,8 @@ export function Tunnel({filterHostKey}: SSHTunnelProps): React.ReactElement {
|
|||||||
endpointSSHKey: endpointHost.authType === 'key' ? endpointHost.key : undefined,
|
endpointSSHKey: endpointHost.authType === 'key' ? endpointHost.key : undefined,
|
||||||
endpointKeyPassword: endpointHost.authType === 'key' ? endpointHost.keyPassword : undefined,
|
endpointKeyPassword: endpointHost.authType === 'key' ? endpointHost.keyPassword : undefined,
|
||||||
endpointKeyType: endpointHost.authType === 'key' ? endpointHost.keyType : undefined,
|
endpointKeyType: endpointHost.authType === 'key' ? endpointHost.keyType : undefined,
|
||||||
|
endpointCredentialId: endpointHost.credentialId,
|
||||||
|
endpointUserId: endpointHost.userId,
|
||||||
sourcePort: tunnel.sourcePort,
|
sourcePort: tunnel.sourcePort,
|
||||||
endpointPort: tunnel.endpointPort,
|
endpointPort: tunnel.endpointPort,
|
||||||
maxRetries: tunnel.maxRetries,
|
maxRetries: tunnel.maxRetries,
|
||||||
|
|||||||
@@ -20,65 +20,7 @@ import {
|
|||||||
X
|
X
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import {Badge} from "@/components/ui/badge.tsx";
|
import {Badge} from "@/components/ui/badge.tsx";
|
||||||
|
import type { SSHHost, TunnelConnection, TunnelStatus, CONNECTION_STATES, SSHTunnelObjectProps } from '../../../types/index.js';
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TunnelObject({
|
export function TunnelObject({
|
||||||
host,
|
host,
|
||||||
|
|||||||
@@ -1,44 +1,7 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import {TunnelObject} from "./TunnelObject.tsx";
|
import {TunnelObject} from "./TunnelObject.tsx";
|
||||||
import {useTranslation} from 'react-i18next';
|
import {useTranslation} from 'react-i18next';
|
||||||
|
import type { SSHHost, TunnelConnection, TunnelStatus } from '../../../types/index.js';
|
||||||
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 SSHTunnelViewerProps {
|
interface SSHTunnelViewerProps {
|
||||||
hosts: SSHHost[];
|
hosts: SSHHost[];
|
||||||
|
|||||||
@@ -4,17 +4,7 @@ import {Button} from "@/components/ui/button.tsx";
|
|||||||
import {Badge} from "@/components/ui/badge.tsx";
|
import {Badge} from "@/components/ui/badge.tsx";
|
||||||
import {X, ExternalLink, AlertTriangle, Info, CheckCircle, AlertCircle} from "lucide-react";
|
import {X, ExternalLink, AlertTriangle, Info, CheckCircle, AlertCircle} from "lucide-react";
|
||||||
import {useTranslation} from "react-i18next";
|
import {useTranslation} from "react-i18next";
|
||||||
|
import type { TermixAlert } from '../../../types/index.js';
|
||||||
interface TermixAlert {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
message: string;
|
|
||||||
expiresAt: string;
|
|
||||||
priority?: 'low' | 'medium' | 'high' | 'critical';
|
|
||||||
type?: 'info' | 'warning' | 'error' | 'success';
|
|
||||||
actionUrl?: string;
|
|
||||||
actionText?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AlertCardProps {
|
interface AlertCardProps {
|
||||||
alert: TermixAlert;
|
alert: TermixAlert;
|
||||||
|
|||||||
@@ -3,17 +3,7 @@ import {HomepageAlertCard} from "./HomepageAlertCard.tsx";
|
|||||||
import {Button} from "@/components/ui/button.tsx";
|
import {Button} from "@/components/ui/button.tsx";
|
||||||
import { getUserAlerts, dismissAlert } from "@/ui/main-axios.ts";
|
import { getUserAlerts, dismissAlert } from "@/ui/main-axios.ts";
|
||||||
import {useTranslation} from "react-i18next";
|
import {useTranslation} from "react-i18next";
|
||||||
|
import type { TermixAlert } from '../../../types/index.js';
|
||||||
interface TermixAlert {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
message: string;
|
|
||||||
expiresAt: string;
|
|
||||||
priority?: 'low' | 'medium' | 'high' | 'critical';
|
|
||||||
type?: 'info' | 'warning' | 'error' | 'success';
|
|
||||||
actionUrl?: string;
|
|
||||||
actionText?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AlertManagerProps {
|
interface AlertManagerProps {
|
||||||
userId: string | null;
|
userId: string | null;
|
||||||
|
|||||||
@@ -5,33 +5,7 @@ import {ButtonGroup} from "@/components/ui/button-group.tsx";
|
|||||||
import {Server, Terminal} from "lucide-react";
|
import {Server, Terminal} from "lucide-react";
|
||||||
import {useTabs} from "@/ui/Desktop/Navigation/Tabs/TabContext.tsx";
|
import {useTabs} from "@/ui/Desktop/Navigation/Tabs/TabContext.tsx";
|
||||||
import {getServerStatusById} from "@/ui/main-axios.ts";
|
import {getServerStatusById} from "@/ui/main-axios.ts";
|
||||||
|
import type { SSHHost, HostProps } from '../../../types/index.js';
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Host({host}: HostProps): React.ReactElement {
|
export function Host({host}: HostProps): React.ReactElement {
|
||||||
const {addTab} = useTabs();
|
const {addTab} = useTabs();
|
||||||
|
|||||||
@@ -1,13 +1,8 @@
|
|||||||
import React, {createContext, useContext, useState, useRef, type ReactNode} from 'react';
|
import React, {createContext, useContext, useState, useRef, type ReactNode} from 'react';
|
||||||
import {useTranslation} from 'react-i18next';
|
import {useTranslation} from 'react-i18next';
|
||||||
|
import type { TabContextTab } from '../../../types/index.js';
|
||||||
|
|
||||||
export interface Tab {
|
export type Tab = TabContextTab;
|
||||||
id: number;
|
|
||||||
type: 'home' | 'terminal' | 'ssh_manager' | 'server' | 'admin' | 'file_manager';
|
|
||||||
title: string;
|
|
||||||
hostConfig?: any;
|
|
||||||
terminalRef?: React.RefObject<any>;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TabContextType {
|
interface TabContextType {
|
||||||
tabs: Tab[];
|
tabs: Tab[];
|
||||||
|
|||||||
@@ -1,13 +1,8 @@
|
|||||||
import React, {createContext, useContext, useState, useRef, type ReactNode} from 'react';
|
import React, {createContext, useContext, useState, useRef, type ReactNode} from 'react';
|
||||||
import {useTranslation} from 'react-i18next';
|
import {useTranslation} from 'react-i18next';
|
||||||
|
import type { TabContextTab } from '../../../../types/index.js';
|
||||||
|
|
||||||
export interface Tab {
|
export type Tab = TabContextTab;
|
||||||
id: number;
|
|
||||||
type: 'terminal';
|
|
||||||
title: string;
|
|
||||||
hostConfig?: any;
|
|
||||||
terminalRef?: React.RefObject<any>;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TabContextType {
|
interface TabContextType {
|
||||||
tabs: Tab[];
|
tabs: Tab[];
|
||||||
|
|||||||
@@ -1,13 +1,8 @@
|
|||||||
import React, {createContext, useContext, useState, useRef, type ReactNode} from 'react';
|
import React, {createContext, useContext, useState, useRef, type ReactNode} from 'react';
|
||||||
import {useTranslation} from 'react-i18next';
|
import {useTranslation} from 'react-i18next';
|
||||||
|
import type { TabContextTab } from '../../../types/index.js';
|
||||||
|
|
||||||
export interface Tab {
|
export type Tab = TabContextTab;
|
||||||
id: number;
|
|
||||||
type: 'terminal';
|
|
||||||
title: string;
|
|
||||||
hostConfig?: any;
|
|
||||||
terminalRef?: React.RefObject<any>;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TabContextType {
|
interface TabContextType {
|
||||||
tabs: Tab[];
|
tabs: Tab[];
|
||||||
|
|||||||
@@ -1,103 +1,16 @@
|
|||||||
import axios, { AxiosError, type AxiosInstance } from 'axios';
|
import axios, { AxiosError, type AxiosInstance } from 'axios';
|
||||||
|
import type {
|
||||||
// ============================================================================
|
SSHHost,
|
||||||
// TYPES & INTERFACES
|
SSHHostData,
|
||||||
// ============================================================================
|
TunnelConfig,
|
||||||
|
TunnelStatus,
|
||||||
interface SSHHostData {
|
Credential,
|
||||||
name?: string;
|
CredentialData,
|
||||||
ip: string;
|
HostInfo,
|
||||||
port: number;
|
ApiResponse,
|
||||||
username: string;
|
FileManagerFile,
|
||||||
folder?: string;
|
FileManagerShortcut
|
||||||
tags?: string[];
|
} from '../types/index.js';
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface FileManagerOperation {
|
interface FileManagerOperation {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -203,10 +116,26 @@ function createApiInstance(baseURL: string): AxiosInstance {
|
|||||||
});
|
});
|
||||||
|
|
||||||
instance.interceptors.response.use(
|
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) => {
|
(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) {
|
if (error.response?.status === 401) {
|
||||||
|
console.warn('🔐 Authentication failed, clearing token');
|
||||||
document.cookie = 'jwt=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
|
document.cookie = 'jwt=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
|
||||||
|
localStorage.removeItem('jwt');
|
||||||
}
|
}
|
||||||
return Promise.reject(error);
|
return Promise.reject(error);
|
||||||
}
|
}
|
||||||
@@ -296,17 +225,33 @@ function handleApiError(error: unknown, operation: string): never {
|
|||||||
if (axios.isAxiosError(error)) {
|
if (axios.isAxiosError(error)) {
|
||||||
const status = error.response?.status;
|
const status = error.response?.status;
|
||||||
const message = error.response?.data?.error || error.message;
|
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) {
|
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) {
|
} 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) {
|
} 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) {
|
} 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 {
|
} 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 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: {
|
export async function connectSSH(sessionId: string, config: {
|
||||||
|
hostId?: number;
|
||||||
ip: string;
|
ip: string;
|
||||||
port: number;
|
port: number;
|
||||||
username: string;
|
username: string;
|
||||||
password?: string;
|
password?: string;
|
||||||
sshKey?: string;
|
sshKey?: string;
|
||||||
keyPassword?: string;
|
keyPassword?: string;
|
||||||
|
authType?: string;
|
||||||
|
credentialId?: number;
|
||||||
|
userId?: string;
|
||||||
}): Promise<any> {
|
}): Promise<any> {
|
||||||
try {
|
try {
|
||||||
const response = await fileManagerApi.post('/ssh/connect', {
|
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 {
|
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;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleApiError(error, 'apply credential to host');
|
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
|
// SSH FOLDER MANAGEMENT
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
Reference in New Issue
Block a user