mirror of
https://github.com/YuzuZensai/Termix.git
synced 2026-11-12 09:51:56 +00:00
* fix: keep concurrent ssh pool connects from overwriting each other (#1619) * fix: stop auto-accepting changed jump host keys (#1620) A jump hop whose key no longer matched the pinned fingerprint was silently accepted and the pin overwritten. Background status probes reconnect to jump hosts every cycle, so a MITM on that hop got the credentials with no user interaction. Changed jump host keys now go through the same path as any other host: prompt when there is a terminal socket, otherwise refuse. * fix(electron): validate external login URL scheme and callback port (#1621) * fix(plugins): read artifact manifest with the same tar parser as the unpack (#1622) The consent check read the first manifest.json with a hand-written tar reader, while the unpack keeps the last one and also follows pax and long-name headers. An archive with two manifests could pass the check with one capability list and get granted another. Read the manifest with node-tar, count only plain files, and refuse archives that hold more than one manifest.json. * fix: only trust the server's origin in the desktop login frame (#1624) The desktop sign-in iframe accepted AUTH_SUCCESS and system browser requests from whatever page it had navigated to, and posted replies (including 2FA temp tokens) to any origin. Messages must now come from the server's origin and replies are posted to it. The web UI also treated any framing page as the desktop app and posted the JWT to it. It now also requires the Termix-Desktop user agent. * fix: check trusted proxy login against nginx's peer, not nginx (#1625) The backend only sees the bundled nginx on loopback, so the allowlist could only work by trusting 127.0.0.1, which let any client that reached nginx log in as anyone by sending the identity headers. nginx now passes its own TCP peer (before real_ip rewriting) in X-Termix-Proxy-Peer, and loopback connections are judged by that address instead. * fix: keep foreign keys on during user data import (#1626) Import credentials before hosts and remap the file's credential ids to the ones created here, so hosts and protocol logins no longer point at another server's ids. With rows written in dependency order, the import no longer turns off SQLite foreign keys on the shared connection. The uploaded file is now removed on every exit path. * fix: enforce unique usernames (#1628) * fix: enforce unique usernames Registration checked for a taken name, hashed the password, then inserted, with nothing in the schema enforcing uniqueness. Concurrent sign-ups could create two accounts with one name, after which login, password reset, proxy login and admin lookups by username picked between them arbitrarily. Add a unique index on users.username for every dialect. Existing duplicates are renamed (later rows get a -<id> suffix, the oldest keeps the name) rather than deleted. Registration and admin user creation now return the same 409 when the insert loses the race. * fix: avoid username collisions during migration * fix: don't auto-enable synced plugins signed only by a server-sent key (#1623) * fix: don't auto-enable synced plugins signed only by a server-sent key Registry keys a linked server sends still let its plugins load on the desktop, but no longer count as a signer for switching them on or copying their grants. Keys the desktop added itself keep their trust. * fix: enforce local plugin trust across remote source labels * fix: require positive local trust for synced plugin keys * fix(desktop): harden external editor and session cookie IPC (#1627) * fix(desktop): stop the renderer choosing the external editor binary or reading the session cookie open-external-editor now launches only the editor saved from a file dialog and refuses to hand executable file types to the OS opener. The unused get-session-cookie and wait-session-cookie channels, which returned the httpOnly jwt, are removed. * fix: validate setting keys before editor authorization * fix(sidebar): sort hosts by IP address numerically (#1629) * fix(desktop): find spawn-helper in universal macOS builds (#1630) The universal build ships app-arm64.asar and app-x64.asar instead of app.asar, so node-pty's helper path rewrite never pointed at the unpacked copy and every local terminal failed with posix_spawnp failed. * fix(sync): send shared hosts with text columns in their stored form (#1631) resolveHostById turns forceKeyboardInteractive into a boolean, and the shared-host export passed it on as is. A desktop could not bind the boolean into the text column, so every shared host failed to apply. * fix: drop ssh_data.vault_profile_id so removing the vault plugin keeps hosts writable (#1633) The vault plugin renames vault_profiles to p_vault_profiles, and SQLite rewrites ssh_data's foreign key to follow it. Uninstalling the plugin then dropped p_vault_profiles and left ssh_data pointing at a missing table, so creating, cloning and deleting hosts failed with "no such table: main.p_vault_profiles". Postgres and MySQL refused the uninstall instead. The column was already copied into the vault plugin's host settings by 2.9, so retire it on every engine. The SQLite boot drop also repairs databases already in the broken state; MySQL drops the column's foreign key first. * fix(sync): let a linked desktop enable mirrored plugins it does not trust on its own (#1632) * fix(sync): let a linked desktop enable mirrored plugins it does not trust on its own * fix(sync): switch off an untrusted mirrored plugin when the server updates it * fix: require local consent for mirrored plugin activation * docs: describe consent fields on the plugin state endpoint * fix(i18n): keep Indonesian technical terms consistent (#1636) Use the English terms Indonesian users know for tunnel, snippet, deploy, file manager, remote desktop, jump host, passphrase, sidebar, toolbar, endpoint, browser and SSH agent, instead of mixing literal translations (terowongan, cuplikan, terapkan, bilah sisi, titik akhir, ...) with the English words. Match the split shortcuts to the split screen menu. Also fix the user data export description, which said secrets are encrypted while the English says they are decrypted. * fix(settings): leave user manage view when switching admin sections (#1649) The admin panel stays mounted across Settings pages, so an open user manage view kept replacing every other admin section until Back to users was clicked. Reset it whenever the selected section changes. * fix(sync): don't delete a local row when the server can't send its copy (#1650) * fix(sync): don't delete a local row when the server can't send its copy A push conflict on a live record whose server row failed to serialize came back as row: null, deleted: false, and the desktop treated that as a delete. The server now rejects that op as unavailable, the desktop never deletes on a conflict without a row, and serialization failures are logged. * fix(sync): retry temporarily unavailable records * fix: smooth host editor jumps to lower sections * chore: add changelog entries for 26.10.2 fixes * fix: stop building ssh2 native crypto in the docker image (#1644) * chore: test that an update keeps a changed auth type (#1601) * feat: option to hide the sidebar after opening a host (#1652) * feat: sort the dashboard host status card (Plugin-Host-Metrics#6) * fix: let the hover side menu overlay content instead of resizing it (Plugin-Remote-Desktop#2) * fix: say the server is down and keep retrying instead of a database error (#1637) * fix: show why a client tunnel failed to start (Plugin-Tunnels#2) * fix(sdk): skip comment-only chunks when splitting migrations * feat: setting to let the hover side menu push content again * fix: use the vite proxy for plugin sockets on any dev port * feat(sdk): let a plugin raise its public route rate limit * chore: update translations * chore: add the overview video to the readme * chore: update the overview video and add a play button to its thumbnail * feat: plugin builds bundle worker entries and import wasm and fonts as bytes * feat: plugin packs include LICENSE and NOTICE * chore: drop guacd from compose files and dev runner * chore: update remote desktop origin comment for the built-in engine * fix: show plugin names and descriptions in the UI language (#1658) * fix: show plugin names and descriptions in the UI language Read plugin.name and plugin.description from each plugin's own locale namespace, falling back to the manifest, everywhere core shows a plugin's name: the Plugins manager, onboarding picker, host editor tabs and sections, host filters and bulk actions, beta updates, interface presets and plugin crash/loading notices. * docs(sdk): document plugin.name and plugin.description locale keys --------- Co-authored-by: ZacharyZcR <zkl205305@gmail.com> * fix(hosts): reject or infer a missing authType on host create (#1664) Creating an SSH host through the API without authType hit the NOT NULL auth_type column and came back as a 500. The create route now infers the type from the credential, key or password in the body, falls back to the host defaults, and otherwise answers 400. A non-string authType is a 400 on create and update. * chore: changelog for merged plugin name and host auth type PRs * fix: move macOS split pane navigation to Cmd+Option+Arrow * fix: pick a free port for the desktop backend when 30001 is taken (#1663) * feat: build the Windows desktop app for ARM64 (#1615) * fix(sync): keep the changes cursor within the 32-bit seq column (#1665) The desktop learned the server's entity types by asking for changes after Number.MAX_SAFE_INTEGER. sync_records.seq is a 32-bit integer on Postgres, which rejects that bound value, so every pass needing the probe failed with 22003 out of range. The server now clamps the cursor to the largest seq the column holds and answers 400 for a cursor or limit that is not a non-negative integer. The desktop asks for a one-row page from its saved cursor instead. * fix(audit): coalesce identical plugin routine audits so they cannot flush the log (#1667) Successful plugin_as_user and plugin_service_* entries written by a plugin with the same user, action, resource and details are recorded once per 10 minutes. Failures and every other action are still written each time. * feat(plugins): publish host.created when a host is added (#1668) Plugins that keep an inventory of hosts had no way to hear about a new one short of polling. Core now publishes host.created with the same { hostId, userId } payload as host.updated wherever a host row is created: the host create route, ctx.hosts.create, JSON and SSH config bulk import, database file import, and sync writing a host it did not have yet. * fix: show the login for signed-out full-screen view links and return to them (#1669) A ?view= link opened without a session spun forever: the gate rendered the bare view when the session check failed, so the plugin runtime never started and the view never registered. A regular browser now keeps the link in sessionStorage, shows the normal login and opens the link again after any login, including redirect logins. Only same-origin paths with a view are restored. The desktop app, embedded sign-in frames and guest views keep their current behavior. * fix(desktop): apply linked server proxy headers and certificate exception to WebSockets (#1670) * fix(desktop): send linked server proxy headers on WebSocket upgrades isLinkedOrigin compared the request origin with the link's http(s) origin, so the terminal WebSocket (wss://host/...) never matched and its upgrade went out without the proxy headers and basic auth. Behind a forward-auth proxy that upgrade was redirected to the login and the terminal closed with "connection closed unexpectedly". ws and wss now count as http and https for the comparison, the same mapping the WebSocket handshake itself uses, with host and port still matched exactly. * fix(desktop): allow a linked server's self-signed certificate for its WebSockets isInvalidCertificateAllowedForUrl compared the URL with a local getOrigin copy, so wss://host never matched the link's https://host origin and an allowed self-signed certificate did not cover the terminal and tunnel sockets. It now uses linkedServer.isLinkedOrigin, which maps ws and wss the same way as the proxy headers, and the local copy is removed. * feat(sdk): let plugins mint a short-lived user session and read a user's locale (#1666) ctx.auth.issueUserToken(userId, { purpose, ttlSeconds }) gives background work a session for calling Termix's own API as an existing user. It needs users:impersonate, defaults to 300s and is capped at 900s, is stored as a normal session row marked as plugin-issued, and every issue is audited. Such sessions are refused on routes that change the user's password, second factors, API keys or linked logins. ctx.users.getLocale(userId) reads the user's UI language under users:read. SDK 1.0.11, plugin API 1.3.0. * chore: changelog for merged sync, audit, event and desktop PRs * feat: add shell.rightEdge slot so plugin panels can push the page over * chore: pin bundled plugins to the latest releases * chore: lint, format, and bump version to 26.10.2 --------- Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com> Co-authored-by: Ferdina Kusumah <19812460+FerdinaKusumah@users.noreply.github.com> Co-authored-by: ZacharyZcR <zkl205305@gmail.com> Co-authored-by: Lukas <80832688+0skater0@users.noreply.github.com>
2388 lines
74 KiB
TypeScript
2388 lines
74 KiB
TypeScript
import { getErrorMessage } from "../../utils/error-message.js";
|
|
import { isQuickConnectAuthType } from "../../hosts/connect/auth-provider-registry.js";
|
|
import { ensureCoreSshAuthProviders } from "../../hosts/connect/core-providers.js";
|
|
import { applyFolderAccessRules } from "../../utils/folder-access-inheritance.js";
|
|
import { findUsableCredential } from "../../hosts/usable-credential.js";
|
|
import type { AuthenticatedRequest } from "../../../types/index.js";
|
|
import express, { type Request, type Response } from "express";
|
|
import multer from "multer";
|
|
import { sshLogger, databaseLogger } from "../../utils/logger.js";
|
|
import { pluginEvents, TOPICS } from "../../plugins/events.js";
|
|
import { AuthManager } from "../../utils/auth-manager.js";
|
|
import { PermissionManager } from "../../utils/permission-manager.js";
|
|
import { DataCrypto } from "../../utils/data-crypto.js";
|
|
import { parseKeyForStorage } from "../../hosts/stored-ssh-key.js";
|
|
import {
|
|
pickResolvedPassword,
|
|
pickResolvedUsername,
|
|
} from "../../hosts/credential-username.js";
|
|
import { emitInternalEvent } from "../../hosts/internal-events.js";
|
|
import { deleteOwnedHost } from "../../hosts/delete-host.js";
|
|
import {
|
|
createCurrentCredentialRepository,
|
|
createCurrentRbacAccessRepository,
|
|
createCurrentRoleRepository,
|
|
createCurrentHostResolutionRepository,
|
|
createCurrentHostRepository,
|
|
createCurrentUserRepository,
|
|
createCurrentSharedHostAuthOverrideRepository,
|
|
createCurrentHostDefaultsRepository,
|
|
} from "../repositories/factory.js";
|
|
import {
|
|
applyHostKeyTypeUpdate,
|
|
containsOwnerPrivateAuthUpdate,
|
|
impliedSshAuthType,
|
|
keepsStoredSshAuth,
|
|
isNonEmptyString,
|
|
isOptionalAuthType,
|
|
isOptionalBoolean,
|
|
isValidPort,
|
|
normalizeProtocolEnableFields,
|
|
OWNER_PRIVATE_AUTH_FIELDS,
|
|
OWNER_PRIVATE_SSH_OPTION_FIELDS,
|
|
OWNER_PRIVATE_TERMINAL_CONFIG_FIELDS,
|
|
hostTerminalExport,
|
|
sanitizeHostForRecipient,
|
|
stripSensitiveFields,
|
|
transformHostResponse,
|
|
} from "./host-normalizers.js";
|
|
import {
|
|
attachHostPluginSettings,
|
|
loadHostPluginSettings,
|
|
withHostPluginSettings,
|
|
} from "./host-plugin-settings.js";
|
|
import { validateParentHostId } from "./host-parent-validation.js";
|
|
import { registerHostFolderRoutes } from "./host-folder-routes.js";
|
|
import { registerHostNetworkRoutes } from "./host-network-routes.js";
|
|
import { registerHostBulkRoutes } from "./host-bulk-routes.js";
|
|
import { registerHostDefaultsRoutes } from "./host-defaults-routes.js";
|
|
import { registerHostTagRoutes } from "./host-tag-routes.js";
|
|
import { registerHostStatusRoutes } from "./host-status-routes.js";
|
|
import {
|
|
applyHostEnrollmentDefaults,
|
|
requireHostEnrollmentAccessForPath,
|
|
} from "./host-enrollment-auth.js";
|
|
import {
|
|
logAudit,
|
|
getAuditUsername,
|
|
getRequestMeta,
|
|
} from "../../utils/audit-logger.js";
|
|
import type {
|
|
HostResolutionCredentialRecord,
|
|
HostResolutionHostRecord,
|
|
} from "../repositories/host-resolution-repository.js";
|
|
import {
|
|
requiresPersonalHostAuthentication,
|
|
resolveRecipientSharedHostAuthentication,
|
|
} from "../../utils/shared-host-auth-resolver.js";
|
|
import { rejectSharedCopyWrites } from "../../sync/shared-copy-guard.js";
|
|
import { sshOptionsForWrite } from "../../hosts/ssh-options.js";
|
|
import {
|
|
applyDefaultsAfterHostWrite,
|
|
applyHostDefaultsToWrite,
|
|
} from "../../hosts/defaults/index.js";
|
|
import { applyPersonalHostValues } from "../../hosts/defaults/personal.js";
|
|
import {
|
|
applyProtocolAuthPlan,
|
|
attachProtocolAuth,
|
|
firstProtocolUsername,
|
|
listProtocolLogins,
|
|
loadProtocolAuthSummaries,
|
|
planProtocolAuthWrite,
|
|
ProtocolAuthWriteError,
|
|
readProtocolAuthPayload,
|
|
toPortableLogins,
|
|
withProtocolAuth,
|
|
writeProtocolAuth,
|
|
type PlannedProtocolAuth,
|
|
} from "../../hosts/protocol-auth/protocol-auth.js";
|
|
import {
|
|
findHostProtocol,
|
|
listHostProtocols,
|
|
} from "../../hosts/protocol-auth/registry.js";
|
|
import {
|
|
mergeStoredSshOptions,
|
|
parseTerminalConfig,
|
|
} from "./host-terminal-fields.js";
|
|
|
|
const router = express.Router();
|
|
router.use(rejectSharedCopyWrites("host", /^\/db\/host\/(\d+)$/));
|
|
|
|
const upload = multer({ storage: multer.memoryStorage() });
|
|
|
|
/**
|
|
* Tells whoever is polling this host that its details changed.
|
|
*
|
|
* An event, so whichever plugin polls the host can listen for it. Fire and
|
|
* forget, so a subscriber that throws cannot fail the host update that
|
|
* caused it.
|
|
*/
|
|
function notifyStatsHostUpdated(
|
|
hostId: number,
|
|
userId: string,
|
|
operation: string,
|
|
): void {
|
|
try {
|
|
// The user travels with the event: a subscriber re-reading the host needs
|
|
// a data key, and the bus carries no session of its own.
|
|
pluginEvents.emit(TOPICS.hostUpdated, { hostId, userId });
|
|
} catch (err) {
|
|
sshLogger.warn("Failed to publish host update event", {
|
|
operation,
|
|
hostId,
|
|
error: err instanceof Error ? err.message : String(err),
|
|
});
|
|
}
|
|
}
|
|
|
|
const authManager = AuthManager.getInstance();
|
|
const permissionManager = PermissionManager.getInstance();
|
|
const authenticateJWT = authManager.createAuthMiddleware();
|
|
const requireDataAccess = authManager.createDataAccessMiddleware();
|
|
|
|
registerHostTagRoutes(
|
|
router,
|
|
authenticateJWT,
|
|
permissionManager.requirePermission("admin.settings.manage"),
|
|
);
|
|
|
|
registerHostStatusRoutes(router, {
|
|
authenticateJWT,
|
|
requireAdmin: authManager.createAdminMiddleware(),
|
|
});
|
|
|
|
/**
|
|
* @openapi
|
|
* /host/db/host:
|
|
* post:
|
|
* summary: Create SSH host
|
|
* description: Creates a new SSH host configuration.
|
|
* tags:
|
|
* - SSH
|
|
* responses:
|
|
* 200:
|
|
* description: Host created successfully.
|
|
* 400:
|
|
* description: Invalid SSH data.
|
|
* 500:
|
|
* description: Failed to save SSH data.
|
|
*/
|
|
router.post(
|
|
["/db/host", "/enroll"],
|
|
authenticateJWT,
|
|
permissionManager.requirePermission("hosts.create"),
|
|
requireDataAccess,
|
|
requireHostEnrollmentAccessForPath,
|
|
upload.single("key"),
|
|
async (req: Request, res: Response) => {
|
|
const userId = (req as AuthenticatedRequest).userId;
|
|
let hostData: Record<string, unknown>;
|
|
|
|
if (req.headers["content-type"]?.includes("multipart/form-data")) {
|
|
if (req.body.data) {
|
|
try {
|
|
hostData = JSON.parse(req.body.data);
|
|
} catch (err) {
|
|
sshLogger.warn("Invalid JSON data in multipart request", {
|
|
operation: "host_create",
|
|
userId,
|
|
error: err,
|
|
});
|
|
return res.status(400).json({ error: "Invalid JSON data" });
|
|
}
|
|
} else {
|
|
sshLogger.warn("Missing data field in multipart request", {
|
|
operation: "host_create",
|
|
userId,
|
|
});
|
|
return res.status(400).json({ error: "Missing data field" });
|
|
}
|
|
|
|
if (req.file) {
|
|
hostData.key = req.file.buffer.toString("utf8");
|
|
}
|
|
} else {
|
|
hostData = req.body;
|
|
}
|
|
|
|
if (req.path === "/enroll") {
|
|
hostData = applyHostEnrollmentDefaults(hostData);
|
|
}
|
|
|
|
const {
|
|
connectionType,
|
|
name,
|
|
folder,
|
|
parentHostId,
|
|
tags,
|
|
ip,
|
|
port,
|
|
username,
|
|
password,
|
|
authMethod,
|
|
authType,
|
|
shareSshAuth,
|
|
credentialId,
|
|
key,
|
|
keyPassword,
|
|
keyType,
|
|
sudoPassword,
|
|
pin,
|
|
jumpHosts,
|
|
statusCheckEnabled,
|
|
statusCheckInterval,
|
|
terminalConfig,
|
|
sshOptions,
|
|
forceKeyboardInteractive,
|
|
notes,
|
|
useSocks5,
|
|
socks5Host,
|
|
socks5Port,
|
|
socks5Username,
|
|
socks5Password,
|
|
socks5ProxyChain,
|
|
connectionOrigin,
|
|
localOnly,
|
|
portKnockSequence,
|
|
overrideCredentialUsername,
|
|
enableSsh,
|
|
sshPort,
|
|
} = hostData;
|
|
const protocolAuthPatch = readProtocolAuthPayload(hostData);
|
|
databaseLogger.info("Creating SSH host", {
|
|
operation: "host_create",
|
|
userId,
|
|
name,
|
|
ip,
|
|
});
|
|
|
|
if (
|
|
!isNonEmptyString(userId) ||
|
|
!isNonEmptyString(ip) ||
|
|
!isValidPort(port) ||
|
|
!isOptionalBoolean(shareSshAuth) ||
|
|
!isOptionalBoolean(enableSsh) ||
|
|
!isOptionalAuthType(authType) ||
|
|
!isOptionalAuthType(authMethod)
|
|
) {
|
|
sshLogger.warn("Invalid SSH data input validation failed", {
|
|
operation: "host_create",
|
|
userId,
|
|
hasIp: !!ip,
|
|
port,
|
|
isValidPort: isValidPort(port),
|
|
});
|
|
return res.status(400).json({ error: "Invalid SSH data" });
|
|
}
|
|
|
|
let validatedParentHostId: number | null = null;
|
|
if (parentHostId !== undefined && parentHostId !== null) {
|
|
const numericParentHostId = Number(parentHostId);
|
|
if (!Number.isInteger(numericParentHostId)) {
|
|
return res.status(400).json({ error: "Invalid parent host" });
|
|
}
|
|
const parentError = await validateParentHostId(
|
|
userId,
|
|
null,
|
|
numericParentHostId,
|
|
);
|
|
if (parentError) {
|
|
return res.status(400).json({ error: parentError });
|
|
}
|
|
validatedParentHostId = numericParentHostId;
|
|
}
|
|
|
|
const effectiveConnectionType = connectionType || "ssh";
|
|
const effectiveAuthType =
|
|
authType ||
|
|
authMethod ||
|
|
(effectiveConnectionType !== "ssh"
|
|
? "password"
|
|
: impliedSshAuthType(hostData));
|
|
const effectiveUsername =
|
|
username || firstProtocolUsername(protocolAuthPatch);
|
|
const effectiveName =
|
|
name || (effectiveUsername ? `${effectiveUsername}@${ip}` : String(ip));
|
|
const sshDataObj: Record<string, unknown> = {
|
|
userId: userId,
|
|
connectionType: effectiveConnectionType,
|
|
name: effectiveName,
|
|
// A host is either placed in a folder or nested under a parent host,
|
|
// never both -- setting one clears the other.
|
|
folder: validatedParentHostId ? null : folder || null,
|
|
parentHostId: validatedParentHostId,
|
|
tags: Array.isArray(tags) ? tags.join(",") : tags || "",
|
|
ip,
|
|
port,
|
|
username: effectiveUsername,
|
|
authType: effectiveAuthType,
|
|
shareSshAuth: shareSshAuth === true ? 1 : 0,
|
|
credentialId: credentialId || null,
|
|
overrideCredentialUsername: overrideCredentialUsername ? 1 : 0,
|
|
pin: pin ? 1 : 0,
|
|
jumpHosts: Array.isArray(jumpHosts) ? JSON.stringify(jumpHosts) : null,
|
|
statusCheckEnabled: statusCheckEnabled === false ? 0 : 1,
|
|
statusCheckInterval: normalizeStatusInterval(statusCheckInterval),
|
|
sshOptions: sshOptionsForWrite({ sshOptions, terminalConfig }) ?? null,
|
|
forceKeyboardInteractive: forceKeyboardInteractive ? "true" : "false",
|
|
notes: notes || null,
|
|
sudoPassword: sudoPassword || null,
|
|
useSocks5: useSocks5 ? 1 : 0,
|
|
socks5Host: socks5Host || null,
|
|
socks5Port: socks5Port || null,
|
|
socks5Username: socks5Username || null,
|
|
socks5Password: socks5Password || null,
|
|
socks5ProxyChain: socks5ProxyChain
|
|
? JSON.stringify(socks5ProxyChain)
|
|
: null,
|
|
connectionOrigin:
|
|
connectionOrigin === "local" || connectionOrigin === "remote"
|
|
? connectionOrigin
|
|
: null,
|
|
...(typeof localOnly === "boolean" ? { localOnly } : {}),
|
|
portKnockSequence: portKnockSequence
|
|
? JSON.stringify(portKnockSequence)
|
|
: null,
|
|
...normalizeProtocolEnableFields(hostData),
|
|
sshPort: sshPort || port || 22,
|
|
};
|
|
|
|
// A host whose main protocol is a plugin's keeps any password it is given.
|
|
if (effectiveConnectionType !== "ssh") {
|
|
sshDataObj.password = password || null;
|
|
sshDataObj.key = null;
|
|
sshDataObj.keyPassword = null;
|
|
sshDataObj.keyType = null;
|
|
} else if (effectiveAuthType === "password") {
|
|
sshDataObj.password = password || null;
|
|
sshDataObj.key = null;
|
|
sshDataObj.keyPassword = null;
|
|
sshDataObj.keyType = null;
|
|
} else if (effectiveAuthType === "key") {
|
|
if (key && typeof key === "string") {
|
|
const keyValidation = parseKeyForStorage(
|
|
key,
|
|
typeof keyPassword === "string" ? keyPassword : undefined,
|
|
);
|
|
if (!keyValidation.success) {
|
|
sshLogger.warn("SSH key validation failed", {
|
|
operation: "host_create",
|
|
userId,
|
|
name,
|
|
ip,
|
|
port,
|
|
error: keyValidation.error,
|
|
});
|
|
return res.status(400).json({
|
|
error: `Invalid SSH key: ${keyValidation.error || "Unable to parse key"}`,
|
|
});
|
|
}
|
|
}
|
|
|
|
sshDataObj.key = key || null;
|
|
sshDataObj.keyPassword = keyPassword || null;
|
|
sshDataObj.keyType = keyType;
|
|
sshDataObj.password = password || null;
|
|
} else if (effectiveAuthType === "credential") {
|
|
sshDataObj.password = password || null;
|
|
sshDataObj.key = null;
|
|
sshDataObj.keyPassword = null;
|
|
sshDataObj.keyType = null;
|
|
} else if (effectiveAuthType === "agent") {
|
|
sshDataObj.password = null;
|
|
sshDataObj.key = null;
|
|
sshDataObj.keyPassword = null;
|
|
sshDataObj.keyType = null;
|
|
} else {
|
|
sshDataObj.password = null;
|
|
sshDataObj.key = null;
|
|
sshDataObj.keyPassword = null;
|
|
sshDataObj.keyType = null;
|
|
}
|
|
|
|
try {
|
|
sshDataObj.defaultOverrides = JSON.stringify(
|
|
await applyHostDefaultsToWrite({
|
|
ownerId: userId,
|
|
hostId: null,
|
|
columns: sshDataObj,
|
|
body: hostData,
|
|
}),
|
|
);
|
|
// Host defaults may have supplied it.
|
|
if (!sshDataObj.authType) {
|
|
return res.status(400).json({
|
|
error:
|
|
"authType is required (password, key, credential, agent or none)",
|
|
});
|
|
}
|
|
const result = await createCurrentHostRepository().createEncryptedForUser(
|
|
userId,
|
|
sshDataObj,
|
|
);
|
|
|
|
if (!result) {
|
|
sshLogger.warn("No host returned after creation", {
|
|
operation: "host_create",
|
|
userId,
|
|
name,
|
|
ip,
|
|
port,
|
|
});
|
|
return res.status(500).json({ error: "Failed to create host" });
|
|
}
|
|
|
|
const createdHost = result;
|
|
await applyDefaultsAfterHostWrite(createdHost.id as number);
|
|
if (protocolAuthPatch) {
|
|
await writeProtocolAuth(
|
|
userId,
|
|
createdHost.id as number,
|
|
protocolAuthPatch,
|
|
{ isOwner: true },
|
|
);
|
|
}
|
|
|
|
// Standing folder shares apply to the newcomer.
|
|
try {
|
|
await applyFolderAccessRules(
|
|
createdHost.id,
|
|
userId!,
|
|
createdHost.folder,
|
|
);
|
|
} catch (folderAccessError) {
|
|
sshLogger.warn("Failed to inherit folder access on host create", {
|
|
operation: "host_create_folder_access",
|
|
hostId: createdHost.id,
|
|
error: getErrorMessage(folderAccessError),
|
|
});
|
|
}
|
|
const baseHost = transformHostResponse(createdHost);
|
|
|
|
const resolvedHost =
|
|
(await resolveHostCredentials(baseHost, userId)) || baseHost;
|
|
databaseLogger.success("SSH host created", {
|
|
operation: "host_create_success",
|
|
userId,
|
|
hostId: createdHost.id as number,
|
|
name,
|
|
});
|
|
|
|
const { ipAddress: chIp, userAgent: chUa } = getRequestMeta(req);
|
|
await logAudit({
|
|
userId,
|
|
username: await getAuditUsername(userId),
|
|
action: "create_host",
|
|
resourceType: "host",
|
|
resourceId: String(createdHost.id),
|
|
resourceName: String(name ?? ip),
|
|
ipAddress: chIp,
|
|
userAgent: chUa,
|
|
success: true,
|
|
});
|
|
|
|
emitInternalEvent("host_added", userId, createdHost.id as number, {
|
|
name: String(name ?? ip),
|
|
});
|
|
|
|
res.json(await withProtocolAuth(stripSensitiveFields(resolvedHost)));
|
|
pluginEvents.emit(TOPICS.hostCreated, { hostId: createdHost.id, userId });
|
|
notifyStatsHostUpdated(createdHost.id as number, userId, "host_create");
|
|
} catch (err) {
|
|
sshLogger.error("Failed to save SSH host to database", err, {
|
|
operation: "host_create",
|
|
userId,
|
|
name,
|
|
ip,
|
|
port,
|
|
authType: effectiveAuthType,
|
|
});
|
|
res.status(500).json({ error: "Failed to save SSH data" });
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* @openapi
|
|
* /host/enroll:
|
|
* post:
|
|
* summary: Enroll a host with an API key
|
|
* description: Creates a host owned by the user assigned to the API key. The user's encrypted data must be unlocked by an active sign-in.
|
|
* tags:
|
|
* - Host Enrollment
|
|
* requestBody:
|
|
* required: true
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* required: [ip]
|
|
* properties:
|
|
* name:
|
|
* type: string
|
|
* ip:
|
|
* type: string
|
|
* port:
|
|
* type: integer
|
|
* minimum: 1
|
|
* maximum: 65535
|
|
* default: 22
|
|
* username:
|
|
* type: string
|
|
* authType:
|
|
* type: string
|
|
* enum: [none, password, key, credential, agent]
|
|
* default: none
|
|
* password:
|
|
* type: string
|
|
* folder:
|
|
* type: string
|
|
* tags:
|
|
* oneOf:
|
|
* - type: string
|
|
* - type: array
|
|
* items:
|
|
* type: string
|
|
* responses:
|
|
* 200:
|
|
* description: Host enrolled successfully.
|
|
* 400:
|
|
* description: Invalid host data.
|
|
* 401:
|
|
* description: Missing or invalid API key.
|
|
* 423:
|
|
* description: The API key user's encrypted data is locked.
|
|
* 500:
|
|
* description: Failed to enroll the host.
|
|
*/
|
|
/**
|
|
* @openapi
|
|
* /host/quick-connect:
|
|
* post:
|
|
* summary: Create a temporary SSH connection without saving to database
|
|
* description: Returns a temporary host configuration for immediate use
|
|
* tags:
|
|
* - SSH
|
|
* requestBody:
|
|
* required: true
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* required:
|
|
* - ip
|
|
* - port
|
|
* - username
|
|
* - authType
|
|
* properties:
|
|
* ip:
|
|
* type: string
|
|
* description: SSH server IP or hostname
|
|
* port:
|
|
* type: number
|
|
* description: SSH server port
|
|
* username:
|
|
* type: string
|
|
* description: SSH username
|
|
* authType:
|
|
* type: string
|
|
* description: Any registered SSH auth type that works for an unsaved host (password, key, credential, agent, none, or one a plugin marks for Quick Connect).
|
|
* password:
|
|
* type: string
|
|
* description: Password (required if authType is password)
|
|
* key:
|
|
* type: string
|
|
* description: SSH private key (required if authType is key)
|
|
* keyPassword:
|
|
* type: string
|
|
* description: SSH key password (optional)
|
|
* keyType:
|
|
* type: string
|
|
* description: SSH key type
|
|
* credentialId:
|
|
* type: number
|
|
* description: Credential ID (required if authType is credential)
|
|
* overrideCredentialUsername:
|
|
* type: boolean
|
|
* description: Use provided username instead of credential username
|
|
* responses:
|
|
* 200:
|
|
* description: Temporary host configuration created successfully
|
|
* 400:
|
|
* description: Invalid request data
|
|
* 401:
|
|
* description: Unauthorized
|
|
* 403:
|
|
* description: Forbidden
|
|
* 404:
|
|
* description: Credential not found
|
|
* 500:
|
|
* description: Server error
|
|
*/
|
|
router.post(
|
|
"/quick-connect",
|
|
authenticateJWT,
|
|
requireDataAccess,
|
|
async (req: AuthenticatedRequest, res: Response) => {
|
|
const userId = req.userId;
|
|
const {
|
|
ip,
|
|
port,
|
|
username,
|
|
authType,
|
|
password,
|
|
key,
|
|
keyPassword,
|
|
keyType,
|
|
credentialId,
|
|
overrideCredentialUsername,
|
|
} = req.body;
|
|
|
|
if (
|
|
!isNonEmptyString(ip) ||
|
|
!isValidPort(port) ||
|
|
!isNonEmptyString(username) ||
|
|
!authType
|
|
) {
|
|
return res.status(400).json({ error: "Missing required fields" });
|
|
}
|
|
|
|
ensureCoreSshAuthProviders();
|
|
if (!isQuickConnectAuthType(String(authType))) {
|
|
return res
|
|
.status(400)
|
|
.json({ error: "That auth type cannot be used for Quick Connect" });
|
|
}
|
|
|
|
try {
|
|
let resolvedPassword = password;
|
|
let resolvedKey = key;
|
|
let resolvedKeyPassword = keyPassword;
|
|
let resolvedKeyType = keyType;
|
|
let resolvedAuthType = authType;
|
|
let resolvedUsername = username;
|
|
|
|
if (authType === "credential" && credentialId) {
|
|
const cred = await findUsableCredential(Number(credentialId), userId);
|
|
|
|
if (!cred) {
|
|
return res.status(404).json({ error: "Credential not found" });
|
|
}
|
|
|
|
resolvedPassword = pickResolvedPassword(password, cred.password) as
|
|
string | undefined;
|
|
resolvedKey = cred.privateKey as string | undefined;
|
|
resolvedKeyPassword = cred.keyPassword as string | undefined;
|
|
resolvedKeyType = cred.keyType as string | undefined;
|
|
resolvedAuthType = cred.authType as string | undefined;
|
|
|
|
if (!overrideCredentialUsername) {
|
|
resolvedUsername = cred.username as string;
|
|
}
|
|
}
|
|
|
|
const tempHost: Record<string, unknown> = {
|
|
id: -Date.now(),
|
|
userId: userId,
|
|
name: `${resolvedUsername}@${ip}:${port}`,
|
|
ip,
|
|
port: Number(port),
|
|
username: resolvedUsername,
|
|
folder: "",
|
|
tags: [],
|
|
pin: false,
|
|
authType: resolvedAuthType || authType,
|
|
password: resolvedPassword,
|
|
key: resolvedKey,
|
|
keyPassword: resolvedKeyPassword,
|
|
keyType: resolvedKeyType,
|
|
jumpHosts: [],
|
|
statusCheckEnabled: true,
|
|
statusCheckInterval: null,
|
|
notes: "",
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
};
|
|
|
|
return res.status(200).json(tempHost);
|
|
} catch (error) {
|
|
sshLogger.error("Quick connect failed", error, {
|
|
operation: "quick_connect",
|
|
userId,
|
|
ip,
|
|
port,
|
|
authType,
|
|
});
|
|
return res
|
|
.status(500)
|
|
.json({ error: "Failed to create quick connection" });
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* @openapi
|
|
* /host/db/host/{id}:
|
|
* put:
|
|
* summary: Update SSH host
|
|
* description: Updates an existing SSH host configuration.
|
|
* tags:
|
|
* - SSH
|
|
* parameters:
|
|
* - in: path
|
|
* name: id
|
|
* required: true
|
|
* schema:
|
|
* type: integer
|
|
* responses:
|
|
* 200:
|
|
* description: Host updated successfully.
|
|
* 400:
|
|
* description: Invalid SSH data.
|
|
* 403:
|
|
* description: Access denied.
|
|
* 404:
|
|
* description: Host not found.
|
|
* 500:
|
|
* description: Failed to update SSH data.
|
|
*/
|
|
router.put(
|
|
"/db/host/:id",
|
|
authenticateJWT,
|
|
permissionManager.requirePermission("hosts.edit"),
|
|
requireDataAccess,
|
|
upload.single("key"),
|
|
async (req: Request, res: Response) => {
|
|
const hostId = Array.isArray(req.params.id)
|
|
? req.params.id[0]
|
|
: req.params.id;
|
|
const userId = (req as AuthenticatedRequest).userId;
|
|
let hostData: Record<string, unknown>;
|
|
|
|
if (req.headers["content-type"]?.includes("multipart/form-data")) {
|
|
if (req.body.data) {
|
|
try {
|
|
hostData = JSON.parse(req.body.data);
|
|
} catch (err) {
|
|
sshLogger.warn("Invalid JSON data in multipart request", {
|
|
operation: "host_update",
|
|
hostId: parseInt(hostId),
|
|
userId,
|
|
error: err,
|
|
});
|
|
return res.status(400).json({ error: "Invalid JSON data" });
|
|
}
|
|
} else {
|
|
sshLogger.warn("Missing data field in multipart request", {
|
|
operation: "host_update",
|
|
hostId: parseInt(hostId),
|
|
userId,
|
|
});
|
|
return res.status(400).json({ error: "Missing data field" });
|
|
}
|
|
|
|
if (req.file) {
|
|
hostData.key = req.file.buffer.toString("utf8");
|
|
}
|
|
} else {
|
|
hostData = req.body;
|
|
}
|
|
|
|
const {
|
|
connectionType,
|
|
name,
|
|
folder,
|
|
parentHostId,
|
|
tags,
|
|
ip,
|
|
port,
|
|
username,
|
|
password,
|
|
authMethod,
|
|
authType,
|
|
shareSshAuth,
|
|
credentialId,
|
|
key,
|
|
keyPassword,
|
|
keyType,
|
|
sudoPassword,
|
|
pin,
|
|
jumpHosts,
|
|
statusCheckEnabled,
|
|
statusCheckInterval,
|
|
terminalConfig,
|
|
sshOptions,
|
|
forceKeyboardInteractive,
|
|
notes,
|
|
useSocks5,
|
|
socks5Host,
|
|
socks5Port,
|
|
socks5Username,
|
|
socks5Password,
|
|
socks5ProxyChain,
|
|
connectionOrigin,
|
|
localOnly,
|
|
portKnockSequence,
|
|
overrideCredentialUsername,
|
|
enableSsh,
|
|
sshPort,
|
|
} = hostData;
|
|
const protocolAuthPatch = readProtocolAuthPayload(hostData);
|
|
databaseLogger.info("Updating SSH host", {
|
|
operation: "host_update",
|
|
userId,
|
|
hostId: parseInt(hostId),
|
|
changes: Object.keys(hostData),
|
|
});
|
|
|
|
if (
|
|
!isNonEmptyString(userId) ||
|
|
!isNonEmptyString(ip) ||
|
|
!isValidPort(port) ||
|
|
!isOptionalBoolean(shareSshAuth) ||
|
|
!isOptionalBoolean(enableSsh) ||
|
|
!isOptionalAuthType(authType) ||
|
|
!isOptionalAuthType(authMethod) ||
|
|
!hostId
|
|
) {
|
|
sshLogger.warn("Invalid SSH data input validation failed for update", {
|
|
operation: "host_update",
|
|
hostId: parseInt(hostId),
|
|
userId,
|
|
hasIp: !!ip,
|
|
port,
|
|
isValidPort: isValidPort(port),
|
|
});
|
|
return res.status(400).json({ error: "Invalid SSH data" });
|
|
}
|
|
|
|
let validatedParentHostId: number | null | undefined = undefined;
|
|
if (parentHostId !== undefined) {
|
|
if (parentHostId === null) {
|
|
validatedParentHostId = null;
|
|
} else {
|
|
const numericParentHostId = Number(parentHostId);
|
|
if (!Number.isInteger(numericParentHostId)) {
|
|
return res.status(400).json({ error: "Invalid parent host" });
|
|
}
|
|
const parentError = await validateParentHostId(
|
|
userId,
|
|
Number(hostId),
|
|
numericParentHostId,
|
|
);
|
|
if (parentError) {
|
|
return res.status(400).json({ error: parentError });
|
|
}
|
|
validatedParentHostId = numericParentHostId;
|
|
}
|
|
}
|
|
|
|
const effectiveAuthType = authType || authMethod;
|
|
const effectiveUsername =
|
|
username || firstProtocolUsername(protocolAuthPatch);
|
|
const effectiveName =
|
|
name || (effectiveUsername ? `${effectiveUsername}@${ip}` : String(ip));
|
|
const sshDataObj: Record<string, unknown> = {
|
|
connectionType: connectionType || "ssh",
|
|
name: effectiveName,
|
|
// A host is either placed in a folder or nested under a parent host,
|
|
// never both. When the caller is assigning a parent, clear folder;
|
|
// when the caller is assigning a folder, clear parentHostId.
|
|
folder: validatedParentHostId ? null : folder,
|
|
tags: Array.isArray(tags) ? tags.join(",") : tags || "",
|
|
ip,
|
|
port,
|
|
username: effectiveUsername,
|
|
authType: effectiveAuthType,
|
|
shareSshAuth: shareSshAuth === true ? 1 : 0,
|
|
credentialId: credentialId || null,
|
|
overrideCredentialUsername: overrideCredentialUsername ? 1 : 0,
|
|
pin: pin ? 1 : 0,
|
|
jumpHosts: Array.isArray(jumpHosts) ? JSON.stringify(jumpHosts) : null,
|
|
statusCheckEnabled: statusCheckEnabled === false ? 0 : 1,
|
|
statusCheckInterval: normalizeStatusInterval(statusCheckInterval),
|
|
forceKeyboardInteractive: forceKeyboardInteractive ? "true" : "false",
|
|
notes: notes || null,
|
|
useSocks5: useSocks5 ? 1 : 0,
|
|
socks5Host: socks5Host || null,
|
|
socks5Port: socks5Port || null,
|
|
socks5Username: socks5Username || null,
|
|
socks5Password: socks5Password || null,
|
|
socks5ProxyChain: socks5ProxyChain
|
|
? JSON.stringify(socks5ProxyChain)
|
|
: null,
|
|
connectionOrigin:
|
|
connectionOrigin === "local" || connectionOrigin === "remote"
|
|
? connectionOrigin
|
|
: null,
|
|
...(typeof localOnly === "boolean" ? { localOnly } : {}),
|
|
portKnockSequence: portKnockSequence
|
|
? JSON.stringify(portKnockSequence)
|
|
: null,
|
|
...normalizeProtocolEnableFields(hostData),
|
|
sshPort: sshPort || port || 22,
|
|
};
|
|
|
|
const nextSshOptions = sshOptionsForWrite({ sshOptions, terminalConfig });
|
|
if (nextSshOptions !== undefined) sshDataObj.sshOptions = nextSshOptions;
|
|
// The editor leaves sudoPassword out when the user did not touch it.
|
|
if (sudoPassword !== undefined) {
|
|
sshDataObj.sudoPassword = sudoPassword || null;
|
|
}
|
|
|
|
// A host whose main protocol is a plugin's keeps any password it is given.
|
|
if ((connectionType || "ssh") !== "ssh") {
|
|
if (password) {
|
|
sshDataObj.password = password;
|
|
}
|
|
sshDataObj.key = null;
|
|
sshDataObj.keyPassword = null;
|
|
sshDataObj.keyType = null;
|
|
} else if (keepsStoredSshAuth(effectiveAuthType)) {
|
|
// The update says nothing about authentication.
|
|
} else if (effectiveAuthType === "password") {
|
|
if (password) {
|
|
sshDataObj.password = password;
|
|
}
|
|
sshDataObj.key = null;
|
|
sshDataObj.keyPassword = null;
|
|
sshDataObj.keyType = null;
|
|
} else if (effectiveAuthType === "key") {
|
|
if (key && typeof key === "string") {
|
|
const keyValidation = parseKeyForStorage(
|
|
key,
|
|
typeof keyPassword === "string" ? keyPassword : undefined,
|
|
);
|
|
if (!keyValidation.success) {
|
|
sshLogger.warn("SSH key validation failed", {
|
|
operation: "host_update",
|
|
hostId: parseInt(hostId),
|
|
userId,
|
|
name,
|
|
ip,
|
|
port,
|
|
error: keyValidation.error,
|
|
});
|
|
return res.status(400).json({
|
|
error: `Invalid SSH key: ${keyValidation.error || "Unable to parse key"}`,
|
|
});
|
|
}
|
|
|
|
sshDataObj.key = key;
|
|
}
|
|
if (keyPassword !== undefined) {
|
|
sshDataObj.keyPassword = keyPassword || null;
|
|
}
|
|
applyHostKeyTypeUpdate(sshDataObj, keyType);
|
|
sshDataObj.password = password || null;
|
|
} else if (effectiveAuthType === "credential") {
|
|
sshDataObj.password = password || null;
|
|
sshDataObj.key = null;
|
|
sshDataObj.keyPassword = null;
|
|
sshDataObj.keyType = null;
|
|
} else if (effectiveAuthType === "agent") {
|
|
sshDataObj.password = null;
|
|
sshDataObj.key = null;
|
|
sshDataObj.keyPassword = null;
|
|
sshDataObj.keyType = null;
|
|
} else {
|
|
sshDataObj.password = null;
|
|
sshDataObj.key = null;
|
|
sshDataObj.keyPassword = null;
|
|
sshDataObj.keyType = null;
|
|
}
|
|
|
|
if (validatedParentHostId !== undefined) {
|
|
sshDataObj.parentHostId = validatedParentHostId;
|
|
} else if (folder !== undefined) {
|
|
// Caller is assigning a folder (including clearing it back to root)
|
|
// without touching parentHostId -- folder placement replaces
|
|
// parent-host placement either way.
|
|
sshDataObj.parentHostId = null;
|
|
}
|
|
|
|
try {
|
|
const accessInfo = await permissionManager.canAccessHost(
|
|
userId,
|
|
Number(hostId),
|
|
"edit",
|
|
);
|
|
|
|
if (!accessInfo.hasAccess) {
|
|
sshLogger.warn("User does not have permission to update host", {
|
|
operation: "host_update",
|
|
hostId: parseInt(hostId),
|
|
userId,
|
|
});
|
|
return res.status(403).json({ error: "Access denied" });
|
|
}
|
|
|
|
const hostRecord =
|
|
await createCurrentHostResolutionRepository().findHostUpdateState(
|
|
Number(hostId),
|
|
);
|
|
|
|
if (!hostRecord) {
|
|
sshLogger.warn("Host not found for update", {
|
|
operation: "host_update",
|
|
hostId: parseInt(hostId),
|
|
userId,
|
|
});
|
|
return res.status(404).json({ error: "Host not found" });
|
|
}
|
|
|
|
const ownerId = hostRecord.userId;
|
|
|
|
if (!accessInfo.isOwner) {
|
|
// Shared editors work on the owner's real record, but the owner's SSH
|
|
// authentication is private and can only be changed by that owner.
|
|
if (containsOwnerPrivateAuthUpdate(hostData, "ssh")) {
|
|
return res.status(403).json({
|
|
error:
|
|
"Only the host owner can change the host's SSH authentication",
|
|
});
|
|
}
|
|
|
|
const incomingTerminalConfig = parseTerminalConfig(
|
|
hostData.terminalConfig,
|
|
);
|
|
const protectedTerminalConfigField =
|
|
OWNER_PRIVATE_TERMINAL_CONFIG_FIELDS.find((field) =>
|
|
Object.prototype.hasOwnProperty.call(
|
|
incomingTerminalConfig ?? {},
|
|
field,
|
|
),
|
|
);
|
|
const incomingSshOptions = parseTerminalConfig(hostData.sshOptions);
|
|
const protectedSshOptionField = OWNER_PRIVATE_SSH_OPTION_FIELDS.find(
|
|
(field) =>
|
|
Object.prototype.hasOwnProperty.call(
|
|
incomingSshOptions ?? {},
|
|
field,
|
|
),
|
|
);
|
|
if (protectedTerminalConfigField || protectedSshOptionField) {
|
|
return res.status(403).json({
|
|
error:
|
|
"Only the host owner can change private SSH authentication settings",
|
|
});
|
|
}
|
|
|
|
for (const field of OWNER_PRIVATE_AUTH_FIELDS.ssh) {
|
|
delete sshDataObj[field];
|
|
}
|
|
}
|
|
|
|
let protocolAuthPlan: PlannedProtocolAuth | null = null;
|
|
if (protocolAuthPatch) {
|
|
try {
|
|
protocolAuthPlan = await planProtocolAuthWrite(
|
|
ownerId,
|
|
Number(hostId),
|
|
protocolAuthPatch,
|
|
{ isOwner: accessInfo.isOwner },
|
|
);
|
|
} catch (error) {
|
|
if (error instanceof ProtocolAuthWriteError) {
|
|
return res.status(error.status).json({ error: error.message });
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
const terminalFieldsError = await mergeStoredSshOptions(
|
|
sshDataObj,
|
|
hostData,
|
|
Number(hostId),
|
|
ownerId,
|
|
accessInfo.isOwner,
|
|
);
|
|
if (terminalFieldsError) {
|
|
return res.status(400).json({ error: terminalFieldsError });
|
|
}
|
|
|
|
const storedRow = (
|
|
await createCurrentHostDefaultsRepository().listHosts({
|
|
hostIds: [Number(hostId)],
|
|
})
|
|
)[0];
|
|
sshDataObj.defaultOverrides = JSON.stringify(
|
|
await applyHostDefaultsToWrite({
|
|
ownerId,
|
|
hostId: Number(hostId),
|
|
columns: sshDataObj,
|
|
body: hostData,
|
|
stored: storedRow,
|
|
lockedKeys: accessInfo.isOwner ? [] : ["auth"],
|
|
}),
|
|
);
|
|
|
|
await createCurrentHostRepository().updateEncryptedForUser(
|
|
ownerId,
|
|
Number(hostId),
|
|
sshDataObj,
|
|
);
|
|
await applyDefaultsAfterHostWrite(Number(hostId), {
|
|
moved:
|
|
!!storedRow &&
|
|
((sshDataObj.folder !== undefined &&
|
|
(sshDataObj.folder ?? null) !== (storedRow.folder ?? null)) ||
|
|
(sshDataObj.parentHostId !== undefined &&
|
|
(sshDataObj.parentHostId ?? null) !==
|
|
(storedRow.parentHostId ?? null))),
|
|
ownerId,
|
|
});
|
|
if (protocolAuthPlan) {
|
|
await applyProtocolAuthPlan(ownerId, Number(hostId), protocolAuthPlan);
|
|
}
|
|
|
|
// A host that moved into a folder inherits that folder's standing shares.
|
|
try {
|
|
await applyFolderAccessRules(
|
|
Number(hostId),
|
|
ownerId,
|
|
sshDataObj.folder as string | null | undefined,
|
|
);
|
|
} catch (folderAccessError) {
|
|
sshLogger.warn("Failed to inherit folder access on host update", {
|
|
operation: "host_update_folder_access",
|
|
hostId: parseInt(hostId),
|
|
error: getErrorMessage(folderAccessError),
|
|
});
|
|
}
|
|
|
|
// Keep every recipient's re-encrypted secret snapshots in sync with
|
|
// the updated host record.
|
|
try {
|
|
const { SharedHostSecretsManager } =
|
|
await import("../../utils/shared-host-secrets-manager.js");
|
|
await SharedHostSecretsManager.getInstance().resyncHost(Number(hostId));
|
|
} catch (resyncError) {
|
|
sshLogger.warn("Failed to resync shared host secrets after update", {
|
|
operation: "host_update_resync",
|
|
hostId: parseInt(hostId),
|
|
error: getErrorMessage(resyncError),
|
|
});
|
|
}
|
|
|
|
const updatedHost =
|
|
await createCurrentHostResolutionRepository().findHostById(
|
|
Number(hostId),
|
|
ownerId,
|
|
);
|
|
|
|
if (!updatedHost) {
|
|
sshLogger.warn("Updated host not found after update", {
|
|
operation: "host_update",
|
|
hostId: parseInt(hostId),
|
|
userId,
|
|
});
|
|
return res.status(404).json({ error: "Host not found after update" });
|
|
}
|
|
|
|
const baseHost = transformHostResponse(updatedHost);
|
|
|
|
const resolvedHost =
|
|
(await resolveHostCredentials(baseHost, userId)) || baseHost;
|
|
databaseLogger.success("SSH host updated", {
|
|
operation: "host_update_success",
|
|
userId,
|
|
hostId: parseInt(hostId),
|
|
});
|
|
|
|
const { ipAddress: uhIp, userAgent: uhUa } = getRequestMeta(req);
|
|
await logAudit({
|
|
userId,
|
|
username: await getAuditUsername(userId),
|
|
action: "update_host",
|
|
resourceType: "host",
|
|
resourceId: hostId,
|
|
resourceName: String(name ?? ip),
|
|
ipAddress: uhIp,
|
|
userAgent: uhUa,
|
|
success: true,
|
|
});
|
|
|
|
res.json(await withProtocolAuth(stripSensitiveFields(resolvedHost)));
|
|
notifyStatsHostUpdated(parseInt(hostId), userId, "host_update");
|
|
} catch (err) {
|
|
sshLogger.error("Failed to update SSH host in database", err, {
|
|
operation: "host_update",
|
|
hostId: parseInt(hostId),
|
|
userId,
|
|
name,
|
|
ip,
|
|
port,
|
|
authType: effectiveAuthType,
|
|
});
|
|
res.status(500).json({ error: "Failed to update SSH data" });
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* @openapi
|
|
* /host/db/host:
|
|
* get:
|
|
* summary: Get all SSH hosts
|
|
* description: Retrieves all SSH hosts for the authenticated user.
|
|
* tags:
|
|
* - SSH
|
|
* responses:
|
|
* 200:
|
|
* description: A list of SSH hosts.
|
|
* 400:
|
|
* description: Invalid userId.
|
|
* 500:
|
|
* description: Failed to fetch SSH data.
|
|
*/
|
|
router.get(
|
|
"/db/host",
|
|
authenticateJWT,
|
|
permissionManager.requirePermission("hosts.view"),
|
|
requireDataAccess,
|
|
async (req: Request, res: Response) => {
|
|
const userId = (req as AuthenticatedRequest).userId;
|
|
if (!isNonEmptyString(userId)) {
|
|
sshLogger.warn("Invalid userId for SSH data fetch", {
|
|
operation: "host_fetch",
|
|
userId,
|
|
});
|
|
return res.status(400).json({ error: "Invalid userId" });
|
|
}
|
|
try {
|
|
const now = new Date().toISOString();
|
|
|
|
const roleIds =
|
|
await createCurrentRoleRepository().listUserRoleIds(userId);
|
|
const accessEntries =
|
|
await createCurrentRbacAccessRepository().listVisibleHostAccessEntries(
|
|
userId,
|
|
roleIds,
|
|
now,
|
|
);
|
|
|
|
const rawData =
|
|
await createCurrentHostResolutionRepository().listHostRowsForAccessList(
|
|
userId,
|
|
accessEntries,
|
|
);
|
|
|
|
const ownHosts = rawData.filter((row) => row.userId === userId);
|
|
const sharedHosts = rawData.filter((row) => row.userId !== userId);
|
|
|
|
const decryptedOwnHosts: Record<string, unknown>[] = [];
|
|
const userDataKey = DataCrypto.getUserDataKey(userId);
|
|
if (userDataKey) {
|
|
for (const host of ownHosts) {
|
|
try {
|
|
decryptedOwnHosts.push(
|
|
DataCrypto.decryptRecord("ssh_data", host, userId, userDataKey),
|
|
);
|
|
} catch (decryptError) {
|
|
sshLogger.warn("Skipping host with invalid encrypted fields", {
|
|
operation: "host_fetch_own_decrypt_failed",
|
|
userId,
|
|
hostId: host.id,
|
|
error: getErrorMessage(decryptError),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// One lookup for every owner rather than one per shared host.
|
|
const ownerUsernames = new Map<string, string>();
|
|
const ownerIds = Array.from(
|
|
new Set(sharedHosts.map((host) => host.userId as string)),
|
|
);
|
|
if (ownerIds.length > 0) {
|
|
try {
|
|
const owners =
|
|
await createCurrentUserRepository().listByIds(ownerIds);
|
|
for (const owner of owners) {
|
|
ownerUsernames.set(owner.id, owner.username ?? "");
|
|
}
|
|
} catch {
|
|
// Falls through to an undefined ownerUsername below.
|
|
}
|
|
}
|
|
|
|
const data = [...decryptedOwnHosts, ...sharedHosts];
|
|
|
|
// Own hosts all resolve against the caller's own credentials, so they can
|
|
// be fetched and decrypted in one batch instead of once per host.
|
|
const ownCredentialIds = decryptedOwnHosts
|
|
.map((host) => host.credentialId)
|
|
.filter((id): id is number => typeof id === "number");
|
|
const credentialsById = await createCurrentHostResolutionRepository()
|
|
.listCredentialsByIdsForUser(ownCredentialIds, userId)
|
|
.catch(() => new Map<number, HostResolutionCredentialRecord>());
|
|
|
|
const result = await Promise.all(
|
|
data.map(async (row: Record<string, unknown>) => {
|
|
const transformed = transformHostResponse(row);
|
|
const baseHost = {
|
|
...transformed,
|
|
isShared: !!row.isShared || !!transformed.sharedCopy,
|
|
permissionLevel:
|
|
row.permissionLevel || transformed.permissionLevel || undefined,
|
|
sharedExpiresAt: row.expiresAt || undefined,
|
|
ownerUsername: row.isShared
|
|
? ownerUsernames.get(row.userId as string) || undefined
|
|
: transformed.ownerUsername,
|
|
};
|
|
|
|
const resolved =
|
|
(await resolveHostCredentials(baseHost, userId, credentialsById)) ||
|
|
baseHost;
|
|
return resolved;
|
|
}),
|
|
);
|
|
|
|
attachProtocolAuth(result, await loadProtocolAuthSummaries(result));
|
|
const sanitized = result.map((host) =>
|
|
host.isShared
|
|
? sanitizeHostForRecipient(
|
|
host,
|
|
host.permissionLevel as string | undefined,
|
|
)
|
|
: stripSensitiveFields(host),
|
|
);
|
|
|
|
// After sanitizing: the connect-level projection reduces a shared host
|
|
// to an allowlist, which would drop this again. One query for the list.
|
|
const pluginSettingsByHost = await loadHostPluginSettings(
|
|
sanitized
|
|
.map((host) => Number(host.id))
|
|
.filter((id) => Number.isInteger(id)),
|
|
);
|
|
await applyPersonalHostValues(pluginSettingsByHost, userId).catch(
|
|
() => {},
|
|
);
|
|
attachHostPluginSettings(sanitized, pluginSettingsByHost);
|
|
|
|
res.json(sanitized);
|
|
} catch (err) {
|
|
sshLogger.error("Failed to fetch SSH hosts from database", err, {
|
|
operation: "host_fetch",
|
|
userId,
|
|
});
|
|
res.status(500).json({ error: "Failed to fetch SSH data" });
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* @openapi
|
|
* /host/db/host/{id}:
|
|
* get:
|
|
* summary: Get SSH host by ID
|
|
* description: Retrieves a specific SSH host by its ID.
|
|
* tags:
|
|
* - SSH
|
|
* parameters:
|
|
* - in: path
|
|
* name: id
|
|
* required: true
|
|
* schema:
|
|
* type: integer
|
|
* responses:
|
|
* 200:
|
|
* description: The requested SSH host.
|
|
* 400:
|
|
* description: Invalid userId or hostId.
|
|
* 404:
|
|
* description: SSH host not found.
|
|
* 500:
|
|
* description: Failed to fetch SSH host.
|
|
*/
|
|
router.get(
|
|
"/db/host/:id",
|
|
authenticateJWT,
|
|
permissionManager.requirePermission("hosts.view"),
|
|
requireDataAccess,
|
|
async (req: Request, res: Response) => {
|
|
const hostId = Array.isArray(req.params.id)
|
|
? req.params.id[0]
|
|
: req.params.id;
|
|
const userId = (req as AuthenticatedRequest).userId;
|
|
|
|
if (!isNonEmptyString(userId) || !hostId) {
|
|
sshLogger.warn("Invalid userId or hostId for SSH host fetch by ID", {
|
|
operation: "host_fetch_by_id",
|
|
hostId: parseInt(hostId),
|
|
userId,
|
|
});
|
|
return res.status(400).json({ error: "Invalid userId or hostId" });
|
|
}
|
|
try {
|
|
const hostResolutionRepository = createCurrentHostResolutionRepository();
|
|
const host = await hostResolutionRepository.findHostByIdForUser(
|
|
Number(hostId),
|
|
userId,
|
|
);
|
|
|
|
if (host) {
|
|
const result = transformHostResponse(host);
|
|
const resolved =
|
|
(await resolveHostCredentials(result, userId)) || result;
|
|
|
|
return res.json(
|
|
await withHostPluginSettings(
|
|
await withProtocolAuth(stripSensitiveFields(resolved)),
|
|
),
|
|
);
|
|
}
|
|
|
|
// Not the owner: shared recipients get a sanitized view of the host.
|
|
const accessInfo = await permissionManager.canAccessHost(
|
|
userId,
|
|
Number(hostId),
|
|
"connect",
|
|
);
|
|
|
|
if (!accessInfo.hasAccess) {
|
|
sshLogger.warn("SSH host not found", {
|
|
operation: "host_fetch_by_id",
|
|
hostId: parseInt(hostId),
|
|
userId,
|
|
});
|
|
return res.status(404).json({ error: "SSH host not found" });
|
|
}
|
|
|
|
const ownerId = await hostResolutionRepository.findHostOwnerId(
|
|
Number(hostId),
|
|
);
|
|
const sharedHost = ownerId
|
|
? await hostResolutionRepository.findHostById(Number(hostId), ownerId)
|
|
: null;
|
|
|
|
if (!sharedHost) {
|
|
return res.status(404).json({ error: "SSH host not found" });
|
|
}
|
|
|
|
let ownerUsername: string | undefined;
|
|
try {
|
|
const owner = ownerId
|
|
? await createCurrentUserRepository().findById(ownerId)
|
|
: null;
|
|
ownerUsername = owner?.username ?? undefined;
|
|
} catch {
|
|
ownerUsername = undefined;
|
|
}
|
|
|
|
const sharedResult = {
|
|
...transformHostResponse(sharedHost),
|
|
isShared: true,
|
|
permissionLevel: accessInfo.permissionLevel,
|
|
sharedExpiresAt: accessInfo.expiresAt || undefined,
|
|
ownerUsername,
|
|
};
|
|
const resolvedSharedResult = await withProtocolAuth(
|
|
(await resolveHostCredentials(sharedResult, userId)) || sharedResult,
|
|
);
|
|
|
|
res.json(
|
|
await withHostPluginSettings(
|
|
sanitizeHostForRecipient(
|
|
resolvedSharedResult,
|
|
accessInfo.permissionLevel,
|
|
),
|
|
userId,
|
|
),
|
|
);
|
|
} catch (err) {
|
|
sshLogger.error("Failed to fetch SSH host by ID from database", err, {
|
|
operation: "host_fetch_by_id",
|
|
hostId: parseInt(hostId),
|
|
userId,
|
|
});
|
|
res.status(500).json({ error: "Failed to fetch SSH host" });
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* @openapi
|
|
* /host/db/host/{id}/local-connection-auth:
|
|
* get:
|
|
* summary: Get the login a desktop needs to reach a shared host itself
|
|
* description: The minimum authentication material for connecting to a shared host from the recipient's own network. Transient; callers must not store or log it.
|
|
* tags:
|
|
* - SSH
|
|
* parameters:
|
|
* - in: path
|
|
* name: id
|
|
* required: true
|
|
* schema:
|
|
* type: integer
|
|
* responses:
|
|
* 200:
|
|
* description: The connection auth.
|
|
* 400:
|
|
* description: Invalid id.
|
|
* 404:
|
|
* description: Shared host not found.
|
|
*/
|
|
router.get(
|
|
"/db/host/:id/local-connection-auth",
|
|
authenticateJWT,
|
|
permissionManager.requirePermission("hosts.view"),
|
|
requireDataAccess,
|
|
async (req: Request, res: Response) => {
|
|
const hostId = Number(req.params.id);
|
|
const userId = (req as AuthenticatedRequest).userId;
|
|
|
|
if (!isNonEmptyString(userId) || !Number.isInteger(hostId) || hostId <= 0) {
|
|
return res.status(400).json({ error: "Invalid userId or hostId" });
|
|
}
|
|
|
|
try {
|
|
const access = await permissionManager.canAccessHost(
|
|
userId,
|
|
hostId,
|
|
"connect",
|
|
);
|
|
if (!access.hasAccess || !access.isShared) {
|
|
return res.status(404).json({ error: "Shared host not found" });
|
|
}
|
|
|
|
const repository = createCurrentHostResolutionRepository();
|
|
const ownerId = await repository.findHostOwnerId(hostId);
|
|
const host = ownerId
|
|
? await repository.findHostById(hostId, ownerId)
|
|
: null;
|
|
if (!host) {
|
|
return res.status(404).json({ error: "Shared host not found" });
|
|
}
|
|
|
|
const resolved = await resolveHostCredentials(
|
|
{
|
|
...transformHostResponse(host),
|
|
isShared: true,
|
|
permissionLevel: access.permissionLevel,
|
|
},
|
|
userId,
|
|
);
|
|
|
|
res.setHeader("Cache-Control", "no-store");
|
|
return res.json({
|
|
username: resolved.username,
|
|
authType: resolved.authType,
|
|
password: resolved.password || null,
|
|
key: resolved.key || null,
|
|
keyPassword: resolved.keyPassword || null,
|
|
keyType: resolved.keyType || null,
|
|
});
|
|
} catch (error) {
|
|
sshLogger.error(
|
|
"Failed to resolve shared host local authentication",
|
|
error,
|
|
{
|
|
operation: "shared_host_local_auth_resolve",
|
|
hostId,
|
|
userId,
|
|
},
|
|
);
|
|
return res
|
|
.status(500)
|
|
.json({ error: "Failed to resolve shared host authentication" });
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* @openapi
|
|
* /host/db/host/{id}/password:
|
|
* get:
|
|
* summary: Get host password for clipboard copy
|
|
* description: Returns the password for a specific host. Used by the copy-password feature.
|
|
* tags:
|
|
* - SSH
|
|
* parameters:
|
|
* - in: path
|
|
* name: id
|
|
* required: true
|
|
* schema:
|
|
* type: integer
|
|
* - in: query
|
|
* name: field
|
|
* schema:
|
|
* type: string
|
|
* enum: [password, sudoPassword, key, keyPassword]
|
|
* - in: query
|
|
* name: protocol
|
|
* schema:
|
|
* type: string
|
|
* description: A plugin protocol id; returns that protocol's saved password instead of field.
|
|
* responses:
|
|
* 200:
|
|
* description: The requested password value.
|
|
* 404:
|
|
* description: Host not found or no password set.
|
|
*/
|
|
router.get(
|
|
"/db/host/:id/password",
|
|
authenticateJWT,
|
|
requireDataAccess,
|
|
async (req: Request, res: Response) => {
|
|
const hostId = Number(req.params.id);
|
|
const userId = (req as AuthenticatedRequest).userId;
|
|
const field = (req.query.field as string) || "password";
|
|
const coreField = [
|
|
"password",
|
|
"sudoPassword",
|
|
"key",
|
|
"keyPassword",
|
|
].includes(field);
|
|
// 2.8 clients ask for "<protocol>Password".
|
|
const protocol =
|
|
typeof req.query.protocol === "string"
|
|
? req.query.protocol
|
|
: coreField
|
|
? undefined
|
|
: /^([a-z][a-zA-Z0-9]*)Password$/.exec(field)?.[1];
|
|
const protocolId = protocol ? findHostProtocol(protocol)?.id : undefined;
|
|
|
|
if (!protocolId && (protocol || !coreField)) {
|
|
return res.status(400).json({ error: "Invalid field" });
|
|
}
|
|
|
|
try {
|
|
const host =
|
|
await createCurrentHostResolutionRepository().findHostByIdForUser(
|
|
hostId,
|
|
userId,
|
|
);
|
|
|
|
if (!host) {
|
|
return res.status(404).json({ error: "Host not found" });
|
|
}
|
|
|
|
if (protocolId) {
|
|
const login = (await listProtocolLogins(hostId, userId)).find(
|
|
(entry) => entry.protocol === protocolId,
|
|
);
|
|
if (!login?.password) {
|
|
return res.status(404).json({ error: "No password set" });
|
|
}
|
|
return res.json({ value: login.password });
|
|
}
|
|
|
|
const resolved = (await resolveHostCredentials(host, userId)) || host;
|
|
const value = resolved[field];
|
|
|
|
if (!value) {
|
|
return res.status(404).json({ error: "No password set" });
|
|
}
|
|
|
|
res.json({ value });
|
|
} catch (err) {
|
|
sshLogger.error("Failed to fetch host password", err, {
|
|
operation: "host_password_fetch",
|
|
hostId,
|
|
userId,
|
|
});
|
|
res.status(500).json({ error: "Failed to fetch password" });
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* @openapi
|
|
* /host/db/host/{id}/export:
|
|
* get:
|
|
* summary: Export SSH host
|
|
* description: Exports a specific SSH host with decrypted credentials.
|
|
* tags:
|
|
* - SSH
|
|
* parameters:
|
|
* - in: path
|
|
* name: id
|
|
* required: true
|
|
* schema:
|
|
* type: integer
|
|
* responses:
|
|
* 200:
|
|
* description: The exported SSH host.
|
|
* 400:
|
|
* description: Invalid userId or hostId.
|
|
* 404:
|
|
* description: SSH host not found.
|
|
* 500:
|
|
* description: Failed to export SSH host.
|
|
*/
|
|
router.get(
|
|
"/db/host/:id/export",
|
|
authenticateJWT,
|
|
requireDataAccess,
|
|
async (req: Request, res: Response) => {
|
|
const hostId = Array.isArray(req.params.id)
|
|
? req.params.id[0]
|
|
: req.params.id;
|
|
const userId = (req as AuthenticatedRequest).userId;
|
|
|
|
if (!isNonEmptyString(userId) || !hostId) {
|
|
return res.status(400).json({ error: "Invalid userId or hostId" });
|
|
}
|
|
|
|
try {
|
|
const host =
|
|
await createCurrentHostResolutionRepository().findHostByIdForUser(
|
|
Number(hostId),
|
|
userId,
|
|
);
|
|
|
|
if (!host) {
|
|
return res.status(404).json({ error: "SSH host not found" });
|
|
}
|
|
|
|
const resolvedHost = (await resolveHostCredentials(host, userId)) || host;
|
|
const hostPluginSettings = (
|
|
await loadHostPluginSettings([Number(hostId)])
|
|
).get(Number(hostId));
|
|
|
|
const exportedConnectionType =
|
|
(resolvedHost.connectionType as string) || "ssh";
|
|
const isRemoteDesktop = exportedConnectionType !== "ssh";
|
|
|
|
const baseExportData = {
|
|
exportId: resolvedHost.id,
|
|
connectionType: exportedConnectionType,
|
|
name: resolvedHost.name,
|
|
ip: resolvedHost.ip,
|
|
port: resolvedHost.port,
|
|
username: resolvedHost.username,
|
|
password: resolvedHost.password || null,
|
|
folder: resolvedHost.folder,
|
|
tags:
|
|
typeof resolvedHost.tags === "string"
|
|
? resolvedHost.tags.split(",").filter(Boolean)
|
|
: resolvedHost.tags || [],
|
|
pin: !!resolvedHost.pin,
|
|
notes: resolvedHost.notes || null,
|
|
// Every plugin's host settings, secrets redacted, for import to hand back.
|
|
pluginSettings: hostPluginSettings ?? {},
|
|
// Each plugin protocol's login, secrets included like the SSH ones.
|
|
protocolAuth: toPortableLogins(
|
|
await listProtocolLogins(Number(hostId), userId),
|
|
),
|
|
};
|
|
|
|
const exportData = isRemoteDesktop
|
|
? baseExportData
|
|
: {
|
|
...baseExportData,
|
|
authType: resolvedHost.authType,
|
|
key: resolvedHost.key || null,
|
|
keyPassword: resolvedHost.keyPassword || null,
|
|
keyType: resolvedHost.keyType || null,
|
|
credentialId: resolvedHost.credentialId || null,
|
|
overrideCredentialUsername:
|
|
!!resolvedHost.overrideCredentialUsername,
|
|
sudoPassword: resolvedHost.sudoPassword || null,
|
|
jumpHosts: resolvedHost.jumpHosts
|
|
? JSON.parse(resolvedHost.jumpHosts as string)
|
|
: null,
|
|
terminalConfig:
|
|
hostTerminalExport(resolvedHost).terminalConfig ?? null,
|
|
sshOptions: hostTerminalExport(resolvedHost).sshOptions,
|
|
forceKeyboardInteractive:
|
|
resolvedHost.forceKeyboardInteractive === "true",
|
|
useSocks5: !!resolvedHost.useSocks5,
|
|
socks5Host: resolvedHost.socks5Host || null,
|
|
socks5Port: resolvedHost.socks5Port || null,
|
|
socks5Username: resolvedHost.socks5Username || null,
|
|
socks5Password: resolvedHost.socks5Password || null,
|
|
socks5ProxyChain: resolvedHost.socks5ProxyChain
|
|
? JSON.parse(resolvedHost.socks5ProxyChain as string)
|
|
: null,
|
|
portKnockSequence: resolvedHost.portKnockSequence
|
|
? JSON.parse(resolvedHost.portKnockSequence as string)
|
|
: null,
|
|
};
|
|
|
|
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" });
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* @openapi
|
|
* /host/db/hosts/export:
|
|
* get:
|
|
* summary: Export all SSH hosts
|
|
* description: Exports all SSH hosts for the current user. By default credentials are decrypted and embedded. With `share=1`, secrets are omitted and credential-authenticated hosts instead reference a scrubbed `credentials` array by alias, suitable for handing off to another user.
|
|
* tags:
|
|
* - SSH
|
|
* parameters:
|
|
* - in: query
|
|
* name: share
|
|
* required: false
|
|
* schema:
|
|
* type: string
|
|
* description: Set to "1" to export without embedded secrets.
|
|
* responses:
|
|
* 200:
|
|
* description: All exported SSH hosts.
|
|
* 400:
|
|
* description: Invalid userId.
|
|
* 500:
|
|
* description: Failed to export SSH hosts.
|
|
*/
|
|
router.get(
|
|
"/db/hosts/export",
|
|
authenticateJWT,
|
|
requireDataAccess,
|
|
async (req: Request, res: Response) => {
|
|
const userId = (req as AuthenticatedRequest).userId;
|
|
const shareMode = req.query.share === "1" || req.query.share === "true";
|
|
|
|
if (!isNonEmptyString(userId)) {
|
|
return res.status(400).json({ error: "Invalid userId" });
|
|
}
|
|
|
|
try {
|
|
const allHosts =
|
|
await createCurrentHostResolutionRepository().findHostsByUserId(userId);
|
|
const pluginSettingsByHost = await loadHostPluginSettings(
|
|
allHosts.map((h) => h.id as number),
|
|
);
|
|
|
|
const exportedHosts = [];
|
|
const usedCredentialIds = new Set<number>();
|
|
|
|
for (const host of allHosts) {
|
|
const resolvedHost = shareMode
|
|
? host
|
|
: (await resolveHostCredentials(host, userId)) || host;
|
|
const hostPluginSettings = pluginSettingsByHost.get(host.id as number);
|
|
|
|
const exportedConnectionType =
|
|
(resolvedHost.connectionType as string) || "ssh";
|
|
const isRemoteDesktop = exportedConnectionType !== "ssh";
|
|
|
|
const baseExportData = {
|
|
exportId: resolvedHost.id,
|
|
connectionType: exportedConnectionType,
|
|
name: resolvedHost.name,
|
|
ip: resolvedHost.ip,
|
|
port: resolvedHost.port,
|
|
username: resolvedHost.username,
|
|
password: shareMode ? null : resolvedHost.password || null,
|
|
folder: resolvedHost.folder,
|
|
tags:
|
|
typeof resolvedHost.tags === "string"
|
|
? resolvedHost.tags.split(",").filter(Boolean)
|
|
: resolvedHost.tags || [],
|
|
pin: !!resolvedHost.pin,
|
|
notes: resolvedHost.notes || null,
|
|
// Every plugin's host settings, secrets redacted, for import to hand back.
|
|
pluginSettings: hostPluginSettings ?? {},
|
|
protocolAuth: shareableLogins(
|
|
toPortableLogins(
|
|
await listProtocolLogins(host.id as number, userId),
|
|
),
|
|
shareMode,
|
|
),
|
|
};
|
|
|
|
const exportData = isRemoteDesktop
|
|
? baseExportData
|
|
: {
|
|
...baseExportData,
|
|
authType: resolvedHost.authType,
|
|
key: shareMode ? null : resolvedHost.key || null,
|
|
keyPassword: shareMode ? null : resolvedHost.keyPassword || null,
|
|
keyType: resolvedHost.keyType || null,
|
|
credentialId: resolvedHost.credentialId || null,
|
|
overrideCredentialUsername:
|
|
!!resolvedHost.overrideCredentialUsername,
|
|
sudoPassword: shareMode
|
|
? null
|
|
: resolvedHost.sudoPassword || null,
|
|
jumpHosts: resolvedHost.jumpHosts
|
|
? JSON.parse(resolvedHost.jumpHosts as string)
|
|
: null,
|
|
terminalConfig:
|
|
hostTerminalExport(resolvedHost).terminalConfig ?? null,
|
|
sshOptions: hostTerminalExport(resolvedHost).sshOptions,
|
|
forceKeyboardInteractive:
|
|
resolvedHost.forceKeyboardInteractive === "true",
|
|
useSocks5: !!resolvedHost.useSocks5,
|
|
socks5Host: resolvedHost.socks5Host || null,
|
|
socks5Port: resolvedHost.socks5Port || null,
|
|
socks5Username: resolvedHost.socks5Username || null,
|
|
socks5Password: shareMode
|
|
? null
|
|
: resolvedHost.socks5Password || null,
|
|
socks5ProxyChain: resolvedHost.socks5ProxyChain
|
|
? JSON.parse(resolvedHost.socks5ProxyChain as string)
|
|
: null,
|
|
};
|
|
|
|
if (
|
|
shareMode &&
|
|
!isRemoteDesktop &&
|
|
resolvedHost.authType === "credential" &&
|
|
resolvedHost.credentialId
|
|
) {
|
|
usedCredentialIds.add(resolvedHost.credentialId as number);
|
|
}
|
|
|
|
exportedHosts.push(exportData);
|
|
}
|
|
|
|
if (!shareMode) {
|
|
sshLogger.success("All hosts exported with decrypted credentials", {
|
|
operation: "hosts_export_all",
|
|
count: exportedHosts.length,
|
|
userId,
|
|
});
|
|
|
|
return res.json({ hosts: exportedHosts });
|
|
}
|
|
|
|
const exportedCredentials: Record<string, unknown>[] = [];
|
|
if (usedCredentialIds.size > 0) {
|
|
const credentialRepository = createCurrentCredentialRepository();
|
|
const ownedCredentials =
|
|
await credentialRepository.listDecryptedByUserId(userId);
|
|
const credentialById = new Map(
|
|
ownedCredentials.map((credential) => [credential.id, credential]),
|
|
);
|
|
|
|
for (const host of exportedHosts as Record<string, unknown>[]) {
|
|
const credentialId = host.credentialId as number | null;
|
|
if (!credentialId) continue;
|
|
const credential = credentialById.get(credentialId);
|
|
if (!credential) continue;
|
|
|
|
host.credentialAlias = credential.name;
|
|
|
|
if (
|
|
!exportedCredentials.some(
|
|
(entry) => entry.alias === credential.name,
|
|
)
|
|
) {
|
|
exportedCredentials.push({
|
|
alias: credential.name,
|
|
name: credential.name,
|
|
description: credential.description || null,
|
|
folder: credential.folder || null,
|
|
tags:
|
|
typeof credential.tags === "string"
|
|
? credential.tags.split(",").filter(Boolean)
|
|
: [],
|
|
authType: credential.authType,
|
|
username: credential.username || null,
|
|
keyType: credential.keyType || null,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const host of exportedHosts as Record<string, unknown>[]) {
|
|
delete host.credentialId;
|
|
}
|
|
|
|
sshLogger.success("All hosts exported for sharing without secrets", {
|
|
operation: "hosts_export_all_share",
|
|
count: exportedHosts.length,
|
|
credentialCount: exportedCredentials.length,
|
|
userId,
|
|
});
|
|
|
|
res.json({
|
|
version: "1",
|
|
exportedAt: new Date().toISOString(),
|
|
credentials: exportedCredentials,
|
|
hosts: exportedHosts,
|
|
});
|
|
} catch (err) {
|
|
sshLogger.error("Failed to export all SSH hosts", err, {
|
|
operation: "hosts_export_all",
|
|
userId,
|
|
});
|
|
res.status(500).json({ error: "Failed to export SSH hosts" });
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* @openapi
|
|
* /host/db/host/{id}:
|
|
* delete:
|
|
* summary: Delete SSH host
|
|
* description: Deletes an SSH host by its ID.
|
|
* tags:
|
|
* - SSH
|
|
* parameters:
|
|
* - in: path
|
|
* name: id
|
|
* required: true
|
|
* schema:
|
|
* type: integer
|
|
* responses:
|
|
* 200:
|
|
* description: SSH host deleted successfully.
|
|
* 400:
|
|
* description: Invalid userId or id.
|
|
* 404:
|
|
* description: SSH host not found.
|
|
* 500:
|
|
* description: Failed to delete SSH host.
|
|
*/
|
|
router.delete(
|
|
"/db/host/:id",
|
|
authenticateJWT,
|
|
permissionManager.requirePermission("hosts.delete"),
|
|
requireDataAccess,
|
|
async (req: Request, res: Response) => {
|
|
const userId = (req as AuthenticatedRequest).userId;
|
|
const hostId = Array.isArray(req.params.id)
|
|
? req.params.id[0]
|
|
: req.params.id;
|
|
|
|
if (!isNonEmptyString(userId) || !hostId) {
|
|
sshLogger.warn("Invalid userId or hostId for SSH host delete", {
|
|
operation: "host_delete",
|
|
hostId: parseInt(hostId),
|
|
userId,
|
|
});
|
|
return res.status(400).json({ error: "Invalid userId or id" });
|
|
}
|
|
databaseLogger.info("Deleting SSH host", {
|
|
operation: "host_delete",
|
|
userId,
|
|
hostId: parseInt(hostId),
|
|
});
|
|
try {
|
|
const deleted = await deleteOwnedHost(userId, Number(hostId));
|
|
|
|
if (!deleted) {
|
|
sshLogger.warn("SSH host not found for deletion", {
|
|
operation: "host_delete",
|
|
hostId: parseInt(hostId),
|
|
userId,
|
|
});
|
|
return res.status(404).json({ error: "SSH host not found" });
|
|
}
|
|
|
|
databaseLogger.success("SSH host deleted", {
|
|
operation: "host_delete_success",
|
|
userId,
|
|
hostId: parseInt(hostId),
|
|
});
|
|
|
|
const { ipAddress: dhIp, userAgent: dhUa } = getRequestMeta(req);
|
|
await logAudit({
|
|
userId,
|
|
username: await getAuditUsername(userId),
|
|
action: "delete_host",
|
|
resourceType: "host",
|
|
resourceId: hostId,
|
|
resourceName: deleted.name,
|
|
ipAddress: dhIp,
|
|
userAgent: dhUa,
|
|
success: true,
|
|
});
|
|
|
|
res.json({ message: "SSH host deleted" });
|
|
} catch (err) {
|
|
sshLogger.error("Failed to delete SSH host from database", err, {
|
|
operation: "host_delete",
|
|
hostId: parseInt(hostId),
|
|
userId,
|
|
});
|
|
res.status(500).json({ error: "Failed to delete SSH host" });
|
|
}
|
|
},
|
|
);
|
|
|
|
// File manager recent/pinned/shortcuts and transfer/recent routes moved to
|
|
// the file-manager plugin, under /plugin-api/file-manager/, and command
|
|
// history to the ssh-terminal plugin, under /plugin-api/ssh-terminal/.
|
|
|
|
/**
|
|
* A share export leaves out every secret and credential link, the same as
|
|
* it does for SSH.
|
|
*/
|
|
function shareableLogins(
|
|
logins: ReturnType<typeof toPortableLogins>,
|
|
shareMode: boolean,
|
|
): ReturnType<typeof toPortableLogins> {
|
|
if (!shareMode) return logins;
|
|
const out: ReturnType<typeof toPortableLogins> = {};
|
|
for (const [protocol, login] of Object.entries(logins)) {
|
|
const declared = findHostProtocol(protocol);
|
|
const secret = new Set(
|
|
(declared?.credentialFields ?? [])
|
|
.filter((field) => field.secret)
|
|
.map((field) => field.key),
|
|
);
|
|
out[protocol] = {
|
|
authType: login.authType === "credential" ? "direct" : login.authType,
|
|
credentialId: null,
|
|
username: login.username,
|
|
password: null,
|
|
fields: Object.fromEntries(
|
|
Object.entries(login.fields).filter(
|
|
([key]) => declared && !secret.has(key),
|
|
),
|
|
),
|
|
};
|
|
}
|
|
return out;
|
|
}
|
|
|
|
async function resolveHostCredentials(
|
|
host: Record<string, unknown>,
|
|
requestingUserId?: string,
|
|
/**
|
|
* Credentials already fetched for this request, keyed by id. The host list
|
|
* preloads them in one query; single-host callers omit it and fall back to
|
|
* fetching the one credential they need.
|
|
*/
|
|
preloadedCredentials?: Map<number, HostResolutionCredentialRecord>,
|
|
): Promise<Record<string, unknown>> {
|
|
try {
|
|
const ownerId = (host.ownerId || host.userId) as string | undefined;
|
|
if (
|
|
requestingUserId &&
|
|
ownerId &&
|
|
requestingUserId !== ownerId &&
|
|
typeof host.id === "number"
|
|
) {
|
|
const authHost = host as unknown as HostResolutionHostRecord;
|
|
const needsPersonalCredential = requiresPersonalHostAuthentication(
|
|
authHost,
|
|
"ssh",
|
|
);
|
|
const baseSshOverrideState = {
|
|
required: needsPersonalCredential,
|
|
ownerAuthShared: !!host.shareSshAuth,
|
|
};
|
|
// Owner auth for the remote desktop protocols is always snapshotted
|
|
// for recipients; only their own override credential varies per user.
|
|
const authOverrides: Record<string, unknown> = {
|
|
ssh: baseSshOverrideState,
|
|
};
|
|
const overrideCredentialIds =
|
|
await createCurrentSharedHostAuthOverrideRepository().listCredentialIds(
|
|
host.id,
|
|
requestingUserId,
|
|
);
|
|
// Whether a plugin protocol is on is its plugin's host setting; the
|
|
// client only offers an override for one that is.
|
|
for (const { id: protocol } of listHostProtocols()) {
|
|
authOverrides[protocol] = {
|
|
credentialId: overrideCredentialIds[protocol],
|
|
required: false,
|
|
ownerAuthShared: true,
|
|
};
|
|
}
|
|
const recipientHost: Record<string, unknown> = {
|
|
...host,
|
|
credentialId: null,
|
|
password: null,
|
|
key: null,
|
|
keyPassword: null,
|
|
keyType: null,
|
|
authOverrides,
|
|
};
|
|
|
|
try {
|
|
const resolution = await resolveRecipientSharedHostAuthentication(
|
|
authHost,
|
|
host.id,
|
|
requestingUserId,
|
|
"ssh",
|
|
);
|
|
|
|
if (resolution.source === "personal-override") {
|
|
const credential = resolution.credential;
|
|
return {
|
|
...recipientHost,
|
|
authOverrides: {
|
|
...authOverrides,
|
|
ssh: {
|
|
credentialId: resolution.credentialId,
|
|
required: false,
|
|
ownerAuthShared: !!host.shareSshAuth,
|
|
},
|
|
},
|
|
authType:
|
|
credential.key || credential.privateKey
|
|
? "key"
|
|
: credential.password
|
|
? "password"
|
|
: "none",
|
|
username: credential.username || recipientHost.username,
|
|
password: credential.password,
|
|
key: credential.privateKey || credential.key,
|
|
keyPassword: credential.keyPassword,
|
|
keyType: credential.keyType,
|
|
};
|
|
}
|
|
|
|
if (resolution.source === "owner-shared") {
|
|
if (resolution.authType === "agent") {
|
|
return {
|
|
...recipientHost,
|
|
authOverrides: {
|
|
...authOverrides,
|
|
ssh: {
|
|
required: false,
|
|
ownerAuthShared: true,
|
|
},
|
|
},
|
|
authType: "agent",
|
|
};
|
|
}
|
|
|
|
const sharedAuth = resolution.secret;
|
|
if (sharedAuth) {
|
|
const resolvedUsername = pickResolvedUsername(
|
|
recipientHost.username,
|
|
sharedAuth.username,
|
|
host.overrideCredentialUsername,
|
|
);
|
|
return {
|
|
...recipientHost,
|
|
authOverrides: {
|
|
...authOverrides,
|
|
ssh: {
|
|
required: false,
|
|
ownerAuthShared: true,
|
|
},
|
|
},
|
|
authType: sharedAuth.key
|
|
? "key"
|
|
: sharedAuth.password
|
|
? "password"
|
|
: "none",
|
|
username: resolvedUsername,
|
|
password: sharedAuth.password,
|
|
key: sharedAuth.key,
|
|
keyPassword: sharedAuth.keyPassword,
|
|
keyType: sharedAuth.keyType,
|
|
};
|
|
}
|
|
}
|
|
|
|
if (resolution.source === "secretless") {
|
|
return {
|
|
...recipientHost,
|
|
authOverrides: {
|
|
...authOverrides,
|
|
ssh: {
|
|
required: false,
|
|
ownerAuthShared: !!host.shareSshAuth,
|
|
},
|
|
},
|
|
};
|
|
}
|
|
} catch {
|
|
// A missing/deleted override or snapshot behaves like unavailable auth.
|
|
}
|
|
|
|
return recipientHost;
|
|
}
|
|
|
|
if (host.credentialId && (host.userId || host.ownerId)) {
|
|
const credentialId = host.credentialId as number;
|
|
const credentialOwnerId = (host.ownerId || host.userId) as string;
|
|
|
|
const credential =
|
|
preloadedCredentials?.get(credentialId) ??
|
|
(await findUsableCredential(credentialId, credentialOwnerId));
|
|
|
|
if (credential) {
|
|
const resolvedHost: Record<string, unknown> = {
|
|
...host,
|
|
password: pickResolvedPassword(host.password, credential.password),
|
|
key: credential.key,
|
|
keyPassword: credential.keyPassword,
|
|
keyType: credential.keyType,
|
|
};
|
|
|
|
const resolvedUsername = pickResolvedUsername(
|
|
host.username,
|
|
credential.username,
|
|
host.overrideCredentialUsername,
|
|
);
|
|
if (resolvedUsername !== undefined) {
|
|
resolvedHost.username = resolvedUsername;
|
|
}
|
|
|
|
return resolvedHost;
|
|
}
|
|
}
|
|
|
|
return { ...host };
|
|
} catch (error) {
|
|
sshLogger.warn(
|
|
`Failed to resolve credentials for host ${host.id}: ${getErrorMessage(error)}`,
|
|
);
|
|
return host;
|
|
}
|
|
}
|
|
|
|
registerHostFolderRoutes(router, {
|
|
authenticateJWT,
|
|
requireViewPermission: permissionManager.requirePermission("hosts.view"),
|
|
requireEditPermission: permissionManager.requirePermission("hosts.edit"),
|
|
requireDeletePermission: permissionManager.requirePermission("hosts.delete"),
|
|
requireCredentialEditPermission:
|
|
permissionManager.requirePermission("credentials.edit"),
|
|
requireDataAccess,
|
|
});
|
|
|
|
registerHostBulkRoutes(
|
|
router,
|
|
authenticateJWT,
|
|
permissionManager.requirePermission("hosts.create"),
|
|
permissionManager.requirePermission("hosts.edit"),
|
|
requireDataAccess,
|
|
);
|
|
|
|
registerHostNetworkRoutes(router, {
|
|
authenticateJWT,
|
|
requireViewPermission: permissionManager.requirePermission("hosts.view"),
|
|
requireDataAccess,
|
|
});
|
|
|
|
registerHostDefaultsRoutes(router, {
|
|
authenticateJWT,
|
|
requireEditPermission: permissionManager.requirePermission("hosts.edit"),
|
|
requireDataAccess,
|
|
requireAdminSettings: permissionManager.requirePermission(
|
|
"admin.settings.manage",
|
|
),
|
|
});
|
|
|
|
export default router;
|
|
|
|
/** Seconds between status checks, or null to follow the global setting. */
|
|
function normalizeStatusInterval(value: unknown): number | null {
|
|
const seconds = Number(value);
|
|
if (value === null || value === undefined || value === "") return null;
|
|
return Number.isInteger(seconds) && seconds >= 5 && seconds <= 86400
|
|
? seconds
|
|
: null;
|
|
}
|