mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 14:15:24 +00:00
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".
322 lines
11 KiB
TypeScript
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;
|
|
}
|