mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-11-04 09:35:24 +00:00
* fix: only show connection lost when the server check fails too * fix: load the macOS tray icon from the right unpacked folder (#1728) * fix: hide update and review buttons on disabled plugins (#1745) * fix: save the database without blocking the server, bump to 26.11.0 * fix: try the key before the password for hosts with both (Plugin-SSH-Terminal#50) * fix: add macOS Window menu item to reopen main window * feat: upload Mac App Store screenshots on submit * chore: drop internal note from changelog * chore: update translations and plugin pins * chore: lint, format, and bump version to 26.11.0
224 lines
6.4 KiB
TypeScript
224 lines
6.4 KiB
TypeScript
/**
|
|
* The SSH auth types core always provides: password, key, credential, agent
|
|
* and none. Everything else is a plugin, through ctx.auth.
|
|
*/
|
|
|
|
import ssh2 from "ssh2";
|
|
import { getErrorMessage } from "../../utils/error-message.js";
|
|
import {
|
|
isPrivateKeyPassphraseError,
|
|
preparePrivateKeyForSSH2,
|
|
} from "../../utils/ssh-key-utils.js";
|
|
import { applyAgentAuth } from "../terminal-auth-helpers.js";
|
|
import { registerSshAuthProvider } from "./auth-provider-registry.js";
|
|
import type {
|
|
MutableConnectConfig,
|
|
SshAuthEnv,
|
|
SshAuthOutcome,
|
|
SshAuthProvider,
|
|
SshConnectHost,
|
|
} from "./types.js";
|
|
|
|
function keyText(key: SshConnectHost["key"]): string {
|
|
if (!key) return "";
|
|
return Buffer.isBuffer(key) ? key.toString("utf8") : key;
|
|
}
|
|
|
|
async function preparePassword(
|
|
config: MutableConnectConfig,
|
|
host: SshConnectHost,
|
|
env: SshAuthEnv,
|
|
): Promise<SshAuthOutcome> {
|
|
if (!host.password) {
|
|
return {
|
|
status: "error",
|
|
code: "missing-secret",
|
|
message: "Password authentication requested but no password provided",
|
|
};
|
|
}
|
|
// Forced keyboard-interactive answers the password prompt from the same
|
|
// value instead of offering it up front.
|
|
if (!host.forceKeyboardInteractive) {
|
|
config.password = host.password;
|
|
}
|
|
env.log("info", "Using password authentication");
|
|
return { status: "ready" };
|
|
}
|
|
|
|
async function prepareKey(
|
|
config: MutableConnectConfig,
|
|
host: SshConnectHost,
|
|
env: SshAuthEnv,
|
|
): Promise<SshAuthOutcome> {
|
|
const key = keyText(host.key);
|
|
if (!key.trim()) {
|
|
return {
|
|
status: "error",
|
|
code: "missing-secret",
|
|
message: "SSH key authentication requested but no key provided",
|
|
};
|
|
}
|
|
|
|
const passphrase = host.keyPassword || undefined;
|
|
try {
|
|
config.privateKey = preparePrivateKeyForSSH2(key, passphrase);
|
|
} catch (error) {
|
|
if (isPrivateKeyPassphraseError(error)) {
|
|
return {
|
|
status: "error",
|
|
code: "passphrase-required",
|
|
message:
|
|
"The SSH key is encrypted. Please enter the passphrase to unlock it.",
|
|
};
|
|
}
|
|
return {
|
|
status: "error",
|
|
code: "invalid-key",
|
|
message: `SSH key format error: ${getErrorMessage(error, "Invalid private key format")}`,
|
|
};
|
|
}
|
|
|
|
// A key that parses in our own helper can still be encrypted with no
|
|
// passphrase given; ssh2 only notices at connect time otherwise.
|
|
const parsed = ssh2.utils.parseKey(config.privateKey as Buffer, passphrase);
|
|
if (parsed instanceof Error) {
|
|
if (isPrivateKeyPassphraseError(parsed)) {
|
|
return {
|
|
status: "error",
|
|
code: "passphrase-required",
|
|
message:
|
|
"The SSH key is encrypted. Please enter the passphrase to unlock it.",
|
|
};
|
|
}
|
|
return {
|
|
status: "error",
|
|
code: "invalid-key",
|
|
message: `SSH key format error: ${parsed.message}`,
|
|
};
|
|
}
|
|
|
|
if (passphrase) config.passphrase = passphrase;
|
|
// Lets a server that wants key then password (or password only) still work.
|
|
// ssh2 tries password first by default, so it has none left to answer with
|
|
// once the key was accepted as the first step.
|
|
if (host.password) {
|
|
config.password = host.password;
|
|
config.authHandler = ["publickey", "password", "keyboard-interactive"];
|
|
}
|
|
|
|
const certPublicKey = host.certPublicKey;
|
|
if (certPublicKey && certPublicKey.trim()) {
|
|
try {
|
|
const { applyCertificateAuth } =
|
|
await import("@termix-ssh/plugin-sdk/ssh-certs");
|
|
await applyCertificateAuth(
|
|
config,
|
|
env.client,
|
|
{
|
|
privateKey: config.privateKey as Buffer,
|
|
certificate: certPublicKey,
|
|
passphrase,
|
|
},
|
|
host.username,
|
|
);
|
|
env.log("info", "Using SSH key authentication with CA certificate");
|
|
return { status: "ready" };
|
|
} catch (certError) {
|
|
env.log(
|
|
"warning",
|
|
`CA certificate setup failed, falling back to key only: ${getErrorMessage(certError)}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
env.log("info", "Using SSH key authentication");
|
|
return { status: "ready" };
|
|
}
|
|
|
|
const passwordProvider: SshAuthProvider = {
|
|
type: "password",
|
|
pluginId: "core",
|
|
labelKey: "hosts.filterAuthPassword",
|
|
quickConnect: true,
|
|
credentialType: true,
|
|
requiresSecret: true,
|
|
prepare: preparePassword,
|
|
};
|
|
|
|
const keyProvider: SshAuthProvider = {
|
|
type: "key",
|
|
pluginId: "core",
|
|
labelKey: "hosts.filterAuthKey",
|
|
quickConnect: true,
|
|
credentialType: true,
|
|
requiresSecret: true,
|
|
prepare: prepareKey,
|
|
};
|
|
|
|
/**
|
|
* The resolver expands a credential into key or password before connect, so
|
|
* this only runs when that expansion found something odd, like an unresolved
|
|
* reference. It behaves like whichever secret is present.
|
|
*/
|
|
const credentialProvider: SshAuthProvider = {
|
|
type: "credential",
|
|
pluginId: "core",
|
|
labelKey: "hosts.filterAuthCredential",
|
|
quickConnect: true,
|
|
requiresSecret: true,
|
|
prepare: async (config, host, env) => {
|
|
if (keyText(host.key).trim()) return prepareKey(config, host, env);
|
|
if (host.password) return preparePassword(config, host, env);
|
|
return {
|
|
status: "error",
|
|
code: "missing-secret",
|
|
message: "The stored credential for this host could not be resolved",
|
|
};
|
|
},
|
|
};
|
|
|
|
const agentProvider: SshAuthProvider = {
|
|
type: "agent",
|
|
pluginId: "core",
|
|
labelKey: "hosts.filterAuthAgent",
|
|
quickConnect: true,
|
|
requiresSecret: true,
|
|
prepare: async (config, host, env) => {
|
|
const result = await applyAgentAuth(config, host.sshOptions);
|
|
if ("error" in result) {
|
|
return { status: "error", code: "failed", message: result.error };
|
|
}
|
|
env.log("info", `SSH agent configured (socket: ${result.socketPath})`);
|
|
return { status: "ready" };
|
|
},
|
|
};
|
|
|
|
const noneProvider: SshAuthProvider = {
|
|
type: "none",
|
|
pluginId: "core",
|
|
labelKey: "hosts.filterAuthNone",
|
|
quickConnect: true,
|
|
// Keyboard-interactive only: a background poll would fire a live prompt
|
|
// (a RADIUS or Duo push) with nobody there to answer it.
|
|
supportsBackground: false,
|
|
prepare: async (_config, _host, env) => {
|
|
env.log("info", "Using keyboard-interactive authentication");
|
|
return { status: "ready" };
|
|
},
|
|
};
|
|
|
|
const BUILTIN_SSH_AUTH_PROVIDERS: readonly SshAuthProvider[] = [
|
|
passwordProvider,
|
|
keyProvider,
|
|
credentialProvider,
|
|
agentProvider,
|
|
noneProvider,
|
|
];
|
|
|
|
export function registerBuiltinSshAuthProviders(): () => void {
|
|
const disposers = BUILTIN_SSH_AUTH_PROVIDERS.map((provider) =>
|
|
registerSshAuthProvider(provider),
|
|
);
|
|
return () => disposers.forEach((dispose) => dispose());
|
|
}
|