mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 22:55:24 +00:00
chore: lock the plugin boundary and remove 2.9.0 leftovers
This commit is contained in:
1 parent
6489b62ece
commit
2961297989
399 files changed
+3964
-5627
No files matched your search
@@ -37,7 +37,7 @@ export function listSshAuthTypeOwners(): Array<
|
||||
return ownerSource();
|
||||
}
|
||||
|
||||
export function findSshAuthTypeOwner(type: string): SshAuthTypeOwner | null {
|
||||
function findSshAuthTypeOwner(type: string): SshAuthTypeOwner | null {
|
||||
const owner = ownerSource().find((entry) => entry.type === type);
|
||||
return owner
|
||||
? { pluginId: owner.pluginId, pluginName: owner.pluginName }
|
||||
@@ -91,6 +91,11 @@ export function listSshAuthProviders(): SshAuthProvider[] {
|
||||
return [...providers.values()];
|
||||
}
|
||||
|
||||
/** Whether a registered provider works for a host that is never saved. */
|
||||
export function isQuickConnectAuthType(type: string): boolean {
|
||||
return providers.get(type)?.quickConnect === true;
|
||||
}
|
||||
|
||||
/** Types a stored credential may use. */
|
||||
export function listCredentialTypes(): string[] {
|
||||
return listSshAuthProviders()
|
||||
|
||||
@@ -15,6 +15,7 @@ import type {
|
||||
SshAuthOutcome,
|
||||
SshAuthProvider,
|
||||
SshConnectHost,
|
||||
SshConnectProfile,
|
||||
SshConnectPurpose,
|
||||
} from "./types.js";
|
||||
|
||||
@@ -48,8 +49,7 @@ const BASIC_ENV: Record<string, string> = {
|
||||
LC_ALL: "en_US.UTF-8",
|
||||
};
|
||||
|
||||
// The values each transport used before it moved onto the pipeline.
|
||||
const PURPOSE_DEFAULTS: Record<SshConnectPurpose, PurposeDefaults> = {
|
||||
const PROFILE_DEFAULTS: Record<SshConnectProfile, PurposeDefaults> = {
|
||||
terminal: {
|
||||
keepaliveIntervalMs: 30000,
|
||||
keepaliveCountMax: 5,
|
||||
@@ -58,116 +58,55 @@ const PURPOSE_DEFAULTS: Record<SshConnectPurpose, PurposeDefaults> = {
|
||||
socketTimeout: 120000,
|
||||
env: TERMINAL_ENV,
|
||||
},
|
||||
"file-manager": {
|
||||
session: {
|
||||
keepaliveIntervalMs: 60000,
|
||||
keepaliveCountMax: 5,
|
||||
hostKeepalive: true,
|
||||
readyTimeout: 60000,
|
||||
env: TERMINAL_ENV,
|
||||
},
|
||||
"file-transfer": {
|
||||
stream: {
|
||||
keepaliveIntervalMs: 30000,
|
||||
keepaliveCountMax: 120,
|
||||
hostKeepalive: false,
|
||||
readyTimeout: 60000,
|
||||
env: null,
|
||||
},
|
||||
tmux: {
|
||||
keepaliveIntervalMs: 30000,
|
||||
keepaliveCountMax: 3,
|
||||
hostKeepalive: false,
|
||||
readyTimeout: 60000,
|
||||
env: null,
|
||||
},
|
||||
metrics: {
|
||||
keepaliveIntervalMs: 30000,
|
||||
keepaliveCountMax: 3,
|
||||
hostKeepalive: false,
|
||||
readyTimeout: 30000,
|
||||
env: BASIC_ENV,
|
||||
},
|
||||
fleet: {
|
||||
keepaliveIntervalMs: 30000,
|
||||
keepaliveCountMax: 3,
|
||||
hostKeepalive: false,
|
||||
readyTimeout: 30000,
|
||||
env: BASIC_ENV,
|
||||
},
|
||||
tunnel: {
|
||||
forward: {
|
||||
keepaliveIntervalMs: 30000,
|
||||
keepaliveCountMax: 3,
|
||||
hostKeepalive: true,
|
||||
readyTimeout: 60000,
|
||||
env: null,
|
||||
},
|
||||
docker: {
|
||||
keepaliveIntervalMs: 60000,
|
||||
keepaliveCountMax: 5,
|
||||
hostKeepalive: true,
|
||||
readyTimeout: 60000,
|
||||
env: TERMINAL_ENV,
|
||||
},
|
||||
"docker-console": {
|
||||
keepaliveIntervalMs: 30000,
|
||||
keepaliveCountMax: 120,
|
||||
hostKeepalive: false,
|
||||
readyTimeout: 60000,
|
||||
env: null,
|
||||
},
|
||||
proxmox: {
|
||||
keepaliveIntervalMs: 30000,
|
||||
keepaliveCountMax: 3,
|
||||
hostKeepalive: false,
|
||||
readyTimeout: 20000,
|
||||
env: null,
|
||||
},
|
||||
snippet: {
|
||||
keepaliveIntervalMs: 30000,
|
||||
keepaliveCountMax: 3,
|
||||
hostKeepalive: false,
|
||||
readyTimeout: 30000,
|
||||
env: null,
|
||||
},
|
||||
"credential-deploy": {
|
||||
keepaliveIntervalMs: 30000,
|
||||
keepaliveCountMax: 3,
|
||||
hostKeepalive: false,
|
||||
readyTimeout: 30000,
|
||||
env: null,
|
||||
},
|
||||
"jump-host": {
|
||||
keepaliveIntervalMs: 30000,
|
||||
keepaliveCountMax: 3,
|
||||
hostKeepalive: false,
|
||||
readyTimeout: 60000,
|
||||
env: null,
|
||||
},
|
||||
"remote-desktop": {
|
||||
keepaliveIntervalMs: 30000,
|
||||
keepaliveCountMax: 3,
|
||||
hostKeepalive: false,
|
||||
readyTimeout: 60000,
|
||||
env: null,
|
||||
},
|
||||
plugin: {
|
||||
background: {
|
||||
keepaliveIntervalMs: 30000,
|
||||
keepaliveCountMax: 3,
|
||||
hostKeepalive: false,
|
||||
readyTimeout: 30000,
|
||||
env: BASIC_ENV,
|
||||
},
|
||||
jump: {
|
||||
keepaliveIntervalMs: 30000,
|
||||
keepaliveCountMax: 3,
|
||||
hostKeepalive: false,
|
||||
readyTimeout: 60000,
|
||||
env: null,
|
||||
},
|
||||
};
|
||||
|
||||
export function getPurposeDefaults(
|
||||
purpose: SshConnectPurpose,
|
||||
export function getProfileDefaults(
|
||||
profile: SshConnectProfile = "background",
|
||||
): PurposeDefaults {
|
||||
return PURPOSE_DEFAULTS[purpose];
|
||||
return PROFILE_DEFAULTS[profile];
|
||||
}
|
||||
|
||||
export interface BuildConnectConfigOptions {
|
||||
/** Acting user: host key trust, certificates and tokens are per user. */
|
||||
userId: string;
|
||||
purpose: SshConnectPurpose;
|
||||
/** Keepalive and timeout defaults. Defaults to "background". */
|
||||
profile?: SshConnectProfile;
|
||||
client: Client;
|
||||
/** Server-side host id when it differs from host.id (sync-id lookups). */
|
||||
serverHostId?: number;
|
||||
@@ -188,7 +127,7 @@ export interface BuiltConnectConfig {
|
||||
|
||||
const noopLog: SshAuthLog = () => {};
|
||||
|
||||
export function stripIpv6Brackets(ip: string): string {
|
||||
function stripIpv6Brackets(ip: string): string {
|
||||
return ip?.replace(/^\[|\]$/g, "") || ip;
|
||||
}
|
||||
|
||||
@@ -202,10 +141,11 @@ export async function buildConnectConfig(
|
||||
): Promise<BuiltConnectConfig> {
|
||||
ensureCoreSshAuthProviders();
|
||||
|
||||
const defaults = PURPOSE_DEFAULTS[options.purpose];
|
||||
const profile = options.profile ?? "background";
|
||||
const defaults = PROFILE_DEFAULTS[profile];
|
||||
const authType = host.authType || "none";
|
||||
const serverHostId = options.serverHostId ?? host.id;
|
||||
const isJumpHost = options.purpose === "jump-host";
|
||||
const isJumpHost = profile === "jump";
|
||||
const terminalConfig = (host.terminalConfig ?? undefined) as
|
||||
Record<string, unknown> | undefined;
|
||||
|
||||
|
||||
@@ -130,19 +130,21 @@ async function prepareKey(
|
||||
return { status: "ready" };
|
||||
}
|
||||
|
||||
export const passwordProvider: SshAuthProvider = {
|
||||
const passwordProvider: SshAuthProvider = {
|
||||
type: "password",
|
||||
pluginId: "core",
|
||||
labelKey: "hosts.filterAuthPassword",
|
||||
quickConnect: true,
|
||||
credentialType: true,
|
||||
requiresSecret: true,
|
||||
prepare: preparePassword,
|
||||
};
|
||||
|
||||
export const keyProvider: SshAuthProvider = {
|
||||
const keyProvider: SshAuthProvider = {
|
||||
type: "key",
|
||||
pluginId: "core",
|
||||
labelKey: "hosts.filterAuthKey",
|
||||
quickConnect: true,
|
||||
credentialType: true,
|
||||
requiresSecret: true,
|
||||
prepare: prepareKey,
|
||||
@@ -153,10 +155,11 @@ export const keyProvider: SshAuthProvider = {
|
||||
* this only runs when that expansion found something odd, like an unresolved
|
||||
* reference. It behaves like whichever secret is present.
|
||||
*/
|
||||
export const credentialProvider: SshAuthProvider = {
|
||||
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);
|
||||
@@ -169,10 +172,11 @@ export const credentialProvider: SshAuthProvider = {
|
||||
},
|
||||
};
|
||||
|
||||
export const agentProvider: SshAuthProvider = {
|
||||
const agentProvider: SshAuthProvider = {
|
||||
type: "agent",
|
||||
pluginId: "core",
|
||||
labelKey: "hosts.filterAuthAgent",
|
||||
quickConnect: true,
|
||||
requiresSecret: true,
|
||||
prepare: async (config, host, env) => {
|
||||
const result = await applyAgentAuth(
|
||||
@@ -187,10 +191,11 @@ export const agentProvider: SshAuthProvider = {
|
||||
},
|
||||
};
|
||||
|
||||
export const noneProvider: SshAuthProvider = {
|
||||
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,
|
||||
@@ -200,7 +205,7 @@ export const noneProvider: SshAuthProvider = {
|
||||
},
|
||||
};
|
||||
|
||||
export const BUILTIN_SSH_AUTH_PROVIDERS: readonly SshAuthProvider[] = [
|
||||
const BUILTIN_SSH_AUTH_PROVIDERS: readonly SshAuthProvider[] = [
|
||||
passwordProvider,
|
||||
keyProvider,
|
||||
credentialProvider,
|
||||
|
||||
@@ -23,6 +23,7 @@ import type {
|
||||
SshAuthLog,
|
||||
SshAuthOutcome,
|
||||
SshConnectHost,
|
||||
SshConnectProfile,
|
||||
SshConnectPurpose,
|
||||
SshPromptChannel,
|
||||
} from "./types.js";
|
||||
@@ -41,7 +42,7 @@ export class SshConnectError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
export class SshHostNotFoundError extends Error {
|
||||
class SshHostNotFoundError extends Error {
|
||||
constructor(readonly hostId: number) {
|
||||
super("Host not found or access denied");
|
||||
this.name = "SshHostNotFoundError";
|
||||
@@ -52,6 +53,8 @@ export interface ConnectHostOptions {
|
||||
/** Acting user. Access and credentials are resolved for them. */
|
||||
userId: string;
|
||||
purpose: SshConnectPurpose;
|
||||
/** Keepalive and timeout defaults. Defaults to "background". */
|
||||
profile?: SshConnectProfile;
|
||||
/** Ask a person for keyboard-interactive answers. Without it, auto-fill. */
|
||||
prompt?: SshPromptChannel;
|
||||
/** Replaces the keyboard-interactive handler entirely. */
|
||||
@@ -100,6 +103,7 @@ export async function connectHost(
|
||||
const { config, provider, outcome, env } = await buildConnectConfig(host, {
|
||||
userId: options.userId,
|
||||
purpose: options.purpose,
|
||||
profile: options.profile,
|
||||
client,
|
||||
hostKeySocket: options.prompt?.hostKeySocket ?? null,
|
||||
interactive: !!options.prompt,
|
||||
|
||||
@@ -11,8 +11,3 @@ export function ensureCoreSshAuthProviders(): void {
|
||||
registered = true;
|
||||
registerBuiltinSshAuthProviders();
|
||||
}
|
||||
|
||||
/** Test helper, pairs with resetSshAuthRegistryForTests. */
|
||||
export function resetCoreSshAuthProvidersForTests(): void {
|
||||
registered = false;
|
||||
}
|
||||
@@ -23,7 +23,7 @@ const FORTI_TOKEN_PATTERN = /type\s+['"]?push['"]?/i;
|
||||
|
||||
// JumpCloud Protect / DUO: a menu choice, then an empty-answerable confirm.
|
||||
// Checked before TOTP because "...or [2] TOTP:" would match that pattern.
|
||||
export const PUSH_PROMPT_PATTERN =
|
||||
const PUSH_PROMPT_PATTERN =
|
||||
/choose.*push.*totp|press enter.*(push|send)|push notification|authentication by phone/i;
|
||||
|
||||
const TOTP_PATTERN =
|
||||
@@ -36,7 +36,7 @@ export interface KeyboardInteractiveRound {
|
||||
}
|
||||
|
||||
/** Stored password for every password prompt, empty for the rest. */
|
||||
export function autoResponses(
|
||||
function autoResponses(
|
||||
prompts: KeyboardInteractivePrompt[],
|
||||
password: string | null | undefined,
|
||||
): string[] {
|
||||
@@ -46,7 +46,7 @@ export function autoResponses(
|
||||
}
|
||||
|
||||
/** Puts the user's answer at one index and auto-fills the rest. */
|
||||
export function responsesWithAnswer(
|
||||
function responsesWithAnswer(
|
||||
prompts: KeyboardInteractivePrompt[],
|
||||
answerIndex: number,
|
||||
answer: string,
|
||||
|
||||
@@ -46,7 +46,7 @@ function findSchemeOwner(scheme: string): SecretResolverOwner | null {
|
||||
: null;
|
||||
}
|
||||
|
||||
export class SecretResolverMissingError extends Error {
|
||||
class SecretResolverMissingError extends Error {
|
||||
readonly code = "SECRET_RESOLVER_MISSING";
|
||||
constructor(
|
||||
readonly scheme: string,
|
||||
|
||||
@@ -21,7 +21,7 @@ import type {
|
||||
SshConnectHost,
|
||||
} from "./types.js";
|
||||
|
||||
export function getHostSocks5Config(host: SshConnectHost): SOCKS5Config | null {
|
||||
function getHostSocks5Config(host: SshConnectHost): SOCKS5Config | null {
|
||||
const chain = Array.isArray(host.socks5ProxyChain)
|
||||
? (host.socks5ProxyChain as ProxyNode[])
|
||||
: [];
|
||||
@@ -36,7 +36,7 @@ export function getHostSocks5Config(host: SshConnectHost): SOCKS5Config | null {
|
||||
};
|
||||
}
|
||||
|
||||
export class SshTransportError extends Error {
|
||||
class SshTransportError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly stage: "cloudflare" | "jump-host" | "jump-forward" | "proxy",
|
||||
|
||||
@@ -2,25 +2,19 @@ import type { Client, ConnectConfig } from "ssh2";
|
||||
import type { WebSocket } from "ws";
|
||||
|
||||
/**
|
||||
* What a connection is for. Picks keepalive and timeout defaults so every
|
||||
* transport keeps the numbers it had before the pipeline existed.
|
||||
* What a connection is for, as a free label ("terminal", "metrics"). Auth
|
||||
* providers see it; core attaches no meaning to any value.
|
||||
*/
|
||||
export type SshConnectPurpose =
|
||||
| "terminal"
|
||||
| "file-manager"
|
||||
| "file-transfer"
|
||||
| "tmux"
|
||||
| "metrics"
|
||||
| "fleet"
|
||||
| "tunnel"
|
||||
| "docker"
|
||||
| "docker-console"
|
||||
| "proxmox"
|
||||
| "snippet"
|
||||
| "credential-deploy"
|
||||
| "jump-host"
|
||||
| "remote-desktop"
|
||||
| "plugin";
|
||||
export type SshConnectPurpose = string;
|
||||
|
||||
/**
|
||||
* Keepalive, timeout and environment defaults for a kind of connection.
|
||||
* terminal: an interactive shell. session: a long-lived browsing session.
|
||||
* stream: a long transfer or console. forward: port forwarding. background:
|
||||
* short exec work such as polling. jump: a hop in a jump chain (core only).
|
||||
*/
|
||||
export type SshConnectProfile =
|
||||
"terminal" | "session" | "stream" | "forward" | "background" | "jump";
|
||||
|
||||
/** The resolved host fields the pipeline reads. */
|
||||
export interface SshConnectHost {
|
||||
@@ -169,12 +163,17 @@ export interface SshAuthProvider {
|
||||
* true; false for types that need a person or a fresh browser sign-in.
|
||||
*/
|
||||
supportsBackground?: boolean;
|
||||
/**
|
||||
* Works for a host that was never saved, so Quick Connect offers it.
|
||||
* Default false.
|
||||
*/
|
||||
quickConnect?: boolean;
|
||||
/** Overrides applied to the base config before prepare runs. */
|
||||
connectOptions?: (
|
||||
host: SshConnectHost,
|
||||
purpose: SshConnectPurpose,
|
||||
) => Partial<MutableConnectConfig>;
|
||||
/** The interaction name this provider's outcomes use, e.g. "opkssh". */
|
||||
/** The interaction name this provider's outcomes use, e.g. "browser-signin". */
|
||||
interaction?: string;
|
||||
/**
|
||||
* Starts the browser step behind an `interaction-required` outcome, for
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
import type { ConnectionStage, LogEntry } from "../../types/connection-log.js";
|
||||
|
||||
export function createConnectionLog(
|
||||
type: "info" | "success" | "warning" | "error",
|
||||
stage: ConnectionStage,
|
||||
message: string,
|
||||
details?: Record<string, unknown>,
|
||||
): Omit<LogEntry, "id" | "timestamp"> {
|
||||
return {
|
||||
type,
|
||||
stage,
|
||||
message,
|
||||
details,
|
||||
};
|
||||
}
|
||||
@@ -32,10 +32,7 @@ export async function deleteOwnedHost(
|
||||
);
|
||||
if (!host) return null;
|
||||
|
||||
// file manager recent/pinned/shortcuts, transfer_recent, command history
|
||||
// and session recordings cascade on the host's refHost() foreign key, as
|
||||
// the file-manager, ssh-terminal and session-recording plugins' adopted
|
||||
// tables.
|
||||
// Plugin rows tied to a host cascade on their refHost() foreign keys.
|
||||
await createCurrentSshCredentialUsageRepository().deleteByHostId(hostId);
|
||||
await createCurrentRecentActivityRepository().deleteByHostId(hostId);
|
||||
await createCurrentRbacAccessRepository().deleteHostAccessForHost(hostId);
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import {
|
||||
getSecretResolver,
|
||||
requireSecretResolver,
|
||||
} from "./connect/secret-resolver-registry.js";
|
||||
import { requireSecretResolver } from "./connect/secret-resolver-registry.js";
|
||||
|
||||
/**
|
||||
* Expands "<scheme>://..." references ("op://vault/item/field" for 1Password
|
||||
@@ -17,7 +14,7 @@ import {
|
||||
* cached briefly in memory; a rotated secret shows up within CACHE_TTL_MS.
|
||||
*/
|
||||
|
||||
export const SECRET_FIELDS = [
|
||||
const SECRET_FIELDS = [
|
||||
"password",
|
||||
"key",
|
||||
"keyPassword",
|
||||
@@ -91,8 +88,3 @@ export async function resolveExternalSecretRefs(
|
||||
host[field] = await resolveSecretReference(userId, value, deps);
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether any plugin currently resolves this reference's scheme. */
|
||||
export function hasSecretResolverFor(reference: string): boolean {
|
||||
return !!getSecretResolver(referenceScheme(reference));
|
||||
}
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
expandOidcUsername,
|
||||
} from "./credential-username.js";
|
||||
import type { SSHHost } from "../../types/index.js";
|
||||
import type { HostAction } from "../utils/permission-manager.js";
|
||||
|
||||
const sshLogger = logger;
|
||||
|
||||
@@ -316,29 +315,3 @@ async function resolveRecipientSshAuth(
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a user has access to a host (owner or shared access).
|
||||
*/
|
||||
export async function checkHostAccess(
|
||||
hostId: number,
|
||||
userId: string,
|
||||
hostUserId: string,
|
||||
requiredPermission: HostAction = "connect",
|
||||
): Promise<boolean> {
|
||||
if (userId === hostUserId) return true;
|
||||
|
||||
try {
|
||||
const { PermissionManager } =
|
||||
await import("../utils/permission-manager.js");
|
||||
const permissionManager = PermissionManager.getInstance();
|
||||
const accessInfo = await permissionManager.canAccessHost(
|
||||
userId,
|
||||
hostId,
|
||||
requiredPermission,
|
||||
);
|
||||
return accessInfo.hasAccess;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -47,7 +47,7 @@ async function resolveJumpHost(
|
||||
}
|
||||
}
|
||||
|
||||
export class JumpHostChainError extends Error {
|
||||
class JumpHostChainError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly hopIndex: number,
|
||||
@@ -161,7 +161,7 @@ export async function createJumpHostChain(
|
||||
// every auth type, not just password, key and agent.
|
||||
const built = await buildConnectConfig(
|
||||
jumpHostConfig as unknown as SshConnectHost,
|
||||
{ userId, purpose: "jump-host", client: jumpClient },
|
||||
{ userId, purpose: "jump-host", profile: "jump", client: jumpClient },
|
||||
);
|
||||
if (built.outcome.status !== "ready") {
|
||||
clearTimeout(timeout);
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
import { getServiceImplementation } from "../plugins/service-registry.js";
|
||||
|
||||
export const LIVE_SESSIONS_SERVICE = "sessions.live";
|
||||
const LIVE_SESSIONS_SERVICE = "sessions.live";
|
||||
const SSH_PROVIDER = "ssh";
|
||||
|
||||
export interface LiveSessionInfo {
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
import type { Client } from "ssh2";
|
||||
import type { SSHHost } from "../../types/index.js";
|
||||
import {
|
||||
connectHost,
|
||||
getConnectionPoolKey,
|
||||
SshConnectError,
|
||||
} from "./connect/connect-host.js";
|
||||
import { buildConnectConfig } from "./connect/build-connect-config.js";
|
||||
import type { MutableConnectConfig, SshConnectHost } from "./connect/types.js";
|
||||
|
||||
/**
|
||||
* Non-interactive SSH connections for background work (fleets, automations,
|
||||
* AI tools). A thin wrapper over the connect pipeline, kept so the pool keys
|
||||
* and call sites stay the same.
|
||||
*/
|
||||
|
||||
export function getFleetPoolKey(host: SSHHost): string {
|
||||
return getConnectionPoolKey("fleet", host as unknown as SshConnectHost);
|
||||
}
|
||||
|
||||
export async function buildFleetSshConfig(
|
||||
host: SSHHost,
|
||||
client?: Client,
|
||||
): Promise<MutableConnectConfig> {
|
||||
const { Client: SshClient } = await import("ssh2");
|
||||
const built = await buildConnectConfig(host as unknown as SshConnectHost, {
|
||||
userId: host.userId || "",
|
||||
purpose: "fleet",
|
||||
client: client ?? new SshClient(),
|
||||
});
|
||||
if (built.outcome.status !== "ready") {
|
||||
throw new SshConnectError(built.outcome);
|
||||
}
|
||||
return built.config;
|
||||
}
|
||||
|
||||
export function createFleetSshFactory(host: SSHHost): () => Promise<Client> {
|
||||
return async () => {
|
||||
const connection = await connectHost(host as unknown as SshConnectHost, {
|
||||
userId: host.userId || "",
|
||||
purpose: "fleet",
|
||||
});
|
||||
return connection.client;
|
||||
};
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import dns from "dns/promises";
|
||||
import net from "net";
|
||||
|
||||
export const SSH_DNS_RETRY_DELAYS_MS = [250, 750, 1500];
|
||||
const SSH_DNS_RETRY_DELAYS_MS = [250, 750, 1500];
|
||||
|
||||
type Lookup = typeof dns.lookup;
|
||||
type Sleep = (ms: number) => Promise<void>;
|
||||
|
||||
@@ -1,151 +0,0 @@
|
||||
export type TransferPlatform = "windows" | "unix";
|
||||
|
||||
/** OpenSSH SFTP on Windows commonly uses `/C:/...` or `C:/...` style paths. */
|
||||
export function isWindowsSftpPath(path: string): boolean {
|
||||
const normalized = path.replace(/\\/g, "/");
|
||||
return /^[A-Za-z]:\//.test(normalized) || /^\/[A-Za-z]:\//.test(normalized);
|
||||
}
|
||||
|
||||
export function inferPlatformFromPath(path: string): TransferPlatform | null {
|
||||
if (isWindowsSftpPath(path)) return "windows";
|
||||
if (path.startsWith("/") && !/^\/[A-Za-z]:/.test(path.replace(/\\/g, "/"))) {
|
||||
return "unix";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function normalizeSftpPath(path: string): string {
|
||||
const normalized = path.replace(/\\/g, "/").replace(/\/+$/, "");
|
||||
if (!normalized) return path.startsWith("/") ? "/" : ".";
|
||||
return normalized.replace(/([^:])\/+/g, "$1/");
|
||||
}
|
||||
|
||||
export function basename(path: string): string {
|
||||
const normalized = path.replace(/\\/g, "/").replace(/\/+$/, "");
|
||||
const idx = normalized.lastIndexOf("/");
|
||||
if (idx < 0) return normalized;
|
||||
return normalized.substring(idx + 1) || normalized;
|
||||
}
|
||||
|
||||
export function dirname(path: string): string {
|
||||
const normalized = path.replace(/\\/g, "/").replace(/\/+$/, "");
|
||||
if (!normalized) return normalized;
|
||||
|
||||
const idx = normalized.lastIndexOf("/");
|
||||
if (idx < 0) return normalized;
|
||||
|
||||
const parent = normalized.substring(0, idx);
|
||||
if (!parent) return "/";
|
||||
if (/^\/[A-Za-z]:$/.test(parent) || /^[A-Za-z]:$/.test(parent)) {
|
||||
return parent;
|
||||
}
|
||||
return parent || "/";
|
||||
}
|
||||
|
||||
export function joinPath(base: string, ...parts: string[]): string {
|
||||
let result = base.replace(/\\/g, "/");
|
||||
for (const part of parts) {
|
||||
if (!part) continue;
|
||||
const segment = part.replace(/\\/g, "/").replace(/^\/+/, "");
|
||||
if (!segment) continue;
|
||||
if (result.endsWith("/")) {
|
||||
result += segment;
|
||||
} else {
|
||||
result = `${result}/${segment}`;
|
||||
}
|
||||
}
|
||||
return normalizeSftpPath(result);
|
||||
}
|
||||
|
||||
export interface PathSegments {
|
||||
/** Drive root prefix, e.g. `/C:` or `C:`; empty for Unix absolute paths. */
|
||||
root: string;
|
||||
/** Path segments below the root (may be empty). */
|
||||
segments: string[];
|
||||
}
|
||||
|
||||
export function splitPathSegments(path: string): PathSegments {
|
||||
const normalized = normalizeSftpPath(path);
|
||||
|
||||
const posixDrive = normalized.match(/^(\/[A-Za-z]:)(?:\/(.*))?$/);
|
||||
if (posixDrive) {
|
||||
return {
|
||||
root: posixDrive[1],
|
||||
segments: (posixDrive[2] ?? "").split("/").filter(Boolean),
|
||||
};
|
||||
}
|
||||
|
||||
const drive = normalized.match(/^([A-Za-z]:)(?:\/(.*))?$/);
|
||||
if (drive) {
|
||||
return {
|
||||
root: drive[1],
|
||||
segments: (drive[2] ?? "").split("/").filter(Boolean),
|
||||
};
|
||||
}
|
||||
|
||||
if (normalized.startsWith("/")) {
|
||||
return {
|
||||
root: "/",
|
||||
segments: normalized.split("/").filter(Boolean),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
root: "",
|
||||
segments: normalized.split("/").filter(Boolean),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildPathFromSegments(
|
||||
root: string,
|
||||
segments: string[],
|
||||
count: number,
|
||||
): string {
|
||||
const slice = segments.slice(0, count);
|
||||
if (/^\/[A-Za-z]:$/.test(root)) {
|
||||
return slice.length ? `${root}/${slice.join("/")}` : root;
|
||||
}
|
||||
if (/^[A-Za-z]:$/.test(root)) {
|
||||
return slice.length ? `${root}/${slice.join("/")}` : root;
|
||||
}
|
||||
if (root === "/") {
|
||||
return slice.length ? `/${slice.join("/")}` : "/";
|
||||
}
|
||||
if (root === "") {
|
||||
return slice.join("/");
|
||||
}
|
||||
return slice.length ? `${root}/${slice.join("/")}` : root;
|
||||
}
|
||||
|
||||
export function pathsOverlap(source: string, dest: string): boolean {
|
||||
const norm = (p: string) => {
|
||||
const n = normalizeSftpPath(p).toLowerCase();
|
||||
return n || (isWindowsSftpPath(p) ? p : "/");
|
||||
};
|
||||
const s = norm(source);
|
||||
const d = norm(dest);
|
||||
const sep = "/";
|
||||
return s === d || s.startsWith(`${d}${sep}`) || d.startsWith(`${s}${sep}`);
|
||||
}
|
||||
|
||||
export function getWorkingDir(paths: string[]): string {
|
||||
if (paths.length === 0) return "/";
|
||||
const parents = paths.map((p) => dirname(p));
|
||||
const first = parents[0];
|
||||
if (parents.every((p) => p === first)) {
|
||||
return first;
|
||||
}
|
||||
return first;
|
||||
}
|
||||
|
||||
/** Convert an SFTP path to a local filesystem path when Termix runs on the dest host. */
|
||||
export function sftpPathToLocalPath(sftpPath: string): string {
|
||||
const normalized = sftpPath.replace(/\\/g, "/");
|
||||
if (/^\/[A-Za-z]:\//.test(normalized)) {
|
||||
return normalized.slice(1).replace(/\//g, "\\");
|
||||
}
|
||||
if (/^[A-Za-z]:\//.test(normalized)) {
|
||||
return normalized.replace(/\//g, "\\");
|
||||
}
|
||||
return sftpPath;
|
||||
}
|
||||
Reference in new issue
Block a user