18
.github/pull_request_template.md
vendored
18
.github/pull_request_template.md
vendored
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "termix",
|
||||
"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",
|
||||
"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"
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
@@ -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,7 +630,7 @@ router.post(
|
||||
.set({
|
||||
credentialId: parseInt(credentialId),
|
||||
username: credential.username,
|
||||
authType: credential.authType,
|
||||
authType: credential.auth_type || credential.authType,
|
||||
password: null,
|
||||
key: null,
|
||||
keyPassword: null,
|
||||
@@ -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({
|
||||
|
||||
@@ -472,7 +472,6 @@ router.put(
|
||||
}
|
||||
sshDataObj.password = null;
|
||||
} else {
|
||||
// For credential auth
|
||||
sshDataObj.password = null;
|
||||
sshDataObj.key = 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)
|
||||
// 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,
|
||||
keyPassword: credential.key_password || credential.keyPassword,
|
||||
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({
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}`, {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
]),
|
||||
};
|
||||
|
||||
|
||||
@@ -2,6 +2,12 @@ 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",
|
||||
};
|
||||
|
||||
static isPlaintextField(value: string): boolean {
|
||||
if (!value) return false;
|
||||
|
||||
@@ -44,6 +50,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 +95,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 +113,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 +124,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 +180,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 +211,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 +286,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 +318,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 +347,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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
@@ -978,7 +979,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",
|
||||
|
||||
@@ -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 个主机",
|
||||
@@ -969,7 +970,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 隧道",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
// ============================================================================
|
||||
|
||||
Reference in New Issue
Block a user