mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
1842 lines
56 KiB
TypeScript
1842 lines
56 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 { resolveHostCredentials } from "../../hosts/resolve-host-credentials.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 } from "../../hosts/credential-username.js";
|
|
import { emitInternalEvent } from "../../hosts/internal-events.js";
|
|
import { deleteOwnedHost } from "../../hosts/delete-host.js";
|
|
import {
|
|
createCurrentRbacAccessRepository,
|
|
createCurrentRoleRepository,
|
|
createCurrentHostResolutionRepository,
|
|
createCurrentHostRepository,
|
|
createCurrentUserRepository,
|
|
createCurrentHostDefaultsRepository,
|
|
} from "../repositories/factory.js";
|
|
import {
|
|
applyHostKeyTypeUpdate,
|
|
containsOwnerPrivateAuthUpdate,
|
|
impliedSshAuthType,
|
|
keepsStoredSshAuth,
|
|
isNonEmptyString,
|
|
isOptionalAuthType,
|
|
isOptionalBoolean,
|
|
isValidPort,
|
|
normalizeProtocolEnableFields,
|
|
OWNER_PRIVATE_AUTH_FIELDS,
|
|
OWNER_PRIVATE_SSH_OPTION_FIELDS,
|
|
OWNER_PRIVATE_TERMINAL_CONFIG_FIELDS,
|
|
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 } from "../repositories/host-resolution-repository.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,
|
|
withProtocolAuth,
|
|
writeProtocolAuth,
|
|
type PlannedProtocolAuth,
|
|
} from "../../hosts/protocol-auth/protocol-auth.js";
|
|
import { findHostProtocol } from "../../hosts/protocol-auth/registry.js";
|
|
import {
|
|
manualCreateBlockedBy,
|
|
requireManualCreate,
|
|
requireUnlockedHost,
|
|
} from "../../hosts/locks.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,
|
|
requireManualCreate,
|
|
requireHostEnrollmentAccessForPath,
|
|
upload.single("key"),
|
|
async (req: Request, res: Response) => {
|
|
const userId = (req as AuthenticatedRequest).userId;
|
|
let hostData: Record<string, unknown>;
|
|
|
|
if (req.headers["content-type"]?.includes("multipart/form-data")) {
|
|
if (req.body.data) {
|
|
try {
|
|
hostData = JSON.parse(req.body.data);
|
|
} catch (err) {
|
|
sshLogger.warn("Invalid JSON data in multipart request", {
|
|
operation: "host_create",
|
|
userId,
|
|
error: err,
|
|
});
|
|
return res.status(400).json({ error: "Invalid JSON data" });
|
|
}
|
|
} else {
|
|
sshLogger.warn("Missing data field in multipart request", {
|
|
operation: "host_create",
|
|
userId,
|
|
});
|
|
return res.status(400).json({ error: "Missing data field" });
|
|
}
|
|
|
|
if (req.file) {
|
|
hostData.key = req.file.buffer.toString("utf8");
|
|
}
|
|
} else {
|
|
hostData = req.body;
|
|
}
|
|
|
|
if (req.path === "/enroll") {
|
|
hostData = applyHostEnrollmentDefaults(hostData);
|
|
}
|
|
|
|
const {
|
|
connectionType,
|
|
name,
|
|
folder,
|
|
parentHostId,
|
|
tags,
|
|
ip,
|
|
port,
|
|
username,
|
|
password,
|
|
authMethod,
|
|
authType,
|
|
shareSshAuth,
|
|
credentialId,
|
|
key,
|
|
keyPassword,
|
|
keyType,
|
|
sudoPassword,
|
|
pin,
|
|
jumpHosts,
|
|
statusCheckEnabled,
|
|
statusCheckInterval,
|
|
terminalConfig,
|
|
sshOptions,
|
|
forceKeyboardInteractive,
|
|
notes,
|
|
useSocks5,
|
|
socks5Host,
|
|
socks5Port,
|
|
socks5Username,
|
|
socks5Password,
|
|
socks5ProxyChain,
|
|
connectionOrigin,
|
|
localOnly,
|
|
portKnockSequence,
|
|
overrideCredentialUsername,
|
|
enableSsh,
|
|
sshPort,
|
|
} = hostData;
|
|
const protocolAuthPatch = readProtocolAuthPayload(hostData);
|
|
databaseLogger.info("Creating SSH host", {
|
|
operation: "host_create",
|
|
userId,
|
|
name,
|
|
ip,
|
|
});
|
|
|
|
if (
|
|
!isNonEmptyString(userId) ||
|
|
!isNonEmptyString(ip) ||
|
|
!isValidPort(port) ||
|
|
!isOptionalBoolean(shareSshAuth) ||
|
|
!isOptionalBoolean(enableSsh) ||
|
|
!isOptionalAuthType(authType) ||
|
|
!isOptionalAuthType(authMethod)
|
|
) {
|
|
sshLogger.warn("Invalid SSH data input validation failed", {
|
|
operation: "host_create",
|
|
userId,
|
|
hasIp: !!ip,
|
|
port,
|
|
isValidPort: isValidPort(port),
|
|
});
|
|
return res.status(400).json({ error: "Invalid SSH data" });
|
|
}
|
|
|
|
let validatedParentHostId: number | null = null;
|
|
if (parentHostId !== undefined && parentHostId !== null) {
|
|
const numericParentHostId = Number(parentHostId);
|
|
if (!Number.isInteger(numericParentHostId)) {
|
|
return res.status(400).json({ error: "Invalid parent host" });
|
|
}
|
|
const parentError = await validateParentHostId(
|
|
userId,
|
|
null,
|
|
numericParentHostId,
|
|
);
|
|
if (parentError) {
|
|
return res.status(400).json({ error: parentError });
|
|
}
|
|
validatedParentHostId = numericParentHostId;
|
|
}
|
|
|
|
const effectiveConnectionType = connectionType || "ssh";
|
|
const effectiveAuthType =
|
|
authType ||
|
|
authMethod ||
|
|
(effectiveConnectionType !== "ssh"
|
|
? "password"
|
|
: impliedSshAuthType(hostData));
|
|
const effectiveUsername =
|
|
username || firstProtocolUsername(protocolAuthPatch);
|
|
const effectiveName =
|
|
name || (effectiveUsername ? `${effectiveUsername}@${ip}` : String(ip));
|
|
const sshDataObj: Record<string, unknown> = {
|
|
userId: userId,
|
|
connectionType: effectiveConnectionType,
|
|
name: effectiveName,
|
|
// A host is either placed in a folder or nested under a parent host,
|
|
// never both -- setting one clears the other.
|
|
folder: validatedParentHostId ? null : folder || null,
|
|
parentHostId: validatedParentHostId,
|
|
tags: Array.isArray(tags) ? tags.join(",") : tags || "",
|
|
ip,
|
|
port,
|
|
username: effectiveUsername,
|
|
authType: effectiveAuthType,
|
|
shareSshAuth: shareSshAuth === true ? 1 : 0,
|
|
credentialId: credentialId || null,
|
|
overrideCredentialUsername: overrideCredentialUsername ? 1 : 0,
|
|
pin: pin ? 1 : 0,
|
|
jumpHosts: Array.isArray(jumpHosts) ? JSON.stringify(jumpHosts) : null,
|
|
statusCheckEnabled: statusCheckEnabled === false ? 0 : 1,
|
|
statusCheckInterval: normalizeStatusInterval(statusCheckInterval),
|
|
sshOptions: sshOptionsForWrite({ sshOptions, terminalConfig }) ?? null,
|
|
forceKeyboardInteractive: forceKeyboardInteractive ? "true" : "false",
|
|
notes: notes || null,
|
|
sudoPassword: sudoPassword || null,
|
|
useSocks5: useSocks5 ? 1 : 0,
|
|
socks5Host: socks5Host || null,
|
|
socks5Port: socks5Port || null,
|
|
socks5Username: socks5Username || null,
|
|
socks5Password: socks5Password || null,
|
|
socks5ProxyChain: socks5ProxyChain
|
|
? JSON.stringify(socks5ProxyChain)
|
|
: null,
|
|
connectionOrigin:
|
|
connectionOrigin === "local" || connectionOrigin === "remote"
|
|
? connectionOrigin
|
|
: null,
|
|
...(typeof localOnly === "boolean" ? { localOnly } : {}),
|
|
portKnockSequence: portKnockSequence
|
|
? JSON.stringify(portKnockSequence)
|
|
: null,
|
|
...normalizeProtocolEnableFields(hostData),
|
|
sshPort: sshPort || port || 22,
|
|
};
|
|
|
|
// A host whose main protocol is a plugin's keeps any password it is given.
|
|
if (effectiveConnectionType !== "ssh") {
|
|
sshDataObj.password = password || null;
|
|
sshDataObj.key = null;
|
|
sshDataObj.keyPassword = null;
|
|
sshDataObj.keyType = null;
|
|
} else if (effectiveAuthType === "password") {
|
|
sshDataObj.password = password || null;
|
|
sshDataObj.key = null;
|
|
sshDataObj.keyPassword = null;
|
|
sshDataObj.keyType = null;
|
|
} else if (effectiveAuthType === "key") {
|
|
if (key && typeof key === "string") {
|
|
const keyValidation = parseKeyForStorage(
|
|
key,
|
|
typeof keyPassword === "string" ? keyPassword : undefined,
|
|
);
|
|
if (!keyValidation.success) {
|
|
sshLogger.warn("SSH key validation failed", {
|
|
operation: "host_create",
|
|
userId,
|
|
name,
|
|
ip,
|
|
port,
|
|
error: keyValidation.error,
|
|
});
|
|
return res.status(400).json({
|
|
error: `Invalid SSH key: ${keyValidation.error || "Unable to parse key"}`,
|
|
});
|
|
}
|
|
}
|
|
|
|
sshDataObj.key = key || null;
|
|
sshDataObj.keyPassword = keyPassword || null;
|
|
sshDataObj.keyType = keyType;
|
|
sshDataObj.password = password || null;
|
|
} else if (effectiveAuthType === "credential") {
|
|
sshDataObj.password = password || null;
|
|
sshDataObj.key = null;
|
|
sshDataObj.keyPassword = null;
|
|
sshDataObj.keyType = null;
|
|
} else if (effectiveAuthType === "agent") {
|
|
sshDataObj.password = null;
|
|
sshDataObj.key = null;
|
|
sshDataObj.keyPassword = null;
|
|
sshDataObj.keyType = null;
|
|
} else {
|
|
sshDataObj.password = null;
|
|
sshDataObj.key = null;
|
|
sshDataObj.keyPassword = null;
|
|
sshDataObj.keyType = null;
|
|
}
|
|
|
|
try {
|
|
sshDataObj.defaultOverrides = JSON.stringify(
|
|
await applyHostDefaultsToWrite({
|
|
ownerId: userId,
|
|
hostId: null,
|
|
columns: sshDataObj,
|
|
body: hostData,
|
|
}),
|
|
);
|
|
// Host defaults may have supplied it.
|
|
if (!sshDataObj.authType) {
|
|
return res.status(400).json({
|
|
error:
|
|
"authType is required (password, key, credential, agent or none)",
|
|
});
|
|
}
|
|
const result = await createCurrentHostRepository().createEncryptedForUser(
|
|
userId,
|
|
sshDataObj,
|
|
);
|
|
|
|
if (!result) {
|
|
sshLogger.warn("No host returned after creation", {
|
|
operation: "host_create",
|
|
userId,
|
|
name,
|
|
ip,
|
|
port,
|
|
});
|
|
return res.status(500).json({ error: "Failed to create host" });
|
|
}
|
|
|
|
const createdHost = result;
|
|
await applyDefaultsAfterHostWrite(createdHost.id as number);
|
|
if (protocolAuthPatch) {
|
|
await writeProtocolAuth(
|
|
userId,
|
|
createdHost.id as number,
|
|
protocolAuthPatch,
|
|
{ isOwner: true },
|
|
);
|
|
}
|
|
|
|
// Standing folder shares apply to the newcomer.
|
|
try {
|
|
await applyFolderAccessRules(
|
|
createdHost.id,
|
|
userId!,
|
|
createdHost.folder,
|
|
);
|
|
} catch (folderAccessError) {
|
|
sshLogger.warn("Failed to inherit folder access on host create", {
|
|
operation: "host_create_folder_access",
|
|
hostId: createdHost.id,
|
|
error: getErrorMessage(folderAccessError),
|
|
});
|
|
}
|
|
const baseHost = transformHostResponse(createdHost);
|
|
|
|
const resolvedHost =
|
|
(await resolveHostCredentials(baseHost, userId)) || baseHost;
|
|
databaseLogger.success("SSH host created", {
|
|
operation: "host_create_success",
|
|
userId,
|
|
hostId: createdHost.id as number,
|
|
name,
|
|
});
|
|
|
|
const { ipAddress: chIp, userAgent: chUa } = getRequestMeta(req);
|
|
await logAudit({
|
|
userId,
|
|
username: await getAuditUsername(userId),
|
|
action: "create_host",
|
|
resourceType: "host",
|
|
resourceId: String(createdHost.id),
|
|
resourceName: String(name ?? ip),
|
|
ipAddress: chIp,
|
|
userAgent: chUa,
|
|
success: true,
|
|
});
|
|
|
|
emitInternalEvent("host_added", userId, createdHost.id as number, {
|
|
name: String(name ?? ip),
|
|
});
|
|
|
|
res.json(await withProtocolAuth(stripSensitiveFields(resolvedHost)));
|
|
pluginEvents.emit(TOPICS.hostCreated, { hostId: createdHost.id, userId });
|
|
notifyStatsHostUpdated(createdHost.id as number, userId, "host_create");
|
|
} catch (err) {
|
|
sshLogger.error("Failed to save SSH host to database", err, {
|
|
operation: "host_create",
|
|
userId,
|
|
name,
|
|
ip,
|
|
port,
|
|
authType: effectiveAuthType,
|
|
});
|
|
res.status(500).json({ error: "Failed to save SSH data" });
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* @openapi
|
|
* /host/enroll:
|
|
* post:
|
|
* summary: Enroll a host with an API key
|
|
* description: Creates a host owned by the user assigned to the API key. The user's encrypted data must be unlocked by an active sign-in.
|
|
* tags:
|
|
* - Host Enrollment
|
|
* requestBody:
|
|
* required: true
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* required: [ip]
|
|
* properties:
|
|
* name:
|
|
* type: string
|
|
* ip:
|
|
* type: string
|
|
* port:
|
|
* type: integer
|
|
* minimum: 1
|
|
* maximum: 65535
|
|
* default: 22
|
|
* username:
|
|
* type: string
|
|
* authType:
|
|
* type: string
|
|
* enum: [none, password, key, credential, agent]
|
|
* default: none
|
|
* password:
|
|
* type: string
|
|
* folder:
|
|
* type: string
|
|
* tags:
|
|
* oneOf:
|
|
* - type: string
|
|
* - type: array
|
|
* items:
|
|
* type: string
|
|
* responses:
|
|
* 200:
|
|
* description: Host enrolled successfully.
|
|
* 400:
|
|
* description: Invalid host data.
|
|
* 401:
|
|
* description: Missing or invalid API key.
|
|
* 423:
|
|
* description: The API key user's encrypted data is locked.
|
|
* 500:
|
|
* description: Failed to enroll the host.
|
|
*/
|
|
/**
|
|
* @openapi
|
|
* /host/quick-connect:
|
|
* post:
|
|
* summary: Create a temporary SSH connection without saving to database
|
|
* description: Returns a temporary host configuration for immediate use
|
|
* tags:
|
|
* - SSH
|
|
* requestBody:
|
|
* required: true
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* required:
|
|
* - ip
|
|
* - port
|
|
* - username
|
|
* - authType
|
|
* properties:
|
|
* ip:
|
|
* type: string
|
|
* description: SSH server IP or hostname
|
|
* port:
|
|
* type: number
|
|
* description: SSH server port
|
|
* username:
|
|
* type: string
|
|
* description: SSH username
|
|
* authType:
|
|
* type: string
|
|
* description: Any registered SSH auth type that works for an unsaved host (password, key, credential, agent, none, or one a plugin marks for Quick Connect).
|
|
* password:
|
|
* type: string
|
|
* description: Password (required if authType is password)
|
|
* key:
|
|
* type: string
|
|
* description: SSH private key (required if authType is key)
|
|
* keyPassword:
|
|
* type: string
|
|
* description: SSH key password (optional)
|
|
* keyType:
|
|
* type: string
|
|
* description: SSH key type
|
|
* credentialId:
|
|
* type: number
|
|
* description: Credential ID (required if authType is credential)
|
|
* overrideCredentialUsername:
|
|
* type: boolean
|
|
* description: Use provided username instead of credential username
|
|
* responses:
|
|
* 200:
|
|
* description: Temporary host configuration created successfully
|
|
* 400:
|
|
* description: Invalid request data
|
|
* 401:
|
|
* description: Unauthorized
|
|
* 403:
|
|
* description: Forbidden
|
|
* 404:
|
|
* description: Credential not found
|
|
* 500:
|
|
* description: Server error
|
|
*/
|
|
router.post(
|
|
"/quick-connect",
|
|
authenticateJWT,
|
|
requireDataAccess,
|
|
async (req: AuthenticatedRequest, res: Response) => {
|
|
const userId = req.userId;
|
|
const {
|
|
ip,
|
|
port,
|
|
username,
|
|
authType,
|
|
password,
|
|
key,
|
|
keyPassword,
|
|
keyType,
|
|
credentialId,
|
|
overrideCredentialUsername,
|
|
} = req.body;
|
|
|
|
if (
|
|
!isNonEmptyString(ip) ||
|
|
!isValidPort(port) ||
|
|
!isNonEmptyString(username) ||
|
|
!authType
|
|
) {
|
|
return res.status(400).json({ error: "Missing required fields" });
|
|
}
|
|
|
|
ensureCoreSshAuthProviders();
|
|
if (!isQuickConnectAuthType(String(authType))) {
|
|
return res
|
|
.status(400)
|
|
.json({ error: "That auth type cannot be used for Quick Connect" });
|
|
}
|
|
|
|
try {
|
|
let resolvedPassword = password;
|
|
let resolvedKey = key;
|
|
let resolvedKeyPassword = keyPassword;
|
|
let resolvedKeyType = keyType;
|
|
let resolvedAuthType = authType;
|
|
let resolvedUsername = username;
|
|
|
|
if (authType === "credential" && credentialId) {
|
|
const cred = await findUsableCredential(Number(credentialId), userId);
|
|
|
|
if (!cred) {
|
|
return res.status(404).json({ error: "Credential not found" });
|
|
}
|
|
|
|
resolvedPassword = pickResolvedPassword(password, cred.password) as
|
|
string | undefined;
|
|
resolvedKey = cred.privateKey as string | undefined;
|
|
resolvedKeyPassword = cred.keyPassword as string | undefined;
|
|
resolvedKeyType = cred.keyType as string | undefined;
|
|
resolvedAuthType = cred.authType as string | undefined;
|
|
|
|
if (!overrideCredentialUsername) {
|
|
resolvedUsername = cred.username as string;
|
|
}
|
|
}
|
|
|
|
const tempHost: Record<string, unknown> = {
|
|
id: -Date.now(),
|
|
userId: userId,
|
|
name: `${resolvedUsername}@${ip}:${port}`,
|
|
ip,
|
|
port: Number(port),
|
|
username: resolvedUsername,
|
|
folder: "",
|
|
tags: [],
|
|
pin: false,
|
|
authType: resolvedAuthType || authType,
|
|
password: resolvedPassword,
|
|
key: resolvedKey,
|
|
keyPassword: resolvedKeyPassword,
|
|
keyType: resolvedKeyType,
|
|
jumpHosts: [],
|
|
statusCheckEnabled: true,
|
|
statusCheckInterval: null,
|
|
notes: "",
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
};
|
|
|
|
return res.status(200).json(tempHost);
|
|
} catch (error) {
|
|
sshLogger.error("Quick connect failed", error, {
|
|
operation: "quick_connect",
|
|
userId,
|
|
ip,
|
|
port,
|
|
authType,
|
|
});
|
|
return res
|
|
.status(500)
|
|
.json({ error: "Failed to create quick connection" });
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* @openapi
|
|
* /host/db/host/{id}:
|
|
* put:
|
|
* summary: Update SSH host
|
|
* description: Updates an existing SSH host configuration.
|
|
* tags:
|
|
* - SSH
|
|
* parameters:
|
|
* - in: path
|
|
* name: id
|
|
* required: true
|
|
* schema:
|
|
* type: integer
|
|
* responses:
|
|
* 200:
|
|
* description: Host updated successfully.
|
|
* 400:
|
|
* description: Invalid SSH data.
|
|
* 403:
|
|
* description: Access denied.
|
|
* 404:
|
|
* description: Host not found.
|
|
* 500:
|
|
* description: Failed to update SSH data.
|
|
*/
|
|
router.put(
|
|
"/db/host/:id",
|
|
authenticateJWT,
|
|
permissionManager.requirePermission("hosts.edit"),
|
|
requireDataAccess,
|
|
requireUnlockedHost,
|
|
upload.single("key"),
|
|
async (req: Request, res: Response) => {
|
|
const hostId = Array.isArray(req.params.id)
|
|
? req.params.id[0]
|
|
: req.params.id;
|
|
const userId = (req as AuthenticatedRequest).userId;
|
|
let hostData: Record<string, unknown>;
|
|
|
|
if (req.headers["content-type"]?.includes("multipart/form-data")) {
|
|
if (req.body.data) {
|
|
try {
|
|
hostData = JSON.parse(req.body.data);
|
|
} catch (err) {
|
|
sshLogger.warn("Invalid JSON data in multipart request", {
|
|
operation: "host_update",
|
|
hostId: parseInt(hostId),
|
|
userId,
|
|
error: err,
|
|
});
|
|
return res.status(400).json({ error: "Invalid JSON data" });
|
|
}
|
|
} else {
|
|
sshLogger.warn("Missing data field in multipart request", {
|
|
operation: "host_update",
|
|
hostId: parseInt(hostId),
|
|
userId,
|
|
});
|
|
return res.status(400).json({ error: "Missing data field" });
|
|
}
|
|
|
|
if (req.file) {
|
|
hostData.key = req.file.buffer.toString("utf8");
|
|
}
|
|
} else {
|
|
hostData = req.body;
|
|
}
|
|
|
|
const {
|
|
connectionType,
|
|
name,
|
|
folder,
|
|
parentHostId,
|
|
tags,
|
|
ip,
|
|
port,
|
|
username,
|
|
password,
|
|
authMethod,
|
|
authType,
|
|
shareSshAuth,
|
|
credentialId,
|
|
key,
|
|
keyPassword,
|
|
keyType,
|
|
sudoPassword,
|
|
pin,
|
|
jumpHosts,
|
|
statusCheckEnabled,
|
|
statusCheckInterval,
|
|
terminalConfig,
|
|
sshOptions,
|
|
forceKeyboardInteractive,
|
|
notes,
|
|
useSocks5,
|
|
socks5Host,
|
|
socks5Port,
|
|
socks5Username,
|
|
socks5Password,
|
|
socks5ProxyChain,
|
|
connectionOrigin,
|
|
localOnly,
|
|
portKnockSequence,
|
|
overrideCredentialUsername,
|
|
enableSsh,
|
|
sshPort,
|
|
} = hostData;
|
|
const protocolAuthPatch = readProtocolAuthPayload(hostData);
|
|
databaseLogger.info("Updating SSH host", {
|
|
operation: "host_update",
|
|
userId,
|
|
hostId: parseInt(hostId),
|
|
changes: Object.keys(hostData),
|
|
});
|
|
|
|
if (
|
|
!isNonEmptyString(userId) ||
|
|
!isNonEmptyString(ip) ||
|
|
!isValidPort(port) ||
|
|
!isOptionalBoolean(shareSshAuth) ||
|
|
!isOptionalBoolean(enableSsh) ||
|
|
!isOptionalAuthType(authType) ||
|
|
!isOptionalAuthType(authMethod) ||
|
|
!hostId
|
|
) {
|
|
sshLogger.warn("Invalid SSH data input validation failed for update", {
|
|
operation: "host_update",
|
|
hostId: parseInt(hostId),
|
|
userId,
|
|
hasIp: !!ip,
|
|
port,
|
|
isValidPort: isValidPort(port),
|
|
});
|
|
return res.status(400).json({ error: "Invalid SSH data" });
|
|
}
|
|
|
|
let validatedParentHostId: number | null | undefined = undefined;
|
|
if (parentHostId !== undefined) {
|
|
if (parentHostId === null) {
|
|
validatedParentHostId = null;
|
|
} else {
|
|
const numericParentHostId = Number(parentHostId);
|
|
if (!Number.isInteger(numericParentHostId)) {
|
|
return res.status(400).json({ error: "Invalid parent host" });
|
|
}
|
|
const parentError = await validateParentHostId(
|
|
userId,
|
|
Number(hostId),
|
|
numericParentHostId,
|
|
);
|
|
if (parentError) {
|
|
return res.status(400).json({ error: parentError });
|
|
}
|
|
validatedParentHostId = numericParentHostId;
|
|
}
|
|
}
|
|
|
|
const effectiveAuthType = authType || authMethod;
|
|
const effectiveUsername =
|
|
username || firstProtocolUsername(protocolAuthPatch);
|
|
const effectiveName =
|
|
name || (effectiveUsername ? `${effectiveUsername}@${ip}` : String(ip));
|
|
const sshDataObj: Record<string, unknown> = {
|
|
connectionType: connectionType || "ssh",
|
|
name: effectiveName,
|
|
// A host is either placed in a folder or nested under a parent host,
|
|
// never both. When the caller is assigning a parent, clear folder;
|
|
// when the caller is assigning a folder, clear parentHostId.
|
|
folder: validatedParentHostId ? null : folder,
|
|
tags: Array.isArray(tags) ? tags.join(",") : tags || "",
|
|
ip,
|
|
port,
|
|
username: effectiveUsername,
|
|
authType: effectiveAuthType,
|
|
shareSshAuth: shareSshAuth === true ? 1 : 0,
|
|
credentialId: credentialId || null,
|
|
overrideCredentialUsername: overrideCredentialUsername ? 1 : 0,
|
|
pin: pin ? 1 : 0,
|
|
jumpHosts: Array.isArray(jumpHosts) ? JSON.stringify(jumpHosts) : null,
|
|
statusCheckEnabled: statusCheckEnabled === false ? 0 : 1,
|
|
statusCheckInterval: normalizeStatusInterval(statusCheckInterval),
|
|
forceKeyboardInteractive: forceKeyboardInteractive ? "true" : "false",
|
|
notes: notes || null,
|
|
useSocks5: useSocks5 ? 1 : 0,
|
|
socks5Host: socks5Host || null,
|
|
socks5Port: socks5Port || null,
|
|
socks5Username: socks5Username || null,
|
|
socks5Password: socks5Password || null,
|
|
socks5ProxyChain: socks5ProxyChain
|
|
? JSON.stringify(socks5ProxyChain)
|
|
: null,
|
|
connectionOrigin:
|
|
connectionOrigin === "local" || connectionOrigin === "remote"
|
|
? connectionOrigin
|
|
: null,
|
|
...(typeof localOnly === "boolean" ? { localOnly } : {}),
|
|
portKnockSequence: portKnockSequence
|
|
? JSON.stringify(portKnockSequence)
|
|
: null,
|
|
...normalizeProtocolEnableFields(hostData),
|
|
sshPort: sshPort || port || 22,
|
|
};
|
|
|
|
const nextSshOptions = sshOptionsForWrite({ sshOptions, terminalConfig });
|
|
if (nextSshOptions !== undefined) sshDataObj.sshOptions = nextSshOptions;
|
|
// The editor leaves sudoPassword out when the user did not touch it.
|
|
if (sudoPassword !== undefined) {
|
|
sshDataObj.sudoPassword = sudoPassword || null;
|
|
}
|
|
|
|
// A host whose main protocol is a plugin's keeps any password it is given.
|
|
if ((connectionType || "ssh") !== "ssh") {
|
|
if (password) {
|
|
sshDataObj.password = password;
|
|
}
|
|
sshDataObj.key = null;
|
|
sshDataObj.keyPassword = null;
|
|
sshDataObj.keyType = null;
|
|
} else if (keepsStoredSshAuth(effectiveAuthType)) {
|
|
// The update says nothing about authentication.
|
|
} else if (effectiveAuthType === "password") {
|
|
if (password) {
|
|
sshDataObj.password = password;
|
|
}
|
|
sshDataObj.key = null;
|
|
sshDataObj.keyPassword = null;
|
|
sshDataObj.keyType = null;
|
|
} else if (effectiveAuthType === "key") {
|
|
if (key && typeof key === "string") {
|
|
const keyValidation = parseKeyForStorage(
|
|
key,
|
|
typeof keyPassword === "string" ? keyPassword : undefined,
|
|
);
|
|
if (!keyValidation.success) {
|
|
sshLogger.warn("SSH key validation failed", {
|
|
operation: "host_update",
|
|
hostId: parseInt(hostId),
|
|
userId,
|
|
name,
|
|
ip,
|
|
port,
|
|
error: keyValidation.error,
|
|
});
|
|
return res.status(400).json({
|
|
error: `Invalid SSH key: ${keyValidation.error || "Unable to parse key"}`,
|
|
});
|
|
}
|
|
|
|
sshDataObj.key = key;
|
|
}
|
|
if (keyPassword !== undefined) {
|
|
sshDataObj.keyPassword = keyPassword || null;
|
|
}
|
|
applyHostKeyTypeUpdate(sshDataObj, keyType);
|
|
sshDataObj.password = password || null;
|
|
} else if (effectiveAuthType === "credential") {
|
|
sshDataObj.password = password || null;
|
|
sshDataObj.key = null;
|
|
sshDataObj.keyPassword = null;
|
|
sshDataObj.keyType = null;
|
|
} else if (effectiveAuthType === "agent") {
|
|
sshDataObj.password = null;
|
|
sshDataObj.key = null;
|
|
sshDataObj.keyPassword = null;
|
|
sshDataObj.keyType = null;
|
|
} else {
|
|
sshDataObj.password = null;
|
|
sshDataObj.key = null;
|
|
sshDataObj.keyPassword = null;
|
|
sshDataObj.keyType = null;
|
|
}
|
|
|
|
if (validatedParentHostId !== undefined) {
|
|
sshDataObj.parentHostId = validatedParentHostId;
|
|
} else if (folder !== undefined) {
|
|
// Caller is assigning a folder (including clearing it back to root)
|
|
// without touching parentHostId -- folder placement replaces
|
|
// parent-host placement either way.
|
|
sshDataObj.parentHostId = null;
|
|
}
|
|
|
|
try {
|
|
const accessInfo = await permissionManager.canAccessHost(
|
|
userId,
|
|
Number(hostId),
|
|
"edit",
|
|
);
|
|
|
|
if (!accessInfo.hasAccess) {
|
|
sshLogger.warn("User does not have permission to update host", {
|
|
operation: "host_update",
|
|
hostId: parseInt(hostId),
|
|
userId,
|
|
});
|
|
return res.status(403).json({ error: "Access denied" });
|
|
}
|
|
|
|
const hostRecord =
|
|
await createCurrentHostResolutionRepository().findHostUpdateState(
|
|
Number(hostId),
|
|
);
|
|
|
|
if (!hostRecord) {
|
|
sshLogger.warn("Host not found for update", {
|
|
operation: "host_update",
|
|
hostId: parseInt(hostId),
|
|
userId,
|
|
});
|
|
return res.status(404).json({ error: "Host not found" });
|
|
}
|
|
|
|
const ownerId = hostRecord.userId;
|
|
|
|
if (!accessInfo.isOwner) {
|
|
// Shared editors work on the owner's real record, but the owner's SSH
|
|
// authentication is private and can only be changed by that owner.
|
|
if (containsOwnerPrivateAuthUpdate(hostData, "ssh")) {
|
|
return res.status(403).json({
|
|
error:
|
|
"Only the host owner can change the host's SSH authentication",
|
|
});
|
|
}
|
|
|
|
const incomingTerminalConfig = parseTerminalConfig(
|
|
hostData.terminalConfig,
|
|
);
|
|
const protectedTerminalConfigField =
|
|
OWNER_PRIVATE_TERMINAL_CONFIG_FIELDS.find((field) =>
|
|
Object.prototype.hasOwnProperty.call(
|
|
incomingTerminalConfig ?? {},
|
|
field,
|
|
),
|
|
);
|
|
const incomingSshOptions = parseTerminalConfig(hostData.sshOptions);
|
|
const protectedSshOptionField = OWNER_PRIVATE_SSH_OPTION_FIELDS.find(
|
|
(field) =>
|
|
Object.prototype.hasOwnProperty.call(
|
|
incomingSshOptions ?? {},
|
|
field,
|
|
),
|
|
);
|
|
if (protectedTerminalConfigField || protectedSshOptionField) {
|
|
return res.status(403).json({
|
|
error:
|
|
"Only the host owner can change private SSH authentication settings",
|
|
});
|
|
}
|
|
|
|
for (const field of OWNER_PRIVATE_AUTH_FIELDS.ssh) {
|
|
delete sshDataObj[field];
|
|
}
|
|
}
|
|
|
|
let protocolAuthPlan: PlannedProtocolAuth | null = null;
|
|
if (protocolAuthPatch) {
|
|
try {
|
|
protocolAuthPlan = await planProtocolAuthWrite(
|
|
ownerId,
|
|
Number(hostId),
|
|
protocolAuthPatch,
|
|
{ isOwner: accessInfo.isOwner },
|
|
);
|
|
} catch (error) {
|
|
if (error instanceof ProtocolAuthWriteError) {
|
|
return res.status(error.status).json({ error: error.message });
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
const terminalFieldsError = await mergeStoredSshOptions(
|
|
sshDataObj,
|
|
hostData,
|
|
Number(hostId),
|
|
ownerId,
|
|
accessInfo.isOwner,
|
|
);
|
|
if (terminalFieldsError) {
|
|
return res.status(400).json({ error: terminalFieldsError });
|
|
}
|
|
|
|
const storedRow = (
|
|
await createCurrentHostDefaultsRepository().listHosts({
|
|
hostIds: [Number(hostId)],
|
|
})
|
|
)[0];
|
|
sshDataObj.defaultOverrides = JSON.stringify(
|
|
await applyHostDefaultsToWrite({
|
|
ownerId,
|
|
hostId: Number(hostId),
|
|
columns: sshDataObj,
|
|
body: hostData,
|
|
stored: storedRow,
|
|
lockedKeys: accessInfo.isOwner ? [] : ["auth"],
|
|
}),
|
|
);
|
|
|
|
await createCurrentHostRepository().updateEncryptedForUser(
|
|
ownerId,
|
|
Number(hostId),
|
|
sshDataObj,
|
|
);
|
|
await applyDefaultsAfterHostWrite(Number(hostId), {
|
|
moved:
|
|
!!storedRow &&
|
|
((sshDataObj.folder !== undefined &&
|
|
(sshDataObj.folder ?? null) !== (storedRow.folder ?? null)) ||
|
|
(sshDataObj.parentHostId !== undefined &&
|
|
(sshDataObj.parentHostId ?? null) !==
|
|
(storedRow.parentHostId ?? null))),
|
|
ownerId,
|
|
});
|
|
if (protocolAuthPlan) {
|
|
await applyProtocolAuthPlan(ownerId, Number(hostId), protocolAuthPlan);
|
|
}
|
|
|
|
// A host that moved into a folder inherits that folder's standing shares.
|
|
try {
|
|
await applyFolderAccessRules(
|
|
Number(hostId),
|
|
ownerId,
|
|
sshDataObj.folder as string | null | undefined,
|
|
);
|
|
} catch (folderAccessError) {
|
|
sshLogger.warn("Failed to inherit folder access on host update", {
|
|
operation: "host_update_folder_access",
|
|
hostId: parseInt(hostId),
|
|
error: getErrorMessage(folderAccessError),
|
|
});
|
|
}
|
|
|
|
// Keep every recipient's re-encrypted secret snapshots in sync with
|
|
// the updated host record.
|
|
try {
|
|
const { SharedHostSecretsManager } =
|
|
await import("../../utils/shared-host-secrets-manager.js");
|
|
await SharedHostSecretsManager.getInstance().resyncHost(Number(hostId));
|
|
} catch (resyncError) {
|
|
sshLogger.warn("Failed to resync shared host secrets after update", {
|
|
operation: "host_update_resync",
|
|
hostId: parseInt(hostId),
|
|
error: getErrorMessage(resyncError),
|
|
});
|
|
}
|
|
|
|
const updatedHost =
|
|
await createCurrentHostResolutionRepository().findHostById(
|
|
Number(hostId),
|
|
ownerId,
|
|
);
|
|
|
|
if (!updatedHost) {
|
|
sshLogger.warn("Updated host not found after update", {
|
|
operation: "host_update",
|
|
hostId: parseInt(hostId),
|
|
userId,
|
|
});
|
|
return res.status(404).json({ error: "Host not found after update" });
|
|
}
|
|
|
|
const baseHost = transformHostResponse(updatedHost);
|
|
|
|
const resolvedHost =
|
|
(await resolveHostCredentials(baseHost, userId)) || baseHost;
|
|
databaseLogger.success("SSH host updated", {
|
|
operation: "host_update_success",
|
|
userId,
|
|
hostId: parseInt(hostId),
|
|
});
|
|
|
|
const { ipAddress: uhIp, userAgent: uhUa } = getRequestMeta(req);
|
|
await logAudit({
|
|
userId,
|
|
username: await getAuditUsername(userId),
|
|
action: "update_host",
|
|
resourceType: "host",
|
|
resourceId: hostId,
|
|
resourceName: String(name ?? ip),
|
|
ipAddress: uhIp,
|
|
userAgent: uhUa,
|
|
success: true,
|
|
});
|
|
|
|
res.json(await withProtocolAuth(stripSensitiveFields(resolvedHost)));
|
|
notifyStatsHostUpdated(parseInt(hostId), userId, "host_update");
|
|
} catch (err) {
|
|
sshLogger.error("Failed to update SSH host in database", err, {
|
|
operation: "host_update",
|
|
hostId: parseInt(hostId),
|
|
userId,
|
|
name,
|
|
ip,
|
|
port,
|
|
authType: effectiveAuthType,
|
|
});
|
|
res.status(500).json({ error: "Failed to update SSH data" });
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* @openapi
|
|
* /host/policy:
|
|
* get:
|
|
* summary: Host creation policy
|
|
* description: Whether hosts can be added by hand, or a plugin manages them all.
|
|
* tags:
|
|
* - SSH
|
|
* responses:
|
|
* 200:
|
|
* description: "{ manualCreate, blockedBy? } where blockedBy is the managing plugin's name."
|
|
*/
|
|
router.get("/policy", authenticateJWT, (_req: Request, res: Response) => {
|
|
const blocked = manualCreateBlockedBy();
|
|
res.json(
|
|
blocked
|
|
? { manualCreate: false, blockedBy: blocked.pluginName }
|
|
: { manualCreate: true },
|
|
);
|
|
});
|
|
|
|
/**
|
|
* @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}:
|
|
* 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,
|
|
requireUnlockedHost,
|
|
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/.
|
|
|
|
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.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;
|
|
}
|