| 1 | import type { DesktopSshEnvironmentTarget } from "@t3tools/contracts"; |
| 2 | import * as HostProcess from "@t3tools/shared/HostProcess"; |
| 3 | import * as Crypto from "effect/Crypto"; |
| 4 | import * as Duration from "effect/Duration"; |
| 5 | import * as Effect from "effect/Effect"; |
| 6 | import * as Hex from "effect/encoding/Hex"; |
| 7 | import * as FileSystem from "effect/FileSystem"; |
| 8 | import * as Option from "effect/Option"; |
| 9 | import * as Path from "effect/Path"; |
| 10 | import * as Scope from "effect/Scope"; |
| 11 | import * as Stream from "effect/Stream"; |
| 12 | import { ChildProcess, ChildProcessSpawner } from "effect/process"; |
| 13 | |
| 14 | import { buildSshChildEnvironment, type SshAuthOptions } from "./auth.ts"; |
| 15 | import { SshCommandError, SshInvalidTargetError } from "./errors.ts"; |
| 16 | |
| 17 | const DEFAULT_SSH_COMMAND_TIMEOUT_MS = 60_000; |
| 18 | const MAX_SSH_ERROR_OUTPUT_LENGTH = 4_000; |
| 19 | |
| 20 | /** |
| 21 | * ssh is a real executable everywhere (`ssh.exe` on Windows), so it is always |
| 22 | * spawned directly — cmd.exe shell mode would re-tokenize arguments such as |
| 23 | * identity-file paths containing spaces. |
| 24 | */ |
| 25 | const sshCommandForPlatform = (platform: NodeJS.Platform): string => |
| 26 | platform === "win32" ? "ssh.exe" : "ssh"; |
| 27 | |
| 28 | export const resolveSshCommand = Effect.map(HostProcess.Platform, sshCommandForPlatform); |
| 29 | |
| 30 | const encoder = new TextEncoder(); |
| 31 | |
| 32 | export interface SshCommandResult { |
| 33 | readonly stdout: string; |
| 34 | readonly stderr: string; |
| 35 | } |
| 36 | |
| 37 | export interface RunSshCommandOptions extends SshAuthOptions { |
| 38 | readonly preHostArgs?: ReadonlyArray<string>; |
| 39 | readonly remoteCommandArgs?: ReadonlyArray<string>; |
| 40 | readonly stdin?: string; |
| 41 | readonly timeoutMs?: number; |
| 42 | } |
| 43 | |
| 44 | export function parseSshResolveOutput(alias: string, stdout: string): DesktopSshEnvironmentTarget { |
| 45 | const values = new Map<string, string>(); |
| 46 | for (const line of stdout.split(/\r?\n/u)) { |
| 47 | const trimmed = line.trim(); |
| 48 | if (trimmed.length === 0) { |
| 49 | continue; |
| 50 | } |
| 51 | const [key, ...rest] = trimmed.split(/\s+/u); |
| 52 | if (!key || rest.length === 0 || values.has(key)) { |
| 53 | continue; |
| 54 | } |
| 55 | values.set(key, rest.join(" ").trim()); |
| 56 | } |
| 57 | |
| 58 | const hostname = values.get("hostname")?.trim() || alias; |
| 59 | const username = values.get("user")?.trim() || null; |
| 60 | const rawPort = values.get("port")?.trim() ?? ""; |
| 61 | const parsedPort = Number.parseInt(rawPort, 10); |
| 62 | |
| 63 | return { |
| 64 | alias, |
| 65 | hostname, |
| 66 | username, |
| 67 | port: Number.isInteger(parsedPort) ? parsedPort : null, |
| 68 | }; |
| 69 | } |
| 70 | |
| 71 | export function targetConnectionKey(target: DesktopSshEnvironmentTarget): string { |
| 72 | return `${target.alias}\u0000${target.hostname}\u0000${target.username ?? ""}\u0000${target.port ?? ""}`; |
| 73 | } |
| 74 | |
| 75 | /** Names the remote state directory for a target: the first 16 hex chars of its SHA-256 key. */ |
| 76 | export const remoteStateKey = Effect.fn("ssh/command.remoteStateKey")(function* ( |
| 77 | target: DesktopSshEnvironmentTarget, |
| 78 | ): Effect.fn.Return<string, never, Crypto.Crypto> { |
| 79 | const crypto = yield* Crypto.Crypto; |
| 80 | const digest = yield* crypto |
| 81 | .digest("SHA-256", encoder.encode(targetConnectionKey(target))) |
| 82 | .pipe(Effect.orDie); |
| 83 | return Hex.encode(digest).slice(0, 16); |
| 84 | }); |
| 85 | |
| 86 | function buildSshHostSpec(target: DesktopSshEnvironmentTarget): string { |
| 87 | const destination = target.alias.trim() || target.hostname.trim(); |
| 88 | if (destination.length === 0) { |
| 89 | throw new Error("SSH target is missing its alias/hostname."); |
| 90 | } |
| 91 | return target.username ? `${target.username}@${destination}` : destination; |
| 92 | } |
| 93 | |
| 94 | export const buildSshHostSpecEffect = ( |
| 95 | target: DesktopSshEnvironmentTarget, |
| 96 | ): Effect.Effect<string, SshInvalidTargetError> => |
| 97 | Effect.try({ |
| 98 | try: () => buildSshHostSpec(target), |
| 99 | catch: (cause) => |
| 100 | new SshInvalidTargetError({ |
| 101 | message: cause instanceof Error ? cause.message : "SSH target is invalid.", |
| 102 | }), |
| 103 | }); |
| 104 | |
| 105 | export function baseSshArgs( |
| 106 | target: DesktopSshEnvironmentTarget, |
| 107 | input?: { readonly batchMode?: "yes" | "no" }, |
| 108 | ): string[] { |
| 109 | return [ |
| 110 | "-o", |
| 111 | `BatchMode=${input?.batchMode ?? "no"}`, |
| 112 | "-o", |
| 113 | "ConnectTimeout=10", |
| 114 | ...(target.port !== null ? ["-p", String(target.port)] : []), |
| 115 | ]; |
| 116 | } |
| 117 | |
| 118 | export function getLastNonEmptyOutputLine(stdout: string): string | null { |
| 119 | return ( |
| 120 | stdout |
| 121 | .trim() |
| 122 | .split(/\r?\n/u) |
| 123 | .map((entry) => entry.trim()) |
| 124 | .findLast((entry) => entry.length > 0) ?? null |
| 125 | ); |
| 126 | } |
| 127 | |
| 128 | export const collectProcessOutput = <E>( |
| 129 | stream: Stream.Stream<Uint8Array, E>, |
| 130 | ): Effect.Effect<string, E> => |
| 131 | stream.pipe( |
| 132 | Stream.decodeText(), |
| 133 | Stream.runFold( |
| 134 | () => "", |
| 135 | (acc, chunk) => acc + chunk, |
| 136 | ), |
| 137 | ); |
| 138 | |
| 139 | function redactSshErrorOutput(output: string): string { |
| 140 | const redacted = output.replace( |
| 141 | /("(?:access_token|bearerToken|credential|pairingToken|token)"\s*:\s*")[^"]+(")/giu, |
| 142 | "$1[redacted]$2", |
| 143 | ); |
| 144 | return redacted.length > MAX_SSH_ERROR_OUTPUT_LENGTH |
| 145 | ? `${redacted.slice(0, MAX_SSH_ERROR_OUTPUT_LENGTH)}\n[truncated]` |
| 146 | : redacted; |
| 147 | } |
| 148 | |
| 149 | function normalizeSshErrorMessage(input: { |
| 150 | readonly stdout?: string; |
| 151 | readonly stderr: string; |
| 152 | readonly fallbackMessage: string; |
| 153 | }): string { |
| 154 | const cleanedStderr = input.stderr.trim(); |
| 155 | if (cleanedStderr.length > 0) { |
| 156 | return cleanedStderr; |
| 157 | } |
| 158 | |
| 159 | const cleanedStdout = input.stdout?.trim() ?? ""; |
| 160 | return cleanedStdout.length > 0 ? cleanedStdout : input.fallbackMessage; |
| 161 | } |
| 162 | |
| 163 | function sshTargetLogFields(target: DesktopSshEnvironmentTarget) { |
| 164 | return { |
| 165 | alias: target.alias, |
| 166 | hostname: target.hostname, |
| 167 | username: target.username, |
| 168 | port: target.port, |
| 169 | }; |
| 170 | } |
| 171 | |
| 172 | function stdinStream(input: string | undefined) { |
| 173 | return input === undefined ? Stream.empty : Stream.make(encoder.encode(input)); |
| 174 | } |
| 175 | |
| 176 | const runSshCommandInScope = Effect.fn("ssh/command.runSshCommand.inScope")(function* ( |
| 177 | target: DesktopSshEnvironmentTarget, |
| 178 | input: RunSshCommandOptions, |
| 179 | commandScope: Scope.Scope, |
| 180 | ): Effect.fn.Return< |
| 181 | SshCommandResult, |
| 182 | SshCommandError | SshInvalidTargetError, |
| 183 | ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | Path.Path |
| 184 | > { |
| 185 | const hostSpec = yield* buildSshHostSpecEffect(target); |
| 186 | const environment = yield* buildSshChildEnvironment({ |
| 187 | ...(input.interactiveAuth === undefined ? {} : { interactiveAuth: input.interactiveAuth }), |
| 188 | ...(input.authSecret === undefined ? {} : { authSecret: input.authSecret }), |
| 189 | }).pipe( |
| 190 | Effect.mapError( |
| 191 | (cause) => |
| 192 | new SshCommandError({ |
| 193 | command: ["ssh"], |
| 194 | exitCode: null, |
| 195 | stderr: "", |
| 196 | message: "Failed to prepare SSH authentication helpers.", |
| 197 | cause, |
| 198 | }), |
| 199 | ), |
| 200 | ); |
| 201 | const args = [ |
| 202 | ...baseSshArgs(target, { |
| 203 | batchMode: input.batchMode ?? (input.interactiveAuth ? "no" : "yes"), |
| 204 | }), |
| 205 | ...(input.preHostArgs ?? []), |
| 206 | hostSpec, |
| 207 | ...(input.remoteCommandArgs ?? []), |
| 208 | ]; |
| 209 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 210 | const sshCommand = yield* resolveSshCommand; |
| 211 | yield* Effect.logDebug("ssh.command.start", { |
| 212 | ...sshTargetLogFields(target), |
| 213 | command: [sshCommand, ...args], |
| 214 | hasStdin: input.stdin !== undefined, |
| 215 | timeoutMs: input.timeoutMs ?? DEFAULT_SSH_COMMAND_TIMEOUT_MS, |
| 216 | }); |
| 217 | const child = yield* spawner |
| 218 | .spawn( |
| 219 | ChildProcess.make(sshCommand, args, { |
| 220 | env: environment, |
| 221 | extendEnv: true, |
| 222 | stdin: { |
| 223 | stream: stdinStream(input.stdin), |
| 224 | endOnDone: true, |
| 225 | }, |
| 226 | }), |
| 227 | ) |
| 228 | .pipe( |
| 229 | Effect.provideService(Scope.Scope, commandScope), |
| 230 | Effect.mapError( |
| 231 | (cause) => |
| 232 | new SshCommandError({ |
| 233 | command: [sshCommand, ...args], |
| 234 | exitCode: null, |
| 235 | stderr: "", |
| 236 | message: |
| 237 | cause instanceof Error |
| 238 | ? cause.message |
| 239 | : `Failed to spawn SSH command for ${hostSpec}.`, |
| 240 | cause, |
| 241 | }), |
| 242 | ), |
| 243 | ); |
| 244 | |
| 245 | const [stdout, stderr, exitCode] = yield* Effect.all( |
| 246 | [ |
| 247 | collectProcessOutput(child.stdout), |
| 248 | collectProcessOutput(child.stderr), |
| 249 | child.exitCode.pipe(Effect.map(Number)), |
| 250 | ], |
| 251 | { concurrency: "unbounded" }, |
| 252 | ).pipe( |
| 253 | Effect.mapError( |
| 254 | (cause) => |
| 255 | new SshCommandError({ |
| 256 | command: ["ssh", ...args], |
| 257 | exitCode: null, |
| 258 | stderr: "", |
| 259 | message: |
| 260 | cause instanceof Error ? cause.message : `Failed to run SSH command for ${hostSpec}.`, |
| 261 | cause, |
| 262 | }), |
| 263 | ), |
| 264 | ); |
| 265 | |
| 266 | if (exitCode !== 0) { |
| 267 | const diagnosticStdout = redactSshErrorOutput(stdout); |
| 268 | yield* Effect.logWarning("ssh.command.failed", { |
| 269 | ...sshTargetLogFields(target), |
| 270 | command: ["ssh", ...args], |
| 271 | exitCode, |
| 272 | stdout: diagnosticStdout, |
| 273 | stderr, |
| 274 | }); |
| 275 | return yield* new SshCommandError({ |
| 276 | command: ["ssh", ...args], |
| 277 | exitCode, |
| 278 | stdout: diagnosticStdout, |
| 279 | stderr, |
| 280 | message: normalizeSshErrorMessage({ |
| 281 | stdout: diagnosticStdout, |
| 282 | stderr, |
| 283 | fallbackMessage: `SSH command failed for ${hostSpec} (exit ${exitCode}).`, |
| 284 | }), |
| 285 | }); |
| 286 | } |
| 287 | |
| 288 | yield* Effect.logDebug("ssh.command.succeeded", { |
| 289 | ...sshTargetLogFields(target), |
| 290 | command: ["ssh", ...args], |
| 291 | }); |
| 292 | return { stdout, stderr }; |
| 293 | }); |
| 294 | |
| 295 | export const runSshCommand = Effect.fn("ssh/command.runSshCommand")(function* ( |
| 296 | target: DesktopSshEnvironmentTarget, |
| 297 | input: RunSshCommandOptions = {}, |
| 298 | ): Effect.fn.Return< |
| 299 | SshCommandResult, |
| 300 | SshCommandError | SshInvalidTargetError, |
| 301 | ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | Path.Path |
| 302 | > { |
| 303 | return yield* Effect.scopedWith((commandScope) => |
| 304 | runSshCommandInScope(target, input, commandScope), |
| 305 | ).pipe( |
| 306 | Effect.timeoutOption(Duration.millis(input.timeoutMs ?? DEFAULT_SSH_COMMAND_TIMEOUT_MS)), |
| 307 | Effect.flatMap((result) => |
| 308 | Option.match(result, { |
| 309 | onSome: Effect.succeed, |
| 310 | onNone: () => |
| 311 | Effect.gen(function* () { |
| 312 | yield* Effect.logWarning("ssh.command.timedOut", { |
| 313 | ...sshTargetLogFields(target), |
| 314 | timeoutMs: input.timeoutMs ?? DEFAULT_SSH_COMMAND_TIMEOUT_MS, |
| 315 | remoteCommandArgs: input.remoteCommandArgs ?? [], |
| 316 | preHostArgs: input.preHostArgs ?? [], |
| 317 | hasStdin: input.stdin !== undefined, |
| 318 | }); |
| 319 | return yield* new SshCommandError({ |
| 320 | command: ["ssh"], |
| 321 | exitCode: null, |
| 322 | stderr: "", |
| 323 | message: `SSH command timed out after ${input.timeoutMs ?? DEFAULT_SSH_COMMAND_TIMEOUT_MS}ms.`, |
| 324 | }); |
| 325 | }), |
| 326 | }), |
| 327 | ), |
| 328 | ); |
| 329 | }); |
| 330 | |
| 331 | export const resolveSshTarget = Effect.fn("ssh/command.resolveSshTarget")(function* ( |
| 332 | alias: string, |
| 333 | ): Effect.fn.Return< |
| 334 | DesktopSshEnvironmentTarget, |
| 335 | SshCommandError | SshInvalidTargetError, |
| 336 | ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | Path.Path |
| 337 | > { |
| 338 | const trimmedAlias = alias.trim(); |
| 339 | if (trimmedAlias.length === 0) { |
| 340 | return yield* new SshInvalidTargetError({ message: "SSH host alias is required." }); |
| 341 | } |
| 342 | |
| 343 | yield* Effect.logDebug("ssh.target.resolve.start", { alias: trimmedAlias }); |
| 344 | return yield* runSshCommand( |
| 345 | { |
| 346 | alias: trimmedAlias, |
| 347 | hostname: trimmedAlias, |
| 348 | username: null, |
| 349 | port: null, |
| 350 | }, |
| 351 | { preHostArgs: ["-G"] }, |
| 352 | ).pipe( |
| 353 | Effect.map((result) => parseSshResolveOutput(trimmedAlias, result.stdout)), |
| 354 | Effect.tap((target) => |
| 355 | Effect.logDebug("ssh.target.resolve.succeeded", sshTargetLogFields(target)), |
| 356 | ), |
| 357 | Effect.catch((cause) => |
| 358 | Effect.logDebug("ssh.target.resolve.fallback", { alias: trimmedAlias, cause }).pipe( |
| 359 | Effect.as({ |
| 360 | alias: trimmedAlias, |
| 361 | hostname: trimmedAlias, |
| 362 | username: null, |
| 363 | port: null, |
| 364 | }), |
| 365 | ), |
| 366 | ), |
| 367 | ); |
| 368 | }); |