217 lines · 6.9 KB · TypeScript
RawDownload
import { HostProcessPlatform } from "@t3tools/shared/hostProcess";import * as Config from "effect/Config";import * as Context from "effect/Context";import * as Effect from "effect/Effect";import * as FileSystem from "effect/FileSystem";import * as Layer from "effect/Layer";import * as Option from "effect/Option";import * as Path from "effect/Path";import * as PlatformError from "effect/PlatformError"; import { SshPasswordPromptError } from "./errors.ts"; export interface SshPasswordRequest {  readonly destination: string;  readonly username: string | null;  readonly prompt: string;  readonly attempt: number;} export interface SshAskpassFile {  readonly path: string;  readonly contents: string;  readonly mode?: number;} export interface SshAskpassHelperDescriptor {  readonly launcherPath: string;  readonly files: ReadonlyArray<SshAskpassFile>;} export interface SshAuthOptions {  readonly authSecret?: string | null;  readonly batchMode?: "yes" | "no";  readonly interactiveAuth?: boolean;} export interface SshPasswordPromptShape {  readonly isAvailable: boolean;  readonly request: (    request: SshPasswordRequest,  ) => Effect.Effect<string | null, SshPasswordPromptError>;} export class SshPasswordPrompt extends Context.Service<SshPasswordPrompt, SshPasswordPromptShape>()(  "@t3tools/ssh/auth/SshPasswordPrompt",) {  static readonly disabledLayer = Layer.succeed(    SshPasswordPrompt,    SshPasswordPrompt.of({      isAvailable: false,      request: () => Effect.succeed(null),    }),  );} export interface SshChildEnvironmentOptions {  readonly interactiveAuth?: boolean;  readonly baseEnv?: NodeJS.ProcessEnv;  readonly askpassDirectory?: string;  readonly authSecret?: string | null;} const SSH_ASKPASS_DIR_NAME = "t3code-ssh-askpass"; function joinSshAskpassPath(  directory: string,  fileName: string,  platform: NodeJS.Platform,): string {  const trimmed = directory.replace(/[\\/]+$/u, "");  return platform === "win32" ? `${trimmed}\\${fileName}` : `${trimmed}/${fileName}`;} export const ASKPASS_POSIX_SCRIPT = `#!/bin/sh# Invoked by ssh via SSH_ASKPASS when T3 Code re-runs ssh with a cached password# from the renderer's in-app prompt. We never expose a native dialog here - if# T3_SSH_AUTH_SECRET is missing, that's a caller bug and we fail loudly.if [ "\${T3_SSH_AUTH_SECRET+x}" = "x" ]; then  printf "%s\\n" "$T3_SSH_AUTH_SECRET"  exit 0fiprintf 'T3 Code ssh-askpass invoked without T3_SSH_AUTH_SECRET.\\n' >&2exit 1`; export const ASKPASS_WINDOWS_LAUNCHER_SCRIPT = `@echo off\rpowershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0ssh-askpass.ps1" %*\r`; export const ASKPASS_WINDOWS_SCRIPT = `# Invoked by ssh via SSH_ASKPASS (through ssh-askpass.cmd) when T3 Code re-runs\r# ssh with a cached password from the renderer's in-app prompt. We never expose\r# a native dialog here - if T3_SSH_AUTH_SECRET is missing, that's a caller bug\r# and we fail loudly.\rif ($null -ne $env:T3_SSH_AUTH_SECRET) {\r  [Console]::Out.WriteLine($env:T3_SSH_AUTH_SECRET)\r  exit 0\r}\r[Console]::Error.WriteLine("T3 Code ssh-askpass invoked without T3_SSH_AUTH_SECRET.")\rexit 1\r`; export const getDefaultSshAskpassDirectory = Effect.fn("ssh/auth.getDefaultSshAskpassDirectory")(  function* () {    const fs = yield* FileSystem.FileSystem;    const path = yield* Path.Path;    const parentDirectory = yield* fs.makeTempDirectory({ prefix: "t3code-ssh-runtime-" });    return path.join(parentDirectory, SSH_ASKPASS_DIR_NAME);  },); export const buildSshAskpassHelperDescriptor = Effect.fn(  "ssh/auth.buildSshAskpassHelperDescriptor",)(function* (input: {  readonly directory: string;}): Effect.fn.Return<SshAskpassHelperDescriptor, never, Path.Path> {  const platform = yield* HostProcessPlatform;  const path = yield* Path.Path;  const directory = input.directory;   if (platform === "win32") {    const powershellPath = joinSshAskpassPath(directory, "ssh-askpass.ps1", platform);    return {      launcherPath: joinSshAskpassPath(directory, "ssh-askpass.cmd", platform),      files: [        {          path: joinSshAskpassPath(directory, "ssh-askpass.cmd", platform),          contents: ASKPASS_WINDOWS_LAUNCHER_SCRIPT,        },        {          path: powershellPath,          contents: ASKPASS_WINDOWS_SCRIPT,        },      ],    };  }   return {    launcherPath: path.join(directory, "ssh-askpass.sh"),    files: [      {        path: path.join(directory, "ssh-askpass.sh"),        contents: ASKPASS_POSIX_SCRIPT,        mode: 0o700,      },    ],  };}); export const ensureSshAskpassHelpers = Effect.fn("ssh/auth.ensureSshAskpassHelpers")(  function* (input: {    readonly directory: string;  }): Effect.fn.Return<string, PlatformError.PlatformError, FileSystem.FileSystem | Path.Path> {    const fs = yield* FileSystem.FileSystem;    const path = yield* Path.Path;    const descriptor = yield* buildSshAskpassHelperDescriptor(input);    const platform = yield* HostProcessPlatform;     yield* fs.makeDirectory(path.dirname(descriptor.launcherPath), { recursive: true });     for (const file of descriptor.files) {      const existing = yield* fs.exists(file.path);      const current = existing ? yield* fs.readFileString(file.path) : null;      if (current !== file.contents) {        yield* fs.writeFileString(file.path, file.contents);      }      if (file.mode !== undefined && platform !== "win32") {        yield* fs.chmod(file.path, file.mode);      }    }     return descriptor.launcherPath;  },); export const buildSshChildEnvironment = Effect.fn("ssh/auth.buildSshChildEnvironment")(function* (  input: SshChildEnvironmentOptions = {},): Effect.fn.Return<  NodeJS.ProcessEnv,  PlatformError.PlatformError,  FileSystem.FileSystem | Path.Path> {  const baseEnv = { ...input.baseEnv };  if (!input.interactiveAuth) {    return baseEnv;  }   const platform = yield* HostProcessPlatform;  const hostDisplay = input.baseEnv    ? input.baseEnv.DISPLAY    : yield* Config.string("DISPLAY").pipe(        Config.option,        Effect.orElseSucceed(() => Option.none<string>()),        Effect.map(Option.getOrUndefined),      );  const directory = input.askpassDirectory ?? (yield* getDefaultSshAskpassDirectory());  const sshAskpass = yield* ensureSshAskpassHelpers({ directory });   return {    ...baseEnv,    SSH_ASKPASS: sshAskpass,    SSH_ASKPASS_REQUIRE: "force",    ...(input.authSecret === undefined ? {} : { T3_SSH_AUTH_SECRET: input.authSecret ?? "" }),    ...(platform === "win32" || baseEnv.DISPLAY || hostDisplay ? {} : { DISPLAY: "t3code" }),  };}); export function isSshAuthFailure(error: unknown): boolean {  const message = error instanceof Error ? error.message : String(error);  const normalized = message.toLowerCase();  return (    /permission denied \((?:publickey|password|keyboard-interactive|hostbased|gssapi-with-mic)[^)]*\)/u.test(      normalized,    ) ||    /authentication failed/u.test(normalized) ||    /too many authentication failures/u.test(normalized)  );}