v1.7.1 #335

Merged
LukeGus merged 1 commits from dev-1.7.1 into main 2025-10-03 05:02:11 +00:00
26 changed files with 877 additions and 186 deletions

View File

@@ -1,23 +1,31 @@
# Overview # Overview
_Short summary of what this PR does_ _Short summary of what this PR does_
- [ ] Added: ... - [ ] Added: ...
- [ ] Updated: ... - [ ] Updated: ...
- [ ] Removed: ... - [ ] Removed: ...
- [ ] Fixed: ... - [ ] Fixed: ...
# Changes Made # Changes Made
_Detailed explanation of changes (if needed)_ _Detailed explanation of changes (if needed)_
- ... - ...
# Related Issues # Related Issues
_Link any issues this PR addresses_ _Link any issues this PR addresses_
- Closes #ISSUE_NUMBER - Closes #ISSUE_NUMBER
- Related to #ISSUE_NUMBER - Related to #ISSUE_NUMBER
# Screenshots / Demos # Screenshots / Demos
_(Optional: add before/after screenshots, GIFs, or console output)_ _(Optional: add before/after screenshots, GIFs, or console output)_
# Checklist # Checklist
- [ ] Code follows project style guidelines - [ ] Code follows project style guidelines
- [ ] Supports mobile and desktop UI/app (if applicable) - [ ] Supports mobile and desktop UI/app (if applicable)
- [ ] I have read [Contributing.md](https://github.com/LukeGus/Termix/blob/main/CONTRIBUTING.md) - [ ] I have read [Contributing.md](https://github.com/LukeGus/Termix/blob/main/CONTRIBUTING.md)

View File

@@ -17,23 +17,23 @@ diverse, inclusive, and healthy community.
Examples of behavior that contributes to a positive environment for our Examples of behavior that contributes to a positive environment for our
community include: community include:
* Demonstrating empathy and kindness toward other people - Demonstrating empathy and kindness toward other people
* Being respectful of differing opinions, viewpoints, and experiences - Being respectful of differing opinions, viewpoints, and experiences
* Giving and gracefully accepting constructive feedback - Giving and gracefully accepting constructive feedback
* Accepting responsibility and apologizing to those affected by our mistakes, - Accepting responsibility and apologizing to those affected by our mistakes,
and learning from the experience and learning from the experience
* Focusing on what is best not just for us as individuals, but for the - Focusing on what is best not just for us as individuals, but for the
overall community overall community
Examples of unacceptable behavior include: Examples of unacceptable behavior include:
* The use of sexualized language or imagery, and sexual attention or - The use of sexualized language or imagery, and sexual attention or
advances of any kind advances of any kind
* Trolling, insulting or derogatory comments, and personal or political attacks - Trolling, insulting or derogatory comments, and personal or political attacks
* Public or private harassment - Public or private harassment
* Publishing others' private information, such as a physical or email - Publishing others' private information, such as a physical or email
address, without their explicit permission address, without their explicit permission
* Other conduct which could reasonably be considered inappropriate in a - Other conduct which could reasonably be considered inappropriate in a
professional setting professional setting
## Enforcement Responsibilities ## Enforcement Responsibilities
@@ -106,7 +106,7 @@ Violating these terms may lead to a permanent ban.
### 4. Permanent Ban ### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community **Community Impact**: Demonstrating a pattern of violation of community
standards, including sustained inappropriate behavior, harassment of an standards, including sustained inappropriate behavior, harassment of an
individual, or aggression toward or disparagement of classes of individuals. individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within **Consequence**: A permanent ban from any sort of public interaction within

View File

@@ -37,8 +37,26 @@
"uninstallDisplayName": "Termix" "uninstallDisplayName": "Termix"
}, },
"linux": { "linux": {
"target": "AppImage", "target": [
{
"target": "AppImage",
"arch": ["x64"]
},
{
"target": "tar.gz",
"arch": ["x64"]
}
],
"icon": "public/icon.png", "icon": "public/icon.png",
"category": "Development" "category": "Development",
"executableName": "termix",
"desktop": {
"entry": {
"Name": "Termix",
"Comment": "A web-based server management platform",
"Keywords": "terminal;ssh;server;management;",
"StartupWMClass": "termix"
}
}
} }
} }

View File

@@ -8,6 +8,12 @@ app.commandLine.appendSwitch("--ignore-ssl-errors");
app.commandLine.appendSwitch("--ignore-certificate-errors-spki-list"); app.commandLine.appendSwitch("--ignore-certificate-errors-spki-list");
app.commandLine.appendSwitch("--enable-features=NetworkService"); app.commandLine.appendSwitch("--enable-features=NetworkService");
if (process.platform === "linux") {
app.commandLine.appendSwitch("--no-sandbox");
app.commandLine.appendSwitch("--disable-setuid-sandbox");
app.commandLine.appendSwitch("--disable-dev-shm-usage");
}
let mainWindow = null; let mainWindow = null;
const isDev = process.env.NODE_ENV === "development" || !app.isPackaged; const isDev = process.env.NODE_ENV === "development" || !app.isPackaged;

View File

@@ -1,7 +1,7 @@
{ {
"name": "termix", "name": "termix",
"private": true, "private": true,
"version": "1.7.0", "version": "1.7.1",
"description": "A web-based server management platform with SSH terminal, tunneling, and file editing capabilities", "description": "A web-based server management platform with SSH terminal, tunneling, and file editing capabilities",
"author": "Karmaa", "author": "Karmaa",
"main": "electron/main.cjs", "main": "electron/main.cjs",
@@ -17,6 +17,8 @@
"build:win-portable": "npm run build && electron-builder --win --dir", "build:win-portable": "npm run build && electron-builder --win --dir",
"build:win-installer": "npm run build && electron-builder --win --publish=never", "build:win-installer": "npm run build && electron-builder --win --publish=never",
"build:linux-portable": "npm run build && electron-builder --linux --dir", "build:linux-portable": "npm run build && electron-builder --linux --dir",
"build:linux-appimage": "npm run build && electron-builder --linux AppImage",
"build:linux-targz": "npm run build && electron-builder --linux tar.gz",
"test:encryption": "tsc -p tsconfig.node.json && node ./dist/backend/backend/utils/encryption-test.js", "test:encryption": "tsc -p tsconfig.node.json && node ./dist/backend/backend/utils/encryption-test.js",
"migrate:encryption": "tsc -p tsconfig.node.json && node ./dist/backend/backend/utils/encryption-migration.js" "migrate:encryption": "tsc -p tsconfig.node.json && node ./dist/backend/backend/utils/encryption-migration.js"
}, },

View File

@@ -333,14 +333,14 @@ router.get(
if (credential.key) { if (credential.key) {
(output as any).key = credential.key; (output as any).key = credential.key;
} }
if (credential.privateKey) { if (credential.private_key) {
(output as any).privateKey = credential.privateKey; (output as any).privateKey = credential.private_key;
} }
if (credential.publicKey) { if (credential.public_key) {
(output as any).publicKey = credential.publicKey; (output as any).publicKey = credential.public_key;
} }
if (credential.keyPassword) { if (credential.key_password) {
(output as any).keyPassword = credential.keyPassword; (output as any).keyPassword = credential.key_password;
} }
res.json(output); res.json(output);
@@ -605,15 +605,19 @@ router.post(
} }
try { try {
const credentials = await db const credentials = await SimpleDBOps.select(
.select() db
.from(sshCredentials) .select()
.where( .from(sshCredentials)
and( .where(
eq(sshCredentials.id, parseInt(credentialId)), and(
eq(sshCredentials.userId, userId), eq(sshCredentials.id, parseInt(credentialId)),
eq(sshCredentials.userId, userId),
),
), ),
); "ssh_credentials",
userId,
);
if (credentials.length === 0) { if (credentials.length === 0) {
return res.status(404).json({ error: "Credential not found" }); return res.status(404).json({ error: "Credential not found" });
@@ -626,7 +630,7 @@ router.post(
.set({ .set({
credentialId: parseInt(credentialId), credentialId: parseInt(credentialId),
username: credential.username, username: credential.username,
authType: credential.authType, authType: credential.auth_type || credential.authType,
password: null, password: null,
key: null, key: null,
keyPassword: null, keyPassword: null,
@@ -715,15 +719,15 @@ function formatCredentialOutput(credential: any): any {
? credential.tags.split(",").filter(Boolean) ? credential.tags.split(",").filter(Boolean)
: [] : []
: [], : [],
authType: credential.authType, authType: credential.authType || credential.auth_type,
username: credential.username, username: credential.username,
publicKey: credential.publicKey, publicKey: credential.public_key || credential.publicKey,
keyType: credential.keyType, keyType: credential.key_type || credential.keyType,
detectedKeyType: credential.detectedKeyType, detectedKeyType: credential.detected_key_type || credential.detectedKeyType,
usageCount: credential.usageCount || 0, usageCount: credential.usage_count || credential.usageCount || 0,
lastUsed: credential.lastUsed, lastUsed: credential.last_used || credential.lastUsed,
createdAt: credential.createdAt, createdAt: credential.created_at || credential.createdAt,
updatedAt: credential.updatedAt, updatedAt: credential.updated_at || credential.updatedAt,
}; };
} }
@@ -1551,14 +1555,15 @@ router.post(
if (hostCredential && hostCredential.length > 0) { if (hostCredential && hostCredential.length > 0) {
const cred = hostCredential[0]; const cred = hostCredential[0];
hostConfig.authType = cred.authType; hostConfig.authType = cred.auth_type || cred.authType;
hostConfig.username = cred.username; hostConfig.username = cred.username;
if (cred.authType === "password") { if ((cred.auth_type || cred.authType) === "password") {
hostConfig.password = cred.password; hostConfig.password = cred.password;
} else if (cred.authType === "key") { } else if ((cred.auth_type || cred.authType) === "key") {
hostConfig.privateKey = cred.privateKey || cred.key; hostConfig.privateKey =
hostConfig.keyPassword = cred.keyPassword; cred.private_key || cred.privateKey || cred.key;
hostConfig.keyPassword = cred.key_password || cred.keyPassword;
} }
} else { } else {
return res.status(400).json({ return res.status(400).json({

View File

@@ -472,7 +472,6 @@ router.put(
} }
sshDataObj.password = null; sshDataObj.password = null;
} else { } else {
// For credential auth
sshDataObj.password = null; sshDataObj.password = null;
sshDataObj.key = null; sshDataObj.key = null;
sshDataObj.keyPassword = null; sshDataObj.keyPassword = null;
@@ -670,6 +669,83 @@ router.get(
}, },
); );
// Route: Export SSH host with decrypted credentials (requires data access)
// GET /ssh/db/host/:id/export
router.get(
"/db/host/:id/export",
authenticateJWT,
requireDataAccess,
async (req: Request, res: Response) => {
const hostId = req.params.id;
const userId = (req as any).userId;
if (!isNonEmptyString(userId) || !hostId) {
return res.status(400).json({ error: "Invalid userId or hostId" });
}
try {
const hosts = await SimpleDBOps.select(
db
.select()
.from(sshData)
.where(
and(eq(sshData.id, Number(hostId)), eq(sshData.userId, userId)),
),
"ssh_data",
userId,
);
if (hosts.length === 0) {
return res.status(404).json({ error: "SSH host not found" });
}
const host = hosts[0];
const resolvedHost = (await resolveHostCredentials(host)) || host;
const exportData = {
name: resolvedHost.name,
ip: resolvedHost.ip,
port: resolvedHost.port,
username: resolvedHost.username,
authType: resolvedHost.authType,
password: resolvedHost.password || null,
key: resolvedHost.key || null,
keyPassword: resolvedHost.keyPassword || null,
keyType: resolvedHost.keyType || null,
folder: resolvedHost.folder,
tags:
typeof resolvedHost.tags === "string"
? resolvedHost.tags.split(",").filter(Boolean)
: resolvedHost.tags || [],
pin: !!resolvedHost.pin,
enableTerminal: !!resolvedHost.enableTerminal,
enableTunnel: !!resolvedHost.enableTunnel,
enableFileManager: !!resolvedHost.enableFileManager,
defaultPath: resolvedHost.defaultPath,
tunnelConnections: resolvedHost.tunnelConnections
? JSON.parse(resolvedHost.tunnelConnections)
: [],
};
sshLogger.success("Host exported with decrypted credentials", {
operation: "host_export",
hostId: parseInt(hostId),
userId,
});
res.json(exportData);
} catch (err) {
sshLogger.error("Failed to export SSH host", err, {
operation: "host_export",
hostId: parseInt(hostId),
userId,
});
res.status(500).json({ error: "Failed to export SSH host" });
}
},
);
// Route: Delete SSH host by id (requires JWT) // Route: Delete SSH host by id (requires JWT)
// DELETE /ssh/host/:id // DELETE /ssh/host/:id
router.delete( router.delete(
@@ -1136,26 +1212,30 @@ router.delete(
async function resolveHostCredentials(host: any): Promise<any> { async function resolveHostCredentials(host: any): Promise<any> {
try { try {
if (host.credentialId && host.userId) { if (host.credentialId && host.userId) {
const credentials = await db const credentials = await SimpleDBOps.select(
.select() db
.from(sshCredentials) .select()
.where( .from(sshCredentials)
and( .where(
eq(sshCredentials.id, host.credentialId), and(
eq(sshCredentials.userId, host.userId), eq(sshCredentials.id, host.credentialId),
eq(sshCredentials.userId, host.userId),
),
), ),
); "ssh_credentials",
host.userId,
);
if (credentials.length > 0) { if (credentials.length > 0) {
const credential = credentials[0]; const credential = credentials[0];
return { return {
...host, ...host,
username: credential.username, username: credential.username,
authType: credential.authType, authType: credential.auth_type || credential.authType,
password: credential.password, password: credential.password,
key: credential.key, key: credential.key,
keyPassword: credential.keyPassword, keyPassword: credential.key_password || credential.keyPassword,
keyType: credential.keyType, keyType: credential.key_type || credential.keyType,
}; };
} }
} }
@@ -1214,7 +1294,6 @@ router.put(
) )
.returning(); .returning();
// Trigger database save after folder rename
DatabaseSaveTrigger.triggerSave("folder_rename"); DatabaseSaveTrigger.triggerSave("folder_rename");
res.json({ res.json({

View File

@@ -1317,6 +1317,43 @@ router.post("/complete-reset", async (req, res) => {
.set({ password_hash }) .set({ password_hash })
.where(eq(users.username, username)); .where(eq(users.username, username));
try {
await authManager.registerUser(userId, newPassword);
authManager.logoutUser(userId);
await db
.update(users)
.set({
totp_enabled: false,
totp_secret: null,
totp_backup_codes: null,
})
.where(eq(users.id, userId));
authLogger.warn(
`Password reset completed for user: ${username}. Existing encrypted data is now inaccessible and will need to be re-entered.`,
{
operation: "password_reset_data_inaccessible",
userId,
username,
},
);
gemini-code-assist[bot] commented 2025-10-03 04:58:05 +00:00 (Migrated from github.com)
Review

critical

This log message correctly identifies a critical side effect of password reset: all existing encrypted data becomes inaccessible. This is a destructive action that must be communicated to the user in the UI before they confirm the reset.

While this backend logic is correct in re-initializing the user's encryption keys, the frontend should display a very strong warning modal explaining that all saved passwords, SSH keys, and other encrypted data will be lost. This is a critical user experience and data integrity issue.

![critical](https://www.gstatic.com/codereviewagent/critical.svg) This log message correctly identifies a critical side effect of password reset: all existing encrypted data becomes inaccessible. This is a destructive action that must be communicated to the user in the UI *before* they confirm the reset. While this backend logic is correct in re-initializing the user's encryption keys, the frontend should display a very strong warning modal explaining that all saved passwords, SSH keys, and other encrypted data will be lost. This is a critical user experience and data integrity issue.
} catch (encryptionError) {
authLogger.error(
"Failed to re-encrypt user data after password reset",
encryptionError,
{
operation: "password_reset_encryption_failed",
userId,
username,
},
);
return res.status(500).json({
error:
"Password reset completed but user data encryption failed. Please contact administrator.",
});
}
authLogger.success(`Password successfully reset for user: ${username}`); authLogger.success(`Password successfully reset for user: ${username}`);
db.$client db.$client
@@ -1495,6 +1532,22 @@ router.post("/totp/verify-login", async (req, res) => {
"totp_secret", "totp_secret",
); );
if (!totpSecret) {
await db
.update(users)
.set({
totp_enabled: false,
totp_secret: null,
totp_backup_codes: null,
})
.where(eq(users.id, userRecord.id));
return res.status(400).json({
error:
"TOTP has been disabled due to password reset. Please set up TOTP again.",
});
}
const verified = speakeasy.totp.verify({ const verified = speakeasy.totp.verify({
secret: totpSecret, secret: totpSecret,
encoding: "base32", encoding: "base32",

View File

@@ -202,9 +202,10 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
const credential = credentials[0]; const credential = credentials[0];
resolvedCredentials = { resolvedCredentials = {
password: credential.password, password: credential.password,
sshKey: credential.privateKey || credential.key, sshKey:
keyPassword: credential.keyPassword, credential.private_key || credential.privateKey || credential.key,
authType: credential.authType, keyPassword: credential.key_password || credential.keyPassword,
authType: credential.auth_type || credential.authType,
}; };
} else { } else {
fileLogger.warn(`No credentials found for host ${hostId}`, { fileLogger.warn(`No credentials found for host ${hostId}`, {

View File

@@ -280,10 +280,8 @@ const app = express();
app.use( app.use(
cors({ cors({
origin: (origin, callback) => { origin: (origin, callback) => {
// Allow requests with no origin (like mobile apps or curl requests)
if (!origin) return callback(null, true); if (!origin) return callback(null, true);
// Allow localhost and 127.0.0.1 for development
const allowedOrigins = [ const allowedOrigins = [
"http://localhost:5173", "http://localhost:5173",
"http://localhost:3000", "http://localhost:3000",
@@ -291,22 +289,18 @@ app.use(
"http://127.0.0.1:3000", "http://127.0.0.1:3000",
]; ];
// Allow any HTTPS origin (production deployments)
if (origin.startsWith("https://")) { if (origin.startsWith("https://")) {
return callback(null, true); return callback(null, true);
} }
// Allow any HTTP origin for self-hosted scenarios
if (origin.startsWith("http://")) { if (origin.startsWith("http://")) {
return callback(null, true); return callback(null, true);
} }
// Check against allowed development origins
if (allowedOrigins.includes(origin)) { if (allowedOrigins.includes(origin)) {
return callback(null, true); return callback(null, true);
} }
// Reject other origins
callback(new Error("Not allowed by CORS")); callback(new Error("Not allowed by CORS"));
}, },
credentials: true, credentials: true,
@@ -322,7 +316,6 @@ app.use(
app.use(cookieParser()); app.use(cookieParser());
app.use(express.json({ limit: "1mb" })); app.use(express.json({ limit: "1mb" }));
// Add authentication middleware - Linus principle: eliminate special cases
app.use(authManager.createAuthMiddleware()); app.use(authManager.createAuthMiddleware());
const hostStatuses: Map<number, StatusEntry> = new Map(); const hostStatuses: Map<number, StatusEntry> = new Map();
@@ -363,7 +356,6 @@ async function fetchHostById(
userId: string, userId: string,
): Promise<SSHHostWithCredentials | undefined> { ): Promise<SSHHostWithCredentials | undefined> {
try { try {
// Check if user data is unlocked before attempting to fetch
if (!SimpleDBOps.isUserDataUnlocked(userId)) { if (!SimpleDBOps.isUserDataUnlocked(userId)) {
statsLogger.debug("User data locked - cannot fetch host", { statsLogger.debug("User data locked - cannot fetch host", {
operation: "fetchHostById_data_locked", operation: "fetchHostById_data_locked",
@@ -446,7 +438,7 @@ async function resolveHostCredentials(
const credential = credentials[0]; const credential = credentials[0];
baseHost.credentialId = credential.id; baseHost.credentialId = credential.id;
baseHost.username = credential.username; baseHost.username = credential.username;
baseHost.authType = credential.authType; baseHost.authType = credential.auth_type || credential.authType;
if (credential.password) { if (credential.password) {
baseHost.password = credential.password; baseHost.password = credential.password;
@@ -454,11 +446,12 @@ async function resolveHostCredentials(
if (credential.key) { if (credential.key) {
baseHost.key = credential.key; baseHost.key = credential.key;
} }
if (credential.keyPassword) { if (credential.key_password || credential.keyPassword) {
baseHost.keyPassword = credential.keyPassword; baseHost.keyPassword =
credential.key_password || credential.keyPassword;
} }
if (credential.keyType) { if (credential.key_type || credential.keyType) {
baseHost.keyType = credential.keyType; baseHost.keyType = credential.key_type || credential.keyType;
} }
} else { } else {
addLegacyCredentials(baseHost, host); addLegacyCredentials(baseHost, host);
@@ -750,6 +743,7 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{
let diskPercent: number | null = null; let diskPercent: number | null = null;
let usedHuman: string | null = null; let usedHuman: string | null = null;
let totalHuman: string | null = null; let totalHuman: string | null = null;
let availableHuman: string | null = null;
try { try {
const [diskOutHuman, diskOutBytes] = await Promise.all([ const [diskOutHuman, diskOutBytes] = await Promise.all([
execCommand(client, "df -h -P / | tail -n +2"), execCommand(client, "df -h -P / | tail -n +2"),
@@ -773,6 +767,7 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{
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;
availableHuman = humanParts[3] || null;
const totalBytes = Number(bytesParts[1]); const totalBytes = Number(bytesParts[1]);
const usedBytes = Number(bytesParts[2]); const usedBytes = Number(bytesParts[2]);
@@ -796,6 +791,7 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{
diskPercent = null; diskPercent = null;
usedHuman = null; usedHuman = null;
totalHuman = null; totalHuman = null;
availableHuman = null;
} }
const result = { const result = {
@@ -805,7 +801,12 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{
usedGiB: usedGiB ? toFixedNum(usedGiB, 2) : null, usedGiB: usedGiB ? toFixedNum(usedGiB, 2) : null,
totalGiB: totalGiB ? toFixedNum(totalGiB, 2) : null, totalGiB: totalGiB ? toFixedNum(totalGiB, 2) : null,
}, },
disk: { percent: toFixedNum(diskPercent, 0), usedHuman, totalHuman }, disk: {
percent: toFixedNum(diskPercent, 0),
usedHuman,
totalHuman,
availableHuman,
},
}; };
metricsCache.set(host.id, result); metricsCache.set(host.id, result);
@@ -887,7 +888,6 @@ async function pollStatusesOnce(userId?: string): Promise<void> {
app.get("/status", async (req, res) => { app.get("/status", async (req, res) => {
const userId = (req as any).userId; const userId = (req as any).userId;
// Check if user data is unlocked
if (!SimpleDBOps.isUserDataUnlocked(userId)) { if (!SimpleDBOps.isUserDataUnlocked(userId)) {
return res.status(401).json({ return res.status(401).json({
error: "Session expired - please log in again", error: "Session expired - please log in again",
@@ -909,7 +909,6 @@ app.get("/status/:id", validateHostId, async (req, res) => {
const id = Number(req.params.id); const id = Number(req.params.id);
const userId = (req as any).userId; const userId = (req as any).userId;
// Check if user data is unlocked
if (!SimpleDBOps.isUserDataUnlocked(userId)) { if (!SimpleDBOps.isUserDataUnlocked(userId)) {
return res.status(401).json({ return res.status(401).json({
error: "Session expired - please log in again", error: "Session expired - please log in again",
@@ -941,7 +940,6 @@ app.get("/status/:id", validateHostId, async (req, res) => {
app.post("/refresh", async (req, res) => { app.post("/refresh", async (req, res) => {
const userId = (req as any).userId; const userId = (req as any).userId;
// Check if user data is unlocked
if (!SimpleDBOps.isUserDataUnlocked(userId)) { if (!SimpleDBOps.isUserDataUnlocked(userId)) {
return res.status(401).json({ return res.status(401).json({
error: "Session expired - please log in again", error: "Session expired - please log in again",
@@ -957,7 +955,6 @@ app.get("/metrics/:id", validateHostId, async (req, res) => {
const id = Number(req.params.id); const id = Number(req.params.id);
const userId = (req as any).userId; const userId = (req as any).userId;
// Check if user data is unlocked
if (!SimpleDBOps.isUserDataUnlocked(userId)) { if (!SimpleDBOps.isUserDataUnlocked(userId)) {
return res.status(401).json({ return res.status(401).json({
error: "Session expired - please log in again", error: "Session expired - please log in again",

View File

@@ -239,7 +239,16 @@ wss.on("connection", async (ws: WebSocket, req) => {
} else if (data.startsWith("\x1b")) { } else if (data.startsWith("\x1b")) {
sshStream.write(data); sshStream.write(data);
} else { } else {
sshStream.write(Buffer.from(data, "utf8")); try {
sshStream.write(Buffer.from(data, "utf8"));
} catch (error) {
sshLogger.error("Error writing input to SSH stream", error, {
operation: "ssh_input_encoding",
userId,
dataLength: data.length,
});
sshStream.write(Buffer.from(data, "latin1"));
}
} }
} }
break; break;
@@ -367,10 +376,11 @@ wss.on("connection", async (ws: WebSocket, req) => {
const credential = credentials[0]; const credential = credentials[0];
resolvedCredentials = { resolvedCredentials = {
password: credential.password, password: credential.password,
key: credential.privateKey || credential.key, key:
keyPassword: credential.keyPassword, credential.private_key || credential.privateKey || credential.key,
keyType: credential.keyType, keyPassword: credential.key_password || credential.keyPassword,
authType: credential.authType, keyType: credential.key_type || credential.keyType,
authType: credential.auth_type || credential.authType,
}; };
} else { } else {
sshLogger.warn(`No credentials found for host ${id}`, { sshLogger.warn(`No credentials found for host ${id}`, {
@@ -427,7 +437,22 @@ wss.on("connection", async (ws: WebSocket, req) => {
sshStream = stream; sshStream = stream;
stream.on("data", (data: Buffer) => { stream.on("data", (data: Buffer) => {
ws.send(JSON.stringify({ type: "data", data: data.toString() })); try {
const utf8String = data.toString("utf-8");
ws.send(JSON.stringify({ type: "data", data: utf8String }));
} catch (error) {
sshLogger.error("Error encoding terminal data", error, {
operation: "terminal_data_encoding",
hostId: id,
dataLength: data.length,
});
ws.send(
JSON.stringify({
type: "data",
data: data.toString("latin1"),
}),
);
}
}); });
stream.on("close", () => { stream.on("close", () => {

View File

@@ -512,10 +512,11 @@ async function connectSSHTunnel(
const credential = credentials[0]; const credential = credentials[0];
resolvedSourceCredentials = { resolvedSourceCredentials = {
password: credential.password, password: credential.password,
sshKey: credential.privateKey || credential.key, sshKey:
keyPassword: credential.keyPassword, credential.private_key || credential.privateKey || credential.key,
keyType: credential.keyType, keyPassword: credential.key_password || credential.keyPassword,
authMethod: credential.authType, keyType: credential.key_type || credential.keyType,
authMethod: credential.auth_type || credential.authType,
}; };
} else { } else {
} }
@@ -591,10 +592,11 @@ async function connectSSHTunnel(
const credential = credentials[0]; const credential = credentials[0];
resolvedEndpointCredentials = { resolvedEndpointCredentials = {
password: credential.password, password: credential.password,
sshKey: credential.privateKey || credential.key, sshKey:
keyPassword: credential.keyPassword, credential.private_key || credential.privateKey || credential.key,
keyType: credential.keyType, keyPassword: credential.key_password || credential.keyPassword,
authMethod: credential.authType, keyType: credential.key_type || credential.keyType,
authMethod: credential.auth_type || credential.authType,
}; };
} else { } else {
tunnelLogger.warn("No endpoint credentials found in database", { tunnelLogger.warn("No endpoint credentials found in database", {
@@ -1025,10 +1027,11 @@ async function killRemoteTunnelByMarker(
const credential = credentials[0]; const credential = credentials[0];
resolvedSourceCredentials = { resolvedSourceCredentials = {
password: credential.password, password: credential.password,
sshKey: credential.privateKey || credential.key, sshKey:
keyPassword: credential.keyPassword, credential.private_key || credential.privateKey || credential.key,
keyType: credential.keyType, keyPassword: credential.key_password || credential.keyPassword,
authMethod: credential.authType, keyType: credential.key_type || credential.keyType,
authMethod: credential.auth_type || credential.authType,
}; };
} }
} else { } else {

View File

@@ -147,7 +147,7 @@ class DataCrypto {
if (needsUpdate) { if (needsUpdate) {
const updateQuery = ` const updateQuery = `
UPDATE ssh_credentials UPDATE ssh_credentials
SET password = ?, key = ?, key_password = ?, private_key = ?, updated_at = CURRENT_TIMESTAMP SET password = ?, key = ?, key_password = ?, private_key = ?, public_key = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ? WHERE id = ?
`; `;
db.prepare(updateQuery).run( db.prepare(updateQuery).run(
@@ -155,6 +155,7 @@ class DataCrypto {
updatedRecord.key || null, updatedRecord.key || null,
updatedRecord.key_password || null, updatedRecord.key_password || null,
updatedRecord.private_key || null, updatedRecord.private_key || null,
updatedRecord.public_key || null,
record.id, record.id,
); );
@@ -216,6 +217,165 @@ class DataCrypto {
return this.userCrypto.getUserDataKey(userId); return this.userCrypto.getUserDataKey(userId);
} }
static async reencryptUserDataAfterPasswordReset(
userId: string,
newUserDataKey: Buffer,
db: any,
): Promise<{
success: boolean;
reencryptedTables: string[];
reencryptedFieldsCount: number;
errors: string[];
}> {
const result = {
success: false,
reencryptedTables: [] as string[],
reencryptedFieldsCount: 0,
errors: [] as string[],
};
try {
const tablesToReencrypt = [
{ table: "ssh_data", fields: ["password", "key", "key_password"] },
{
table: "ssh_credentials",
fields: [
"password",
"private_key",
"key_password",
"key",
"public_key",
],
},
{
table: "users",
fields: [
"client_secret",
"totp_secret",
"totp_backup_codes",
"oidc_identifier",
],
},
];
for (const { table, fields } of tablesToReencrypt) {
try {
const records = db
.prepare(`SELECT * FROM ${table} WHERE user_id = ?`)
.all(userId);
for (const record of records) {
const recordId = record.id.toString();
let needsUpdate = false;
const updatedRecord = { ...record };
for (const fieldName of fields) {
const fieldValue = record[fieldName];
if (fieldValue && fieldValue.trim() !== "") {
try {
const reencryptedValue = FieldCrypto.encryptField(
fieldValue,
newUserDataKey,
recordId,
fieldName,
);
updatedRecord[fieldName] = reencryptedValue;
needsUpdate = true;
result.reencryptedFieldsCount++;
} catch (error) {
const errorMsg = `Failed to re-encrypt ${fieldName} for ${table} record ${recordId}: ${error instanceof Error ? error.message : "Unknown error"}`;
result.errors.push(errorMsg);
databaseLogger.warn(
"Field re-encryption failed during password reset",
{
operation: "password_reset_reencrypt_failed",
userId,
table,
recordId,
fieldName,
error:
error instanceof Error
? error.message
: "Unknown error",
},
);
}
}
}
if (needsUpdate) {
const updateFields = fields.filter(
(field) => updatedRecord[field] !== record[field],
);
if (updateFields.length > 0) {
const updateQuery = `UPDATE ${table} SET ${updateFields.map((f) => `${f} = ?`).join(", ")}, updated_at = CURRENT_TIMESTAMP WHERE id = ?`;
const updateValues = updateFields.map(
(field) => updatedRecord[field],
);
updateValues.push(record.id);
db.prepare(updateQuery).run(...updateValues);
if (!result.reencryptedTables.includes(table)) {
result.reencryptedTables.push(table);
}
}
}
}
} catch (tableError) {
const errorMsg = `Failed to re-encrypt table ${table}: ${tableError instanceof Error ? tableError.message : "Unknown error"}`;
result.errors.push(errorMsg);
databaseLogger.error(
"Table re-encryption failed during password reset",
tableError,
{
operation: "password_reset_table_reencrypt_failed",
userId,
table,
error:
tableError instanceof Error
? tableError.message
: "Unknown error",
},
);
}
}
result.success = result.errors.length === 0;
databaseLogger.info(
"User data re-encryption completed after password reset",
{
operation: "password_reset_reencrypt_completed",
userId,
success: result.success,
reencryptedTables: result.reencryptedTables,
reencryptedFieldsCount: result.reencryptedFieldsCount,
errorsCount: result.errors.length,
},
);
return result;
} catch (error) {
databaseLogger.error(
"User data re-encryption failed after password reset",
error,
{
operation: "password_reset_reencrypt_failed",
userId,
error: error instanceof Error ? error.message : "Unknown error",
},
);
result.errors.push(
`Critical error during re-encryption: ${error instanceof Error ? error.message : "Unknown error"}`,
);
return result;
}
}
static validateUserAccess(userId: string): Buffer { static validateUserAccess(userId: string): Buffer {
const userDataKey = this.getUserDataKey(userId); const userDataKey = this.getUserDataKey(userId);
if (!userDataKey) { if (!userDataKey) {

View File

@@ -22,13 +22,13 @@ class FieldCrypto {
"totp_backup_codes", "totp_backup_codes",
"oidc_identifier", "oidc_identifier",
]), ]),
ssh_data: new Set(["password", "key", "keyPassword"]), ssh_data: new Set(["password", "key", "key_password"]),
ssh_credentials: new Set([ ssh_credentials: new Set([
"password", "password",
"privateKey", "private_key",
"keyPassword", "key_password",
"key", "key",
"publicKey", "public_key",
]), ]),
}; };

View File

@@ -2,6 +2,12 @@ import { FieldCrypto } from "./field-crypto.js";
import { databaseLogger } from "./logger.js"; import { databaseLogger } from "./logger.js";
export class LazyFieldEncryption { export class LazyFieldEncryption {
private static readonly LEGACY_FIELD_NAME_MAP: Record<string, string> = {
key_password: "keyPassword",
private_key: "privateKey",
public_key: "publicKey",
};
static isPlaintextField(value: string): boolean { static isPlaintextField(value: string): boolean {
if (!value) return false; if (!value) return false;
@@ -44,6 +50,35 @@ export class LazyFieldEncryption {
); );
return decrypted; return decrypted;
} catch (error) { } catch (error) {
const legacyFieldName = this.LEGACY_FIELD_NAME_MAP[fieldName];
if (legacyFieldName) {
try {
const decrypted = FieldCrypto.decryptField(
fieldValue,
userKEK,
recordId,
legacyFieldName,
);
return decrypted;
} catch (legacyError) {}
}
const sensitiveFields = [
"totp_secret",
"totp_backup_codes",
"password",
"key",
"key_password",
"private_key",
"public_key",
"client_secret",
"oidc_identifier",
];
if (sensitiveFields.includes(fieldName)) {
return "";
}
databaseLogger.error("Failed to decrypt field", error, { databaseLogger.error("Failed to decrypt field", error, {
operation: "lazy_encryption_decrypt_failed", operation: "lazy_encryption_decrypt_failed",
recordId, recordId,
@@ -60,9 +95,13 @@ export class LazyFieldEncryption {
userKEK: Buffer, userKEK: Buffer,
recordId: string, recordId: string,
fieldName: string, fieldName: string,
): { encrypted: string; wasPlaintext: boolean } { ): {
encrypted: string;
wasPlaintext: boolean;
wasLegacyEncryption: boolean;
} {
if (!fieldValue) { if (!fieldValue) {
return { encrypted: "", wasPlaintext: false }; return { encrypted: "", wasPlaintext: false, wasLegacyEncryption: false };
} }
if (this.isPlaintextField(fieldValue)) { if (this.isPlaintextField(fieldValue)) {
@@ -74,7 +113,7 @@ export class LazyFieldEncryption {
fieldName, fieldName,
); );
return { encrypted, wasPlaintext: true }; return { encrypted, wasPlaintext: true, wasLegacyEncryption: false };
} catch (error) { } catch (error) {
databaseLogger.error("Failed to encrypt plaintext field", error, { databaseLogger.error("Failed to encrypt plaintext field", error, {
operation: "lazy_encryption_migrate_failed", operation: "lazy_encryption_migrate_failed",
@@ -85,7 +124,42 @@ export class LazyFieldEncryption {
throw error; throw error;
} }
} else { } else {
return { encrypted: fieldValue, wasPlaintext: false }; try {
FieldCrypto.decryptField(fieldValue, userKEK, recordId, fieldName);
return {
encrypted: fieldValue,
wasPlaintext: false,
wasLegacyEncryption: false,
};
} catch (error) {
const legacyFieldName = this.LEGACY_FIELD_NAME_MAP[fieldName];
if (legacyFieldName) {
try {
const decrypted = FieldCrypto.decryptField(
fieldValue,
userKEK,
recordId,
legacyFieldName,
);
const reencrypted = FieldCrypto.encryptField(
decrypted,
userKEK,
recordId,
fieldName,
);
return {
encrypted: reencrypted,
wasPlaintext: false,
wasLegacyEncryption: true,
};
} catch (legacyError) {}
}
return {
encrypted: fieldValue,
wasPlaintext: false,
wasLegacyEncryption: false,
};
}
} }
} }
@@ -106,18 +180,21 @@ export class LazyFieldEncryption {
for (const fieldName of sensitiveFields) { for (const fieldName of sensitiveFields) {
const fieldValue = record[fieldName]; const fieldValue = record[fieldName];
if (fieldValue && this.isPlaintextField(fieldValue)) { if (fieldValue) {
try { try {
const { encrypted } = this.migrateFieldToEncrypted( const { encrypted, wasPlaintext, wasLegacyEncryption } =
fieldValue, this.migrateFieldToEncrypted(
userKEK, fieldValue,
recordId, userKEK,
fieldName, recordId,
); fieldName,
);
updatedRecord[fieldName] = encrypted; if (wasPlaintext || wasLegacyEncryption) {
migratedFields.push(fieldName); updatedRecord[fieldName] = encrypted;
needsUpdate = true; migratedFields.push(fieldName);
needsUpdate = true;
}
} catch (error) { } catch (error) {
databaseLogger.error("Failed to migrate record field", error, { databaseLogger.error("Failed to migrate record field", error, {
operation: "lazy_encryption_record_field_failed", operation: "lazy_encryption_record_field_failed",
@@ -134,13 +211,53 @@ export class LazyFieldEncryption {
static getSensitiveFieldsForTable(tableName: string): string[] { static getSensitiveFieldsForTable(tableName: string): string[] {
const sensitiveFieldsMap: Record<string, string[]> = { const sensitiveFieldsMap: Record<string, string[]> = {
ssh_data: ["password", "key", "key_password"], ssh_data: ["password", "key", "key_password"],
ssh_credentials: ["password", "key", "key_password", "private_key"], ssh_credentials: [
"password",
"key",
"key_password",
"private_key",
"public_key",
],
users: ["totp_secret", "totp_backup_codes"], users: ["totp_secret", "totp_backup_codes"],
}; };
return sensitiveFieldsMap[tableName] || []; return sensitiveFieldsMap[tableName] || [];
} }
static fieldNeedsMigration(
fieldValue: string,
userKEK: Buffer,
recordId: string,
fieldName: string,
): boolean {
if (!fieldValue) return false;
if (this.isPlaintextField(fieldValue)) {
return true;
}
try {
FieldCrypto.decryptField(fieldValue, userKEK, recordId, fieldName);
return false;
} catch (error) {
const legacyFieldName = this.LEGACY_FIELD_NAME_MAP[fieldName];
if (legacyFieldName) {
try {
FieldCrypto.decryptField(
fieldValue,
userKEK,
recordId,
legacyFieldName,
);
return true;
} catch (legacyError) {
return false;
}
}
return false;
}
}
static async checkUserNeedsMigration( static async checkUserNeedsMigration(
userId: string, userId: string,
userKEK: Buffer, userKEK: Buffer,
@@ -169,7 +286,15 @@ export class LazyFieldEncryption {
const hostPlaintextFields: string[] = []; const hostPlaintextFields: string[] = [];
for (const field of sensitiveFields) { for (const field of sensitiveFields) {
if (host[field] && this.isPlaintextField(host[field])) { if (
host[field] &&
this.fieldNeedsMigration(
host[field],
userKEK,
host.id.toString(),
field,
)
) {
hostPlaintextFields.push(field); hostPlaintextFields.push(field);
needsMigration = true; needsMigration = true;
} }
@@ -193,7 +318,15 @@ export class LazyFieldEncryption {
const credentialPlaintextFields: string[] = []; const credentialPlaintextFields: string[] = [];
for (const field of sensitiveFields) { for (const field of sensitiveFields) {
if (credential[field] && this.isPlaintextField(credential[field])) { if (
credential[field] &&
this.fieldNeedsMigration(
credential[field],
userKEK,
credential.id.toString(),
field,
)
) {
credentialPlaintextFields.push(field); credentialPlaintextFields.push(field);
needsMigration = true; needsMigration = true;
} }
@@ -214,7 +347,10 @@ export class LazyFieldEncryption {
const userPlaintextFields: string[] = []; const userPlaintextFields: string[] = [];
for (const field of sensitiveFields) { for (const field of sensitiveFields) {
if (user[field] && this.isPlaintextField(user[field])) { if (
user[field] &&
this.fieldNeedsMigration(user[field], userKEK, userId, field)
) {
userPlaintextFields.push(field); userPlaintextFields.push(field);
needsMigration = true; needsMigration = true;
} }

View File

@@ -564,10 +564,11 @@
"downloadSample": "Download Sample", "downloadSample": "Download Sample",
"formatGuide": "Format Guide", "formatGuide": "Format Guide",
"exportCredentialWarning": "Warning: Host \"{{name}}\" uses credential authentication. The exported file will not include the credential data and will need to be manually reconfigured after import. Do you want to continue?", "exportCredentialWarning": "Warning: Host \"{{name}}\" uses credential authentication. The exported file will not include the credential data and will need to be manually reconfigured after import. Do you want to continue?",
"exportSensitiveDataWarning": "Warning: Host \"{{name}}\" contains sensitive authentication data (password/SSH key). The exported file will not include this data for security reasons. You'll need to reconfigure authentication after import. Do you want to continue?", "exportSensitiveDataWarning": "Warning: Host \"{{name}}\" contains sensitive authentication data (password/SSH key). The exported file will include this data in plaintext. Please keep the file secure and delete it after use. Do you want to continue?",
"uncategorized": "Uncategorized", "uncategorized": "Uncategorized",
"confirmDelete": "Are you sure you want to delete \"{{name}}\" ?", "confirmDelete": "Are you sure you want to delete \"{{name}}\" ?",
"failedToDeleteHost": "Failed to delete host", "failedToDeleteHost": "Failed to delete host",
"failedToExportHost": "Failed to export host. Please ensure you're logged in and have access to the host data.",
"jsonMustContainHosts": "JSON must contain a \"hosts\" array or be an array of hosts", "jsonMustContainHosts": "JSON must contain a \"hosts\" array or be an array of hosts",
"noHostsInJson": "No hosts found in JSON file", "noHostsInJson": "No hosts found in JSON file",
"maxHostsAllowed": "Maximum 100 hosts allowed per import", "maxHostsAllowed": "Maximum 100 hosts allowed per import",
@@ -978,7 +979,16 @@
"move": "Move", "move": "Move",
"searchInFile": "Search in file (Ctrl+F)", "searchInFile": "Search in file (Ctrl+F)",
"showKeyboardShortcuts": "Show keyboard shortcuts", "showKeyboardShortcuts": "Show keyboard shortcuts",
"startWritingMarkdown": "Start writing your markdown content..." "startWritingMarkdown": "Start writing your markdown content...",
"loadingFileComparison": "Loading file comparison...",
"reload": "Reload",
"compare": "Compare",
"sideBySide": "Side by Side",
"inline": "Inline",
"fileComparison": "File Comparison: {{file1}} vs {{file2}}",
"fileTooLarge": "File too large: {{error}}",
"sshConnectionFailed": "SSH connection failed. Please check your connection to {{name}} ({{ip}}:{{port}})",
"loadFileFailed": "Failed to load file: {{error}}"
}, },
"tunnels": { "tunnels": {
"title": "SSH Tunnels", "title": "SSH Tunnels",

View File

@@ -548,10 +548,11 @@
"downloadSample": "下载示例", "downloadSample": "下载示例",
"formatGuide": "格式指南", "formatGuide": "格式指南",
"exportCredentialWarning": "警告:主机 \"{{name}}\" 使用凭据认证。导出的文件将不包含凭据数据,导入后需要手动重新配置。您确定要继续吗?", "exportCredentialWarning": "警告:主机 \"{{name}}\" 使用凭据认证。导出的文件将不包含凭据数据,导入后需要手动重新配置。您确定要继续吗?",
"exportSensitiveDataWarning": "警告:主机 \"{{name}}\" 包含敏感认证数据(密码/SSH密钥出于安全考虑,导出的文件将不包含此数据。导入后您需要重新配置认证。您确定要继续吗?", "exportSensitiveDataWarning": "警告:主机 \"{{name}}\" 包含敏感认证数据(密码/SSH密钥导出的文件将以明文形式包含这些数据。请妥善保管文件,使用后建议删除。您确定要继续吗?",
"uncategorized": "未分类", "uncategorized": "未分类",
"confirmDelete": "确定要删除 \"{{name}}\" 吗?", "confirmDelete": "确定要删除 \"{{name}}\" 吗?",
"failedToDeleteHost": "删除主机失败", "failedToDeleteHost": "删除主机失败",
"failedToExportHost": "导出主机失败。请确保您已登录并有权访问主机数据。",
"jsonMustContainHosts": "JSON 必须包含 \"hosts\" 数组或是一个主机数组", "jsonMustContainHosts": "JSON 必须包含 \"hosts\" 数组或是一个主机数组",
"noHostsInJson": "JSON 文件中未找到主机", "noHostsInJson": "JSON 文件中未找到主机",
"maxHostsAllowed": "每次导入最多允许 100 个主机", "maxHostsAllowed": "每次导入最多允许 100 个主机",
@@ -969,7 +970,16 @@
"move": "移动", "move": "移动",
"searchInFile": "在文件中搜索 (Ctrl+F)", "searchInFile": "在文件中搜索 (Ctrl+F)",
"showKeyboardShortcuts": "显示键盘快捷键", "showKeyboardShortcuts": "显示键盘快捷键",
"startWritingMarkdown": "开始编写您的 markdown 内容..." "startWritingMarkdown": "开始编写您的 markdown 内容...",
"loadingFileComparison": "正在加载文件对比...",
"reload": "重新加载",
"compare": "对比",
"sideBySide": "并排显示",
"inline": "内嵌显示",
"fileComparison": "文件对比:{{file1}} 与 {{file2}}",
"fileTooLarge": "文件过大:{{error}}",
"sshConnectionFailed": "SSH 连接失败。请检查与 {{name}} ({{ip}}:{{port}}) 的连接",
"loadFileFailed": "加载文件失败:{{error}}"
}, },
"tunnels": { "tunnels": {
"title": "SSH 隧道", "title": "SSH 隧道",

View File

@@ -17,7 +17,7 @@ import {
getSSHStatus, getSSHStatus,
connectSSH, connectSSH,
} from "@/ui/main-axios"; } from "@/ui/main-axios";
import type { FileItem, SSHHost } from "../../../../types/index.js"; import type { FileItem, SSHHost } from "@/types/index";
interface DiffViewerProps { interface DiffViewerProps {
file1: FileItem; file1: FileItem;
@@ -62,8 +62,22 @@ export function DiffViewer({
}); });
} }
} catch (error) { } catch (error) {
console.error("SSH connection check/reconnect failed:", error); try {
throw error; await connectSSH(sshSessionId, {
hostId: sshHost.id,
ip: sshHost.ip,
port: sshHost.port,
username: sshHost.username,
password: sshHost.password,
sshKey: sshHost.key,
keyPassword: sshHost.keyPassword,
authType: sshHost.authType,
credentialId: sshHost.credentialId,
userId: sshHost.userId,
});
} catch (reconnectError) {
throw reconnectError;
}
} }
}; };
@@ -310,7 +324,6 @@ export function DiffViewer({
automaticLayout: true, automaticLayout: true,
readOnly: true, readOnly: true,
originalEditable: false, originalEditable: false,
modifiedEditable: false,
scrollbar: { scrollbar: {
vertical: "visible", vertical: "visible",
horizontal: "visible", horizontal: "visible",

View File

@@ -15,6 +15,7 @@ interface DraggableWindowProps {
onClose: () => void; onClose: () => void;
onMinimize?: () => void; onMinimize?: () => void;
onMaximize?: () => void; onMaximize?: () => void;
onResize?: () => void;
isMaximized?: boolean; isMaximized?: boolean;
zIndex?: number; zIndex?: number;
onFocus?: () => void; onFocus?: () => void;
@@ -33,6 +34,7 @@ export function DraggableWindow({
onClose, onClose,
onMinimize, onMinimize,
onMaximize, onMaximize,
onResize,
isMaximized = false, isMaximized = false,
zIndex = 1000, zIndex = 1000,
onFocus, onFocus,
@@ -197,6 +199,10 @@ export function DraggableWindow({
setSize({ width: newWidth, height: newHeight }); setSize({ width: newWidth, height: newHeight });
setPosition({ x: newX, y: newY }); setPosition({ x: newX, y: newY });
if (onResize) {
onResize();
}
} }
}, },
[ [
@@ -211,6 +217,7 @@ export function DraggableWindow({
minWidth, minWidth,
minHeight, minHeight,
resizeDirection, resizeDirection,
onResize,
], ],
); );

View File

@@ -1257,17 +1257,6 @@ export function FileViewer({
</Button> </Button>
</div> </div>
</div> </div>
{onDownload && (
<Button
variant="outline"
size="sm"
onClick={onDownload}
className="flex items-center gap-2"
>
<Download className="w-4 h-4" />
{t("fileManager.download")}
</Button>
)}
</div> </div>
</div> </div>

View File

@@ -38,6 +38,8 @@ export function TerminalWindow({
const { t } = useTranslation(); const { t } = useTranslation();
const { closeWindow, minimizeWindow, maximizeWindow, focusWindow, windows } = const { closeWindow, minimizeWindow, maximizeWindow, focusWindow, windows } =
useWindowManager(); useWindowManager();
const terminalRef = React.useRef<any>(null);
const resizeTimeoutRef = React.useRef<NodeJS.Timeout | null>(null);
const currentWindow = windows.find((w) => w.id === windowId); const currentWindow = windows.find((w) => w.id === windowId);
if (!currentWindow) { if (!currentWindow) {
@@ -60,6 +62,26 @@ export function TerminalWindow({
focusWindow(windowId); focusWindow(windowId);
}; };
const handleResize = () => {
if (resizeTimeoutRef.current) {
clearTimeout(resizeTimeoutRef.current);
}
resizeTimeoutRef.current = setTimeout(() => {
if (terminalRef.current?.fit) {
terminalRef.current.fit();
}
}, 100);
};
React.useEffect(() => {
return () => {
if (resizeTimeoutRef.current) {
clearTimeout(resizeTimeoutRef.current);
}
};
}, []);
const terminalTitle = executeCommand const terminalTitle = executeCommand
? t("terminal.runTitle", { host: hostConfig.name, command: executeCommand }) ? t("terminal.runTitle", { host: hostConfig.name, command: executeCommand })
: initialPath : initialPath
@@ -81,10 +103,12 @@ export function TerminalWindow({
onClose={handleClose} onClose={handleClose}
onMaximize={handleMaximize} onMaximize={handleMaximize}
onFocus={handleFocus} onFocus={handleFocus}
onResize={handleResize}
isMaximized={currentWindow.isMaximized} isMaximized={currentWindow.isMaximized}
zIndex={currentWindow.zIndex} zIndex={currentWindow.zIndex}
> >
<Terminal <Terminal
ref={terminalRef}
hostConfig={hostConfig} hostConfig={hostConfig}
isVisible={!currentWindow.isMinimized} isVisible={!currentWindow.isMinimized}
initialPath={initialPath} initialPath={initialPath}

View File

@@ -21,6 +21,7 @@ import {
bulkImportSSHHosts, bulkImportSSHHosts,
updateSSHHost, updateSSHHost,
renameFolder, renameFolder,
exportSSHHostWithCredentials,
} from "@/ui/main-axios.ts"; } from "@/ui/main-axios.ts";
import { toast } from "sonner"; import { toast } from "sonner";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -159,46 +160,34 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
performExport(host, actualAuthType); performExport(host, actualAuthType);
}; };
const performExport = (host: SSHHost, actualAuthType: string) => { const performExport = async (host: SSHHost, actualAuthType: string) => {
const exportData: any = { try {
name: host.name, const decryptedHost = await exportSSHHostWithCredentials(host.id);
ip: host.ip,
port: host.port,
username: host.username,
authType: actualAuthType,
folder: host.folder,
tags: host.tags,
pin: host.pin,
enableTerminal: host.enableTerminal,
enableTunnel: host.enableTunnel,
enableFileManager: host.enableFileManager,
defaultPath: host.defaultPath,
tunnelConnections: host.tunnelConnections,
};
if (actualAuthType === "credential") { const cleanExportData = Object.fromEntries(
exportData.credentialId = null; Object.entries(decryptedHost).filter(
([_, value]) => value !== undefined,
),
);
const blob = new Blob([JSON.stringify(cleanExportData, null, 2)], {
type: "application/json",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${host.name || host.username + "@" + host.ip}-host-config.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
toast.success(
`Exported host configuration for ${host.name || host.username}@${host.ip}`,
);
} catch (error) {
toast.error(t("hosts.failedToExportHost"));
} }
const cleanExportData = Object.fromEntries(
Object.entries(exportData).filter(([_, value]) => value !== undefined),
);
const blob = new Blob([JSON.stringify(cleanExportData, null, 2)], {
type: "application/json",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${host.name || host.username + "@" + host.ip}-host-config.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
toast.success(
`Exported host configuration for ${host.name || host.username}@${host.ip}`,
);
}; };
const handleEdit = (host: SSHHost) => { const handleEdit = (host: SSHHost) => {

View File

@@ -434,10 +434,9 @@ export function Server({
<div className="text-xs text-gray-500"> <div className="text-xs text-gray-500">
{(() => { {(() => {
const used = metrics?.disk?.usedHuman; const available = metrics?.disk?.availableHuman;
const total = metrics?.disk?.totalHuman; return available
return used && total ? `Available: ${available}`
? `Available: ${total}`
: "Available: N/A"; : "Available: N/A";
})()} })()}
</div> </div>

View File

@@ -352,7 +352,11 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
try { try {
const msg = JSON.parse(event.data); const msg = JSON.parse(event.data);
if (msg.type === "data") { if (msg.type === "data") {
terminal.write(msg.data); if (typeof msg.data === "string") {
terminal.write(msg.data);
} else {
terminal.write(String(msg.data));
}
} else if (msg.type === "error") { } else if (msg.type === "error") {
const errorMessage = msg.message || t("terminal.unknownError"); const errorMessage = msg.message || t("terminal.unknownError");
@@ -520,6 +524,9 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
fastScrollModifier: "alt", fastScrollModifier: "alt",
fastScrollSensitivity: 5, fastScrollSensitivity: 5,
allowProposedApi: true, allowProposedApi: true,
minimumContrastRatio: 1,
letterSpacing: 0,
lineHeight: 1.2,
}; };
const fitAddon = new FitAddon(); const fitAddon = new FitAddon();
@@ -532,6 +539,9 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
terminal.loadAddon(clipboardAddon); terminal.loadAddon(clipboardAddon);
terminal.loadAddon(unicode11Addon); terminal.loadAddon(unicode11Addon);
terminal.loadAddon(webLinksAddon); terminal.loadAddon(webLinksAddon);
terminal.unicode.activeVersion = "11";
terminal.open(xtermRef.current); terminal.open(xtermRef.current);
const element = xtermRef.current; const element = xtermRef.current;
@@ -796,5 +806,69 @@ style.innerHTML = `
.xterm .xterm-screen .xterm-char[data-char-code^="\\uE"] { .xterm .xterm-screen .xterm-char[data-char-code^="\\uE"] {
font-family: 'JetBrains Mono Nerd Font', 'MesloLGS NF', 'FiraCode Nerd Font' !important; font-family: 'JetBrains Mono Nerd Font', 'MesloLGS NF', 'FiraCode Nerd Font' !important;
} }
.xterm .xterm-screen .xterm-char[data-char-code^="\\uE000"] {
font-family: 'JetBrains Mono Nerd Font', 'MesloLGS NF', 'FiraCode Nerd Font' !important;
}
.xterm .xterm-screen .xterm-char[data-char-code^="\\uE001"] {
font-family: 'JetBrains Mono Nerd Font', 'MesloLGS NF', 'FiraCode Nerd Font' !important;
}
.xterm .xterm-screen .xterm-char[data-char-code^="\\uE002"] {
font-family: 'JetBrains Mono Nerd Font', 'MesloLGS NF', 'FiraCode Nerd Font' !important;
}
.xterm .xterm-screen .xterm-char[data-char-code^="\\uE003"] {
font-family: 'JetBrains Mono Nerd Font', 'MesloLGS NF', 'FiraCode Nerd Font' !important;
}
.xterm .xterm-screen .xterm-char[data-char-code^="\\uE004"] {
font-family: 'JetBrains Mono Nerd Font', 'MesloLGS NF', 'FiraCode Nerd Font' !important;
}
.xterm .xterm-screen .xterm-char[data-char-code^="\\uE005"] {
font-family: 'JetBrains Mono Nerd Font', 'MesloLGS NF', 'FiraCode Nerd Font' !important;
}
.xterm .xterm-screen .xterm-char[data-char-code^="\\uE006"] {
font-family: 'JetBrains Mono Nerd Font', 'MesloLGS NF', 'FiraCode Nerd Font' !important;
}
.xterm .xterm-screen .xterm-char[data-char-code^="\\uE007"] {
font-family: 'JetBrains Mono Nerd Font', 'MesloLGS NF', 'FiraCode Nerd Font' !important;
}
.xterm .xterm-screen .xterm-char[data-char-code^="\\uE008"] {
font-family: 'JetBrains Mono Nerd Font', 'MesloLGS NF', 'FiraCode Nerd Font' !important;
}
.xterm .xterm-screen .xterm-char[data-char-code^="\\uE009"] {
font-family: 'JetBrains Mono Nerd Font', 'MesloLGS NF', 'FiraCode Nerd Font' !important;
}
.xterm .xterm-screen .xterm-char[data-char-code^="\\uE00A"] {
font-family: 'JetBrains Mono Nerd Font', 'MesloLGS NF', 'FiraCode Nerd Font' !important;
}
.xterm .xterm-screen .xterm-char[data-char-code^="\\uE00B"] {
font-family: 'JetBrains Mono Nerd Font', 'MesloLGS NF', 'FiraCode Nerd Font' !important;
}
.xterm .xterm-screen .xterm-char[data-char-code^="\\uE00C"] {
font-family: 'JetBrains Mono Nerd Font', 'MesloLGS NF', 'FiraCode Nerd Font' !important;
}
.xterm .xterm-screen .xterm-char[data-char-code^="\\uE00D"] {
font-family: 'JetBrains Mono Nerd Font', 'MesloLGS NF', 'FiraCode Nerd Font' !important;
}
.xterm .xterm-screen .xterm-char[data-char-code^="\\uE00E"] {
font-family: 'JetBrains Mono Nerd Font', 'MesloLGS NF', 'FiraCode Nerd Font' !important;
}
.xterm .xterm-screen .xterm-char[data-char-code^="\\uE00F"] {
font-family: 'JetBrains Mono Nerd Font', 'MesloLGS NF', 'FiraCode Nerd Font' !important;
}
`; `;
document.head.appendChild(style); document.head.appendChild(style);

View File

@@ -158,8 +158,13 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
ws.addEventListener("message", (event) => { ws.addEventListener("message", (event) => {
try { try {
const msg = JSON.parse(event.data); const msg = JSON.parse(event.data);
if (msg.type === "data") terminal.write(msg.data); if (msg.type === "data") {
else if (msg.type === "error") if (typeof msg.data === "string") {
terminal.write(msg.data);
} else {
terminal.write(String(msg.data));
}
} else if (msg.type === "error")
terminal.writeln(`\r\n[${t("terminal.error")}] ${msg.message}`); terminal.writeln(`\r\n[${t("terminal.error")}] ${msg.message}`);
else if (msg.type === "connected") { else if (msg.type === "connected") {
isConnectingRef.current = false; isConnectingRef.current = false;
@@ -221,6 +226,9 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
allowProposedApi: true, allowProposedApi: true,
disableStdin: true, disableStdin: true,
cursorInactiveStyle: "bar", cursorInactiveStyle: "bar",
minimumContrastRatio: 1,
letterSpacing: 0,
lineHeight: 1.2,
}; };
const fitAddon = new FitAddon(); const fitAddon = new FitAddon();
@@ -233,6 +241,9 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
terminal.loadAddon(clipboardAddon); terminal.loadAddon(clipboardAddon);
terminal.loadAddon(unicode11Addon); terminal.loadAddon(unicode11Addon);
terminal.loadAddon(webLinksAddon); terminal.loadAddon(webLinksAddon);
terminal.unicode.activeVersion = "11";
terminal.open(xtermRef.current); terminal.open(xtermRef.current);
const textarea = xtermRef.current.querySelector( const textarea = xtermRef.current.querySelector(
@@ -444,5 +455,65 @@ style.innerHTML = `
.xterm .xterm-screen .xterm-char[data-char-code^="\uE000"] { .xterm .xterm-screen .xterm-char[data-char-code^="\uE000"] {
font-family: 'JetBrains Mono Nerd Font', 'MesloLGS NF', 'FiraCode Nerd Font' !important; font-family: 'JetBrains Mono Nerd Font', 'MesloLGS NF', 'FiraCode Nerd Font' !important;
} }
.xterm .xterm-screen .xterm-char[data-char-code^="\uE001"] {
font-family: 'JetBrains Mono Nerd Font', 'MesloLGS NF', 'FiraCode Nerd Font' !important;
}
.xterm .xterm-screen .xterm-char[data-char-code^="\uE002"] {
font-family: 'JetBrains Mono Nerd Font', 'MesloLGS NF', 'FiraCode Nerd Font' !important;
}
.xterm .xterm-screen .xterm-char[data-char-code^="\uE003"] {
font-family: 'JetBrains Mono Nerd Font', 'MesloLGS NF', 'FiraCode Nerd Font' !important;
}
.xterm .xterm-screen .xterm-char[data-char-code^="\uE004"] {
font-family: 'JetBrains Mono Nerd Font', 'MesloLGS NF', 'FiraCode Nerd Font' !important;
}
.xterm .xterm-screen .xterm-char[data-char-code^="\uE005"] {
font-family: 'JetBrains Mono Nerd Font', 'MesloLGS NF', 'FiraCode Nerd Font' !important;
}
.xterm .xterm-screen .xterm-char[data-char-code^="\uE006"] {
font-family: 'JetBrains Mono Nerd Font', 'MesloLGS NF', 'FiraCode Nerd Font' !important;
}
.xterm .xterm-screen .xterm-char[data-char-code^="\uE007"] {
font-family: 'JetBrains Mono Nerd Font', 'MesloLGS NF', 'FiraCode Nerd Font' !important;
}
.xterm .xterm-screen .xterm-char[data-char-code^="\uE008"] {
font-family: 'JetBrains Mono Nerd Font', 'MesloLGS NF', 'FiraCode Nerd Font' !important;
}
.xterm .xterm-screen .xterm-char[data-char-code^="\uE009"] {
font-family: 'JetBrains Mono Nerd Font', 'MesloLGS NF', 'FiraCode Nerd Font' !important;
}
.xterm .xterm-screen .xterm-char[data-char-code^="\uE00A"] {
font-family: 'JetBrains Mono Nerd Font', 'MesloLGS NF', 'FiraCode Nerd Font' !important;
}
.xterm .xterm-screen .xterm-char[data-char-code^="\uE00B"] {
font-family: 'JetBrains Mono Nerd Font', 'MesloLGS NF', 'FiraCode Nerd Font' !important;
}
.xterm .xterm-screen .xterm-char[data-char-code^="\uE00C"] {
font-family: 'JetBrains Mono Nerd Font', 'MesloLGS NF', 'FiraCode Nerd Font' !important;
}
.xterm .xterm-screen .xterm-char[data-char-code^="\uE00D"] {
font-family: 'JetBrains Mono Nerd Font', 'MesloLGS NF', 'FiraCode Nerd Font' !important;
}
.xterm .xterm-screen .xterm-char[data-char-code^="\uE00E"] {
font-family: 'JetBrains Mono Nerd Font', 'MesloLGS NF', 'FiraCode Nerd Font' !important;
}
.xterm .xterm-screen .xterm-char[data-char-code^="\uE00F"] {
font-family: 'JetBrains Mono Nerd Font', 'MesloLGS NF', 'FiraCode Nerd Font' !important;
}
`; `;
document.head.appendChild(style); document.head.appendChild(style);

View File

@@ -51,6 +51,7 @@ interface DiskMetrics {
percent: number | null; percent: number | null;
usedHuman: string | null; usedHuman: string | null;
totalHuman: string | null; totalHuman: string | null;
availableHuman?: string | null;
} }
export type ServerMetrics = { export type ServerMetrics = {
@@ -796,6 +797,17 @@ export async function getSSHHostById(hostId: number): Promise<SSHHost> {
} }
} }
export async function exportSSHHostWithCredentials(
hostId: number,
): Promise<SSHHost> {
try {
const response = await sshHostApi.get(`/db/host/${hostId}/export`);
return response.data;
} catch (error) {
handleApiError(error, "export SSH host with credentials");
}
}
// ============================================================================ // ============================================================================
// SSH AUTOSTART MANAGEMENT // SSH AUTOSTART MANAGEMENT
// ============================================================================ // ============================================================================