Files
Termix/src/backend/hosts/host-resolver.ts
T
ZacharyZcR 59083aac00 fix(ssh): read force keyboard-interactive as a boolean when resolving hosts (#1553)
The column holds the text "true"/"false", and the resolver passed it
through as-is. The string "false" is truthy, so the password provider
treated every saved host as forced keyboard-interactive and left the
password out. Jump hops are built straight from the resolved host, so a
password hop whose server doesn't offer keyboard-interactive failed with
"All configured authentication methods failed".
2026-10-03 21:04:21 +08:00

322 lines
11 KiB
TypeScript

import { getErrorMessage } from "../utils/error-message.js";
import { parseSshOptions } from "./ssh-options.js";
import { findUsableCredential } from "./usable-credential.js";
import { resolveExternalSecretRefs } from "./external-secrets.js";
import {
createCurrentHostResolutionRepository,
createCurrentUserRepository,
} from "../database/repositories/factory.js";
import type { HostResolutionHostRecord } from "../database/repositories/host-resolution-repository.js";
import { logAudit } from "../utils/audit-logger.js";
import { logger } from "../utils/logger.js";
import { resolveRecipientSharedHostAuthentication } from "../utils/shared-host-auth-resolver.js";
import {
pickResolvedPassword,
pickResolvedUsername,
expandExternalUsername,
} from "./credential-username.js";
import type { SSHHost } from "../../types/index.js";
const sshLogger = logger;
/**
* Resolve a host the client named by its sync identity.
*
* `id` is an autoincrement belonging to whichever database produced the row.
* When the desktop app delegates a connection to a sync server, the two
* sequences have no reason to agree, and resolving the client's id here lands
* on whatever host happens to own that number — a different machine, with its
* own address, credentials and host key. `syncId` is the same string on both
* sides, so it names the host the user actually picked.
*
* Returns null when the sync id is unknown here, rather than falling back to
* the numeric id: an unknown host is exactly the case where guessing picks the
* wrong machine.
*/
export async function resolveHostBySyncId(
syncId: string,
userId: string,
): Promise<SSHHost | null> {
const hostId =
await createCurrentHostResolutionRepository().findHostIdBySyncId(syncId);
if (hostId === null) return null;
// Permissions, decryption, shared-host handling and auditing all belong to
// the id-based path; this only decides which row it is pointed at.
return resolveHostById(hostId, userId);
}
/**
* Resolve a host with its credentials server-side by hostId.
* This avoids passing credentials through the frontend.
*/
export async function resolveHostById(
hostId: number,
userId: string,
): Promise<SSHHost | null> {
const { PermissionManager } = await import("../utils/permission-manager.js");
const access = await PermissionManager.getInstance().canAccessHost(
userId,
hostId,
"connect",
);
if (!access.hasAccess) return null;
const repository = createCurrentHostResolutionRepository();
// Decrypt under the owner's DEK: shared hosts carry owner-encrypted fields
// (socks5Password, inline auth, ...) that the requester's key cannot open.
const ownerId = (await repository.findHostOwnerId(hostId)) ?? userId;
const resolvedHost = await repository.findHostById(hostId, ownerId);
if (!resolvedHost) return null;
const host = resolvedHost as Record<string, unknown>;
// Admin bypass resolves like the owner would; every such access is audited.
const ownerEquivalent = userId === ownerId || access.isAdminBypass === true;
if (access.isAdminBypass && userId !== ownerId) {
try {
const admin = await createCurrentUserRepository().findById(userId);
void logAudit({
userId,
username: admin?.username ?? "unknown",
action: "admin_connect_host",
resourceType: "host",
resourceId: String(hostId),
resourceName: (host.name as string) || (host.ip as string) || "",
details: JSON.stringify({ ownerId }),
success: true,
});
} catch {
// never block resolution on audit bookkeeping
}
}
if (!ownerEquivalent) {
// Owner-only operational secrets are never shared.
host.sudoPassword = null;
}
// Parse JSON fields
if (typeof host.jumpHosts === "string" && host.jumpHosts) {
try {
host.jumpHosts = JSON.parse(host.jumpHosts as string);
} catch {
host.jumpHosts = [];
}
}
if (typeof host.terminalConfig === "string" && host.terminalConfig) {
try {
host.terminalConfig = JSON.parse(host.terminalConfig as string);
} catch {
host.terminalConfig = undefined;
}
}
if (
host.terminalConfig &&
typeof host.terminalConfig === "object" &&
!Array.isArray(host.terminalConfig)
) {
// 2.8 editors kept the sudo password inside terminal_config. It is only
// ever handed out as sudoPassword, and only to the owner.
const { sudoPassword: legacySudo, ...rest } = host.terminalConfig as Record<
string,
unknown
>;
if (ownerEquivalent && !host.sudoPassword && legacySudo) {
host.sudoPassword = legacySudo;
}
host.terminalConfig = rest;
}
// Stored as the text "true"/"false"; the string "false" is truthy and would
// make the password provider skip password auth.
host.forceKeyboardInteractive =
String(host.forceKeyboardInteractive) === "true";
// A row the boot copy has not reached yet still has them in terminal_config.
host.sshOptions = parseSshOptions(
host.sshOptions != null ? host.sshOptions : host.terminalConfig,
);
if (typeof host.socks5ProxyChain === "string" && host.socks5ProxyChain) {
try {
host.socks5ProxyChain = JSON.parse(host.socks5ProxyChain as string);
} catch {
host.socks5ProxyChain = [];
}
}
if (typeof host.portKnockSequence === "string" && host.portKnockSequence) {
try {
host.portKnockSequence = JSON.parse(host.portKnockSequence as string);
} catch {
host.portKnockSequence = [];
}
}
let sharedAuthResolution: SharedAuthResolution | undefined;
if (!ownerEquivalent) {
sharedAuthResolution = await resolveRecipientSshAuth(host, hostId, userId);
if (!sharedAuthResolution) return null;
} else {
let effectiveCredentialId = host.credentialId as number | null | undefined;
if (
!effectiveCredentialId &&
host.authType === "credential" &&
host.folder
) {
try {
effectiveCredentialId = await repository.findFolderCredentialId(
ownerId,
host.folder as string,
);
} catch (e) {
sshLogger.warn("Failed to resolve folder credential for host", {
operation: "host_resolver_folder_credential",
hostId,
error: getErrorMessage(e, "Unknown"),
});
}
}
if (effectiveCredentialId) {
try {
const cred = (await findUsableCredential(
effectiveCredentialId,
ownerId,
)) as Record<string, unknown> | null;
if (cred) {
host.password = pickResolvedPassword(host.password, cred.password);
// Prefer the normalised private key; fall back to raw key field
host.key = (cred.privateKey || cred.key) as string | null;
host.keyPassword = cred.keyPassword;
host.keyType = cred.keyType;
// CA-signed certificate for cert-based auth
(host as Record<string, unknown>).certPublicKey =
cred.certPublicKey || null;
host.username = pickResolvedUsername(
host.username,
cred.username,
host.overrideCredentialUsername,
);
host.authType = host.key
? "key"
: host.password
? "password"
: "none";
}
} catch (e) {
sshLogger.warn("Failed to resolve credential for host", {
operation: "host_resolver_credential",
hostId,
error: getErrorMessage(e, "Unknown"),
});
}
}
}
host.username = await expandExternalUsername(
host.username as string | undefined,
ownerEquivalent ? ownerId : userId,
);
// "op://..." references become real secrets here, once, for everyone
// downstream. They resolve in the context of whoever owns the secret
// fields: the owner for their own host, the recipient for an override.
await resolveExternalSecretRefs(
host as Record<string, unknown>,
sharedAuthResolution === "recipient-override" ? userId : ownerId,
);
// Keyboard-interactive handlers run synchronously mid-handshake and can
// only read their host settings from here.
try {
const { loadHostPluginSettings } =
await import("../database/routes/host-plugin-settings.js");
const settings = (await loadHostPluginSettings([hostId])).get(hostId);
if (settings) host.pluginSettings = settings;
} catch (e) {
sshLogger.warn("Failed to load plugin settings for host", {
operation: "host_resolver_plugin_settings",
hostId,
error: getErrorMessage(e, "Unknown"),
});
}
return host as unknown as SSHHost;
}
/**
* Resolve SSH auth for a shared (non-owner) requester without exposing the
* owner's password, key, or credential reference. A recipient-owned override
* fully replaces the host auth. An owner-enabled shared snapshot is the
* fallback; otherwise only secret-less auth types pass.
*/
type SharedAuthResolution =
"recipient-override" | "shared-snapshot" | "shared-agent" | "secretless";
async function resolveRecipientSshAuth(
host: Record<string, unknown>,
hostId: number,
userId: string,
): Promise<SharedAuthResolution | null> {
const ownerAuthHost = { ...host } as HostResolutionHostRecord;
// The host row is decrypted under its owner's DEK so connection settings are
// available. Remove owner SSH auth before resolving anything for a recipient.
host.password = null;
host.key = null;
host.keyPassword = null;
host.keyType = null;
host.certPublicKey = null;
host.credentialId = null;
try {
const resolution = await resolveRecipientSharedHostAuthentication(
ownerAuthHost,
hostId,
userId,
"ssh",
);
if (resolution.source === "personal-override") {
const credential = resolution.credential;
host.password = credential.password;
host.key = credential.privateKey || credential.key;
host.keyPassword = credential.keyPassword;
host.keyType = credential.keyType;
host.certPublicKey = credential.certPublicKey || null;
host.username = credential.username || host.username;
host.authType = host.key ? "key" : host.password ? "password" : "none";
return "recipient-override";
}
if (resolution.source === "owner-shared") {
if (resolution.authType === "agent") {
return "shared-agent";
}
const sharedAuth = resolution.secret;
if (sharedAuth) {
host.password = sharedAuth.password || null;
host.key = sharedAuth.key || null;
host.keyPassword = sharedAuth.keyPassword || null;
host.keyType = sharedAuth.keyType || null;
host.username = pickResolvedUsername(
host.username,
sharedAuth.username,
host.overrideCredentialUsername,
);
host.authType = host.key ? "key" : host.password ? "password" : "none";
return "shared-snapshot";
}
}
if (resolution.source === "secretless") {
return "secretless";
}
} catch {
// A missing/deleted override or snapshot behaves like unavailable auth.
}
return null;
}