Files
Termix/src/backend/database/routes/host-bulk-routes.ts
T

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),
]);
}