Files
Termix/src/backend/database/routes/host.ts
T
0149260926 v26.10.0 (#1573)
* feat: optional auto reconnect for dropped ssh sessions (#1321)

* feat: tighter compact host rows with inline tags (#1325)

* feat: bring back the alert feed homepage widget

* feat: pick target terminals for snippets again

* feat: share button on connection tabs again

* feat: recent servers list when linking the desktop app

* feat: list the fixed app shortcuts in keyboard shortcuts

* fix: translate hardcoded toasts and drop em dashes from ui text

* fix: translate leftover hardcoded ui text

* fix: toolbar files button opens at the shell directory again

* fix: show the remote desktop note under connection origin again

* chore: openapi docs for host metrics manager routes

* chore: openapi docs for undocumented routes and fix two broken doc blocks

* fix: declare terminal.listSessions use for snippet targets

* revert: drop the alert feed widget, the notification center replaces it

* feat: restore the alert feed homepage widget

* fix: quick connect fills the host address from plugin auth editors like tailscale

* fix: docker container logs use the base background color

* chore: fix dependabot alerts and apply safe npm upgrades

* fix: proxmox stats tab reads its switch from plugin host settings

* fix: count plugin ssh logins toward host status and stop showing reachable as a warning

* fix: hide the built-in source pill in the host editor

* fix: host status shows online when the port answers and only warns on a failed login

* fix: drop the top border on the split layout menu

* fix: host defaults tabs sit where the host editor puts them and skip plugins with nothing to default

* fix: remote desktop auth method labels and notes for vnc and telnet

* fix: add description to the web endpoint enable switch

* fix: crowdin sync and pretranslate for plugin locales, run plugin tests on release

* feat: move telemetry out of core into a usage statistics plugin

* fix: recent activity card no longer cuts off host names early

* fix: remove duplicate top border on split screen menu

* fix: redesign session log player controls, fix tooltip overlap and drop dot separators

* fix: file manager and sftp text showing raw i18n keys

* fix: onboarding rerunning for existing users after upgrading

* fix: fleet detail buttons overflowing the sidebar

* fix: session log player scrolling off screen horizontally

* fix: network graph lag when adding hosts and on every render

* fix: maintenance index name, shared host read only view and badge polling

* fix: meeting tabs, workspace tab merging, rail panels as tabs and general ui lag

* chore: update release notes

* fix: remote desktop tabs reconnecting on every tab switch

* fix: build plugin sdk before linting in release workflow

* chore: lint, format, and bump version to 2.9.0

* chore: skip crowdin pre-translate in release for now

* chore: switch to calver, bump version to 26.10.0

* fix(sso): send the PKCE verifier for GitHub login (#1534)

* fix(remote-desktop): clip cursor overflow without disabling zoom (#1535)

* test(remote-desktop): preserve sessions beyond one hour (#1536)

* fix(hosts): expose controls for overflowing editor tabs (#1537)

* fix(hosts): expose controls for overflowing editor tabs

* test(hosts): mock resize observation in admin panel tests

* fix(status): skip TCP probes while host sessions are active (#1538)

* fix(database): batch session activity persistence (#1539)

* docs(api): describe cookie and API key authentication (#1540)

* fix(snippets): repair missing note column on SQLite upgrades (#1541)

* fix(docker): retain stored SSH credential association (#1543)

* fix(file-manager): render transfer toasts without plugin hooks (#1546)

* feat(hosts): keep compact row actions inline (#1547)

* fix(auth): allow retrying unavailable second-factor interfaces (#1549)

* fix(auth): reject unresolved legacy identity provisioning (#1550)

* fix(desktop): ship the plugin SDK inside Mac App Store builds (#1551)

* fix(tmux): pass full session info to the terminal's session picker (#1552)

The tmux.sessions service reduced detected sessions to bare names, but the
picker reads session.name, so every entry rendered blank and selecting one
sent an empty name, which created a new session instead of attaching.

* fix(ssh): read force keyboard-interactive as a boolean when resolving hosts (#1553)

The column holds the text "true"/"false", and the resolver passed it
through as-is. The string "false" is truthy, so the password provider
treated every saved host as forced keyboard-interactive and left the
password out. Jump hops are built straight from the resolved host, so a
password hop whose server doesn't offer keyboard-interactive failed with
"All configured authentication methods failed".

* fix(database): batch database saves for bulk host writes (#1554)

* fix(database): yield to the event loop between database saves

Each SQLite save serializes and encrypts the whole database into new
buffers, which V8 only releases once the event loop turns. Boot
migrations and bulk host import write plugin settings per host and per
key in an awaited loop, so hundreds of full copies piled up and the
container ran out of memory.

* fix(database): save once per bulk host write instead of once per row

On SQLite every repository write force-saves the whole database. Boot
plugin data migrations, host defaults materialization and the bulk
host routes write one row per host per setting, so they serialized and
encrypted the full database hundreds of times in a row.

DatabaseSaveTrigger.batched wraps a function so force saves made while
it runs collapse into a single save when it finishes. Nested scopes fold
into the outer one, and work that outlives its scope saves normally.

* feat(hosts): add instance-wide predefined tag suggestions (#1548)

* feat(hosts): add shared predefined tag suggestions

* style(hosts): format tag catalog routes

* test(database): advance fake timers past the save yield (#1555)

* fix(i18n): complete Simplified Chinese core and plugin translations (#1542)

* fix(i18n): complete Chinese onboarding and navigation labels

* fix(i18n): translate remaining Chinese core and plugin interfaces

* fix(i18n): translate host editor tab overflow controls

* fix(i18n): use consistent Chinese fleet terminology

* fix(i18n): localize hardcoded controls and plugin views

* fix(i18n): translate built-in homepage widget catalog

* test(homepage): follow translated timezone placeholder

* fix(i18n): translate plugin-provided homepage widgets

* fix(i18n): localize feature settings section titles

* fix: 2.8 oidc accounts unable to sign in after upgrading (#1381)

* fix: plugins losing the saved ssh login when copying a host (#1391)

* fix: fleets, proxmox and automations not getting the host sudo password

* fix: host imports running a defaults pass and metrics restart per host (#1384)

* fix: high cpu from status probes and full database saves on every sample (#1300)

* fix: user data export freezing the server (#1393)

* fix: op:// secret references rejected as ssh keys on credentials (#1394)

* fix: slow file deletes from the trash lookups on every delete (#1390)

* fix: add openapi docs and error handling to host tag routes

* test: compare download stream buffers directly so it stops timing out

* chore: drop em dash from host row comment

* chore: update release notes for 2.9.1

* feat(ai): add remote coding agent sessions for SSH hosts (#1545)

* feat(ai): add host coding agent sessions with configured providers

* Wait for agent initialization and shutdown before resuming sessions

* Keep Pi transcripts outside its configuration migration directory

* Cover Pi session directory separation

* Update core permission catalog expectations for remote agents

* Install remote agent runtimes from verified official sources

* Wait for the identity panel credentials request in its test

* Add scoped SSH forwarding setup for remote agents

* Verify forwarding setup through a fresh SSH connection

* Bound the SSH forwarding verification timeout

* Return the forwarding verification callback result

* fix(ai): support agent send and interrupt keyboard shortcuts

* feat(ai): add agent queues attachments worktrees and session recovery

* fix(ai): handle deleted workspace paths and duplicate status events

* fix(ai): accept file references in root workspaces

* fix(ai): refresh paused queues after agent errors

* fix(ai): store agent sessions in plugin tables and lock down OpenCode

Agent sessions lived in a single ctx.kv value holding up to 2000 events,
which passed the 256 KB kv limit within minutes of a normal run. Every
save after that failed, so history, nativeId, queue and archive changes
were lost and every event wrote a kv audit row.

Sessions, events and queued prompts now have their own tables. A running
agent appends events in batches at most once a second with a lazy save;
session and queue changes save immediately. Prompts, drafts and stored
event text are capped in UTF-8 bytes to fit MySQL TEXT. kv:own is no
longer requested.

The OpenCode server now requires a per-session password, so other local
accounts on the host cannot drive the agent through its loopback port.

* test(upgrade): include agent session tables in the reviewed upgrade SQL

* test: run the posix-only ai agent tests on linux only

* fix: restore space to add host tags and redesign predefined tags editor

* fix: host key silently accepted when the host is missing from the database (#1397)

* fix: clearer host login failed status label and fix its translations (#1396)

* chore: add host key and status label fixes to release notes

* fix: sso and ldap provider dialogs overflowing the screen and using mismatched toggles

* chore: increment ver

* fix(audit): store plugin entries with no acting user as a null user_id (#1556)

Plugin audit entries written outside a request used the literal "system"
as user_id. That column references users.id, so the insert was refused
(Postgres logs it as an FK violation) and the entry was silently dropped.
Write null instead and keep "system" / plugin:<id> in username.

* chore: add sso/ldap dialog and audit log fixes to release notes

* fix: tunnels and host settings missing from shared hosts on desktop

* fix: remote desktop logins missing from shared hosts on desktop

* fix: simplify host status to online/offline and keep it live without a refresh

* chore: sync Crowdin translations for 2.9.1

* fix: build docker frontend/backend natively to stop arm64 hang, build sdk before openapi

* chore: rework plugin readmes, json changelogs and standalone repo setup

* chore: move plugins to their own repos and drop the plugins folder

* chore(deps-dev): bump globals (#1560)

Bumps the dev-minor-updates group with 1 update: [globals](https://github.com/sindresorhus/globals).


Updates `globals` from 17.12.0 to 17.13.0
- [Release notes](https://github.com/sindresorhus/globals/releases)
- [Commits](https://github.com/sindresorhus/globals/compare/v17.12.0...v17.13.0)

---
updated-dependencies:
- dependency-name: globals
  dependency-version: 17.13.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-minor-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* feat: drop the 2.8 columns, moves and aliases kept for a downgrade

* chore: remove the 2.9 finish list

* feat: port the demo redesign with Manage and Settings tabs

* chore: pin the plugin signing key and use TERMIX_PAT in workflows

* chore: rename the plugin sdk to @termix-ssh/plugin-sdk

* feat: add a publish workflow for the plugin sdk

* chore: cleanup

* fix: harden plugin sockets, public routes, prebuilt binaries and login fallback

* chore: add a 2.9.1 upgrade check for postgres and mysql

* fix: stop full database rewrites on every plugin db call

* fix: stop full database rewrites on every plugin db call

* fix: restore release video link in 2.9.2 notes

* fix: use TERMIX_PAT in workflows

* feat: pin the official plugins in the docker image

* feat: implement plugin management UI

* feat: shared list kit, settings search, rail order and ui consistency fixes

* chore: run all workflows on blacksmith runners

* chore: split dashboard quick actions into quarters and drop host status row lines

* fix: open plain settings from the profile button, navigation only from the rail menu

* feat: consent check, developer mode file installs, install counts and issue links for plugins

* feat: row detail options for hosts and credentials, open manage from sidebar header

* fix: unify connection failure screens and rebuild stale local plugins

* feat: dev:all runner for web, electron and docker with plugin hot reload, termix-plugin dev

* chore: clean up npm scripts, make npm run dev the full runner and drop concurrently

* fix: settle quick connect panel loads in tests so they stop flaking

* fix: stop the dev runner rebuilding plugins when windows reports file reads as changes

* chore: update the plugin sdk ui export snapshot

* feat: full screen onboarding with a plugin picker and per step versions

* feat: changelog.md release notes, v tags, plugin release notes and videos

* chore: add temp changelog

* chore: release the plugin sdk as 1.0.1

* feat: show only the latest plugin release notes and cap long ones

* chore: publish docker images to termix-ssh on ghcr and termixssh on docker hub

* chore: rename docker hub token secret to DOCKERHUB_BUGATTIGUY527_TOKEN

* chore: renew mac app store provisioning profile

* chore: drop docs job from release workflow

* chore: run sdk publish on github hosted runner

* chore: remove dependabot

* chore: general file cleanup

* chore: rewrite readme and taglines for plugin-based termix

* chore: drop optional from ai plugin row in readme

* feat: per-repo issue forms and prefilled bug reports instead of the support repo

* feat: add report a bug link to the your account settings section

* fix: open settings goes to the plugin's page and hides when it has none

* feat: show plugin features from the manifest on the plugin page

* fix: retry failed or blocked plugins on restart instead of leaving them disabled

* fix: refresh plugin settings after enabling a plugin and match select and disabled bar styling

* feat: request a feature link for plugins and use the bug form for unverified plugins

* feat: beta channels for core and plugins with one stable/beta release workflow

* fix(sync): resolve shared hosts for connect-only recipients (#1568)

* fix(ssh): accept full passwords in keyboard-interactive prompts (#1569)

* fix(ssh): normalize PKCS8 private keys before connecting (#1570)

* fix(sidebar): reclaim space from hidden folder actions (#1571)

* fix(hosts): display protocol credential usernames for non-SSH hosts (#1572)

* fix: ask for a push/TOTP menu choice as text instead of a push confirm

* fix: keep client tunnels open when one of their connections fails (Support#1417)

* fix: send plugin form uploads to the plugin's route (Support#1406, Support#1420)

* fix: add missing sync ids to old snippet tables so snippets sync again (Support#1419)

* fix: start the macOS local terminal when the shell or spawn helper is broken (Support#1418)

* fix: refresh cached plugin translations when only a translation changes (Support#1411)

* fix: import sshpk as a default so the backend starts under ESM

* feat: add a plugin sdk changelog and tag sdk releases, release 1.0.2

* fix: drop the ./ from the plugin sdk bin path so npm stops warning

* chore: name core and sdk releases Termix vX and Plugin SDK vX

* fix: lock the desktop backend to the app's own origin (GHSA-frx8-qqgg-7j7g)

* fix: trust devices by a server-issued token, not a client header (GHSA-6gr3-r7jx-wfmh)

* fix: keep global settings out of non-admin data exports (GHSA-v3vp-ffww-q7pw)

* fix: refuse sessionless tokens and keep the secrets file owner only

* feat: add TRUSTED_PROXIES to choose who nginx takes client IPs from (GHSA-7j7m-85p4-2cpm)

* chore: standardize repo setup files

* chore: remove unused knip config

* chore: add LICENSE and use Node 24 from .nvmrc

* chore: update translation guidance, only en.json needs editing

* feat: plugin docs and env manifest fields, in-app docs links, SDK 1.0.3

* fix: header separators render as borders and center the status divider

* fix: lighter confirm prompt overlay to stop lag when it opens

* fix: put plugin-only tailwind classes in core's utilities layer, SDK 1.0.4

* fix: dev stages local plugin builds on paths with spaces and builds them with the local SDK

* fix: keep plugin tailwind variants in core's layer so overrides keep their order

* fix: manage tab refetches before giving up on a host or credential it cannot find

* fix: tab buttons like share show as soon as the terminal is ready

* fix: folder rows no longer nest action buttons inside a button

* fix: repin remote-desktop after its 1.0.0 re-release

* chore: repin bundled plugins after the 1.0.0 re-release

* fix: recent activity rows use the same separator as host status

* feat: upload banner

* fix: reject expired sessions on sockets, api key desktop links and 192.0.0.0/24

* fix: plugin sdk 1.0.5 with optional ui peers, safer timeouts and validate checks

* fix: bundled plugins path for source runs and backend cleanup

* fix: load plugin strings on guest pages, plugin wording and drop unused keys

* fix: beta version stamping, homebrew cask bump, dockerignore and readme platforms

* docs: 26.10.0 changelog

* fix: current version in the version route example

* chore: stop pushing the release- docker tag

* feat: remove the donation reminder and its column

* docs: note the removed donation reminder

* fix: link server wizard always opens inline, onboarding card grows for it

* feat: plugin text columns are longtext on mysql so they are not capped at 64KB

* fix: linked desktops keep a plugin when its update from the server fails

* chore: default mobile download links to v tags

* fix: sso only accounts cannot sign in with a passkey or other local method

* feat: note in the plugins tab that community plugins are coming

* fix: plugin validate accepts beta versions

* chore: pin bundled plugins rebuilt on sdk 1.0.5

* fix: skip locale fetches for disabled plugins

* fix: show translated names for the built-in admin and user roles

* fix: show host keys as SHA256 fingerprints like ssh-keygen

* fix: host editor scrolls to a newly enabled protocol section

* fix: pass the host to dashboard host row plugins

* fix: plugin cards show dividers between author, version and category

* fix: refuse plugin archives with paths outside the plugin folder

* fix: theme picker shows one dropdown arrow

* fix: read activity and audit times as UTC so they show in local time

* fix: show the real error for a wrong password or code on plugin sign in

* fix: explain that external sign in needs Auto-create external accounts

* fix: clear leftover plugin unpack folders at startup

* fix: shared session links open for guests again

* fix: share panels no longer list yourself as a share target

* fix: a host update without an auth type keeps the saved password

* fix: guest pages get the tab ids a guest plugin registers

* fix: refuse a 2.8 database, which already had a plugins table

* fix: dashboard cards fit on a phone

* fix: desktop setup no longer asks to secure the local account

* fix: the desktop app keeps the desktop layout in a narrow window

* fix: the desktop app shows the rail and no phone bar in a narrow window

* fix: name every sync type in the Sync panel and pluralize the link prompt

* chore: update the sdk ui snapshot for the host key dialog

* fix: turning a plugin back on restarts the plugins it turned off

* fix: quick connect asks to trust the host key

* fix: revoking a manager also removes the shares they handed out

* fix: database export works for users with TOTP on (#1426)

* fix: rdp and vnc logins use the folder credential when none is picked (#1429)

* fix: imported hosts get status checks without a restart

* fix: re-pin bundled plugins after 1.0.0 re-release

* fix: re-pin bundled plugins after search bar fix

* chore: update app icon to smaller version

* chore: round the readme logo, keep square source for app icons

* feat: grid columns and table view in the plugins tab

* fix: match the host key copy button to the fingerprint height

* fix: use brand color for toggles in sync settings

* fix: re-pin bundled plugins after 1.0.0 re-release

* fix: web terminals failing auth behind a proxy that rewrites Host (Support#651)

* fix: re-pin snippets and termix-identity after 1.0.0 overwrite

* fix: re-pin file-manager after 1.0.0 overwrite

* fix: re-pin file-manager after 1.0.0 overwrite

* chore: remove Crowdin from the release workflow

* chore: remove crowdin config

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-10 14:58:27 -05:00

2372 lines
73 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,
keepsStoredSshAuth,
isNonEmptyString,
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)
) {
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" : undefined);
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,
}),
);
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)));
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) ||
!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;
}