mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 14:45:24 +00:00
1025 lines
33 KiB
TypeScript
1025 lines
33 KiB
TypeScript
import {
|
|
prepareHostImports,
|
|
remapImportedJumpHosts,
|
|
} from "./host-import-order.js";
|
|
import { sshOptionsForWrite } from "../../hosts/ssh-options.js";
|
|
import {
|
|
keepUsableProtocolCredentials,
|
|
readProtocolAuthPayload,
|
|
writeProtocolAuth,
|
|
} from "../../hosts/protocol-auth/protocol-auth.js";
|
|
import { getErrorMessage } from "../../utils/error-message.js";
|
|
import type { AuthenticatedRequest } from "../../../types/index.js";
|
|
import type { Request, RequestHandler, Response, Router } from "express";
|
|
import { sshLogger } from "../../utils/logger.js";
|
|
import {
|
|
createCurrentCredentialRepository,
|
|
createCurrentHostRepository,
|
|
createCurrentHostResolutionRepository,
|
|
} from "../repositories/factory.js";
|
|
import { validateParentHostId } from "./host-parent-validation.js";
|
|
import {
|
|
listSshAuthProviders,
|
|
listSshAuthTypeOwners,
|
|
} from "../../hosts/connect/auth-provider-registry.js";
|
|
import {
|
|
applyPluginHostImportSettings,
|
|
setHostPluginEnabled,
|
|
} from "./host-plugin-settings.js";
|
|
import {
|
|
isNonEmptyString,
|
|
isValidPort,
|
|
normalizeImportedHost,
|
|
} from "./host-normalizers.js";
|
|
|
|
type SSHConfigHost = {
|
|
name: string;
|
|
hostname?: string;
|
|
user?: string;
|
|
port?: number;
|
|
identityFile?: string;
|
|
proxyJump?: string;
|
|
};
|
|
|
|
type ShareCredential = {
|
|
alias?: unknown;
|
|
name?: unknown;
|
|
description?: unknown;
|
|
folder?: unknown;
|
|
tags?: unknown;
|
|
authType?: unknown;
|
|
username?: unknown;
|
|
keyType?: unknown;
|
|
};
|
|
|
|
function textValue(value: unknown): string | null {
|
|
return typeof value === "string" && value.trim() ? value.trim() : null;
|
|
}
|
|
|
|
function tagString(value: unknown): string {
|
|
if (Array.isArray(value)) {
|
|
return value
|
|
.map((tag) => textValue(tag))
|
|
.filter((tag): tag is string => !!tag)
|
|
.join(",");
|
|
}
|
|
return textValue(value) || "";
|
|
}
|
|
|
|
function normalizeCredentialAuthType(value: unknown): "password" | "key" {
|
|
return value === "key" ? "key" : "password";
|
|
}
|
|
|
|
export function parseSSHConfig(content: string): SSHConfigHost[] {
|
|
const results: SSHConfigHost[] = [];
|
|
let current: SSHConfigHost | null = null;
|
|
|
|
for (const rawLine of content.split("\n")) {
|
|
const line = rawLine.trim();
|
|
if (!line || line.startsWith("#")) continue;
|
|
|
|
const spaceIdx = line.indexOf(" ");
|
|
if (spaceIdx === -1) continue;
|
|
|
|
const key = line.slice(0, spaceIdx).toLowerCase();
|
|
const value = line.slice(spaceIdx + 1).trim();
|
|
|
|
if (key === "host") {
|
|
if (current && current.hostname) results.push(current);
|
|
// Skip wildcard patterns
|
|
if (value === "*" || value.includes("*") || value.includes("?")) {
|
|
current = null;
|
|
} else {
|
|
current = { name: value };
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (!current) continue;
|
|
|
|
switch (key) {
|
|
case "hostname":
|
|
current.hostname = value;
|
|
break;
|
|
case "user":
|
|
current.user = value;
|
|
break;
|
|
case "port": {
|
|
const p = Number.parseInt(value, 10);
|
|
if (p > 0 && p <= 65535) current.port = p;
|
|
break;
|
|
}
|
|
case "identityfile":
|
|
if (!current.identityFile) current.identityFile = value;
|
|
break;
|
|
case "proxyjump":
|
|
current.proxyJump = value;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (current && current.hostname) results.push(current);
|
|
|
|
return results;
|
|
}
|
|
|
|
export function importedHostUsername(
|
|
connectionType: string,
|
|
authType: unknown,
|
|
username: unknown,
|
|
): string | null {
|
|
if (isNonEmptyString(username)) return username;
|
|
if (connectionType !== "ssh" || authType === "credential") return "";
|
|
return null;
|
|
}
|
|
|
|
export function registerHostBulkRoutes(
|
|
router: Router,
|
|
authenticateJWT: RequestHandler,
|
|
requireCreatePermission: RequestHandler,
|
|
requireEditPermission: RequestHandler,
|
|
requireDataAccess: RequestHandler,
|
|
): void {
|
|
/**
|
|
* @openapi
|
|
* /host/bulk-import:
|
|
* post:
|
|
* summary: Bulk import SSH hosts
|
|
* description: Bulk imports multiple SSH hosts.
|
|
* tags:
|
|
* - SSH
|
|
* requestBody:
|
|
* required: true
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* properties:
|
|
* hosts:
|
|
* type: array
|
|
* items:
|
|
* type: object
|
|
* responses:
|
|
* 200:
|
|
* description: Import completed.
|
|
* 400:
|
|
* description: Invalid request body.
|
|
*/
|
|
|
|
/**
|
|
* @swagger
|
|
* /host/bulk-update:
|
|
* patch:
|
|
* summary: Bulk update partial fields on multiple SSH hosts
|
|
* tags: [SSH]
|
|
* security:
|
|
* - bearerAuth: []
|
|
* requestBody:
|
|
* required: true
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* properties:
|
|
* hostIds:
|
|
* type: array
|
|
* items:
|
|
* type: number
|
|
* updates:
|
|
* type: object
|
|
* description: Partial fields to apply. Setting folder clears parentHostId and vice versa, since a host is either in a folder or nested under a parent host.
|
|
* properties:
|
|
* folder:
|
|
* type: string
|
|
* parentHostId:
|
|
* type: integer
|
|
* nullable: true
|
|
* statusCheckEnabled:
|
|
* type: boolean
|
|
* pin:
|
|
* type: boolean
|
|
* pluginEnable:
|
|
* type: object
|
|
* description: Plugin id to on or off, for each plugin that declares a host enable switch.
|
|
* additionalProperties:
|
|
* type: boolean
|
|
* responses:
|
|
* 200:
|
|
* description: Bulk update completed.
|
|
* 400:
|
|
* description: Invalid request body.
|
|
*/
|
|
router.patch(
|
|
"/bulk-update",
|
|
authenticateJWT,
|
|
requireEditPermission,
|
|
requireDataAccess,
|
|
async (req: Request, res: Response) => {
|
|
const userId = (req as AuthenticatedRequest).userId;
|
|
const { hostIds, updates } = req.body;
|
|
|
|
if (!Array.isArray(hostIds) || hostIds.length === 0) {
|
|
return res
|
|
.status(400)
|
|
.json({ error: "hostIds array is required and must not be empty" });
|
|
}
|
|
|
|
if (hostIds.length > 1000) {
|
|
return res
|
|
.status(400)
|
|
.json({ error: "Maximum 1000 hosts allowed per bulk update" });
|
|
}
|
|
|
|
if (
|
|
!updates ||
|
|
typeof updates !== "object" ||
|
|
Object.keys(updates).length === 0
|
|
) {
|
|
return res.status(400).json({
|
|
error:
|
|
"updates object is required and must contain at least one field",
|
|
});
|
|
}
|
|
|
|
try {
|
|
const hostRepository = createCurrentHostRepository();
|
|
const ownedHosts = await hostRepository.listBulkUpdateState(
|
|
userId,
|
|
hostIds,
|
|
);
|
|
|
|
const ownedIds = ownedHosts.map((h) => h.id);
|
|
const unauthorizedIds = hostIds.filter(
|
|
(id: number) => !ownedIds.includes(id),
|
|
);
|
|
|
|
if (ownedIds.length === 0) {
|
|
return res.status(404).json({ error: "No matching hosts found" });
|
|
}
|
|
|
|
const errors: string[] = [];
|
|
if (unauthorizedIds.length > 0) {
|
|
errors.push(
|
|
`${unauthorizedIds.length} host(s) not found or not owned`,
|
|
);
|
|
}
|
|
|
|
const simpleUpdates: Record<string, unknown> = {};
|
|
if (typeof updates.pin === "boolean") simpleUpdates.pin = updates.pin;
|
|
if (typeof updates.folder === "string") {
|
|
simpleUpdates.folder = updates.folder || null;
|
|
// Folder placement and parent-host placement are mutually
|
|
// exclusive -- assigning a folder (including moving to root, an
|
|
// empty folder) clears any parent host, matching the single-host
|
|
// update route's behavior.
|
|
simpleUpdates.parentHostId = null;
|
|
}
|
|
if (updates.parentHostId !== undefined) {
|
|
if (updates.parentHostId === null) {
|
|
simpleUpdates.parentHostId = null;
|
|
} else {
|
|
const numericParentHostId = Number(updates.parentHostId);
|
|
if (!Number.isInteger(numericParentHostId)) {
|
|
return res.status(400).json({ error: "Invalid parent host" });
|
|
}
|
|
// A bulk move can only ever target one parent host at a time
|
|
// (the caller drags a selection onto one drop target), so every
|
|
// id in the batch is checked against the same candidate parent.
|
|
for (const id of ownedIds) {
|
|
const parentError = await validateParentHostId(
|
|
userId,
|
|
id,
|
|
numericParentHostId,
|
|
);
|
|
if (parentError) {
|
|
return res.status(400).json({ error: parentError });
|
|
}
|
|
}
|
|
simpleUpdates.parentHostId = numericParentHostId;
|
|
simpleUpdates.folder = null;
|
|
}
|
|
}
|
|
if (typeof updates.statusCheckEnabled === "boolean")
|
|
simpleUpdates.statusCheckEnabled = updates.statusCheckEnabled;
|
|
|
|
if (Object.keys(simpleUpdates).length > 0) {
|
|
await hostRepository.updateManyForUser(
|
|
userId,
|
|
ownedIds,
|
|
simpleUpdates,
|
|
);
|
|
}
|
|
|
|
// Each plugin's own host switch, by plugin id: the field its manifest
|
|
// names in contributes.settings.host.enableKey.
|
|
if (updates.pluginEnable && typeof updates.pluginEnable === "object") {
|
|
for (const [pluginId, enabled] of Object.entries(
|
|
updates.pluginEnable as Record<string, unknown>,
|
|
)) {
|
|
if (typeof enabled !== "boolean") continue;
|
|
if (!(await setHostPluginEnabled(pluginId, ownedIds, enabled))) {
|
|
errors.push(`Plugin ${pluginId} has no host switch`);
|
|
}
|
|
}
|
|
}
|
|
|
|
return res.json({
|
|
updated: ownedIds.length,
|
|
failed: unauthorizedIds.length,
|
|
errors,
|
|
});
|
|
} catch (error) {
|
|
sshLogger.error("Failed to bulk update hosts:", error);
|
|
return res.status(500).json({ error: "Failed to bulk update hosts" });
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* @openapi
|
|
* /host/reorder:
|
|
* put:
|
|
* summary: Reorder hosts
|
|
* description: Sets a manual sortOrder for multiple hosts within the same folder, used by drag-to-reorder in the sidebar's manual sort mode.
|
|
* tags:
|
|
* - SSH
|
|
* requestBody:
|
|
* required: true
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* properties:
|
|
* positions:
|
|
* type: array
|
|
* items:
|
|
* type: object
|
|
* properties:
|
|
* id:
|
|
* type: integer
|
|
* sortOrder:
|
|
* type: integer
|
|
* responses:
|
|
* 200:
|
|
* description: Hosts reordered successfully.
|
|
* 400:
|
|
* description: Invalid positions array.
|
|
* 500:
|
|
* description: Failed to reorder hosts.
|
|
*/
|
|
router.put(
|
|
"/reorder",
|
|
authenticateJWT,
|
|
requireEditPermission,
|
|
requireDataAccess,
|
|
async (req: Request, res: Response) => {
|
|
const userId = (req as AuthenticatedRequest).userId;
|
|
const { positions } = req.body as {
|
|
positions?: { id?: unknown; sortOrder?: unknown }[];
|
|
};
|
|
|
|
if (!Array.isArray(positions)) {
|
|
return res.status(400).json({ error: "positions array is required" });
|
|
}
|
|
|
|
const normalized: { id: number; sortOrder: number }[] = [];
|
|
for (const entry of positions) {
|
|
if (
|
|
typeof entry?.id !== "number" ||
|
|
!Number.isInteger(entry.id) ||
|
|
typeof entry.sortOrder !== "number" ||
|
|
!Number.isFinite(entry.sortOrder)
|
|
) {
|
|
return res.status(400).json({
|
|
error:
|
|
"Each position requires an integer id and a numeric sortOrder",
|
|
});
|
|
}
|
|
normalized.push({ id: entry.id, sortOrder: entry.sortOrder });
|
|
}
|
|
|
|
if (normalized.length === 0) {
|
|
return res.status(400).json({ error: "positions array is required" });
|
|
}
|
|
|
|
try {
|
|
const updated = await createCurrentHostRepository().reorderForUser(
|
|
userId,
|
|
normalized,
|
|
);
|
|
return res.json({ updated });
|
|
} catch (error) {
|
|
sshLogger.error("Failed to reorder hosts:", error);
|
|
return res.status(500).json({ error: "Failed to reorder hosts" });
|
|
}
|
|
},
|
|
);
|
|
|
|
router.post(
|
|
"/bulk-import",
|
|
authenticateJWT,
|
|
requireCreatePermission,
|
|
requireEditPermission,
|
|
requireDataAccess,
|
|
async (req: Request, res: Response) => {
|
|
const userId = (req as AuthenticatedRequest).userId;
|
|
const {
|
|
hosts: hostsToImport,
|
|
overwrite,
|
|
credentials: credentialsToImport,
|
|
} = req.body;
|
|
|
|
if (!Array.isArray(hostsToImport) || hostsToImport.length === 0) {
|
|
return res
|
|
.status(400)
|
|
.json({ error: "Hosts array is required and must not be empty" });
|
|
}
|
|
|
|
if (hostsToImport.length > 100) {
|
|
return res
|
|
.status(400)
|
|
.json({ error: "Maximum 100 hosts allowed per import" });
|
|
}
|
|
|
|
let orderedHosts: ReturnType<typeof prepareHostImports>;
|
|
try {
|
|
orderedHosts = prepareHostImports(hostsToImport);
|
|
} catch (error) {
|
|
return res.status(400).json({ error: getErrorMessage(error) });
|
|
}
|
|
const importedIds = new Map<unknown, number>();
|
|
|
|
const results = {
|
|
success: 0,
|
|
updated: 0,
|
|
skipped: 0,
|
|
failed: 0,
|
|
errors: [] as string[],
|
|
};
|
|
|
|
const credentialAliasMap = new Map<string, number>();
|
|
const addCredentialAlias = (alias: unknown, id: number) => {
|
|
const key = textValue(alias);
|
|
if (key) credentialAliasMap.set(key.toLowerCase(), id);
|
|
};
|
|
|
|
try {
|
|
const credentialRepository = createCurrentCredentialRepository();
|
|
const existingCredentials =
|
|
await credentialRepository.listDecryptedByUserId(userId);
|
|
|
|
for (const credential of existingCredentials) {
|
|
addCredentialAlias(credential.name, credential.id as number);
|
|
}
|
|
|
|
if (Array.isArray(credentialsToImport)) {
|
|
for (const rawCredential of credentialsToImport as ShareCredential[]) {
|
|
const alias = textValue(rawCredential.alias);
|
|
const name = textValue(rawCredential.name) || alias;
|
|
if (!alias || !name) continue;
|
|
|
|
const existingId = credentialAliasMap.get(name.toLowerCase());
|
|
if (existingId) {
|
|
addCredentialAlias(alias, existingId);
|
|
continue;
|
|
}
|
|
|
|
const now = new Date().toISOString();
|
|
const created = await credentialRepository.createEncryptedForUser(
|
|
userId,
|
|
{
|
|
userId,
|
|
name,
|
|
description:
|
|
textValue(rawCredential.description) ||
|
|
"Imported placeholder. Add the secret before connecting.",
|
|
folder: textValue(rawCredential.folder),
|
|
tags: tagString(rawCredential.tags),
|
|
authType: normalizeCredentialAuthType(rawCredential.authType),
|
|
username: textValue(rawCredential.username),
|
|
password: null,
|
|
key: null,
|
|
privateKey: null,
|
|
publicKey: null,
|
|
keyPassword: null,
|
|
keyType: textValue(rawCredential.keyType),
|
|
detectedKeyType: null,
|
|
usageCount: 0,
|
|
lastUsed: null,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
},
|
|
);
|
|
|
|
const createdCredential = created as Record<string, unknown>;
|
|
addCredentialAlias(alias, createdCredential.id as number);
|
|
addCredentialAlias(name, createdCredential.id as number);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
results.errors.push(
|
|
`Credential placeholders: ${getErrorMessage(error, "failed to prepare credential aliases")}`,
|
|
);
|
|
}
|
|
|
|
let existingHostMap: Map<string, { id: number }> | undefined;
|
|
const hostRepository = createCurrentHostRepository();
|
|
if (overwrite) {
|
|
try {
|
|
const allHosts =
|
|
await createCurrentHostResolutionRepository().findHostsByUserId(
|
|
userId,
|
|
);
|
|
existingHostMap = new Map();
|
|
for (const h of allHosts) {
|
|
const key = `${h.ip}:${h.port}:${h.username}`;
|
|
existingHostMap.set(key, { id: h.id as number });
|
|
}
|
|
} catch {
|
|
existingHostMap = undefined;
|
|
}
|
|
}
|
|
|
|
const knownAuthTypes = listKnownAuthTypes();
|
|
for (const { host: hostData, index: i, exportId } of orderedHosts) {
|
|
try {
|
|
const effectiveConnectionType = hostData.connectionType || "ssh";
|
|
|
|
if (
|
|
effectiveConnectionType === "ssh" &&
|
|
hostData.authType === "credential" &&
|
|
!hostData.credentialId &&
|
|
hostData.credentialAlias
|
|
) {
|
|
hostData.credentialId = credentialAliasMap.get(
|
|
hostData.credentialAlias.toLowerCase(),
|
|
);
|
|
}
|
|
|
|
if (!isNonEmptyString(hostData.ip) || !isValidPort(hostData.port)) {
|
|
results.failed++;
|
|
results.errors.push(
|
|
`Host ${i + 1}: Missing required fields (ip, port)`,
|
|
);
|
|
continue;
|
|
}
|
|
|
|
const username = importedHostUsername(
|
|
effectiveConnectionType,
|
|
hostData.authType,
|
|
hostData.username,
|
|
);
|
|
if (username === null) {
|
|
results.failed++;
|
|
results.errors.push(
|
|
`Host ${i + 1}: Username required for SSH connections`,
|
|
);
|
|
continue;
|
|
}
|
|
|
|
if (
|
|
effectiveConnectionType === "ssh" &&
|
|
hostData.authType &&
|
|
!knownAuthTypes.has(hostData.authType)
|
|
) {
|
|
results.failed++;
|
|
results.errors.push(
|
|
`Host ${i + 1}: Invalid authType. Must be one of ${[...knownAuthTypes].join(", ")}`,
|
|
);
|
|
continue;
|
|
}
|
|
|
|
if (
|
|
effectiveConnectionType === "ssh" &&
|
|
hostData.authType === "password" &&
|
|
!isNonEmptyString(hostData.password)
|
|
) {
|
|
results.failed++;
|
|
results.errors.push(
|
|
`Host ${i + 1}: Password required for password authentication`,
|
|
);
|
|
continue;
|
|
}
|
|
|
|
if (
|
|
effectiveConnectionType === "ssh" &&
|
|
hostData.authType === "key" &&
|
|
!isNonEmptyString(hostData.key)
|
|
) {
|
|
results.failed++;
|
|
results.errors.push(
|
|
`Host ${i + 1}: Key required for key authentication`,
|
|
);
|
|
continue;
|
|
}
|
|
|
|
if (
|
|
effectiveConnectionType === "ssh" &&
|
|
hostData.authType === "credential" &&
|
|
!hostData.credentialId
|
|
) {
|
|
results.failed++;
|
|
results.errors.push(
|
|
`Host ${i + 1}: credentialId required for credential authentication`,
|
|
);
|
|
continue;
|
|
}
|
|
|
|
if (
|
|
effectiveConnectionType === "ssh" &&
|
|
hostData.authType === "credential" &&
|
|
hostData.credentialId
|
|
) {
|
|
const credentialRepository = createCurrentCredentialRepository();
|
|
const cred = await credentialRepository.findByIdForUser(
|
|
userId,
|
|
hostData.credentialId,
|
|
);
|
|
|
|
if (!cred) {
|
|
const fallback = await credentialRepository.listByUserId(userId);
|
|
|
|
if (fallback.length > 0) {
|
|
hostData.credentialId = fallback[0].id;
|
|
} else if (isNonEmptyString(hostData.key)) {
|
|
hostData.authType = "key";
|
|
hostData.credentialId = undefined;
|
|
} else if (isNonEmptyString(hostData.password)) {
|
|
hostData.authType = "password";
|
|
hostData.credentialId = undefined;
|
|
} else {
|
|
results.failed++;
|
|
results.errors.push(
|
|
`Host ${i + 1}: credentialId ${hostData.credentialId} not found and no fallback credential available`,
|
|
);
|
|
continue;
|
|
}
|
|
}
|
|
}
|
|
|
|
const jumpHosts = remapImportedJumpHosts(
|
|
hostData.jumpHosts,
|
|
importedIds,
|
|
);
|
|
const sshDataObj: Record<string, unknown> = {
|
|
userId: userId,
|
|
connectionType: effectiveConnectionType,
|
|
name: hostData.name || `${username}@${hostData.ip}`,
|
|
folder: hostData.folder || "Default",
|
|
tags: Array.isArray(hostData.tags) ? hostData.tags.join(",") : "",
|
|
ip: hostData.ip,
|
|
port: hostData.port,
|
|
username,
|
|
pin: hostData.pin || false,
|
|
sudoPassword: hostData.sudoPassword || null,
|
|
jumpHosts: jumpHosts ? JSON.stringify(jumpHosts) : null,
|
|
...importedStatusCheck(hostData as Record<string, unknown>),
|
|
terminalConfig: hostData.terminalConfig
|
|
? JSON.stringify(hostData.terminalConfig)
|
|
: null,
|
|
// A 2.8 export carries these inside terminalConfig.
|
|
sshOptions:
|
|
sshOptionsForWrite({
|
|
sshOptions: hostData.sshOptions,
|
|
terminalConfig: hostData.terminalConfig,
|
|
}) ?? null,
|
|
forceKeyboardInteractive: hostData.forceKeyboardInteractive
|
|
? "true"
|
|
: "false",
|
|
notes: hostData.notes || null,
|
|
useSocks5: hostData.useSocks5 ? 1 : 0,
|
|
socks5Host: hostData.socks5Host || null,
|
|
socks5Port: hostData.socks5Port || null,
|
|
socks5Username: hostData.socks5Username || null,
|
|
socks5Password: hostData.socks5Password || null,
|
|
socks5ProxyChain: hostData.socks5ProxyChain
|
|
? JSON.stringify(hostData.socks5ProxyChain)
|
|
: null,
|
|
portKnockSequence: hostData.portKnockSequence
|
|
? JSON.stringify(hostData.portKnockSequence)
|
|
: null,
|
|
overrideCredentialUsername: hostData.overrideCredentialUsername
|
|
? 1
|
|
: 0,
|
|
enableSsh: hostData.enableSsh ?? effectiveConnectionType === "ssh",
|
|
updatedAt: new Date().toISOString(),
|
|
};
|
|
|
|
if (effectiveConnectionType !== "ssh") {
|
|
sshDataObj.password = hostData.password || null;
|
|
sshDataObj.authType = "password";
|
|
sshDataObj.credentialId = null;
|
|
sshDataObj.key = null;
|
|
sshDataObj.keyPassword = null;
|
|
sshDataObj.keyType = null;
|
|
} else {
|
|
sshDataObj.password =
|
|
hostData.authType === "password" ? hostData.password : null;
|
|
sshDataObj.authType = hostData.authType || "password";
|
|
sshDataObj.credentialId =
|
|
hostData.authType === "credential" ? hostData.credentialId : null;
|
|
sshDataObj.key = hostData.authType === "key" ? hostData.key : null;
|
|
sshDataObj.keyPassword =
|
|
hostData.authType === "key" ? hostData.keyPassword || null : null;
|
|
sshDataObj.keyType =
|
|
hostData.authType === "key" ? hostData.keyType || "auto" : null;
|
|
}
|
|
|
|
const lookupKey = `${hostData.ip}:${hostData.port}:${hostData.username}`;
|
|
const existing = existingHostMap?.get(lookupKey);
|
|
|
|
let savedHostId: number;
|
|
if (existing) {
|
|
const saved = await hostRepository.updateEncryptedForUser(
|
|
userId,
|
|
existing.id,
|
|
sshDataObj,
|
|
);
|
|
if (!saved) throw new Error("Host no longer exists");
|
|
if (exportId !== undefined) importedIds.set(exportId, existing.id);
|
|
savedHostId = existing.id;
|
|
results.updated++;
|
|
} else {
|
|
sshDataObj.createdAt = new Date().toISOString();
|
|
const saved = await hostRepository.createEncryptedForUser(
|
|
userId,
|
|
sshDataObj,
|
|
);
|
|
if (exportId !== undefined) importedIds.set(exportId, saved.id);
|
|
savedHostId = saved.id;
|
|
results.success++;
|
|
}
|
|
|
|
const protocolAuth = readProtocolAuthPayload(
|
|
hostData as Record<string, unknown>,
|
|
);
|
|
if (protocolAuth) {
|
|
await writeProtocolAuth(
|
|
userId,
|
|
savedHostId,
|
|
await keepUsableProtocolCredentials(protocolAuth, userId),
|
|
{ isOwner: true },
|
|
);
|
|
}
|
|
|
|
// Every enabled plugin that declares host-scope settings and
|
|
// registered a hostImportNormalizer validates and writes its own
|
|
// fields here, so this loop does not need to know which plugins
|
|
// exist. See host-plugin-settings.ts.
|
|
await applyPluginHostImportSettings(
|
|
savedHostId,
|
|
hostData as Record<string, unknown>,
|
|
);
|
|
} catch (error) {
|
|
results.failed++;
|
|
results.errors.push(`Host ${i + 1}: ${getErrorMessage(error)}`);
|
|
}
|
|
}
|
|
|
|
res.json({
|
|
message: `Import completed: ${results.success} created, ${results.updated} updated, ${results.failed} failed`,
|
|
success: results.success,
|
|
updated: results.updated,
|
|
skipped: results.skipped,
|
|
failed: results.failed,
|
|
errors: results.errors,
|
|
});
|
|
},
|
|
);
|
|
|
|
/**
|
|
* @openapi
|
|
* /host/ssh-config-import:
|
|
* post:
|
|
* summary: Import hosts from an OpenSSH config file
|
|
* description: Parses an OpenSSH ~/.ssh/config file and imports the defined hosts.
|
|
* tags:
|
|
* - SSH
|
|
* requestBody:
|
|
* required: true
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* required:
|
|
* - content
|
|
* properties:
|
|
* content:
|
|
* type: string
|
|
* description: Raw text content of the SSH config file.
|
|
* overwrite:
|
|
* type: boolean
|
|
* responses:
|
|
* 200:
|
|
* description: Import completed.
|
|
* 400:
|
|
* description: Invalid request body.
|
|
*/
|
|
router.post(
|
|
"/ssh-config-import",
|
|
authenticateJWT,
|
|
requireCreatePermission,
|
|
requireEditPermission,
|
|
requireDataAccess,
|
|
async (req: Request, res: Response) => {
|
|
const userId = (req as AuthenticatedRequest).userId;
|
|
const { content, overwrite } = req.body;
|
|
|
|
if (!isNonEmptyString(content)) {
|
|
return res.status(400).json({
|
|
error: "content is required and must be a non-empty string",
|
|
});
|
|
}
|
|
|
|
let parsed: SSHConfigHost[];
|
|
try {
|
|
parsed = parseSSHConfig(content);
|
|
} catch {
|
|
return res
|
|
.status(400)
|
|
.json({ error: "Failed to parse SSH config file" });
|
|
}
|
|
|
|
if (parsed.length === 0) {
|
|
return res.status(400).json({
|
|
error: "No valid Host entries found in the SSH config file",
|
|
});
|
|
}
|
|
|
|
if (parsed.length > 100) {
|
|
return res
|
|
.status(400)
|
|
.json({ error: "Maximum 100 hosts allowed per import" });
|
|
}
|
|
|
|
const hostsToImport = parsed.map((h) => ({
|
|
name: h.name,
|
|
ip: h.hostname,
|
|
port: h.port ?? 22,
|
|
username: h.user,
|
|
authType: h.identityFile ? "key" : undefined,
|
|
connectionType: "ssh",
|
|
enableSsh: true,
|
|
...(h.proxyJump
|
|
? {
|
|
jumpHosts: [{ host: h.proxyJump, port: 22 }],
|
|
}
|
|
: {}),
|
|
}));
|
|
|
|
const results = {
|
|
success: 0,
|
|
updated: 0,
|
|
skipped: 0,
|
|
failed: 0,
|
|
errors: [] as string[],
|
|
};
|
|
|
|
let existingHostMap: Map<string, { id: number }> | undefined;
|
|
const hostRepository = createCurrentHostRepository();
|
|
if (overwrite) {
|
|
try {
|
|
const allHosts =
|
|
await createCurrentHostResolutionRepository().findHostsByUserId(
|
|
userId,
|
|
);
|
|
existingHostMap = new Map();
|
|
for (const h of allHosts) {
|
|
const key = `${h.ip}:${h.port}:${h.username}`;
|
|
existingHostMap.set(key, { id: h.id as number });
|
|
}
|
|
} catch {
|
|
existingHostMap = undefined;
|
|
}
|
|
}
|
|
|
|
for (let i = 0; i < hostsToImport.length; i++) {
|
|
const hostData = normalizeImportedHost(
|
|
hostsToImport[i] as Record<string, unknown>,
|
|
);
|
|
|
|
try {
|
|
if (!isNonEmptyString(hostData.ip) || !isValidPort(hostData.port)) {
|
|
results.failed++;
|
|
results.errors.push(
|
|
`Host "${parsed[i].name}": Missing required fields (HostName, Port)`,
|
|
);
|
|
continue;
|
|
}
|
|
|
|
const sshDataObj: Record<string, unknown> = {
|
|
userId,
|
|
connectionType: "ssh",
|
|
name: hostData.name || hostData.ip,
|
|
folder: "Default",
|
|
tags: "",
|
|
ip: hostData.ip,
|
|
port: hostData.port,
|
|
username: hostData.username || null,
|
|
authType: hostData.authType || "none",
|
|
password: null,
|
|
key: null,
|
|
keyPassword: null,
|
|
keyType: null,
|
|
credentialId: null,
|
|
pin: false,
|
|
sudoPassword: null,
|
|
jumpHosts: hostData.jumpHosts
|
|
? JSON.stringify(hostData.jumpHosts)
|
|
: null,
|
|
statusCheckEnabled: true,
|
|
statusCheckInterval: null,
|
|
terminalConfig: null,
|
|
sshOptions: null,
|
|
forceKeyboardInteractive: "false",
|
|
notes: null,
|
|
useSocks5: 0,
|
|
socks5Host: null,
|
|
socks5Port: null,
|
|
socks5Username: null,
|
|
socks5Password: null,
|
|
socks5ProxyChain: null,
|
|
portKnockSequence: null,
|
|
overrideCredentialUsername: 0,
|
|
enableSsh: true,
|
|
updatedAt: new Date().toISOString(),
|
|
};
|
|
|
|
const lookupKey = `${hostData.ip}:${hostData.port}:${hostData.username}`;
|
|
const existing = existingHostMap?.get(lookupKey);
|
|
|
|
if (existing) {
|
|
await hostRepository.updateEncryptedForUser(
|
|
userId,
|
|
existing.id,
|
|
sshDataObj,
|
|
);
|
|
results.updated++;
|
|
} else {
|
|
sshDataObj.createdAt = new Date().toISOString();
|
|
await hostRepository.createEncryptedForUser(userId, sshDataObj);
|
|
results.success++;
|
|
}
|
|
} catch (error) {
|
|
results.failed++;
|
|
results.errors.push(
|
|
`Host "${parsed[i].name}": ${getErrorMessage(error)}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
res.json({
|
|
message: `Import completed: ${results.success} created, ${results.updated} updated, ${results.failed} failed`,
|
|
success: results.success,
|
|
updated: results.updated,
|
|
skipped: results.skipped,
|
|
failed: results.failed,
|
|
errors: results.errors,
|
|
});
|
|
},
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Status check fields from an import row. Exports from before 2.9.0 carried
|
|
* them inside statsConfig.
|
|
*/
|
|
function importedStatusCheck(raw: Record<string, unknown>): {
|
|
statusCheckEnabled: boolean;
|
|
statusCheckInterval: number | null;
|
|
} {
|
|
const legacy =
|
|
raw.statsConfig && typeof raw.statsConfig === "object"
|
|
? (raw.statsConfig as Record<string, unknown>)
|
|
: {};
|
|
const enabled = raw.statusCheckEnabled ?? legacy.statusCheckEnabled;
|
|
const useGlobal = legacy.useGlobalStatusInterval !== false;
|
|
const interval =
|
|
raw.statusCheckInterval ?? (useGlobal ? null : legacy.statusCheckInterval);
|
|
const seconds = Number(interval);
|
|
return {
|
|
statusCheckEnabled: enabled !== false && legacy.disableTcpPing !== true,
|
|
statusCheckInterval:
|
|
interval != null && Number.isInteger(seconds) && seconds >= 5
|
|
? seconds
|
|
: null,
|
|
};
|
|
}
|
|
|
|
const BUILTIN_SSH_AUTH_TYPES = [
|
|
"password",
|
|
"key",
|
|
"credential",
|
|
"agent",
|
|
"none",
|
|
];
|
|
|
|
/** Core's own types plus every type a plugin registers or declares. */
|
|
function listKnownAuthTypes(): Set<string> {
|
|
return new Set([
|
|
...BUILTIN_SSH_AUTH_TYPES,
|
|
...listSshAuthProviders().map((provider) => provider.type),
|
|
...listSshAuthTypeOwners().map((owner) => owner.type),
|
|
]);
|
|
}
|