mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 22:55:24 +00:00
feat: move terminal settings, look and ssh tools into ssh-terminal plugin
This commit is contained in:
1 parent
ffa872ebd3
commit
d9bba10612
242 files changed
+25071
-7207
No files matched your search
@@ -22,7 +22,7 @@ import type {
|
||||
interface PurposeDefaults {
|
||||
keepaliveIntervalMs: number;
|
||||
keepaliveCountMax: number;
|
||||
/** Let terminalConfig.keepalive* override the defaults. */
|
||||
/** Let the host's sshOptions.keepalive* override the defaults. */
|
||||
hostKeepalive: boolean;
|
||||
readyTimeout: number;
|
||||
/** Socket idle timeout, when the transport set one. */
|
||||
@@ -146,13 +146,12 @@ export async function buildConnectConfig(
|
||||
const authType = host.authType || "none";
|
||||
const serverHostId = options.serverHostId ?? host.id;
|
||||
const isJumpHost = profile === "jump";
|
||||
const terminalConfig = (host.terminalConfig ?? undefined) as
|
||||
Record<string, unknown> | undefined;
|
||||
const sshOptions = host.sshOptions ?? undefined;
|
||||
|
||||
const keepalive = defaults.hostKeepalive
|
||||
? resolveSshKeepalive(
|
||||
terminalConfig?.keepaliveInterval as number | undefined,
|
||||
terminalConfig?.keepaliveCountMax as number | undefined,
|
||||
sshOptions?.keepaliveInterval,
|
||||
sshOptions?.keepaliveCountMax,
|
||||
defaults.keepaliveIntervalMs,
|
||||
defaults.keepaliveCountMax,
|
||||
)
|
||||
@@ -184,9 +183,7 @@ export async function buildConnectConfig(
|
||||
isJumpHost,
|
||||
preloaded,
|
||||
),
|
||||
algorithms: buildSSHAlgorithms(
|
||||
terminalConfig?.allowLegacyAlgorithms !== false,
|
||||
),
|
||||
algorithms: buildSSHAlgorithms(sshOptions?.allowLegacyAlgorithms !== false),
|
||||
};
|
||||
if (defaults.env) config.env = { ...defaults.env };
|
||||
if (defaults.socketTimeout) config.timeout = defaults.socketTimeout;
|
||||
|
||||
@@ -179,10 +179,7 @@ const agentProvider: SshAuthProvider = {
|
||||
quickConnect: true,
|
||||
requiresSecret: true,
|
||||
prepare: async (config, host, env) => {
|
||||
const result = await applyAgentAuth(
|
||||
config,
|
||||
host.terminalConfig as Record<string, unknown> | undefined,
|
||||
);
|
||||
const result = await applyAgentAuth(config, host.sshOptions);
|
||||
if ("error" in result) {
|
||||
return { status: "error", code: "failed", message: result.error };
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Client, ConnectConfig } from "ssh2";
|
||||
import type { WebSocket } from "ws";
|
||||
import type { HostSshOptions } from "../ssh-options.js";
|
||||
|
||||
/**
|
||||
* What a connection is for, as a free label ("terminal", "metrics"). Auth
|
||||
@@ -35,7 +36,7 @@ export interface SshConnectHost {
|
||||
*/
|
||||
pluginSettings?: Record<string, Record<string, unknown>> | null;
|
||||
forceKeyboardInteractive?: boolean | null;
|
||||
terminalConfig?: Record<string, unknown> | null;
|
||||
sshOptions?: HostSshOptions | null;
|
||||
jumpHosts?: Array<{ hostId: number }> | null;
|
||||
useSocks5?: boolean | null;
|
||||
socks5Host?: string | null;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
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 {
|
||||
@@ -116,16 +117,25 @@ export async function resolveHostById(
|
||||
}
|
||||
}
|
||||
if (
|
||||
!ownerEquivalent &&
|
||||
host.terminalConfig &&
|
||||
typeof host.terminalConfig === "object" &&
|
||||
!Array.isArray(host.terminalConfig)
|
||||
) {
|
||||
host.terminalConfig = {
|
||||
...(host.terminalConfig as Record<string, unknown>),
|
||||
sudoPassword: null,
|
||||
};
|
||||
// 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;
|
||||
}
|
||||
// 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);
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* A host's SSH connection options (`ssh_data.ssh_options`).
|
||||
*
|
||||
* These lived in `terminal_config` next to the terminal's look until 2.9.0.
|
||||
* The connect pipeline reads them for every SSH connection, not only the
|
||||
* terminal's, so they are core's and have a column of their own.
|
||||
*/
|
||||
|
||||
import type { HostSshOptions } from "@termix/plugin-sdk/backend";
|
||||
|
||||
export type { HostSshOptions };
|
||||
|
||||
export const SSH_OPTION_KEYS = [
|
||||
"keepaliveInterval",
|
||||
"keepaliveCountMax",
|
||||
"allowLegacyAlgorithms",
|
||||
"agentSocketPath",
|
||||
"agentIdentity",
|
||||
"agentForwarding",
|
||||
"environmentVariables",
|
||||
] as const satisfies readonly (keyof HostSshOptions)[];
|
||||
|
||||
function asObject(value: unknown): Record<string, unknown> | null {
|
||||
let parsed = value;
|
||||
if (typeof parsed === "string") {
|
||||
if (!parsed) return null;
|
||||
try {
|
||||
parsed = JSON.parse(parsed);
|
||||
// Some rows were stringified twice.
|
||||
if (typeof parsed === "string") parsed = JSON.parse(parsed);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
||||
? (parsed as Record<string, unknown>)
|
||||
: null;
|
||||
}
|
||||
|
||||
function finiteNumber(value: unknown): number | undefined {
|
||||
if (value === null || value === undefined || value === "") return undefined;
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
}
|
||||
|
||||
function optionalString(value: unknown): string | null | undefined {
|
||||
if (value === null) return null;
|
||||
return typeof value === "string" ? value : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The SSH options in a value (a JSON string or an object), typed and with
|
||||
* anything else dropped. A terminal_config value works too, which is how the
|
||||
* 2.8 keys are read.
|
||||
*/
|
||||
export function parseSshOptions(value: unknown): HostSshOptions {
|
||||
const source = asObject(value);
|
||||
if (!source) return {};
|
||||
const options: HostSshOptions = {};
|
||||
|
||||
const keepaliveInterval = finiteNumber(source.keepaliveInterval);
|
||||
if (keepaliveInterval !== undefined)
|
||||
options.keepaliveInterval = keepaliveInterval;
|
||||
const keepaliveCountMax = finiteNumber(source.keepaliveCountMax);
|
||||
if (keepaliveCountMax !== undefined)
|
||||
options.keepaliveCountMax = keepaliveCountMax;
|
||||
if (typeof source.allowLegacyAlgorithms === "boolean") {
|
||||
options.allowLegacyAlgorithms = source.allowLegacyAlgorithms;
|
||||
}
|
||||
const agentSocketPath = optionalString(source.agentSocketPath);
|
||||
if (agentSocketPath !== undefined) options.agentSocketPath = agentSocketPath;
|
||||
const agentIdentity = optionalString(source.agentIdentity);
|
||||
if (agentIdentity !== undefined) options.agentIdentity = agentIdentity;
|
||||
if (typeof source.agentForwarding === "boolean") {
|
||||
options.agentForwarding = source.agentForwarding;
|
||||
}
|
||||
if (Array.isArray(source.environmentVariables)) {
|
||||
options.environmentVariables = source.environmentVariables
|
||||
.filter(
|
||||
(entry): entry is { key: unknown; value: unknown } =>
|
||||
!!entry && typeof entry === "object",
|
||||
)
|
||||
.map((entry) => ({
|
||||
key: String(entry.key ?? ""),
|
||||
value: String(entry.value ?? ""),
|
||||
}));
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
/** Whether a value carries any SSH option at all. */
|
||||
export function hasSshOptions(value: unknown): boolean {
|
||||
return Object.keys(parseSshOptions(value)).length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* The column value to store for a host write: the payload's own sshOptions,
|
||||
* or, from a client that still sends the 2.8 shape, the options inside its
|
||||
* terminalConfig. Undefined when the payload carries neither, so an update
|
||||
* leaves the column alone.
|
||||
*/
|
||||
export function sshOptionsForWrite(payload: {
|
||||
sshOptions?: unknown;
|
||||
terminalConfig?: unknown;
|
||||
}): string | null | undefined {
|
||||
if (payload.sshOptions !== undefined) {
|
||||
if (payload.sshOptions === null) return null;
|
||||
return JSON.stringify(parseSshOptions(payload.sshOptions));
|
||||
}
|
||||
if (hasSshOptions(payload.terminalConfig)) {
|
||||
return JSON.stringify(parseSshOptions(payload.terminalConfig));
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -10,6 +10,8 @@ import ssh2Pkg, {
|
||||
type SigningRequestOptions,
|
||||
} from "ssh2";
|
||||
|
||||
import type { HostSshOptions } from "./ssh-options.js";
|
||||
|
||||
type KnownPublicKey = KnownPublicKeys[number];
|
||||
|
||||
const { BaseAgent } = ssh2Pkg;
|
||||
@@ -28,11 +30,9 @@ type PortKnockingOptions = {
|
||||
const sleep: Sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
export async function resolveAgentSocket(
|
||||
terminalConfig: Record<string, unknown> | undefined,
|
||||
sshOptions: HostSshOptions | null | undefined,
|
||||
): Promise<{ socketPath: string } | { error: string }> {
|
||||
const explicit = (
|
||||
terminalConfig?.agentSocketPath as string | undefined
|
||||
)?.trim();
|
||||
const explicit = sshOptions?.agentSocketPath?.trim();
|
||||
const resolved = explicit || process.env.SSH_AUTH_SOCK;
|
||||
|
||||
if (!resolved) {
|
||||
@@ -135,17 +135,15 @@ function parseAgentIdentityBlob(agentIdentity: string): Buffer | null {
|
||||
|
||||
export async function applyAgentAuth(
|
||||
connectConfig: Record<string, unknown>,
|
||||
terminalConfig: Record<string, unknown> | undefined,
|
||||
sshOptions: HostSshOptions | null | undefined,
|
||||
): Promise<{ socketPath: string } | { error: string }> {
|
||||
const result = await resolveAgentSocket(terminalConfig);
|
||||
const result = await resolveAgentSocket(sshOptions);
|
||||
if ("error" in result) return result;
|
||||
|
||||
const { createAgent } = ssh2Pkg;
|
||||
const agent = createAgent(result.socketPath);
|
||||
|
||||
const agentIdentity = (
|
||||
terminalConfig?.agentIdentity as string | undefined
|
||||
)?.trim();
|
||||
const agentIdentity = sshOptions?.agentIdentity?.trim();
|
||||
if (agentIdentity) {
|
||||
const publicKeyBlob = parseAgentIdentityBlob(agentIdentity);
|
||||
if (!publicKeyBlob) {
|
||||
|
||||
Reference in new issue
Block a user