2 Commits

Author SHA1 Message Date
Karmaa
2bf61bda4d v1.7.2 (#364)
* Feature request: Add delete confirmation dialog to file manager (#344)

* Feature request: Add delete confirmation dialog to file manager

- Added confirmation dialog before deleting files/folders
- Users must confirm deletion with a warning message
- Works for both Delete key and right-click delete
- Shows different messages for single file, folder, or multiple items
- Includes permanent deletion warning
- Follows existing design patterns using confirmWithToast

* Adds confirmation for deletion of items including folders

Updates the file deletion confirmation logic to distinguish between
deleting multiple items with or without folders. Introduces a new
translation string for a clearer user prompt when folders and their
contents are included in the deletion.

Improves clarity and reduces user error when performing bulk deletions.

* feat: Add Chinese translations for delete confirmation messages

* Adds camelCase support for encrypted field mappings (#342)

Extends encrypted field mappings to include camelCase variants
to support consistency and compatibility with different naming
conventions. Updates reverse mappings for Drizzle ORM to allow
conversion between camelCase and snake_case field names.

Improves integration with systems using mixed naming styles.

* Run code cleanup, add sidebar persistence, fix OIDC credentials, force SSH password.

* Fix snake case mismatching

* Add real client IP

* Fix OIDC credential persistence issue

The issue was that OIDC users were getting a new random Data Encryption Key (DEK)
on every login, which made previously encrypted credentials inaccessible.

Changes:
- Modified setupOIDCUserEncryption() to persist the DEK encrypted with a system-derived key
- Updated authenticateOIDCUser() to properly retrieve and use the persisted DEK
- Ensured OIDC users now have the same encryption persistence as password-based users

This fix ensures that credentials created by OIDC users remain accessible across
multiple login sessions.

* Fix race condition and remove redundant kekSalt for OIDC users

Critical fixes:

1. Race Condition Mitigation:
   - Added read-after-write verification in setupOIDCUserEncryption()
   - Ensures session uses the DEK that's actually in the database
   - Prevents data loss when concurrent logins occur for new OIDC users
   - If race is detected, discards generated DEK and uses stored one

2. Remove Redundant kekSalt Logic:
   - Removed unnecessary kekSalt generation and checks for OIDC users
   - kekSalt is not used in OIDC key derivation (uses userId as salt)
   - Reduces database operations from 4 to 2 per authentication
   - Simplifies code and removes potential confusion

3. Improved Error Handling:
   - systemKey cleanup moved to finally block
   - Ensures sensitive key material is always cleared from memory

These changes ensure data consistency and prevent potential data loss
in high-concurrency scenarios.

* Cleanup OIDC pr and run prettier

---------

Co-authored-by: Ved Prakash <54140516+thorved@users.noreply.github.com>
2025-10-06 10:11:25 -05:00
Karmaa
937e04fa5c v.1.7.1 (#335) 2025-10-03 00:02:10 -05:00
34 changed files with 1073 additions and 275 deletions

View File

@@ -1,23 +1,31 @@
# Overview
_Short summary of what this PR does_
- [ ] Added: ...
- [ ] Updated: ...
- [ ] Removed: ...
- [ ] Fixed: ...
# Changes Made
_Detailed explanation of changes (if needed)_
- ...
# Related Issues
_Link any issues this PR addresses_
- Closes #ISSUE_NUMBER
- Related to #ISSUE_NUMBER
- Closes #ISSUE_NUMBER
- Related to #ISSUE_NUMBER
# Screenshots / Demos
_(Optional: add before/after screenshots, GIFs, or console output)_
# Checklist
- [ ] Code follows project style guidelines
- [ ] Supports mobile and desktop UI/app (if applicable)
- [ ] I have read [Contributing.md](https://github.com/LukeGus/Termix/blob/main/CONTRIBUTING.md)
- [ ] Code follows project style guidelines
- [ ] Supports mobile and desktop UI/app (if applicable)
- [ ] 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
community include:
* Demonstrating empathy and kindness toward other people
* Being respectful of differing opinions, viewpoints, and experiences
* Giving and gracefully accepting constructive feedback
* Accepting responsibility and apologizing to those affected by our mistakes,
- Demonstrating empathy and kindness toward other people
- Being respectful of differing opinions, viewpoints, and experiences
- Giving and gracefully accepting constructive feedback
- Accepting responsibility and apologizing to those affected by our mistakes,
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
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
* Trolling, insulting or derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or email
- Trolling, insulting or derogatory comments, and personal or political attacks
- Public or private harassment
- Publishing others' private information, such as a physical or email
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
## Enforcement Responsibilities
@@ -106,7 +106,7 @@ Violating these terms may lead to a permanent ban.
### 4. Permanent Ban
**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.
**Consequence**: A permanent ban from any sort of public interaction within

View File

@@ -10,6 +10,9 @@ http {
keepalive_timeout 65;
client_header_timeout 300s;
set_real_ip_from 127.0.0.1;
real_ip_header X-Forwarded-For;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-SHA256:ECDHE-RSA-AES256-SHA384;
ssl_prefer_server_ciphers off;
@@ -23,7 +26,6 @@ http {
return 301 https://$host:${SSL_PORT}$request_uri;
}
# HTTPS Server
server {
listen ${SSL_PORT} ssl;
server_name _;
@@ -41,7 +43,6 @@ http {
index index.html index.htm;
}
# Handle missing source map files gracefully
location ~* \.map$ {
return 404;
access_log off;

View File

@@ -10,6 +10,9 @@ http {
keepalive_timeout 65;
client_header_timeout 300s;
set_real_ip_from 127.0.0.1;
real_ip_header X-Forwarded-For;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-SHA256:ECDHE-RSA-AES256-SHA384;
ssl_prefer_server_ciphers off;
@@ -29,7 +32,6 @@ http {
index index.html index.htm;
}
# Handle missing source map files gracefully
location ~* \.map$ {
return 404;
access_log off;

View File

@@ -37,8 +37,26 @@
"uninstallDisplayName": "Termix"
},
"linux": {
"target": "AppImage",
"target": [
{
"target": "AppImage",
"arch": ["x64"]
},
{
"target": "tar.gz",
"arch": ["x64"]
}
],
"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("--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;
const isDev = process.env.NODE_ENV === "development" || !app.isPackaged;

View File

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

View File

@@ -46,7 +46,7 @@ export const sshData = sqliteTable("ssh_data", {
password: text("password"),
key: text("key", { length: 8192 }),
keyPassword: text("key_password"),
key_password: text("key_password"),
keyType: text("key_type"),
autostartPassword: text("autostart_password"),
@@ -142,9 +142,9 @@ export const sshCredentials = sqliteTable("ssh_credentials", {
username: text("username").notNull(),
password: text("password"),
key: text("key", { length: 16384 }),
privateKey: text("private_key", { length: 16384 }),
publicKey: text("public_key", { length: 4096 }),
keyPassword: text("key_password"),
private_key: text("private_key", { length: 16384 }),
public_key: text("public_key", { length: 4096 }),
key_password: text("key_password"),
keyType: text("key_type"),
detectedKeyType: text("detected_key_type"),
usageCount: integer("usage_count").notNull().default(0),

View File

@@ -174,9 +174,9 @@ router.post(
username: username.trim(),
password: plainPassword,
key: plainKey,
privateKey: keyInfo?.privateKey || plainKey,
publicKey: keyInfo?.publicKey || null,
keyPassword: plainKeyPassword,
private_key: keyInfo?.privateKey || plainKey,
public_key: keyInfo?.publicKey || null,
key_password: plainKeyPassword,
keyType: keyType || null,
detectedKeyType: keyInfo?.keyType || null,
usageCount: 0,
@@ -333,14 +333,14 @@ router.get(
if (credential.key) {
(output as any).key = credential.key;
}
if (credential.privateKey) {
(output as any).privateKey = credential.privateKey;
if (credential.private_key) {
(output as any).privateKey = credential.private_key;
}
if (credential.publicKey) {
(output as any).publicKey = credential.publicKey;
if (credential.public_key) {
(output as any).publicKey = credential.public_key;
}
if (credential.keyPassword) {
(output as any).keyPassword = credential.keyPassword;
if (credential.key_password) {
(output as any).keyPassword = credential.key_password;
}
res.json(output);
@@ -424,13 +424,13 @@ router.put(
error: `Invalid SSH key: ${keyInfo.error}`,
});
}
updateFields.privateKey = keyInfo.privateKey;
updateFields.publicKey = keyInfo.publicKey;
updateFields.private_key = keyInfo.privateKey;
updateFields.public_key = keyInfo.publicKey;
updateFields.detectedKeyType = keyInfo.keyType;
}
}
if (updateData.keyPassword !== undefined) {
updateFields.keyPassword = updateData.keyPassword || null;
updateFields.key_password = updateData.keyPassword || null;
}
if (Object.keys(updateFields).length === 0) {
@@ -537,7 +537,7 @@ router.delete(
credentialId: null,
password: null,
key: null,
keyPassword: null,
key_password: null,
authType: "password",
})
.where(
@@ -605,15 +605,19 @@ router.post(
}
try {
const credentials = await db
.select()
.from(sshCredentials)
.where(
and(
eq(sshCredentials.id, parseInt(credentialId)),
eq(sshCredentials.userId, userId),
const credentials = await SimpleDBOps.select(
db
.select()
.from(sshCredentials)
.where(
and(
eq(sshCredentials.id, parseInt(credentialId)),
eq(sshCredentials.userId, userId),
),
),
);
"ssh_credentials",
userId,
);
if (credentials.length === 0) {
return res.status(404).json({ error: "Credential not found" });
@@ -626,10 +630,10 @@ router.post(
.set({
credentialId: parseInt(credentialId),
username: credential.username,
authType: credential.authType,
authType: credential.auth_type || credential.authType,
password: null,
key: null,
keyPassword: null,
key_password: null,
keyType: null,
updatedAt: new Date().toISOString(),
})
@@ -715,15 +719,15 @@ function formatCredentialOutput(credential: any): any {
? credential.tags.split(",").filter(Boolean)
: []
: [],
authType: credential.authType,
authType: credential.authType || credential.auth_type,
username: credential.username,
publicKey: credential.publicKey,
keyType: credential.keyType,
detectedKeyType: credential.detectedKeyType,
usageCount: credential.usageCount || 0,
lastUsed: credential.lastUsed,
createdAt: credential.createdAt,
updatedAt: credential.updatedAt,
publicKey: credential.public_key || credential.publicKey,
keyType: credential.key_type || credential.keyType,
detectedKeyType: credential.detected_key_type || credential.detectedKeyType,
usageCount: credential.usage_count || credential.usageCount || 0,
lastUsed: credential.last_used || credential.lastUsed,
createdAt: credential.created_at || credential.createdAt,
updatedAt: credential.updated_at || credential.updatedAt,
};
}
@@ -1551,14 +1555,15 @@ router.post(
if (hostCredential && hostCredential.length > 0) {
const cred = hostCredential[0];
hostConfig.authType = cred.authType;
hostConfig.authType = cred.auth_type || cred.authType;
hostConfig.username = cred.username;
if (cred.authType === "password") {
if ((cred.auth_type || cred.authType) === "password") {
hostConfig.password = cred.password;
} else if (cred.authType === "key") {
hostConfig.privateKey = cred.privateKey || cred.key;
hostConfig.keyPassword = cred.keyPassword;
} else if ((cred.auth_type || cred.authType) === "key") {
hostConfig.privateKey =
cred.private_key || cred.privateKey || cred.key;
hostConfig.keyPassword = cred.key_password || cred.keyPassword;
}
} else {
return res.status(400).json({

View File

@@ -91,7 +91,7 @@ router.get("/db/host/internal", async (req: Request, res: Response) => {
username: host.username,
password: host.autostartPassword,
key: host.autostartKey,
keyPassword: host.autostartKeyPassword,
key_password: host.autostartKeyPassword,
autostartPassword: host.autostartPassword,
autostartKey: host.autostartKey,
autostartKeyPassword: host.autostartKeyPassword,
@@ -151,7 +151,7 @@ router.get("/db/host/internal/all", async (req: Request, res: Response) => {
username: host.username,
password: host.autostartPassword || host.password,
key: host.autostartKey || host.key,
keyPassword: host.autostartKeyPassword || host.keyPassword,
key_password: host.autostartKeyPassword || host.key_password,
autostartPassword: host.autostartPassword,
autostartKey: host.autostartKey,
autostartKeyPassword: host.autostartKeyPassword,
@@ -226,7 +226,7 @@ router.post(
authType,
credentialId,
key,
keyPassword,
key_password,
keyType,
pin,
enableTerminal,
@@ -274,17 +274,17 @@ router.post(
if (effectiveAuthType === "password") {
sshDataObj.password = password || null;
sshDataObj.key = null;
sshDataObj.keyPassword = null;
sshDataObj.key_password = null;
sshDataObj.keyType = null;
} else if (effectiveAuthType === "key") {
sshDataObj.key = key || null;
sshDataObj.keyPassword = keyPassword || null;
sshDataObj.key_password = key_password || null;
sshDataObj.keyType = keyType;
sshDataObj.password = null;
} else {
sshDataObj.password = null;
sshDataObj.key = null;
sshDataObj.keyPassword = null;
sshDataObj.key_password = null;
sshDataObj.keyType = null;
}
@@ -407,7 +407,7 @@ router.put(
authType,
credentialId,
key,
keyPassword,
key_password,
keyType,
pin,
enableTerminal,
@@ -458,24 +458,23 @@ router.put(
sshDataObj.password = password;
}
sshDataObj.key = null;
sshDataObj.keyPassword = null;
sshDataObj.key_password = null;
sshDataObj.keyType = null;
} else if (effectiveAuthType === "key") {
if (key) {
sshDataObj.key = key;
}
if (keyPassword !== undefined) {
sshDataObj.keyPassword = keyPassword || null;
if (key_password !== undefined) {
sshDataObj.key_password = key_password || null;
}
if (keyType) {
sshDataObj.keyType = keyType;
}
sshDataObj.password = null;
} else {
// For credential auth
sshDataObj.password = null;
sshDataObj.key = null;
sshDataObj.keyPassword = null;
sshDataObj.key_password = null;
sshDataObj.keyType = 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,
key_password: resolvedHost.key_password || 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)
// DELETE /ssh/host/:id
router.delete(
@@ -1136,26 +1212,30 @@ router.delete(
async function resolveHostCredentials(host: any): Promise<any> {
try {
if (host.credentialId && host.userId) {
const credentials = await db
.select()
.from(sshCredentials)
.where(
and(
eq(sshCredentials.id, host.credentialId),
eq(sshCredentials.userId, host.userId),
const credentials = await SimpleDBOps.select(
db
.select()
.from(sshCredentials)
.where(
and(
eq(sshCredentials.id, host.credentialId),
eq(sshCredentials.userId, host.userId),
),
),
);
"ssh_credentials",
host.userId,
);
if (credentials.length > 0) {
const credential = credentials[0];
return {
...host,
username: credential.username,
authType: credential.authType,
authType: credential.auth_type || credential.authType,
password: credential.password,
key: credential.key,
keyPassword: credential.keyPassword,
keyType: credential.keyType,
key_password: credential.key_password || credential.key_password,
keyType: credential.key_type || credential.keyType,
};
}
}
@@ -1214,7 +1294,6 @@ router.put(
)
.returning();
// Trigger database save after folder rename
DatabaseSaveTrigger.triggerSave("folder_rename");
res.json({
@@ -1325,8 +1404,8 @@ router.post(
credentialId:
hostData.authType === "credential" ? hostData.credentialId : null,
key: hostData.authType === "key" ? hostData.key : null,
keyPassword:
hostData.authType === "key" ? hostData.keyPassword : null,
key_password:
hostData.authType === "key" ? hostData.key_password : null,
keyType:
hostData.authType === "key" ? hostData.keyType || "auto" : null,
pin: hostData.pin || false,
@@ -1461,7 +1540,7 @@ router.post(
...tunnel,
endpointPassword: decryptedEndpoint.password || null,
endpointKey: decryptedEndpoint.key || null,
endpointKeyPassword: decryptedEndpoint.keyPassword || null,
endpointKeyPassword: decryptedEndpoint.key_password || null,
endpointAuthType: endpointHost.authType,
};
}
@@ -1484,7 +1563,7 @@ router.post(
.set({
autostartPassword: decryptedConfig.password || null,
autostartKey: decryptedConfig.key || null,
autostartKeyPassword: decryptedConfig.keyPassword || null,
autostartKeyPassword: decryptedConfig.key_password || null,
tunnelConnections: updatedTunnelConnections,
})
.where(eq(sshData.id, sshConfigId));

View File

@@ -1317,6 +1317,43 @@ router.post("/complete-reset", async (req, res) => {
.set({ password_hash })
.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,
},
);
} 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}`);
db.$client
@@ -1495,6 +1532,22 @@ router.post("/totp/verify-login", async (req, res) => {
"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({
secret: totpSecret,
encoding: "base32",

View File

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

View File

@@ -280,10 +280,8 @@ const app = express();
app.use(
cors({
origin: (origin, callback) => {
// Allow requests with no origin (like mobile apps or curl requests)
if (!origin) return callback(null, true);
// Allow localhost and 127.0.0.1 for development
const allowedOrigins = [
"http://localhost:5173",
"http://localhost:3000",
@@ -291,22 +289,18 @@ app.use(
"http://127.0.0.1:3000",
];
// Allow any HTTPS origin (production deployments)
if (origin.startsWith("https://")) {
return callback(null, true);
}
// Allow any HTTP origin for self-hosted scenarios
if (origin.startsWith("http://")) {
return callback(null, true);
}
// Check against allowed development origins
if (allowedOrigins.includes(origin)) {
return callback(null, true);
}
// Reject other origins
callback(new Error("Not allowed by CORS"));
},
credentials: true,
@@ -322,7 +316,6 @@ app.use(
app.use(cookieParser());
app.use(express.json({ limit: "1mb" }));
// Add authentication middleware - Linus principle: eliminate special cases
app.use(authManager.createAuthMiddleware());
const hostStatuses: Map<number, StatusEntry> = new Map();
@@ -363,7 +356,6 @@ async function fetchHostById(
userId: string,
): Promise<SSHHostWithCredentials | undefined> {
try {
// Check if user data is unlocked before attempting to fetch
if (!SimpleDBOps.isUserDataUnlocked(userId)) {
statsLogger.debug("User data locked - cannot fetch host", {
operation: "fetchHostById_data_locked",
@@ -446,7 +438,7 @@ async function resolveHostCredentials(
const credential = credentials[0];
baseHost.credentialId = credential.id;
baseHost.username = credential.username;
baseHost.authType = credential.authType;
baseHost.authType = credential.auth_type || credential.authType;
if (credential.password) {
baseHost.password = credential.password;
@@ -454,11 +446,12 @@ async function resolveHostCredentials(
if (credential.key) {
baseHost.key = credential.key;
}
if (credential.keyPassword) {
baseHost.keyPassword = credential.keyPassword;
if (credential.key_password || credential.keyPassword) {
baseHost.keyPassword =
credential.key_password || credential.keyPassword;
}
if (credential.keyType) {
baseHost.keyType = credential.keyType;
if (credential.key_type || credential.keyType) {
baseHost.keyType = credential.key_type || credential.keyType;
}
} else {
addLegacyCredentials(baseHost, host);
@@ -750,6 +743,7 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{
let diskPercent: number | null = null;
let usedHuman: string | null = null;
let totalHuman: string | null = null;
let availableHuman: string | null = null;
try {
const [diskOutHuman, diskOutBytes] = await Promise.all([
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) {
totalHuman = humanParts[1] || null;
usedHuman = humanParts[2] || null;
availableHuman = humanParts[3] || null;
const totalBytes = Number(bytesParts[1]);
const usedBytes = Number(bytesParts[2]);
@@ -796,6 +791,7 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{
diskPercent = null;
usedHuman = null;
totalHuman = null;
availableHuman = null;
}
const result = {
@@ -805,7 +801,12 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{
usedGiB: usedGiB ? toFixedNum(usedGiB, 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);
@@ -887,7 +888,6 @@ async function pollStatusesOnce(userId?: string): Promise<void> {
app.get("/status", async (req, res) => {
const userId = (req as any).userId;
// Check if user data is unlocked
if (!SimpleDBOps.isUserDataUnlocked(userId)) {
return res.status(401).json({
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 userId = (req as any).userId;
// Check if user data is unlocked
if (!SimpleDBOps.isUserDataUnlocked(userId)) {
return res.status(401).json({
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) => {
const userId = (req as any).userId;
// Check if user data is unlocked
if (!SimpleDBOps.isUserDataUnlocked(userId)) {
return res.status(401).json({
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 userId = (req as any).userId;
// Check if user data is unlocked
if (!SimpleDBOps.isUserDataUnlocked(userId)) {
return res.status(401).json({
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")) {
sshStream.write(data);
} 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;
@@ -367,10 +376,11 @@ wss.on("connection", async (ws: WebSocket, req) => {
const credential = credentials[0];
resolvedCredentials = {
password: credential.password,
key: credential.privateKey || credential.key,
keyPassword: credential.keyPassword,
keyType: credential.keyType,
authType: credential.authType,
key:
credential.private_key || credential.privateKey || credential.key,
keyPassword: credential.key_password || credential.keyPassword,
keyType: credential.key_type || credential.keyType,
authType: credential.auth_type || credential.authType,
};
} else {
sshLogger.warn(`No credentials found for host ${id}`, {
@@ -427,7 +437,22 @@ wss.on("connection", async (ws: WebSocket, req) => {
sshStream = stream;
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", () => {

View File

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

View File

@@ -147,7 +147,7 @@ class DataCrypto {
if (needsUpdate) {
const updateQuery = `
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 = ?
`;
db.prepare(updateQuery).run(
@@ -155,6 +155,7 @@ class DataCrypto {
updatedRecord.key || null,
updatedRecord.key_password || null,
updatedRecord.private_key || null,
updatedRecord.public_key || null,
record.id,
);
@@ -216,6 +217,165 @@ class DataCrypto {
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 {
const userDataKey = this.getUserDataKey(userId);
if (!userDataKey) {

View File

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

View File

@@ -2,6 +2,26 @@ import { FieldCrypto } from "./field-crypto.js";
import { databaseLogger } from "./logger.js";
export class LazyFieldEncryption {
private static readonly LEGACY_FIELD_NAME_MAP: Record<string, string> = {
key_password: "keyPassword",
private_key: "privateKey",
public_key: "publicKey",
password_hash: "passwordHash",
client_secret: "clientSecret",
totp_secret: "totpSecret",
totp_backup_codes: "totpBackupCodes",
oidc_identifier: "oidcIdentifier",
keyPassword: "key_password",
privateKey: "private_key",
publicKey: "public_key",
passwordHash: "password_hash",
clientSecret: "client_secret",
totpSecret: "totp_secret",
totpBackupCodes: "totp_backup_codes",
oidcIdentifier: "oidc_identifier",
};
static isPlaintextField(value: string): boolean {
if (!value) return false;
@@ -44,6 +64,35 @@ export class LazyFieldEncryption {
);
return decrypted;
} 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, {
operation: "lazy_encryption_decrypt_failed",
recordId,
@@ -60,9 +109,13 @@ export class LazyFieldEncryption {
userKEK: Buffer,
recordId: string,
fieldName: string,
): { encrypted: string; wasPlaintext: boolean } {
): {
encrypted: string;
wasPlaintext: boolean;
wasLegacyEncryption: boolean;
} {
if (!fieldValue) {
return { encrypted: "", wasPlaintext: false };
return { encrypted: "", wasPlaintext: false, wasLegacyEncryption: false };
}
if (this.isPlaintextField(fieldValue)) {
@@ -74,7 +127,7 @@ export class LazyFieldEncryption {
fieldName,
);
return { encrypted, wasPlaintext: true };
return { encrypted, wasPlaintext: true, wasLegacyEncryption: false };
} catch (error) {
databaseLogger.error("Failed to encrypt plaintext field", error, {
operation: "lazy_encryption_migrate_failed",
@@ -85,7 +138,42 @@ export class LazyFieldEncryption {
throw error;
}
} 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 +194,21 @@ export class LazyFieldEncryption {
for (const fieldName of sensitiveFields) {
const fieldValue = record[fieldName];
if (fieldValue && this.isPlaintextField(fieldValue)) {
if (fieldValue) {
try {
const { encrypted } = this.migrateFieldToEncrypted(
fieldValue,
userKEK,
recordId,
fieldName,
);
const { encrypted, wasPlaintext, wasLegacyEncryption } =
this.migrateFieldToEncrypted(
fieldValue,
userKEK,
recordId,
fieldName,
);
updatedRecord[fieldName] = encrypted;
migratedFields.push(fieldName);
needsUpdate = true;
if (wasPlaintext || wasLegacyEncryption) {
updatedRecord[fieldName] = encrypted;
migratedFields.push(fieldName);
needsUpdate = true;
}
} catch (error) {
databaseLogger.error("Failed to migrate record field", error, {
operation: "lazy_encryption_record_field_failed",
@@ -134,13 +225,53 @@ export class LazyFieldEncryption {
static getSensitiveFieldsForTable(tableName: string): string[] {
const sensitiveFieldsMap: Record<string, string[]> = {
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"],
};
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(
userId: string,
userKEK: Buffer,
@@ -169,7 +300,15 @@ export class LazyFieldEncryption {
const hostPlaintextFields: string[] = [];
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);
needsMigration = true;
}
@@ -193,7 +332,15 @@ export class LazyFieldEncryption {
const credentialPlaintextFields: string[] = [];
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);
needsMigration = true;
}
@@ -214,7 +361,10 @@ export class LazyFieldEncryption {
const userPlaintextFields: string[] = [];
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);
needsMigration = true;
}

View File

@@ -70,7 +70,36 @@ class UserCrypto {
}
async setupOIDCUserEncryption(userId: string): Promise<void> {
const DEK = crypto.randomBytes(UserCrypto.DEK_LENGTH);
const existingEncryptedDEK = await this.getEncryptedDEK(userId);
let DEK: Buffer;
if (existingEncryptedDEK) {
const systemKey = this.deriveOIDCSystemKey(userId);
DEK = this.decryptDEK(existingEncryptedDEK, systemKey);
systemKey.fill(0);
} else {
DEK = crypto.randomBytes(UserCrypto.DEK_LENGTH);
const systemKey = this.deriveOIDCSystemKey(userId);
try {
const encryptedDEK = this.encryptDEK(DEK, systemKey);
await this.storeEncryptedDEK(userId, encryptedDEK);
const storedEncryptedDEK = await this.getEncryptedDEK(userId);
if (
storedEncryptedDEK &&
storedEncryptedDEK.data !== encryptedDEK.data
) {
DEK.fill(0);
DEK = this.decryptDEK(storedEncryptedDEK, systemKey);
} else if (!storedEncryptedDEK) {
throw new Error("Failed to store and retrieve user encryption key.");
}
} finally {
systemKey.fill(0);
}
}
const now = Date.now();
this.userSessions.set(userId, {
@@ -134,20 +163,14 @@ class UserCrypto {
async authenticateOIDCUser(userId: string): Promise<boolean> {
try {
const kekSalt = await this.getKEKSalt(userId);
if (!kekSalt) {
const encryptedDEK = await this.getEncryptedDEK(userId);
if (!encryptedDEK) {
await this.setupOIDCUserEncryption(userId);
return true;
}
const systemKey = this.deriveOIDCSystemKey(userId);
const encryptedDEK = await this.getEncryptedDEK(userId);
if (!encryptedDEK) {
systemKey.fill(0);
await this.setupOIDCUserEncryption(userId);
return true;
}
const DEK = this.decryptDEK(encryptedDEK, systemKey);
systemKey.fill(0);

View File

@@ -564,10 +564,11 @@
"downloadSample": "Download Sample",
"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?",
"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",
"confirmDelete": "Are you sure you want to delete \"{{name}}\" ?",
"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",
"noHostsInJson": "No hosts found in JSON file",
"maxHostsAllowed": "Maximum 100 hosts allowed per import",
@@ -843,8 +844,13 @@
"selectServerToEdit": "Select a server from the sidebar to start editing files",
"fileOperations": "File Operations",
"confirmDeleteMessage": "Are you sure you want to delete <strong>{{name}}</strong>?",
"confirmDeleteSingleItem": "Are you sure you want to permanently delete \"{{name}}\"?",
"confirmDeleteMultipleItems": "Are you sure you want to permanently delete {{count}} items?",
"confirmDeleteMultipleItemsWithFolders": "Are you sure you want to permanently delete {{count}} items? This includes folders and their contents.",
"confirmDeleteFolder": "Are you sure you want to permanently delete the folder \"{{name}}\" and all its contents?",
"deleteDirectoryWarning": "This will delete the folder and all its contents.",
"actionCannotBeUndone": "This action cannot be undone.",
"permanentDeleteWarning": "This action cannot be undone. The item(s) will be permanently deleted from the server.",
"recent": "Recent",
"pinned": "Pinned",
"folderShortcuts": "Folder Shortcuts",
@@ -978,7 +984,16 @@
"move": "Move",
"searchInFile": "Search in file (Ctrl+F)",
"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": {
"title": "SSH Tunnels",

View File

@@ -548,10 +548,11 @@
"downloadSample": "下载示例",
"formatGuide": "格式指南",
"exportCredentialWarning": "警告:主机 \"{{name}}\" 使用凭据认证。导出的文件将不包含凭据数据,导入后需要手动重新配置。您确定要继续吗?",
"exportSensitiveDataWarning": "警告:主机 \"{{name}}\" 包含敏感认证数据(密码/SSH密钥出于安全考虑,导出的文件将不包含此数据。导入后您需要重新配置认证。您确定要继续吗?",
"exportSensitiveDataWarning": "警告:主机 \"{{name}}\" 包含敏感认证数据(密码/SSH密钥导出的文件将以明文形式包含这些数据。请妥善保管文件,使用后建议删除。您确定要继续吗?",
"uncategorized": "未分类",
"confirmDelete": "确定要删除 \"{{name}}\" 吗?",
"failedToDeleteHost": "删除主机失败",
"failedToExportHost": "导出主机失败。请确保您已登录并有权访问主机数据。",
"jsonMustContainHosts": "JSON 必须包含 \"hosts\" 数组或是一个主机数组",
"noHostsInJson": "JSON 文件中未找到主机",
"maxHostsAllowed": "每次导入最多允许 100 个主机",
@@ -851,8 +852,13 @@
"selectServerToEdit": "从侧边栏选择服务器以开始编辑文件",
"fileOperations": "文件操作",
"confirmDeleteMessage": "确定要删除 <strong>{{name}}</strong> 吗?",
"confirmDeleteSingleItem": "确定要永久删除 \"{{name}}\" 吗?",
"confirmDeleteMultipleItems": "确定要永久删除 {{count}} 个项目吗?",
"confirmDeleteMultipleItemsWithFolders": "确定要永久删除 {{count}} 个项目吗?这包括文件夹及其内容。",
"confirmDeleteFolder": "确定要永久删除文件夹 \"{{name}}\" 及其所有内容吗?",
"deleteDirectoryWarning": "这将删除文件夹及其所有内容。",
"actionCannotBeUndone": "此操作无法撤销。",
"permanentDeleteWarning": "此操作无法撤销。项目将从服务器永久删除。",
"dragSystemFilesToUpload": "拖拽系统文件到此处上传",
"dragFilesToWindowToDownload": "拖拽文件到窗口外下载",
"openTerminalHere": "在此处打开终端",
@@ -969,7 +975,16 @@
"move": "移动",
"searchInFile": "在文件中搜索 (Ctrl+F)",
"showKeyboardShortcuts": "显示键盘快捷键",
"startWritingMarkdown": "开始编写您的 markdown 内容..."
"startWritingMarkdown": "开始编写您的 markdown 内容...",
"loadingFileComparison": "正在加载文件对比...",
"reload": "重新加载",
"compare": "对比",
"sideBySide": "并排显示",
"inline": "内嵌显示",
"fileComparison": "文件对比:{{file1}} 与 {{file2}}",
"fileTooLarge": "文件过大:{{error}}",
"sshConnectionFailed": "SSH 连接失败。请检查与 {{name}} ({{ip}}:{{port}}) 的连接",
"loadFileFailed": "加载文件失败:{{error}}"
},
"tunnels": {
"title": "SSH 隧道",

View File

@@ -9,6 +9,7 @@ import { FileWindow } from "./components/FileWindow";
import { DiffWindow } from "./components/DiffWindow";
import { useDragToDesktop } from "../../../hooks/useDragToDesktop";
import { useDragToSystemDesktop } from "../../../hooks/useDragToSystemDesktop";
import { useConfirmation } from "@/hooks/use-confirmation.ts";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { toast } from "sonner";
@@ -82,6 +83,7 @@ function formatFileSize(bytes?: number): string {
function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
const { openWindow } = useWindowManager();
const { t } = useTranslation();
const { confirmWithToast } = useConfirmation();
const [currentHost, setCurrentHost] = useState<SSHHost | null>(
initialHost || null,
@@ -587,54 +589,85 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
async function handleDeleteFiles(files: FileItem[]) {
if (!sshSessionId || files.length === 0) return;
try {
await ensureSSHConnection();
for (const file of files) {
await deleteSSHItem(
sshSessionId,
file.path,
file.type === "directory",
currentHost?.id,
currentHost?.userId?.toString(),
);
}
const deletedFiles = files.map((file) => ({
path: file.path,
name: file.name,
}));
const undoAction: UndoAction = {
type: "delete",
description: t("fileManager.deletedItems", { count: files.length }),
data: {
operation: "cut",
deletedFiles,
targetDirectory: currentPath,
},
timestamp: Date.now(),
};
setUndoHistory((prev) => [...prev.slice(-9), undoAction]);
toast.success(
t("fileManager.itemsDeletedSuccessfully", { count: files.length }),
);
handleRefreshDirectory();
clearSelection();
} catch (error: any) {
if (
error.message?.includes("connection") ||
error.message?.includes("established")
) {
toast.error(
`SSH connection failed. Please check your connection to ${currentHost?.name} (${currentHost?.ip}:${currentHost?.port})`,
);
let confirmMessage: string;
if (files.length === 1) {
const file = files[0];
if (file.type === "directory") {
confirmMessage = t("fileManager.confirmDeleteFolder", {
name: file.name,
});
} else {
toast.error(t("fileManager.failedToDeleteItems"));
confirmMessage = t("fileManager.confirmDeleteSingleItem", {
name: file.name,
});
}
console.error("Delete failed:", error);
} else {
const hasDirectory = files.some((file) => file.type === "directory");
const translationKey = hasDirectory
? "fileManager.confirmDeleteMultipleItemsWithFolders"
: "fileManager.confirmDeleteMultipleItems";
confirmMessage = t(translationKey, {
count: files.length,
});
}
const fullMessage = `${confirmMessage}\n\n${t("fileManager.permanentDeleteWarning")}`;
confirmWithToast(
fullMessage,
async () => {
try {
await ensureSSHConnection();
for (const file of files) {
await deleteSSHItem(
sshSessionId,
file.path,
file.type === "directory",
currentHost?.id,
currentHost?.userId?.toString(),
);
}
const deletedFiles = files.map((file) => ({
path: file.path,
name: file.name,
}));
const undoAction: UndoAction = {
type: "delete",
description: t("fileManager.deletedItems", { count: files.length }),
data: {
operation: "cut",
deletedFiles,
targetDirectory: currentPath,
},
timestamp: Date.now(),
};
setUndoHistory((prev) => [...prev.slice(-9), undoAction]);
toast.success(
t("fileManager.itemsDeletedSuccessfully", { count: files.length }),
);
handleRefreshDirectory();
clearSelection();
} catch (error: any) {
if (
error.message?.includes("connection") ||
error.message?.includes("established")
) {
toast.error(
`SSH connection failed. Please check your connection to ${currentHost?.name} (${currentHost?.ip}:${currentHost?.port})`,
);
} else {
toast.error(t("fileManager.failedToDeleteItems"));
}
console.error("Delete failed:", error);
}
},
"destructive",
);
}
function handleCreateNewFolder() {

View File

@@ -17,7 +17,7 @@ import {
getSSHStatus,
connectSSH,
} from "@/ui/main-axios";
import type { FileItem, SSHHost } from "../../../../types/index.js";
import type { FileItem, SSHHost } from "@/types/index";
interface DiffViewerProps {
file1: FileItem;
@@ -62,8 +62,22 @@ export function DiffViewer({
});
}
} catch (error) {
console.error("SSH connection check/reconnect failed:", error);
throw error;
try {
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,
readOnly: true,
originalEditable: false,
modifiedEditable: false,
scrollbar: {
vertical: "visible",
horizontal: "visible",

View File

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

View File

@@ -1257,17 +1257,6 @@ export function FileViewer({
</Button>
</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>

View File

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

View File

@@ -210,7 +210,18 @@ export function HostManagerEditor({
defaultPath: z.string().optional(),
})
.superRefine((data, ctx) => {
if (data.authType === "key") {
if (data.authType === "password") {
if (
!data.password ||
(typeof data.password === "string" && data.password.trim() === "")
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: t("hosts.passwordRequired"),
path: ["password"],
});
}
} else if (data.authType === "key") {
if (
!data.key ||
(typeof data.key === "string" && data.key.trim() === "")

View File

@@ -21,6 +21,7 @@ import {
bulkImportSSHHosts,
updateSSHHost,
renameFolder,
exportSSHHostWithCredentials,
} from "@/ui/main-axios.ts";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
@@ -159,46 +160,34 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
performExport(host, actualAuthType);
};
const performExport = (host: SSHHost, actualAuthType: string) => {
const exportData: any = {
name: host.name,
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,
};
const performExport = async (host: SSHHost, actualAuthType: string) => {
try {
const decryptedHost = await exportSSHHostWithCredentials(host.id);
if (actualAuthType === "credential") {
exportData.credentialId = null;
const cleanExportData = Object.fromEntries(
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) => {

View File

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

View File

@@ -352,7 +352,11 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
try {
const msg = JSON.parse(event.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") {
const errorMessage = msg.message || t("terminal.unknownError");
@@ -520,6 +524,9 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
fastScrollModifier: "alt",
fastScrollSensitivity: 5,
allowProposedApi: true,
minimumContrastRatio: 1,
letterSpacing: 0,
lineHeight: 1.2,
};
const fitAddon = new FitAddon();
@@ -532,6 +539,9 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
terminal.loadAddon(clipboardAddon);
terminal.loadAddon(unicode11Addon);
terminal.loadAddon(webLinksAddon);
terminal.unicode.activeVersion = "11";
terminal.open(xtermRef.current);
const element = xtermRef.current;
@@ -796,5 +806,69 @@ style.innerHTML = `
.xterm .xterm-screen .xterm-char[data-char-code^="\\uE"] {
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);

View File

@@ -24,7 +24,10 @@ function AppContent() {
const [isAdmin, setIsAdmin] = useState(false);
const [authLoading, setAuthLoading] = useState(true);
const [showVersionCheck, setShowVersionCheck] = useState(true);
const [isTopbarOpen, setIsTopbarOpen] = useState<boolean>(true);
const [isTopbarOpen, setIsTopbarOpen] = useState<boolean>(() => {
const saved = localStorage.getItem("topNavbarOpen");
return saved !== null ? JSON.parse(saved) : true;
});
const { currentTab, tabs } = useTabs();
useEffect(() => {
@@ -64,6 +67,10 @@ function AppContent() {
return () => window.removeEventListener("storage", handleStorageChange);
}, []);
useEffect(() => {
localStorage.setItem("topNavbarOpen", JSON.stringify(isTopbarOpen));
}, [isTopbarOpen]);
const handleSelectView = (nextView: string) => {
setMountedViews((prev) => {
if (prev.has(nextView)) return prev;

View File

@@ -101,7 +101,10 @@ export function LeftSidebar({
const [deleteLoading, setDeleteLoading] = React.useState(false);
const [deleteError, setDeleteError] = React.useState<string | null>(null);
const [isSidebarOpen, setIsSidebarOpen] = useState<boolean>(true);
const [isSidebarOpen, setIsSidebarOpen] = useState<boolean>(() => {
const saved = localStorage.getItem("leftSidebarOpen");
return saved !== null ? JSON.parse(saved) : true;
});
const {
tabs: tabList,
@@ -181,7 +184,6 @@ export function LeftSidebar({
newHost.key !== existingHost.key ||
newHost.keyPassword !== existingHost.keyPassword ||
newHost.keyType !== existingHost.keyType ||
newHost.credentialId !== existingHost.credentialId ||
newHost.defaultPath !== existingHost.defaultPath ||
JSON.stringify(newHost.tags) !==
JSON.stringify(existingHost.tags) ||
@@ -247,6 +249,10 @@ export function LeftSidebar({
return () => clearTimeout(handler);
}, [search]);
React.useEffect(() => {
localStorage.setItem("leftSidebarOpen", JSON.stringify(isSidebarOpen));
}, [isSidebarOpen]);
const filteredHosts = React.useMemo(() => {
if (!debouncedSearch.trim()) return hosts;
const q = debouncedSearch.trim().toLowerCase();

View File

@@ -158,8 +158,13 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
ws.addEventListener("message", (event) => {
try {
const msg = JSON.parse(event.data);
if (msg.type === "data") terminal.write(msg.data);
else if (msg.type === "error")
if (msg.type === "data") {
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}`);
else if (msg.type === "connected") {
isConnectingRef.current = false;
@@ -221,6 +226,9 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
allowProposedApi: true,
disableStdin: true,
cursorInactiveStyle: "bar",
minimumContrastRatio: 1,
letterSpacing: 0,
lineHeight: 1.2,
};
const fitAddon = new FitAddon();
@@ -233,6 +241,9 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
terminal.loadAddon(clipboardAddon);
terminal.loadAddon(unicode11Addon);
terminal.loadAddon(webLinksAddon);
terminal.unicode.activeVersion = "11";
terminal.open(xtermRef.current);
const textarea = xtermRef.current.querySelector(
@@ -444,5 +455,65 @@ style.innerHTML = `
.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);

View File

@@ -51,6 +51,7 @@ interface DiskMetrics {
percent: number | null;
usedHuman: string | null;
totalHuman: string | null;
availableHuman?: string | null;
}
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
// ============================================================================