[Init] Initial commit - NetMesh terminal manager
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
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
This commit is contained in:
343
domain/sshAuth.ts
Normal file
343
domain/sshAuth.ts
Normal file
@@ -0,0 +1,343 @@
|
||||
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,
|
||||
);
|
||||
Reference in New Issue
Block a user