2026-08-19 14:12:06 -05:00
import { getErrorMessage } from "../../utils/error-message.js" ;
2026-10-01 16:20:16 -05:00
import { isQuickConnectAuthType } from "../../hosts/connect/auth-provider-registry.js" ;
import { ensureCoreSshAuthProviders } from "../../hosts/connect/core-providers.js" ;
2026-09-20 14:56:39 -05:00
import { applyFolderAccessRules } from "../../utils/folder-access-inheritance.js" ;
import { findUsableCredential } from "../../hosts/usable-credential.js" ;
2025-11-05 10:36:16 -06:00
import type { AuthenticatedRequest } from "../../../types/index.js" ;
2026-08-19 14:12:06 -05:00
import express , { type Request , type Response } from "express" ;
2025-09-12 14:42:00 -05:00
import multer from "multer" ;
2026-02-12 22:28:13 -06:00
import { sshLogger , databaseLogger } from "../../utils/logger.js" ;
2026-10-01 16:20:16 -05:00
import { pluginEvents , TOPICS } from "../../plugins/events.js" ;
2025-10-01 15:40:10 -05:00
import { AuthManager } from "../../utils/auth-manager.js" ;
2025-12-31 22:20:12 -06:00
import { PermissionManager } from "../../utils/permission-manager.js" ;
2025-10-01 15:40:10 -05:00
import { DataCrypto } from "../../utils/data-crypto.js" ;
2026-10-04 15:11:41 -05:00
import { parseKeyForStorage } from "../../hosts/stored-ssh-key.js" ;
2026-07-19 12:29:52 -05:00
import {
pickResolvedPassword ,
pickResolvedUsername ,
} from "../../hosts/credential-username.js" ;
2026-10-01 16:20:16 -05:00
import { emitInternalEvent } from "../../hosts/internal-events.js" ;
import { deleteOwnedHost } from "../../hosts/delete-host.js" ;
2026-07-19 12:29:52 -05:00
import {
2026-07-26 18:47:27 -05:00
createCurrentCredentialRepository ,
2026-07-19 12:29:52 -05:00
createCurrentRbacAccessRepository ,
createCurrentRoleRepository ,
createCurrentHostResolutionRepository ,
createCurrentHostRepository ,
createCurrentUserRepository ,
2026-09-20 14:56:39 -05:00
createCurrentSharedHostAuthOverrideRepository ,
2026-10-01 16:20:16 -05:00
createCurrentHostDefaultsRepository ,
2026-07-19 12:29:52 -05:00
} from "../repositories/factory.js" ;
2026-06-04 15:16:53 -04:00
import {
2026-09-20 14:56:39 -05:00
applyHostKeyTypeUpdate ,
2026-08-06 14:41:39 -05:00
containsOwnerPrivateAuthUpdate ,
2026-10-18 00:34:50 -05:00
impliedSshAuthType ,
2026-10-10 14:58:27 -05:00
keepsStoredSshAuth ,
2026-06-04 15:16:53 -04:00
isNonEmptyString ,
2026-10-18 00:34:50 -05:00
isOptionalAuthType ,
2026-08-06 14:41:39 -05:00
isOptionalBoolean ,
2026-06-04 15:16:53 -04:00
isValidPort ,
2026-09-20 14:56:39 -05:00
normalizeProtocolEnableFields ,
2026-08-06 14:41:39 -05:00
OWNER_PRIVATE_AUTH_FIELDS ,
2026-10-01 16:20:16 -05:00
OWNER_PRIVATE_SSH_OPTION_FIELDS ,
2026-08-06 14:41:39 -05:00
OWNER_PRIVATE_TERMINAL_CONFIG_FIELDS ,
2026-10-01 16:20:16 -05:00
hostTerminalExport ,
2026-07-19 12:29:52 -05:00
sanitizeHostForRecipient ,
2026-06-04 15:16:53 -04:00
stripSensitiveFields ,
transformHostResponse ,
} from "./host-normalizers.js" ;
2026-10-01 16:20:16 -05:00
import {
attachHostPluginSettings ,
loadHostPluginSettings ,
withHostPluginSettings ,
} from "./host-plugin-settings.js" ;
2026-08-19 14:12:06 -05:00
import { validateParentHostId } from "./host-parent-validation.js" ;
2026-06-04 15:16:53 -04:00
import { registerHostFolderRoutes } from "./host-folder-routes.js" ;
import { registerHostNetworkRoutes } from "./host-network-routes.js" ;
import { registerHostBulkRoutes } from "./host-bulk-routes.js" ;
2026-10-01 16:20:16 -05:00
import { registerHostDefaultsRoutes } from "./host-defaults-routes.js" ;
2026-10-04 15:11:41 -05:00
import { registerHostTagRoutes } from "./host-tag-routes.js" ;
2026-10-01 16:20:16 -05:00
import { registerHostStatusRoutes } from "./host-status-routes.js" ;
2026-07-19 12:29:52 -05:00
import {
applyHostEnrollmentDefaults ,
requireHostEnrollmentAccessForPath ,
} from "./host-enrollment-auth.js" ;
2026-08-06 14:41:39 -05:00
import {
logAudit ,
getAuditUsername ,
getRequestMeta ,
} from "../../utils/audit-logger.js" ;
2026-08-19 14:12:06 -05:00
import type {
HostResolutionCredentialRecord ,
HostResolutionHostRecord ,
} from "../repositories/host-resolution-repository.js" ;
2026-08-06 14:41:39 -05:00
import {
requiresPersonalHostAuthentication ,
resolveRecipientSharedHostAuthentication ,
} from "../../utils/shared-host-auth-resolver.js" ;
2026-10-01 16:20:16 -05:00
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 {
2026-10-10 14:58:27 -05:00
mergeStoredSshOptions ,
2026-10-01 16:20:16 -05:00
parseTerminalConfig ,
} from "./host-terminal-fields.js" ;
2025-08-07 02:20:27 -05:00
const router = express . Router ();
2026-10-01 16:20:16 -05:00
router . use ( rejectSharedCopyWrites ( "host" , /^\/db\/host\/(\d+)$/ ));
2025-08-07 02:20:27 -05:00
2025-09-12 14:42:00 -05:00
const upload = multer ({ storage : multer.memoryStorage () });
2025-08-07 02:20:27 -05:00
2026-10-01 16:20:16 -05:00
/**
* 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.
*/
2026-05-06 15:12:07 -05:00
function notifyStatsHostUpdated (
hostId : number ,
2026-10-01 16:20:16 -05:00
userId : string ,
2026-05-06 15:12:07 -05:00
operation : string ,
) : void {
2026-10-01 16:20:16 -05:00
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 ),
2026-05-06 15:12:07 -05:00
});
2026-10-01 16:20:16 -05:00
}
2026-05-06 15:12:07 -05:00
}
2025-10-01 15:40:10 -05:00
const authManager = AuthManager . getInstance ();
2025-12-31 22:20:12 -06:00
const permissionManager = PermissionManager . getInstance ();
2025-10-01 15:40:10 -05:00
const authenticateJWT = authManager . createAuthMiddleware ();
const requireDataAccess = authManager . createDataAccessMiddleware ();
2025-08-07 02:20:27 -05:00
2026-10-04 15:11:41 -05:00
registerHostTagRoutes (
router ,
authenticateJWT ,
permissionManager . requirePermission ( "admin.settings.manage" ),
);
2026-10-01 16:20:16 -05:00
registerHostStatusRoutes ( router , {
authenticateJWT ,
requireAdmin : authManager.createAdminMiddleware (),
});
2025-08-07 02:20:27 -05:00
2026-01-24 19:49:42 -06:00
/**
* @openapi
2026-04-22 16:55:23 -05:00
* /host/db/host:
2026-01-24 19:49:42 -06:00
* 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.
*/
2025-09-12 14:42:00 -05:00
router . post (
2026-07-19 12:29:52 -05:00
[ "/db/host" , "/enroll" ],
2025-09-12 14:42:00 -05:00
authenticateJWT ,
2026-09-20 14:56:39 -05:00
permissionManager . requirePermission ( "hosts.create" ),
2025-10-01 15:40:10 -05:00
requireDataAccess ,
2026-07-19 12:29:52 -05:00
requireHostEnrollmentAccessForPath ,
2025-09-12 14:42:00 -05:00
upload . single ( "key" ),
async ( req : Request , res : Response ) => {
2025-11-05 10:36:16 -06:00
const userId = ( req as AuthenticatedRequest ). userId ;
let hostData : Record < string , unknown >;
2025-08-07 02:20:27 -05:00
2025-09-12 14:42:00 -05:00
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" });
2025-08-07 02:20:27 -05:00
}
2025-09-12 14:42:00 -05:00
} else {
sshLogger . warn ( "Missing data field in multipart request" , {
operation : "host_create" ,
userId ,
});
return res . status ( 400 ). json ({ error : "Missing data field" });
}
2025-08-07 02:20:27 -05:00
2025-09-12 14:42:00 -05:00
if ( req . file ) {
hostData . key = req . file . buffer . toString ( "utf8" );
}
2025-08-07 02:20:27 -05:00
} else {
2025-09-12 14:42:00 -05:00
hostData = req . body ;
2025-08-07 02:20:27 -05:00
}
2026-07-19 12:29:52 -05:00
if ( req . path === "/enroll" ) {
hostData = applyHostEnrollmentDefaults ( hostData );
}
2025-08-07 02:20:27 -05:00
const {
2026-03-14 20:05:05 -05:00
connectionType ,
2025-09-12 14:42:00 -05:00
name ,
folder ,
2026-08-19 14:12:06 -05:00
parentHostId ,
2025-09-12 14:42:00 -05:00
tags ,
ip ,
port ,
username ,
password ,
authMethod ,
authType ,
2026-08-06 14:41:39 -05:00
shareSshAuth ,
2025-09-12 14:42:00 -05:00
credentialId ,
key ,
2025-10-08 23:55:33 -05:00
keyPassword ,
2025-09-12 14:42:00 -05:00
keyType ,
2025-12-31 22:20:12 -06:00
sudoPassword ,
2025-09-12 14:42:00 -05:00
pin ,
2025-11-17 09:46:05 -06:00
jumpHosts ,
2026-10-01 16:20:16 -05:00
statusCheckEnabled ,
statusCheckInterval ,
2025-11-05 10:36:16 -06:00
terminalConfig ,
2026-10-01 16:20:16 -05:00
sshOptions ,
2025-11-05 10:36:16 -06:00
forceKeyboardInteractive ,
2025-12-31 22:20:12 -06:00
notes ,
useSocks5 ,
socks5Host ,
socks5Port ,
socks5Username ,
socks5Password ,
socks5ProxyChain ,
2026-07-26 18:47:27 -05:00
connectionOrigin ,
2026-10-01 16:20:16 -05:00
localOnly ,
2026-04-22 16:55:23 -05:00
portKnockSequence ,
2025-12-31 22:20:12 -06:00
overrideCredentialUsername ,
2026-05-11 01:23:11 -05:00
enableSsh ,
sshPort ,
2025-08-07 02:20:27 -05:00
} = hostData ;
2026-10-01 16:20:16 -05:00
const protocolAuthPatch = readProtocolAuthPayload ( hostData );
2026-02-12 22:28:13 -06:00
databaseLogger . info ( "Creating SSH host" , {
operation : "host_create" ,
userId ,
name ,
ip ,
});
2025-12-31 22:20:12 -06:00
2025-09-12 14:42:00 -05:00
if (
! isNonEmptyString ( userId ) ||
! isNonEmptyString ( ip ) ||
2026-08-06 14:41:39 -05:00
! isValidPort ( port ) ||
2026-09-20 14:56:39 -05:00
! isOptionalBoolean ( shareSshAuth ) ||
2026-10-18 00:34:50 -05:00
! isOptionalBoolean ( enableSsh ) ||
! isOptionalAuthType ( authType ) ||
! isOptionalAuthType ( authMethod )
2025-09-12 14:42:00 -05:00
) {
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" });
2025-08-07 02:20:27 -05:00
}
2026-08-19 14:12:06 -05:00
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 ;
}
2026-03-14 20:05:05 -05:00
const effectiveConnectionType = connectionType || "ssh" ;
2026-04-22 16:55:23 -05:00
const effectiveAuthType =
authType ||
authMethod ||
2026-10-18 00:34:50 -05:00
( effectiveConnectionType !== "ssh"
? "password"
: impliedSshAuthType ( hostData ));
2026-05-28 22:29:20 -05:00
const effectiveUsername =
2026-10-01 16:20:16 -05:00
username || firstProtocolUsername ( protocolAuthPatch );
2026-05-28 22:29:20 -05:00
const effectiveName =
name || ( effectiveUsername ? ` ${ effectiveUsername } @ ${ ip } ` : String ( ip ));
2025-11-05 10:36:16 -06:00
const sshDataObj : Record < string , unknown > = {
2025-09-12 14:42:00 -05:00
userId : userId ,
2026-03-14 20:05:05 -05:00
connectionType : effectiveConnectionType ,
2026-05-28 22:29:20 -05:00
name : effectiveName ,
2026-08-19 14:12:06 -05:00
// 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 ,
2025-09-12 14:42:00 -05:00
tags : Array.isArray ( tags ) ? tags . join ( "," ) : tags || "" ,
ip ,
port ,
2026-05-28 22:29:20 -05:00
username : effectiveUsername ,
2025-09-12 14:42:00 -05:00
authType : effectiveAuthType ,
2026-08-06 14:41:39 -05:00
shareSshAuth : shareSshAuth === true ? 1 : 0 ,
2025-09-12 14:42:00 -05:00
credentialId : credentialId || null ,
2025-12-31 22:20:12 -06:00
overrideCredentialUsername : overrideCredentialUsername ? 1 : 0 ,
2025-09-12 14:42:00 -05:00
pin : pin ? 1 : 0 ,
2025-11-17 09:46:05 -06:00
jumpHosts : Array.isArray ( jumpHosts ) ? JSON . stringify ( jumpHosts ) : null ,
2026-10-01 16:20:16 -05:00
statusCheckEnabled : statusCheckEnabled === false ? 0 : 1 ,
statusCheckInterval : normalizeStatusInterval ( statusCheckInterval ),
sshOptions : sshOptionsForWrite ({ sshOptions , terminalConfig }) ?? null ,
2025-11-05 10:36:16 -06:00
forceKeyboardInteractive : forceKeyboardInteractive ? "true" : "false" ,
2025-12-31 22:20:12 -06:00
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 ,
2026-07-26 18:47:27 -05:00
connectionOrigin :
connectionOrigin === "local" || connectionOrigin === "remote"
? connectionOrigin
: null ,
2026-10-01 16:20:16 -05:00
...( typeof localOnly === "boolean" ? { localOnly } : {}),
2026-04-22 16:55:23 -05:00
portKnockSequence : portKnockSequence
? JSON . stringify ( portKnockSequence )
: null ,
2026-09-20 14:56:39 -05:00
... normalizeProtocolEnableFields ( hostData ),
2026-05-11 01:23:11 -05:00
sshPort : sshPort || port || 22 ,
2025-08-07 02:20:27 -05:00
};
2026-10-01 16:20:16 -05:00
// A host whose main protocol is a plugin's keeps any password it is given.
2026-03-14 20:05:05 -05:00
if ( effectiveConnectionType !== "ssh" ) {
sshDataObj . password = password || null ;
sshDataObj . key = null ;
sshDataObj . keyPassword = null ;
sshDataObj . keyType = null ;
} else if ( effectiveAuthType === "password" ) {
2025-09-12 14:42:00 -05:00
sshDataObj . password = password || null ;
sshDataObj . key = null ;
2026-03-08 18:02:14 -05:00
sshDataObj . keyPassword = null ;
2025-09-12 14:42:00 -05:00
sshDataObj . keyType = null ;
} else if ( effectiveAuthType === "key" ) {
2026-01-24 19:49:42 -06:00
if ( key && typeof key === "string" ) {
2026-10-04 15:11:41 -05:00
const keyValidation = parseKeyForStorage (
2026-01-24 19:49:42 -06:00
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" } ` ,
});
}
}
2025-09-12 14:42:00 -05:00
sshDataObj . key = key || null ;
2026-03-08 18:02:14 -05:00
sshDataObj . keyPassword = keyPassword || null ;
2025-09-12 14:42:00 -05:00
sshDataObj . keyType = keyType ;
2026-07-19 12:29:52 -05:00
sshDataObj . password = password || null ;
} else if ( effectiveAuthType === "credential" ) {
sshDataObj . password = password || null ;
sshDataObj . key = null ;
sshDataObj . keyPassword = null ;
sshDataObj . keyType = null ;
2026-06-29 13:28:26 -05:00
} else if ( effectiveAuthType === "agent" ) {
sshDataObj . password = null ;
sshDataObj . key = null ;
sshDataObj . keyPassword = null ;
sshDataObj . keyType = null ;
2025-10-01 15:40:10 -05:00
} else {
sshDataObj . password = null ;
sshDataObj . key = null ;
2026-03-08 18:02:14 -05:00
sshDataObj . keyPassword = null ;
2025-10-01 15:40:10 -05:00
sshDataObj . keyType = null ;
2025-08-07 02:20:27 -05:00
}
try {
2026-10-01 16:20:16 -05:00
sshDataObj . defaultOverrides = JSON . stringify (
await applyHostDefaultsToWrite ({
ownerId : userId ,
hostId : null ,
columns : sshDataObj ,
body : hostData ,
}),
);
2026-10-18 00:34:50 -05:00
// Host defaults may have supplied it.
if ( ! sshDataObj . authType ) {
return res . status ( 400 ). json ({
error :
"authType is required (password, key, credential, agent or none)" ,
});
}
2026-07-19 12:29:52 -05:00
const result = await createCurrentHostRepository (). createEncryptedForUser (
2025-10-01 15:40:10 -05:00
userId ,
2026-07-19 12:29:52 -05:00
sshDataObj ,
2025-10-01 15:40:10 -05:00
);
2025-09-12 14:42:00 -05:00
2025-10-01 15:40:10 -05:00
if ( ! result ) {
2025-09-12 14:42:00 -05:00
sshLogger . warn ( "No host returned after creation" , {
operation : "host_create" ,
userId ,
name ,
ip ,
port ,
});
return res . status ( 500 ). json ({ error : "Failed to create host" });
}
2025-10-01 15:40:10 -05:00
const createdHost = result ;
2026-10-01 16:20:16 -05:00
await applyDefaultsAfterHostWrite ( createdHost . id as number );
if ( protocolAuthPatch ) {
await writeProtocolAuth (
userId ,
createdHost . id as number ,
protocolAuthPatch ,
{ isOwner : true },
);
}
2026-09-20 14:56:39 -05:00
// 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 ),
});
}
2026-01-26 00:28:40 -06:00
const baseHost = transformHostResponse ( createdHost );
2025-09-12 14:42:00 -05:00
2025-12-31 22:20:12 -06:00
const resolvedHost =
( await resolveHostCredentials ( baseHost , userId )) || baseHost ;
2026-02-12 22:28:13 -06:00
databaseLogger . success ( "SSH host created" , {
operation : "host_create_success" ,
userId ,
hostId : createdHost.id as number ,
name ,
});
2025-09-12 14:42:00 -05:00
2026-06-16 15:59:53 -05:00
const { ipAddress : chIp , userAgent : chUa } = getRequestMeta ( req );
await logAudit ({
userId ,
2026-07-19 12:29:52 -05:00
username : await getAuditUsername ( userId ),
2026-06-16 15:59:53 -05:00
action : "create_host" ,
resourceType : "host" ,
resourceId : String ( createdHost . id ),
resourceName : String ( name ?? ip ),
ipAddress : chIp ,
userAgent : chUa ,
success : true ,
});
2026-10-01 16:20:16 -05:00
emitInternalEvent ( "host_added" , userId , createdHost . id as number , {
name : String ( name ?? ip ),
});
2026-08-19 14:12:06 -05:00
2026-10-01 16:20:16 -05:00
res . json ( await withProtocolAuth ( stripSensitiveFields ( resolvedHost )));
2026-10-18 00:34:50 -05:00
pluginEvents . emit ( TOPICS . hostCreated , { hostId : createdHost.id , userId });
2026-10-01 16:20:16 -05:00
notifyStatsHostUpdated ( createdHost . id as number , userId , "host_create" );
2025-08-07 02:20:27 -05:00
} catch ( err ) {
2025-09-12 14:42:00 -05:00
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" });
2025-08-07 02:20:27 -05:00
}
2025-09-12 14:42:00 -05:00
},
);
2025-08-07 02:20:27 -05:00
2026-07-19 12:29:52 -05:00
/**
* @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.
*/
2026-01-24 19:49:42 -06:00
/**
* @openapi
2026-04-22 16:55:23 -05:00
* /host/quick-connect:
2026-01-24 19:49:42 -06:00
* 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
2026-10-01 16:20:16 -05:00
* 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).
2026-01-24 19:49:42 -06:00
* 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" });
}
2026-10-01 16:20:16 -05:00
ensureCoreSshAuthProviders ();
if ( ! isQuickConnectAuthType ( String ( authType ))) {
return res
. status ( 400 )
. json ({ error : "That auth type cannot be used for Quick Connect" });
}
2026-01-24 19:49:42 -06:00
try {
let resolvedPassword = password ;
let resolvedKey = key ;
let resolvedKeyPassword = keyPassword ;
let resolvedKeyType = keyType ;
let resolvedAuthType = authType ;
let resolvedUsername = username ;
if ( authType === "credential" && credentialId ) {
2026-09-20 14:56:39 -05:00
const cred = await findUsableCredential ( Number ( credentialId ), userId );
2026-01-24 19:49:42 -06:00
2026-07-19 12:29:52 -05:00
if ( ! cred ) {
2026-01-24 19:49:42 -06:00
return res . status ( 404 ). json ({ error : "Credential not found" });
}
2026-07-19 12:29:52 -05:00
resolvedPassword = pickResolvedPassword ( password , cred . password ) as
2026-08-06 14:41:39 -05:00
string | undefined ;
2026-03-08 18:02:14 -05:00
resolvedKey = cred . privateKey as string | undefined ;
resolvedKeyPassword = cred . keyPassword as string | undefined ;
resolvedKeyType = cred . keyType as string | undefined ;
resolvedAuthType = cred . authType as string | undefined ;
2026-01-24 19:49:42 -06:00
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 : [],
2026-10-01 16:20:16 -05:00
statusCheckEnabled : true ,
statusCheckInterval : null ,
2026-01-24 19:49:42 -06:00
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
2026-04-22 16:55:23 -05:00
* /host/db/host/{id}:
2026-01-24 19:49:42 -06:00
* 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.
*/
2025-09-12 14:42:00 -05:00
router . put (
"/db/host/:id" ,
authenticateJWT ,
2026-09-20 14:56:39 -05:00
permissionManager . requirePermission ( "hosts.edit" ),
2025-11-17 09:46:05 -06:00
requireDataAccess ,
2025-09-12 14:42:00 -05:00
upload . single ( "key" ),
async ( req : Request , res : Response ) => {
2026-01-24 19:49:42 -06:00
const hostId = Array . isArray ( req . params . id )
? req . params . id [ 0 ]
: req . params . id ;
2025-11-05 10:36:16 -06:00
const userId = ( req as AuthenticatedRequest ). userId ;
let hostData : Record < string , unknown >;
2025-08-07 02:20:27 -05:00
2025-09-12 14:42:00 -05:00
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" });
2025-08-07 02:20:27 -05:00
}
2025-09-12 14:42:00 -05:00
} 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" });
}
2025-08-07 02:20:27 -05:00
2025-09-12 14:42:00 -05:00
if ( req . file ) {
hostData . key = req . file . buffer . toString ( "utf8" );
}
2025-08-07 02:20:27 -05:00
} else {
2025-09-12 14:42:00 -05:00
hostData = req . body ;
2025-08-07 02:20:27 -05:00
}
const {
2026-03-14 20:05:05 -05:00
connectionType ,
2025-09-12 14:42:00 -05:00
name ,
folder ,
2026-08-19 14:12:06 -05:00
parentHostId ,
2025-09-12 14:42:00 -05:00
tags ,
ip ,
port ,
username ,
password ,
authMethod ,
authType ,
2026-08-06 14:41:39 -05:00
shareSshAuth ,
2025-09-12 14:42:00 -05:00
credentialId ,
key ,
2025-10-08 23:55:33 -05:00
keyPassword ,
2025-09-12 14:42:00 -05:00
keyType ,
2025-12-31 22:20:12 -06:00
sudoPassword ,
2025-09-12 14:42:00 -05:00
pin ,
2025-11-17 09:46:05 -06:00
jumpHosts ,
2026-10-01 16:20:16 -05:00
statusCheckEnabled ,
statusCheckInterval ,
2025-11-05 10:36:16 -06:00
terminalConfig ,
2026-10-01 16:20:16 -05:00
sshOptions ,
2025-11-05 10:36:16 -06:00
forceKeyboardInteractive ,
2025-12-31 22:20:12 -06:00
notes ,
useSocks5 ,
socks5Host ,
socks5Port ,
socks5Username ,
socks5Password ,
socks5ProxyChain ,
2026-07-26 18:47:27 -05:00
connectionOrigin ,
2026-10-01 16:20:16 -05:00
localOnly ,
2026-04-22 16:55:23 -05:00
portKnockSequence ,
2025-12-31 22:20:12 -06:00
overrideCredentialUsername ,
2026-05-11 01:23:11 -05:00
enableSsh ,
sshPort ,
2025-08-07 02:20:27 -05:00
} = hostData ;
2026-10-01 16:20:16 -05:00
const protocolAuthPatch = readProtocolAuthPayload ( hostData );
2026-02-12 22:28:13 -06:00
databaseLogger . info ( "Updating SSH host" , {
operation : "host_update" ,
userId ,
hostId : parseInt ( hostId ),
changes : Object.keys ( hostData ),
});
2025-12-31 22:20:12 -06:00
2025-09-12 14:42:00 -05:00
if (
! isNonEmptyString ( userId ) ||
! isNonEmptyString ( ip ) ||
! isValidPort ( port ) ||
2026-08-06 14:41:39 -05:00
! isOptionalBoolean ( shareSshAuth ) ||
2026-10-01 16:20:16 -05:00
! isOptionalBoolean ( enableSsh ) ||
2026-10-18 00:34:50 -05:00
! isOptionalAuthType ( authType ) ||
! isOptionalAuthType ( authMethod ) ||
2025-09-12 14:42:00 -05:00
! 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" });
2025-08-07 02:20:27 -05:00
}
2026-08-19 14:12:06 -05:00
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 ;
}
}
2025-09-12 14:42:00 -05:00
const effectiveAuthType = authType || authMethod ;
2026-05-28 22:29:20 -05:00
const effectiveUsername =
2026-10-01 16:20:16 -05:00
username || firstProtocolUsername ( protocolAuthPatch );
2026-05-28 22:29:20 -05:00
const effectiveName =
name || ( effectiveUsername ? ` ${ effectiveUsername } @ ${ ip } ` : String ( ip ));
2025-11-05 10:36:16 -06:00
const sshDataObj : Record < string , unknown > = {
2026-03-14 20:05:05 -05:00
connectionType : connectionType || "ssh" ,
2026-05-28 22:29:20 -05:00
name : effectiveName ,
2026-08-19 14:12:06 -05:00
// 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 ,
2025-09-12 14:42:00 -05:00
tags : Array.isArray ( tags ) ? tags . join ( "," ) : tags || "" ,
ip ,
port ,
2026-05-28 22:29:20 -05:00
username : effectiveUsername ,
2025-09-12 14:42:00 -05:00
authType : effectiveAuthType ,
2026-08-06 14:41:39 -05:00
shareSshAuth : shareSshAuth === true ? 1 : 0 ,
2025-09-12 14:42:00 -05:00
credentialId : credentialId || null ,
2025-12-31 22:20:12 -06:00
overrideCredentialUsername : overrideCredentialUsername ? 1 : 0 ,
2025-09-12 14:42:00 -05:00
pin : pin ? 1 : 0 ,
2025-11-17 09:46:05 -06:00
jumpHosts : Array.isArray ( jumpHosts ) ? JSON . stringify ( jumpHosts ) : null ,
2026-10-01 16:20:16 -05:00
statusCheckEnabled : statusCheckEnabled === false ? 0 : 1 ,
statusCheckInterval : normalizeStatusInterval ( statusCheckInterval ),
2025-11-05 10:36:16 -06:00
forceKeyboardInteractive : forceKeyboardInteractive ? "true" : "false" ,
2025-12-31 22:20:12 -06:00
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 ,
2026-07-26 18:47:27 -05:00
connectionOrigin :
connectionOrigin === "local" || connectionOrigin === "remote"
? connectionOrigin
: null ,
2026-10-01 16:20:16 -05:00
...( typeof localOnly === "boolean" ? { localOnly } : {}),
2026-04-22 16:55:23 -05:00
portKnockSequence : portKnockSequence
? JSON . stringify ( portKnockSequence )
: null ,
2026-09-20 14:56:39 -05:00
... normalizeProtocolEnableFields ( hostData ),
2026-05-11 01:23:11 -05:00
sshPort : sshPort || port || 22 ,
2025-08-07 02:20:27 -05:00
};
2026-10-01 16:20:16 -05:00
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.
2026-03-14 20:05:05 -05:00
if (( connectionType || "ssh" ) !== "ssh" ) {
if ( password ) {
sshDataObj . password = password ;
}
sshDataObj . key = null ;
sshDataObj . keyPassword = null ;
sshDataObj . keyType = null ;
2026-10-10 14:58:27 -05:00
} else if ( keepsStoredSshAuth ( effectiveAuthType )) {
// The update says nothing about authentication.
2026-03-14 20:05:05 -05:00
} else if ( effectiveAuthType === "password" ) {
2025-09-12 14:42:00 -05:00
if ( password ) {
2025-08-07 02:20:27 -05:00
sshDataObj . password = password ;
2025-09-12 14:42:00 -05:00
}
sshDataObj . key = null ;
2026-03-08 18:02:14 -05:00
sshDataObj . keyPassword = null ;
2025-09-12 14:42:00 -05:00
sshDataObj . keyType = null ;
} else if ( effectiveAuthType === "key" ) {
2026-01-24 19:49:42 -06:00
if ( key && typeof key === "string" ) {
2026-10-04 15:11:41 -05:00
const keyValidation = parseKeyForStorage (
2026-01-24 19:49:42 -06:00
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" } ` ,
});
}
2025-08-07 02:20:27 -05:00
sshDataObj . key = key ;
2025-09-12 14:42:00 -05:00
}
2025-10-08 23:55:33 -05:00
if ( keyPassword !== undefined ) {
2026-03-08 18:02:14 -05:00
sshDataObj . keyPassword = keyPassword || null ;
2025-09-12 14:42:00 -05:00
}
2026-09-20 14:56:39 -05:00
applyHostKeyTypeUpdate ( sshDataObj , keyType );
2026-07-19 12:29:52 -05:00
sshDataObj . password = password || null ;
} else if ( effectiveAuthType === "credential" ) {
sshDataObj . password = password || null ;
sshDataObj . key = null ;
sshDataObj . keyPassword = null ;
sshDataObj . keyType = null ;
2026-06-29 13:28:26 -05:00
} else if ( effectiveAuthType === "agent" ) {
sshDataObj . password = null ;
sshDataObj . key = null ;
sshDataObj . keyPassword = null ;
sshDataObj . keyType = null ;
2025-10-01 15:40:10 -05:00
} else {
sshDataObj . password = null ;
sshDataObj . key = null ;
2026-03-08 18:02:14 -05:00
sshDataObj . keyPassword = null ;
2025-10-01 15:40:10 -05:00
sshDataObj . keyType = null ;
2025-08-07 02:20:27 -05:00
}
2026-08-19 14:12:06 -05:00
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 ;
}
2025-08-07 02:20:27 -05:00
try {
2025-12-31 22:20:12 -06:00
const accessInfo = await permissionManager . canAccessHost (
userId ,
Number ( hostId ),
2026-07-19 12:29:52 -05:00
"edit" ,
2025-12-31 22:20:12 -06:00
);
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" });
}
2026-07-19 12:29:52 -05:00
const hostRecord =
await createCurrentHostResolutionRepository (). findHostUpdateState (
Number ( hostId ),
);
2025-12-31 22:20:12 -06:00
2026-07-19 12:29:52 -05:00
if ( ! hostRecord ) {
2025-12-31 22:20:12 -06:00
sshLogger . warn ( "Host not found for update" , {
operation : "host_update" ,
hostId : parseInt ( hostId ),
userId ,
});
return res . status ( 404 ). json ({ error : "Host not found" });
}
2026-07-19 12:29:52 -05:00
const ownerId = hostRecord . userId ;
2025-12-31 22:20:12 -06:00
2026-07-19 12:29:52 -05:00
if ( ! accessInfo . isOwner ) {
2026-08-06 14:41:39 -05:00
// 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" ,
});
}
2026-10-01 16:20:16 -05:00
const incomingTerminalConfig = parseTerminalConfig (
hostData . terminalConfig ,
);
const protectedTerminalConfigField =
OWNER_PRIVATE_TERMINAL_CONFIG_FIELDS . find (( field ) =>
Object . prototype . hasOwnProperty . call (
incomingTerminalConfig ?? {},
field ,
),
2026-08-06 14:41:39 -05:00
);
2026-10-01 16:20:16 -05:00
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" ,
});
2026-07-19 12:29:52 -05:00
}
2025-12-31 22:20:12 -06:00
2026-08-06 14:41:39 -05:00
for ( const field of OWNER_PRIVATE_AUTH_FIELDS . ssh ) {
delete sshDataObj [ field ];
2025-12-31 22:20:12 -06:00
}
2026-10-01 16:20:16 -05:00
}
let protocolAuthPlan : PlannedProtocolAuth | null = null ;
if ( protocolAuthPatch ) {
try {
protocolAuthPlan = await planProtocolAuthWrite (
2026-08-19 14:12:06 -05:00
ownerId ,
2026-10-01 16:20:16 -05:00
Number ( hostId ),
protocolAuthPatch ,
{ isOwner : accessInfo.isOwner },
2026-08-19 14:12:06 -05:00
);
2026-10-01 16:20:16 -05:00
} catch ( error ) {
if ( error instanceof ProtocolAuthWriteError ) {
return res . status ( error . status ). json ({ error : error.message });
}
throw error ;
2026-08-19 14:12:06 -05:00
}
2025-12-31 22:20:12 -06:00
}
2026-10-10 14:58:27 -05:00
const terminalFieldsError = await mergeStoredSshOptions (
2026-10-01 16:20:16 -05:00
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" ],
}),
);
2026-07-19 12:29:52 -05:00
await createCurrentHostRepository (). updateEncryptedForUser (
ownerId ,
Number ( hostId ),
2025-10-01 15:40:10 -05:00
sshDataObj ,
);
2026-10-01 16:20:16 -05:00
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 );
}
2025-09-12 14:42:00 -05:00
2026-09-20 14:56:39 -05:00
// 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 ),
});
}
2026-07-19 12:29:52 -05:00
// 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 ),
2026-08-19 14:12:06 -05:00
error : getErrorMessage ( resyncError ),
2026-07-19 12:29:52 -05:00
});
}
2025-09-12 14:42:00 -05:00
2026-07-19 12:29:52 -05:00
const updatedHost =
await createCurrentHostResolutionRepository (). findHostById (
Number ( hostId ),
ownerId ,
);
if ( ! updatedHost ) {
2025-09-12 14:42:00 -05:00
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" });
}
2026-01-26 00:28:40 -06:00
const baseHost = transformHostResponse ( updatedHost );
2025-09-12 14:42:00 -05:00
2025-12-31 22:20:12 -06:00
const resolvedHost =
( await resolveHostCredentials ( baseHost , userId )) || baseHost ;
2026-02-12 22:28:13 -06:00
databaseLogger . success ( "SSH host updated" , {
operation : "host_update_success" ,
userId ,
hostId : parseInt ( hostId ),
});
2025-09-12 14:42:00 -05:00
2026-06-16 15:59:53 -05:00
const { ipAddress : uhIp , userAgent : uhUa } = getRequestMeta ( req );
await logAudit ({
userId ,
2026-07-19 12:29:52 -05:00
username : await getAuditUsername ( userId ),
2026-06-16 15:59:53 -05:00
action : "update_host" ,
resourceType : "host" ,
resourceId : hostId ,
resourceName : String ( name ?? ip ),
ipAddress : uhIp ,
userAgent : uhUa ,
success : true ,
});
2026-10-01 16:20:16 -05:00
res . json ( await withProtocolAuth ( stripSensitiveFields ( resolvedHost )));
notifyStatsHostUpdated ( parseInt ( hostId ), userId , "host_update" );
2025-08-07 02:20:27 -05:00
} catch ( err ) {
2025-09-12 14:42:00 -05:00
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" });
2025-08-07 02:20:27 -05:00
}
2025-09-12 14:42:00 -05:00
},
);
2025-08-07 02:20:27 -05:00
2026-01-24 19:49:42 -06:00
/**
* @openapi
2026-04-22 16:55:23 -05:00
* /host/db/host:
2026-01-24 19:49:42 -06:00
* 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.
*/
2025-11-17 09:46:05 -06:00
router . get (
"/db/host" ,
authenticateJWT ,
2026-09-20 14:56:39 -05:00
permissionManager . requirePermission ( "hosts.view" ),
2025-11-17 09:46:05 -06:00
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 {
2025-12-31 22:20:12 -06:00
const now = new Date (). toISOString ();
2026-07-19 12:29:52 -05:00
const roleIds =
await createCurrentRoleRepository (). listUserRoleIds ( userId );
const accessEntries =
await createCurrentRbacAccessRepository (). listVisibleHostAccessEntries (
userId ,
roleIds ,
now ,
);
2025-12-31 22:20:12 -06:00
2026-07-19 12:29:52 -05:00
const rawData =
await createCurrentHostResolutionRepository (). listHostRowsForAccessList (
userId ,
accessEntries ,
2025-12-31 22:20:12 -06:00
);
const ownHosts = rawData . filter (( row ) => row . userId === userId );
const sharedHosts = rawData . filter (( row ) => row . userId !== userId );
2026-05-12 21:55:14 -05:00
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 ,
2026-08-19 14:12:06 -05:00
error : getErrorMessage ( decryptError ),
2026-05-12 21:55:14 -05:00
});
}
}
2025-12-31 22:20:12 -06:00
}
2026-08-19 14:12:06 -05:00
// One lookup for every owner rather than one per shared host.
2026-07-19 12:29:52 -05:00
const ownerUsernames = new Map < string , string >();
2026-08-19 14:12:06 -05:00
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 ?? "" );
2026-07-19 12:29:52 -05:00
}
2026-08-19 14:12:06 -05:00
} catch {
// Falls through to an undefined ownerUsername below.
2026-07-19 12:29:52 -05:00
}
}
2025-12-31 22:20:12 -06:00
2026-07-19 12:29:52 -05:00
const data = [... decryptedOwnHosts , ... sharedHosts ];
2025-09-12 14:42:00 -05:00
2026-08-19 14:12:06 -05:00
// 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 >());
2025-11-17 09:46:05 -06:00
const result = await Promise . all (
data . map ( async ( row : Record < string , unknown >) => {
2026-10-01 16:20:16 -05:00
const transformed = transformHostResponse ( row );
2025-11-17 09:46:05 -06:00
const baseHost = {
2026-10-01 16:20:16 -05:00
... transformed ,
isShared : !! row . isShared || !! transformed . sharedCopy ,
permissionLevel :
row.permissionLevel || transformed . permissionLevel || undefined ,
2025-12-31 22:20:12 -06:00
sharedExpiresAt : row.expiresAt || undefined ,
2026-07-19 12:29:52 -05:00
ownerUsername : row.isShared
? ownerUsernames . get ( row . userId as string ) || undefined
2026-10-01 16:20:16 -05:00
: transformed . ownerUsername ,
2025-11-17 09:46:05 -06:00
};
2025-09-12 14:42:00 -05:00
2025-12-31 22:20:12 -06:00
const resolved =
2026-08-19 14:12:06 -05:00
( await resolveHostCredentials ( baseHost , userId , credentialsById )) ||
baseHost ;
2025-12-31 22:20:12 -06:00
return resolved ;
2025-11-17 09:46:05 -06:00
}),
);
2025-09-12 14:42:00 -05:00
2026-10-01 16:20:16 -05:00
attachProtocolAuth ( result , await loadProtocolAuthSummaries ( result ));
2026-07-19 12:29:52 -05:00
const sanitized = result . map (( host ) =>
host . isShared
? sanitizeHostForRecipient (
host ,
host . permissionLevel as string | undefined ,
)
: stripSensitiveFields ( host ),
);
2026-10-01 16:20:16 -05:00
// 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 );
2026-04-22 16:55:23 -05:00
res . json ( sanitized );
2025-11-17 09:46:05 -06:00
} 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" });
}
},
);
2025-08-07 02:20:27 -05:00
2026-01-24 19:49:42 -06:00
/**
* @openapi
2026-04-22 16:55:23 -05:00
* /host/db/host/{id}:
2026-01-24 19:49:42 -06:00
* 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.
*/
2025-09-12 14:42:00 -05:00
router . get (
"/db/host/:id" ,
authenticateJWT ,
2026-09-20 14:56:39 -05:00
permissionManager . requirePermission ( "hosts.view" ),
2025-11-17 09:46:05 -06:00
requireDataAccess ,
2025-09-12 14:42:00 -05:00
async ( req : Request , res : Response ) => {
2026-01-24 19:49:42 -06:00
const hostId = Array . isArray ( req . params . id )
? req . params . id [ 0 ]
: req . params . id ;
2025-11-05 10:36:16 -06:00
const userId = ( req as AuthenticatedRequest ). userId ;
2025-08-07 02:20:27 -05:00
2025-09-12 14:42:00 -05:00
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" });
2025-08-07 02:20:27 -05:00
}
try {
2026-07-19 12:29:52 -05:00
const hostResolutionRepository = createCurrentHostResolutionRepository ();
const host = await hostResolutionRepository . findHostByIdForUser (
Number ( hostId ),
2026-03-14 20:05:05 -05:00
userId ,
);
2025-08-07 02:20:27 -05:00
2026-07-19 12:29:52 -05:00
if ( host ) {
const result = transformHostResponse ( host );
const resolved =
( await resolveHostCredentials ( result , userId )) || result ;
2026-10-01 16:20:16 -05:00
return res . json (
await withHostPluginSettings (
await withProtocolAuth ( stripSensitiveFields ( resolved )),
),
);
2026-07-19 12:29:52 -05:00
}
// Not the owner: shared recipients get a sanitized view of the host.
const accessInfo = await permissionManager . canAccessHost (
userId ,
Number ( hostId ),
"connect" ,
);
if ( ! accessInfo . hasAccess ) {
2025-09-12 14:42:00 -05:00
sshLogger . warn ( "SSH host not found" , {
operation : "host_fetch_by_id" ,
hostId : parseInt ( hostId ),
userId ,
2025-08-07 02:20:27 -05:00
});
2025-09-12 14:42:00 -05:00
return res . status ( 404 ). json ({ error : "SSH host not found" });
}
2025-08-07 02:20:27 -05:00
2026-07-19 12:29:52 -05:00
const ownerId = await hostResolutionRepository . findHostOwnerId (
Number ( hostId ),
);
const sharedHost = ownerId
? await hostResolutionRepository . findHostById ( Number ( hostId ), ownerId )
: null ;
2025-08-07 02:20:27 -05:00
2026-07-19 12:29:52 -05:00
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 ,
};
2026-10-01 16:20:16 -05:00
const resolvedSharedResult = await withProtocolAuth (
( await resolveHostCredentials ( sharedResult , userId )) || sharedResult ,
);
2026-07-19 12:29:52 -05:00
res . json (
2026-10-01 16:20:16 -05:00
await withHostPluginSettings (
sanitizeHostForRecipient (
resolvedSharedResult ,
accessInfo . permissionLevel ,
),
userId ,
2026-08-06 14:41:39 -05:00
),
2026-07-19 12:29:52 -05:00
);
2025-08-07 02:20:27 -05:00
} catch ( err ) {
2025-09-12 14:42:00 -05:00
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" });
2025-08-07 02:20:27 -05:00
}
2025-09-12 14:42:00 -05:00
},
);
2025-08-07 02:20:27 -05:00
2026-09-20 14:56:39 -05:00
/**
2026-10-01 16:20:16 -05:00
* @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.
2026-09-20 14:56:39 -05:00
*/
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" });
}
},
);
2026-01-24 19:49:42 -06:00
/**
* @openapi
2026-04-22 16:55:23 -05:00
* /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
2026-10-01 16:20:16 -05:00
* 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.
2026-04-22 16:55:23 -05:00
* 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" ;
2026-10-01 16:20:16 -05:00
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 ;
2026-04-22 16:55:23 -05:00
2026-10-01 16:20:16 -05:00
if ( ! protocolId && ( protocol || ! coreField )) {
2026-04-22 16:55:23 -05:00
return res . status ( 400 ). json ({ error : "Invalid field" });
}
try {
2026-08-06 14:41:39 -05:00
const host =
await createCurrentHostResolutionRepository (). findHostByIdForUser (
hostId ,
userId ,
);
2026-04-22 16:55:23 -05:00
2026-07-19 12:29:52 -05:00
if ( ! host ) {
2026-04-22 16:55:23 -05:00
return res . status ( 404 ). json ({ error : "Host not found" });
}
2026-10-01 16:20:16 -05:00
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 });
}
2026-04-22 16:55:23 -05:00
const resolved = ( await resolveHostCredentials ( host , userId )) || host ;
2026-10-10 14:58:27 -05:00
const value = resolved [ field ];
2026-04-22 16:55:23 -05:00
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:
2026-01-24 19:49:42 -06:00
* 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.
*/
2025-10-03 00:02:10 -05:00
router . get (
"/db/host/:id/export" ,
authenticateJWT ,
requireDataAccess ,
async ( req : Request , res : Response ) => {
2026-01-24 19:49:42 -06:00
const hostId = Array . isArray ( req . params . id )
? req . params . id [ 0 ]
: req . params . id ;
2025-11-05 10:36:16 -06:00
const userId = ( req as AuthenticatedRequest ). userId ;
2025-10-03 00:02:10 -05:00
if ( ! isNonEmptyString ( userId ) || ! hostId ) {
return res . status ( 400 ). json ({ error : "Invalid userId or hostId" });
}
try {
2026-07-19 12:29:52 -05:00
const host =
await createCurrentHostResolutionRepository (). findHostByIdForUser (
Number ( hostId ),
userId ,
);
2025-10-03 00:02:10 -05:00
2026-07-19 12:29:52 -05:00
if ( ! host ) {
2025-10-03 00:02:10 -05:00
return res . status ( 404 ). json ({ error : "SSH host not found" });
}
2025-12-31 22:20:12 -06:00
const resolvedHost = ( await resolveHostCredentials ( host , userId )) || host ;
2026-10-01 16:20:16 -05:00
const hostPluginSettings = (
await loadHostPluginSettings ([ Number ( hostId )])
). get ( Number ( hostId ));
2025-10-03 00:02:10 -05:00
2026-03-14 20:05:05 -05:00
const exportedConnectionType =
( resolvedHost . connectionType as string ) || "ssh" ;
2026-10-01 16:20:16 -05:00
const isRemoteDesktop = exportedConnectionType !== "ssh" ;
2026-03-14 20:05:05 -05:00
const baseExportData = {
2026-09-20 14:56:39 -05:00
exportId : resolvedHost.id ,
2026-03-14 20:05:05 -05:00
connectionType : exportedConnectionType ,
2025-10-03 00:02:10 -05:00
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 ,
2026-03-08 18:02:14 -05:00
notes : resolvedHost.notes || null ,
2026-10-01 16:20:16 -05:00
// 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 ),
),
2025-10-03 00:02:10 -05:00
};
2026-03-14 20:05:05 -05:00
const exportData = isRemoteDesktop
2026-10-01 16:20:16 -05:00
? baseExportData
2026-03-14 20:05:05 -05:00
: {
... baseExportData ,
authType : resolvedHost.authType ,
key : resolvedHost.key || null ,
keyPassword : resolvedHost.keyPassword || null ,
keyType : resolvedHost.keyType || null ,
credentialId : resolvedHost.credentialId || null ,
overrideCredentialUsername :
!! resolvedHost . overrideCredentialUsername ,
2026-10-10 14:58:27 -05:00
sudoPassword : resolvedHost.sudoPassword || null ,
2026-03-14 20:05:05 -05:00
jumpHosts : resolvedHost.jumpHosts
? JSON . parse ( resolvedHost . jumpHosts as string )
: null ,
2026-10-01 16:20:16 -05:00
terminalConfig :
hostTerminalExport ( resolvedHost ). terminalConfig ?? null ,
sshOptions : hostTerminalExport ( resolvedHost ). sshOptions ,
2026-03-14 20:05:05 -05:00
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 ,
2026-04-22 16:55:23 -05:00
portKnockSequence : resolvedHost.portKnockSequence
? JSON . parse ( resolvedHost . portKnockSequence as string )
: null ,
2026-03-14 20:05:05 -05:00
};
2025-10-03 00:02:10 -05:00
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" });
}
},
);
2026-04-22 16:55:23 -05:00
/**
* @openapi
2026-06-16 15:59:53 -05:00
* /host/db/hosts/export:
2026-04-22 16:55:23 -05:00
* get:
* summary: Export all SSH hosts
2026-07-26 18:47:27 -05:00
* 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.
2026-04-22 16:55:23 -05:00
* tags:
* - SSH
2026-07-26 18:47:27 -05:00
* parameters:
* - in: query
* name: share
* required: false
* schema:
* type: string
* description: Set to "1" to export without embedded secrets.
2026-04-22 16:55:23 -05:00
* 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 ;
2026-07-26 18:47:27 -05:00
const shareMode = req . query . share === "1" || req . query . share === "true" ;
2026-04-22 16:55:23 -05:00
if ( ! isNonEmptyString ( userId )) {
return res . status ( 400 ). json ({ error : "Invalid userId" });
}
try {
2026-07-19 12:29:52 -05:00
const allHosts =
await createCurrentHostResolutionRepository (). findHostsByUserId ( userId );
2026-10-01 16:20:16 -05:00
const pluginSettingsByHost = await loadHostPluginSettings (
allHosts . map (( h ) => h . id as number ),
);
2026-06-29 13:28:26 -05:00
2026-04-22 16:55:23 -05:00
const exportedHosts = [];
2026-07-26 18:47:27 -05:00
const usedCredentialIds = new Set < number >();
2026-04-22 16:55:23 -05:00
for ( const host of allHosts ) {
2026-07-26 18:47:27 -05:00
const resolvedHost = shareMode
? host
: ( await resolveHostCredentials ( host , userId )) || host ;
2026-10-01 16:20:16 -05:00
const hostPluginSettings = pluginSettingsByHost . get ( host . id as number );
2026-04-22 16:55:23 -05:00
const exportedConnectionType =
( resolvedHost . connectionType as string ) || "ssh" ;
2026-10-01 16:20:16 -05:00
const isRemoteDesktop = exportedConnectionType !== "ssh" ;
2026-04-22 16:55:23 -05:00
const baseExportData = {
2026-09-20 14:56:39 -05:00
exportId : resolvedHost.id ,
2026-04-22 16:55:23 -05:00
connectionType : exportedConnectionType ,
name : resolvedHost.name ,
ip : resolvedHost.ip ,
port : resolvedHost.port ,
username : resolvedHost.username ,
2026-07-26 18:47:27 -05:00
password : shareMode ? null : resolvedHost . password || null ,
2026-04-22 16:55:23 -05:00
folder : resolvedHost.folder ,
tags :
typeof resolvedHost . tags === "string"
? resolvedHost . tags . split ( "," ). filter ( Boolean )
: resolvedHost . tags || [],
pin : !! resolvedHost . pin ,
notes : resolvedHost.notes || null ,
2026-10-01 16:20:16 -05:00
// 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 ,
),
2026-04-22 16:55:23 -05:00
};
const exportData = isRemoteDesktop
2026-10-01 16:20:16 -05:00
? baseExportData
2026-04-22 16:55:23 -05:00
: {
... baseExportData ,
authType : resolvedHost.authType ,
2026-07-26 18:47:27 -05:00
key : shareMode ? null : resolvedHost . key || null ,
keyPassword : shareMode ? null : resolvedHost . keyPassword || null ,
2026-04-22 16:55:23 -05:00
keyType : resolvedHost.keyType || null ,
credentialId : resolvedHost.credentialId || null ,
overrideCredentialUsername :
!! resolvedHost . overrideCredentialUsername ,
2026-07-26 18:47:27 -05:00
sudoPassword : shareMode
? null
2026-10-10 14:58:27 -05:00
: resolvedHost . sudoPassword || null ,
2026-04-22 16:55:23 -05:00
jumpHosts : resolvedHost.jumpHosts
? JSON . parse ( resolvedHost . jumpHosts as string )
: null ,
2026-10-01 16:20:16 -05:00
terminalConfig :
hostTerminalExport ( resolvedHost ). terminalConfig ?? null ,
sshOptions : hostTerminalExport ( resolvedHost ). sshOptions ,
2026-04-22 16:55:23 -05:00
forceKeyboardInteractive :
resolvedHost.forceKeyboardInteractive === "true" ,
useSocks5 : !! resolvedHost . useSocks5 ,
socks5Host : resolvedHost.socks5Host || null ,
socks5Port : resolvedHost.socks5Port || null ,
socks5Username : resolvedHost.socks5Username || null ,
2026-07-26 18:47:27 -05:00
socks5Password : shareMode
? null
: resolvedHost . socks5Password || null ,
2026-04-22 16:55:23 -05:00
socks5ProxyChain : resolvedHost.socks5ProxyChain
? JSON . parse ( resolvedHost . socks5ProxyChain as string )
: null ,
};
2026-07-26 18:47:27 -05:00
if (
shareMode &&
! isRemoteDesktop &&
resolvedHost . authType === "credential" &&
resolvedHost . credentialId
) {
usedCredentialIds . add ( resolvedHost . credentialId as number );
}
2026-04-22 16:55:23 -05:00
exportedHosts . push ( exportData );
}
2026-07-26 18:47:27 -05:00
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" ,
2026-04-22 16:55:23 -05:00
count : exportedHosts.length ,
2026-07-26 18:47:27 -05:00
credentialCount : exportedCredentials.length ,
2026-04-22 16:55:23 -05:00
userId ,
});
2026-07-26 18:47:27 -05:00
res . json ({
version : "1" ,
exportedAt : new Date (). toISOString (),
credentials : exportedCredentials ,
hosts : exportedHosts ,
});
2026-04-22 16:55:23 -05:00
} 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" });
}
},
);
2026-01-24 19:49:42 -06:00
/**
* @openapi
2026-06-16 15:59:53 -05:00
* /host/db/host/{id}:
2026-01-24 19:49:42 -06:00
* 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.
*/
2025-09-12 14:42:00 -05:00
router . delete (
"/db/host/:id" ,
authenticateJWT ,
2026-09-20 14:56:39 -05:00
permissionManager . requirePermission ( "hosts.delete" ),
2025-11-17 09:46:05 -06:00
requireDataAccess ,
2025-09-12 14:42:00 -05:00
async ( req : Request , res : Response ) => {
2025-11-05 10:36:16 -06:00
const userId = ( req as AuthenticatedRequest ). userId ;
2026-01-24 19:49:42 -06:00
const hostId = Array . isArray ( req . params . id )
? req . params . id [ 0 ]
: req . params . id ;
2025-09-12 14:42:00 -05:00
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" });
2025-08-07 02:20:27 -05:00
}
2026-02-12 22:28:13 -06:00
databaseLogger . info ( "Deleting SSH host" , {
operation : "host_delete" ,
userId ,
hostId : parseInt ( hostId ),
});
2025-08-07 02:20:27 -05:00
try {
2026-10-01 16:20:16 -05:00
const deleted = await deleteOwnedHost ( userId , Number ( hostId ));
2025-09-12 14:42:00 -05:00
2026-10-01 16:20:16 -05:00
if ( ! deleted ) {
2025-09-12 14:42:00 -05:00
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" });
}
2026-02-12 22:28:13 -06:00
databaseLogger . success ( "SSH host deleted" , {
operation : "host_delete_success" ,
userId ,
hostId : parseInt ( hostId ),
});
2025-09-12 14:42:00 -05:00
2026-06-16 15:59:53 -05:00
const { ipAddress : dhIp , userAgent : dhUa } = getRequestMeta ( req );
await logAudit ({
userId ,
2026-07-19 12:29:52 -05:00
username : await getAuditUsername ( userId ),
2026-06-16 15:59:53 -05:00
action : "delete_host" ,
resourceType : "host" ,
resourceId : hostId ,
2026-10-01 16:20:16 -05:00
resourceName : deleted.name ,
2026-06-16 15:59:53 -05:00
ipAddress : dhIp ,
userAgent : dhUa ,
success : true ,
});
2025-09-12 14:42:00 -05:00
res . json ({ message : "SSH host deleted" });
2025-08-07 02:20:27 -05:00
} catch ( err ) {
2025-09-12 14:42:00 -05:00
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" });
2025-08-07 02:20:27 -05:00
}
2025-09-12 14:42:00 -05:00
},
);
2025-08-07 02:20:27 -05:00
2026-10-01 16:20:16 -05:00
// 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/.
2026-06-04 15:16:53 -04:00
2026-10-01 16:20:16 -05:00
/**
* 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 ;
}
2025-11-17 09:46:05 -06:00
2025-11-05 10:36:16 -06:00
async function resolveHostCredentials (
host : Record < string , unknown >,
2025-12-31 22:20:12 -06:00
requestingUserId? : string ,
2026-08-19 14:12:06 -05:00
/**
* 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 >,
2025-11-05 10:36:16 -06:00
) : Promise < Record < string , unknown >> {
2025-09-12 14:42:00 -05:00
try {
2026-08-06 14:41:39 -05:00
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 ,
};
2026-09-20 14:56:39 -05:00
// 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 ,
);
2026-10-01 16:20:16 -05:00
// 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 ()) {
2026-09-20 14:56:39 -05:00
authOverrides [ protocol ] = {
credentialId : overrideCredentialIds [ protocol ],
required : false ,
ownerAuthShared : true ,
};
}
2026-08-06 14:41:39 -05:00
const recipientHost : Record < string , unknown > = {
... host ,
credentialId : null ,
password : null ,
key : null ,
keyPassword : null ,
keyType : null ,
2026-09-20 14:56:39 -05:00
authOverrides ,
2026-08-06 14:41:39 -05:00
};
2025-12-31 22:20:12 -06:00
2026-08-06 14:41:39 -05:00
try {
const resolution = await resolveRecipientSharedHostAuthentication (
authHost ,
host . id ,
requestingUserId ,
"ssh" ,
);
2025-12-31 22:20:12 -06:00
2026-08-06 14:41:39 -05:00
if ( resolution . source === "personal-override" ) {
const credential = resolution . credential ;
return {
... recipientHost ,
authOverrides : {
2026-09-20 14:56:39 -05:00
... authOverrides ,
2026-08-06 14:41:39 -05:00
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 : {
2026-09-20 14:56:39 -05:00
... authOverrides ,
2026-08-06 14:41:39 -05:00
ssh : {
required : false ,
ownerAuthShared : true ,
},
},
authType : "agent" ,
2025-12-31 22:20:12 -06:00
};
2026-08-06 14:41:39 -05:00
}
2025-12-31 22:20:12 -06:00
2026-08-06 14:41:39 -05:00
const sharedAuth = resolution . secret ;
if ( sharedAuth ) {
2026-06-16 15:59:53 -05:00
const resolvedUsername = pickResolvedUsername (
2026-08-06 14:41:39 -05:00
recipientHost . username ,
sharedAuth . username ,
2026-06-16 15:59:53 -05:00
host . overrideCredentialUsername ,
);
2026-08-06 14:41:39 -05:00
return {
... recipientHost ,
authOverrides : {
2026-09-20 14:56:39 -05:00
... authOverrides ,
2026-08-06 14:41:39 -05:00
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 ,
};
2025-12-31 22:20:12 -06:00
}
}
2026-08-06 14:41:39 -05:00
if ( resolution . source === "secretless" ) {
return {
... recipientHost ,
authOverrides : {
2026-09-20 14:56:39 -05:00
... authOverrides ,
2026-08-06 14:41:39 -05:00
ssh : {
required : false ,
ownerAuthShared : !! host . shareSshAuth ,
},
},
};
}
} catch {
// A missing/deleted override or snapshot behaves like unavailable auth.
2025-12-31 22:20:12 -06:00
}
2025-11-05 10:36:16 -06:00
2026-08-06 14:41:39 -05:00
return recipientHost ;
}
if ( host . credentialId && ( host . userId || host . ownerId )) {
const credentialId = host . credentialId as number ;
const credentialOwnerId = ( host . ownerId || host . userId ) as string ;
2026-07-19 12:29:52 -05:00
const credential =
2026-08-19 14:12:06 -05:00
preloadedCredentials ? . get ( credentialId ) ??
2026-09-20 14:56:39 -05:00
( await findUsableCredential ( credentialId , credentialOwnerId ));
2025-09-12 14:42:00 -05:00
2026-07-19 12:29:52 -05:00
if ( credential ) {
2025-12-31 22:20:12 -06:00
const resolvedHost : Record < string , unknown > = {
2025-09-12 14:42:00 -05:00
... host ,
2026-07-19 12:29:52 -05:00
password : pickResolvedPassword ( host . password , credential . password ),
2025-09-12 14:42:00 -05:00
key : credential.key ,
2026-03-08 18:02:14 -05:00
keyPassword : credential.keyPassword ,
keyType : credential.keyType ,
2025-09-12 14:42:00 -05:00
};
2025-12-31 22:20:12 -06:00
2026-06-16 15:59:53 -05:00
const resolvedUsername = pickResolvedUsername (
host . username ,
credential . username ,
host . overrideCredentialUsername ,
);
if ( resolvedUsername !== undefined ) {
resolvedHost . username = resolvedUsername ;
2025-12-31 22:20:12 -06:00
}
return resolvedHost ;
2025-09-12 14:42:00 -05:00
}
}
2025-11-05 10:36:16 -06:00
2026-03-08 18:02:14 -05:00
return { ... host };
2025-09-12 14:42:00 -05:00
} catch ( error ) {
sshLogger . warn (
2026-08-19 14:12:06 -05:00
`Failed to resolve credentials for host ${ host . id } : ${ getErrorMessage ( error ) } ` ,
2025-09-12 14:42:00 -05:00
);
return host ;
}
}
2026-06-04 15:16:53 -04:00
registerHostFolderRoutes ( router , {
2025-09-12 14:42:00 -05:00
authenticateJWT ,
2026-09-20 14:56:39 -05:00
requireViewPermission : permissionManager.requirePermission ( "hosts.view" ),
requireEditPermission : permissionManager.requirePermission ( "hosts.edit" ),
requireDeletePermission : permissionManager.requirePermission ( "hosts.delete" ),
requireCredentialEditPermission :
permissionManager.requirePermission ( "credentials.edit" ),
requireDataAccess ,
2025-11-17 09:46:05 -06:00
});
2026-09-20 14:56:39 -05:00
registerHostBulkRoutes (
router ,
authenticateJWT ,
permissionManager . requirePermission ( "hosts.create" ),
permissionManager . requirePermission ( "hosts.edit" ),
requireDataAccess ,
);
2025-08-13 00:26:32 -05:00
2026-06-04 15:16:53 -04:00
registerHostNetworkRoutes ( router , {
authenticateJWT ,
2026-09-20 14:56:39 -05:00
requireViewPermission : permissionManager.requirePermission ( "hosts.view" ),
2026-06-04 15:16:53 -04:00
requireDataAccess ,
2026-02-12 22:28:13 -06:00
});
2026-10-01 16:20:16 -05:00
registerHostDefaultsRoutes ( router , {
authenticateJWT ,
requireEditPermission : permissionManager.requirePermission ( "hosts.edit" ),
requireDataAccess ,
requireAdminSettings : permissionManager.requirePermission (
"admin.settings.manage" ,
),
});
2025-09-12 14:42:00 -05:00
export default router ;
2026-09-20 14:56:39 -05:00
2026-10-01 16:20:16 -05:00
/** 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 ;
2026-09-20 14:56:39 -05:00
}