8
.github/pull_request_template.md
vendored
8
.github/pull_request_template.md
vendored
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} 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",
|
||||||
|
|||||||
@@ -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}`, {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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", () => {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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",
|
||||||
]),
|
]),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 隧道",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
Reference in New Issue
Block a user