Files
NetMesh/domain/sshAuth.ts
zhaolei 3c72efcb7f
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
[Init] Initial commit - NetMesh terminal manager
2026-09-13 18:24:01 +08:00

344 lines
10 KiB
TypeScript

import type { GroupConfig, Host, HostAuthMethod, Identity, SSHKey } from "./models";
import { sanitizeCredentialValue } from "./credentials";
import { applyGroupDefaults } from "./groupConfig";
import { isSshAgentNoneValue } from "./sshAgentSettings";
type HostAuthOverride = {
authMethod?: HostAuthMethod;
username?: string;
password?: string;
keyId?: string;
passphrase?: string;
};
type ResolvedHostAuth = {
identity?: Identity;
authMethod: HostAuthMethod;
username: string;
password?: string;
keyId?: string;
key?: SSHKey;
passphrase?: string;
identityFilePath?: string;
};
const hasAgentEnablingDirectives = (
host: Pick<Host, "addKeysToAgent" | "useKeychain">,
identityAgent?: string,
): boolean => Boolean(
identityAgent
|| (
typeof host.addKeysToAgent === "string"
&& host.addKeysToAgent.trim().length > 0
&& host.addKeysToAgent.trim().toLowerCase() !== "no"
)
|| host.useKeychain === true,
);
export const resolveHostAuthMethodSelection = (
host: Pick<Host, "authMethod" | "authPolicyVersion" | "identityFileId" | "identityFilePaths" | "password" | "useSshAgent">,
): HostAuthMethod => host.authMethod || (
host.useSshAgent === true
? "auto"
: host.identityFileId || host.identityFilePaths?.length
? "key"
: host.password && host.authPolicyVersion !== 1
? "password"
: "auto"
);
export const applyHostAuthMethodSelection = <T extends Host>(
host: T,
authMethod: HostAuthMethod,
previousMethod: HostAuthMethod = resolveHostAuthMethodSelection(host),
): T => {
if (previousMethod === authMethod) {
return {
...host,
authMethod,
authPolicyVersion: 1,
};
}
const isAutomatic = authMethod === "auto";
const automaticIdentityAgent = isAutomatic && isSshAgentNoneValue(host.identityAgent)
? undefined
: host.identityAgent;
const hasAutomaticAgentSettings = hasAgentEnablingDirectives(host, automaticIdentityAgent);
return {
...host,
authMethod,
authPolicyVersion: 1,
identityId: "",
identityFileId: undefined,
identityFilePaths: undefined,
...(isAutomatic
? { identityAgent: automaticIdentityAgent, identitiesOnly: undefined }
: {}),
useSshAgent: isAutomatic
? (hasAutomaticAgentSettings ? true : undefined)
: authMethod === "key" && previousMethod === "key"
? host.useSshAgent
: false,
};
};
export const resolveSshAgentToggleUpdate = (
host: Pick<Host, "identityAgent" | "addKeysToAgent" | "useKeychain">,
authMethod: HostAuthMethod,
enabling: boolean,
): Pick<Host, "useSshAgent" | "identityAgent"> => {
const identityAgent = enabling && isSshAgentNoneValue(host.identityAgent)
? undefined
: host.identityAgent;
const hasExplicitAgentSettings = hasAgentEnablingDirectives(host, identityAgent);
return {
useSshAgent: enabling
? (authMethod === "auto" && !hasExplicitAgentSettings ? undefined : true)
: false,
identityAgent,
};
};
const inferAuthMethod = (opts: {
explicit?: HostAuthMethod;
keyId?: string;
password?: string;
hostAuthMethod?: HostAuthMethod;
key?: SSHKey;
}): HostAuthMethod => {
if (opts.explicit) return opts.explicit;
if (opts.hostAuthMethod === "auto") return "auto";
if (opts.keyId) {
if (opts.hostAuthMethod === "key" || opts.hostAuthMethod === "certificate") {
return opts.hostAuthMethod;
}
return opts.key?.certificate ? "certificate" : "key";
}
if (opts.hostAuthMethod) return opts.hostAuthMethod;
if (opts.password) return "password";
return "auto";
};
export const resolveHostAuth = (args: {
host: Host;
keys: SSHKey[];
identities?: Identity[];
override?: HostAuthOverride | null;
}): ResolvedHostAuth => {
const { host, keys, identities = [], override } = args;
const identity = host.identityId
? identities.find((i) => i.id === host.identityId)
: undefined;
const username =
override?.username?.trim() ||
identity?.username?.trim() ||
host.username?.trim() ||
"";
const selectedAuthMethod = (
override?.authMethod ||
identity?.authMethod ||
host.authMethod ||
(
host.useSshAgent === true
? "auto"
: host.identityFilePaths?.length
? "key"
: host.authPolicyVersion === 1 && !host.identityFileId
? "auto"
: undefined
)
) as HostAuthMethod | undefined;
// Don't load key when password auth is selected.
// This ensures the user's auth method selection is strictly respected.
const keyId = selectedAuthMethod === "password"
? undefined
: (override?.keyId || identity?.keyId || host.identityFileId || undefined);
const key = keyId ? keys.find((k) => k.id === keyId) : undefined;
const password = override?.password ?? identity?.password ?? host.password;
const authMethod = inferAuthMethod({
explicit: override?.authMethod,
hostAuthMethod: selectedAuthMethod,
keyId,
password,
key,
});
const passphrase = override?.passphrase || key?.passphrase || undefined;
const identityFilePath = key?.source === 'reference' && key.filePath
? key.filePath
: undefined;
return {
identity,
authMethod,
username,
password,
keyId,
key,
passphrase,
identityFilePath,
};
};
export const resolveHostAuthMethodForPersistence = (args: {
host: Host;
keys: SSHKey[];
identities?: Identity[];
groupDefaults?: Partial<GroupConfig>;
}): HostAuthMethod | undefined => {
const { host, keys, identities, groupDefaults } = args;
if (host.authMethod) return host.authMethod;
const resolveEffectiveMethod = (candidate: Host) => resolveHostAuth({
host: groupDefaults ? applyGroupDefaults(candidate, groupDefaults) : candidate,
keys,
identities,
}).authMethod;
const methodBeforeSave = resolveEffectiveMethod(host);
const methodAfterSave = resolveEffectiveMethod(
host.savePassword === false ? { ...host, password: undefined } : host,
);
return methodBeforeSave === methodAfterSave ? undefined : methodBeforeSave;
};
/**
* Resolve the password to use for sudo/su autofill the same way SSH login does
* (through resolveHostAuth), so a password stored in a referenced Keychain
* identity (host.identityId) is found — not just host.password (issue #1284).
* Returns undefined when the host opts out of saving its password, or none is
* available (pure key auth, or an undecryptable placeholder).
* Used for both sudo and su confirm-to-fill hints (#2156).
*/
export const resolveHostAutofillPassword = (args: {
host: Host;
keys: SSHKey[];
identities?: Identity[];
}): string | undefined => {
if (args.host.savePassword === false) return undefined;
return sanitizeCredentialValue(resolveHostAuth(args).password) || undefined;
};
export const resolveBridgeKeyAuth = (args: {
key?: SSHKey | null;
fallbackIdentityFilePaths?: string[];
passphrase?: string;
}): {
privateKey?: string;
identityFilePaths?: string[];
passphrase?: string;
} => {
const { key, fallbackIdentityFilePaths, passphrase } = args;
const identityFilePaths = key?.source === "reference" && key.filePath
? [key.filePath]
: fallbackIdentityFilePaths;
return {
privateKey: key?.source === "reference" ? undefined : sanitizeCredentialValue(key?.privateKey),
identityFilePaths,
passphrase: sanitizeCredentialValue(passphrase ?? key?.passphrase),
};
};
export const resolveBridgeSshAgentAuth = (
host: Pick<Host, "authMethod" | "useSshAgent" | "identityAgent" | "identityFilePaths" | "identitiesOnly" | "addKeysToAgent" | "useKeychain" | "agentForwarding">,
key?: Pick<SSHKey, "certificate" | "publicKey" | "source" | "filePath">,
authMethod?: HostAuthMethod,
): {
useSshAgent?: boolean;
identityAgent?: string;
identitiesOnly?: boolean;
addKeysToAgent?: string;
useKeychain?: boolean;
agentPublicKeys?: string[];
} => {
const forwardingAgent = host.agentForwarding
&& host.identityAgent !== undefined
&& !isSshAgentNoneValue(host.identityAgent)
? { identityAgent: host.identityAgent }
: {};
if (authMethod === "password" || authMethod === "certificate" || key?.certificate?.trim()) {
return { useSshAgent: false, ...forwardingAgent };
}
if (authMethod === "key") {
const hasAgentSelector = Boolean(
key?.publicKey?.trim()
|| (key?.source === "reference" && key.filePath?.trim())
|| host.identityFilePaths?.some((filePath) => filePath.trim()),
);
if (host.useSshAgent !== true || !hasAgentSelector) {
return { useSshAgent: false, ...forwardingAgent };
}
return {
useSshAgent: true,
identityAgent: host.identityAgent,
identitiesOnly: true,
addKeysToAgent: host.addKeysToAgent,
useKeychain: host.useKeychain,
...(key?.publicKey?.trim() ? { agentPublicKeys: [key.publicKey] } : {}),
};
}
if (host.useSshAgent !== true) {
return authMethod === "auto" && host.useSshAgent !== false
? forwardingAgent
: { useSshAgent: false, ...forwardingAgent };
}
return {
useSshAgent: true,
identityAgent: host.identityAgent,
identitiesOnly: host.identitiesOnly,
addKeysToAgent: host.addKeysToAgent,
useKeychain: host.useKeychain,
...(key?.publicKey?.trim() ? { agentPublicKeys: [key.publicKey] } : {}),
};
};
export const hasRequiredHostAuthCredential = (args: {
host: Host;
keys: SSHKey[];
identities?: Identity[];
}): boolean => {
if (args.host.protocol && args.host.protocol !== "ssh") return true;
if (args.host.identityId && !args.identities?.some((identity) => identity.id === args.host.identityId)) {
return false;
}
const resolved = resolveHostAuth(args);
if (resolved.authMethod === "key") {
return Boolean(resolved.key || args.host.identityFilePaths?.some((value) => value.trim()));
}
if (resolved.authMethod === "certificate") {
return Boolean(resolved.key?.certificate?.trim());
}
return true;
};
export const hasMacKeychainAgentDirectives = (
host: Pick<Host, "addKeysToAgent" | "useKeychain">,
): boolean => host.useKeychain === true
&& host.addKeysToAgent?.toLowerCase() === "yes";
export const hasBridgeSshCredentials = (auth: {
authMethod?: HostAuthMethod;
password?: string;
privateKey?: string;
identityFilePaths?: string[];
useSshAgent?: boolean;
}): boolean => Boolean(
auth.authMethod === "auto" ||
auth.password ||
auth.privateKey ||
auth.identityFilePaths?.length ||
auth.useSshAgent,
);