| 1 | import * as Data from "effect/Data"; |
| 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as FileSystem from "effect/FileSystem"; |
| 4 | import * as Layer from "effect/Layer"; |
| 5 | import * as Path from "effect/Path"; |
| 6 | import * as Redacted from "effect/Redacted"; |
| 7 | import type * as Scope from "effect/Scope"; |
| 8 | import * as Stream from "effect/Stream"; |
| 9 | import * as ChildProcess from "effect/process/ChildProcess"; |
| 10 | import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; |
| 11 | import * as Binding from "../Binding.ts"; |
| 12 | import type { Server } from "./Server.ts"; |
| 13 | |
| 14 | export class SshError extends Data.TaggedError("Hetzner.SshError")<{ |
| 15 | message: string; |
| 16 | host?: string; |
| 17 | command?: string; |
| 18 | code?: number; |
| 19 | stderr?: string; |
| 20 | }> {} |
| 21 | |
| 22 | export interface SshExecResult { |
| 23 | stdout: string; |
| 24 | stderr: string; |
| 25 | code: number; |
| 26 | } |
| 27 | |
| 28 | export interface SshOptions { |
| 29 | /** SSH user. @default "root" */ |
| 30 | user?: string; |
| 31 | /** PKCS8 PEM private key. Defaults to the Server's deploy key. */ |
| 32 | privateKey?: string | Redacted.Redacted<string>; |
| 33 | } |
| 34 | |
| 35 | export type SshServices = |
| 36 | | FileSystem.FileSystem |
| 37 | | Path.Path |
| 38 | | ChildProcessSpawner.ChildProcessSpawner |
| 39 | | Scope.Scope; |
| 40 | |
| 41 | export interface SshClient { |
| 42 | exec: ( |
| 43 | command: string, |
| 44 | ) => Effect.Effect<SshExecResult, SshError, SshServices>; |
| 45 | scp: ( |
| 46 | local: string | Uint8Array<ArrayBufferLike>, |
| 47 | remote: string, |
| 48 | ) => Effect.Effect<void, SshError, SshServices>; |
| 49 | } |
| 50 | |
| 51 | /** |
| 52 | * SSH exec/scp against a Hetzner Server. Uses the Server's Alchemy-managed |
| 53 | * deploy key (injected at create) unless `privateKey` is passed. |
| 54 | * |
| 55 | * ### Remote access |
| 56 | * **Example:** Exec a command |
| 57 | * ```typescript |
| 58 | * const ssh = yield* Hetzner.Ssh(server); |
| 59 | * const { stdout } = yield* ssh.exec("uname -a"); |
| 60 | * ``` |
| 61 | * |
| 62 | * **Example:** Copy a file |
| 63 | * ```typescript |
| 64 | * const ssh = yield* Hetzner.Ssh(server); |
| 65 | * yield* ssh.scp("/tmp/app.zip", "/opt/app/bundle.zip"); |
| 66 | * ``` |
| 67 | * |
| 68 | * @binding |
| 69 | * @product Server |
| 70 | */ |
| 71 | export interface Ssh extends Binding.Service< |
| 72 | Ssh, |
| 73 | "Hetzner.Ssh", |
| 74 | ( |
| 75 | server: Server, |
| 76 | options?: SshOptions, |
| 77 | ) => Effect.Effect<SshClient, SshError, SshServices> |
| 78 | > {} |
| 79 | |
| 80 | export const Ssh = Binding.Service<Ssh>("Hetzner.Ssh"); |
| 81 | |
| 82 | const unwrapKey = (value: unknown): string | undefined => { |
| 83 | if (value === undefined || value === null) return undefined; |
| 84 | if (typeof value === "string") return value; |
| 85 | if (Redacted.isRedacted(value)) { |
| 86 | const inner = Redacted.value(value); |
| 87 | return typeof inner === "string" ? inner : undefined; |
| 88 | } |
| 89 | return undefined; |
| 90 | }; |
| 91 | |
| 92 | const ipv4Of = (server: Server): string | undefined => { |
| 93 | const value = (server as { ipv4?: unknown }).ipv4; |
| 94 | return typeof value === "string" && value.length > 0 ? value : undefined; |
| 95 | }; |
| 96 | |
| 97 | const sshArgs = (keyPath: string): string[] => [ |
| 98 | "-i", |
| 99 | keyPath, |
| 100 | "-o", |
| 101 | "StrictHostKeyChecking=no", |
| 102 | "-o", |
| 103 | "UserKnownHostsFile=/dev/null", |
| 104 | "-o", |
| 105 | "IdentitiesOnly=yes", |
| 106 | "-o", |
| 107 | "BatchMode=yes", |
| 108 | "-o", |
| 109 | "ConnectTimeout=10", |
| 110 | "-o", |
| 111 | "LogLevel=ERROR", |
| 112 | ]; |
| 113 | |
| 114 | const runCommand = Effect.fn(function* (input: { |
| 115 | bin: string; |
| 116 | args: string[]; |
| 117 | host: string; |
| 118 | command?: string; |
| 119 | }) { |
| 120 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 121 | const result = yield* ChildProcess.make(input.bin, input.args, { |
| 122 | stdin: "ignore", |
| 123 | stdout: "pipe", |
| 124 | stderr: "pipe", |
| 125 | detached: false, |
| 126 | }).pipe( |
| 127 | spawner.spawn, |
| 128 | Effect.flatMap((child) => |
| 129 | Effect.all( |
| 130 | { |
| 131 | exitCode: child.exitCode, |
| 132 | stdout: child.stdout.pipe(Stream.decodeText, Stream.mkString), |
| 133 | stderr: child.stderr.pipe(Stream.decodeText, Stream.mkString), |
| 134 | }, |
| 135 | { concurrency: "unbounded" }, |
| 136 | ), |
| 137 | ), |
| 138 | Effect.mapError( |
| 139 | (error) => |
| 140 | new SshError({ |
| 141 | message: `ssh spawn failed: ${String(error)}`, |
| 142 | host: input.host, |
| 143 | command: input.command, |
| 144 | }), |
| 145 | ), |
| 146 | ); |
| 147 | return { |
| 148 | code: Number(result.exitCode), |
| 149 | stdout: result.stdout, |
| 150 | stderr: result.stderr, |
| 151 | }; |
| 152 | }); |
| 153 | |
| 154 | /** |
| 155 | * Open an SSH session against `host` with the given private key. Writes |
| 156 | * the key to a temp file (mode 0600) for `ssh`/`scp`. |
| 157 | */ |
| 158 | export const openSshClient = Effect.fn(function* (input: { |
| 159 | host: string; |
| 160 | privateKey: string; |
| 161 | user?: string; |
| 162 | }) { |
| 163 | const fs = yield* FileSystem.FileSystem; |
| 164 | const path = yield* Path.Path; |
| 165 | const user = input.user ?? "root"; |
| 166 | const dest = `${user}@${input.host}`; |
| 167 | const dir = yield* fs.makeTempDirectory({ prefix: "alchemy-hetzner-ssh-" }); |
| 168 | const keyPath = path.join(dir, "id"); |
| 169 | yield* fs.writeFileString(keyPath, input.privateKey); |
| 170 | yield* fs.chmod(keyPath, 0o600); |
| 171 | yield* runCommand({ |
| 172 | bin: "chmod", |
| 173 | args: ["600", keyPath], |
| 174 | host: input.host, |
| 175 | command: `chmod 600 ${keyPath}`, |
| 176 | }).pipe(Effect.ignore); |
| 177 | |
| 178 | const close = fs.remove(dir, { recursive: true }).pipe(Effect.ignore); |
| 179 | |
| 180 | const exec = (command: string) => |
| 181 | runCommand({ |
| 182 | bin: "ssh", |
| 183 | args: [...sshArgs(keyPath), dest, command], |
| 184 | host: input.host, |
| 185 | command, |
| 186 | }).pipe( |
| 187 | Effect.flatMap((result) => |
| 188 | result.code === 0 |
| 189 | ? Effect.succeed(result) |
| 190 | : Effect.fail( |
| 191 | new SshError({ |
| 192 | message: `ssh exited ${result.code}: ${ |
| 193 | [result.stderr, result.stdout] |
| 194 | .map((s) => s.trim()) |
| 195 | .filter((s) => s.length > 0) |
| 196 | .join("\n") || "no output" |
| 197 | }`, |
| 198 | host: input.host, |
| 199 | command, |
| 200 | code: result.code, |
| 201 | stderr: result.stderr, |
| 202 | }), |
| 203 | ), |
| 204 | ), |
| 205 | ); |
| 206 | |
| 207 | const toSshError = (error: unknown) => |
| 208 | error instanceof SshError |
| 209 | ? error |
| 210 | : new SshError({ |
| 211 | message: `ssh failed: ${String(error)}`, |
| 212 | host: input.host, |
| 213 | }); |
| 214 | |
| 215 | const scp = (local: string | Uint8Array<ArrayBufferLike>, remote: string) => |
| 216 | Effect.gen(function* () { |
| 217 | let localPath: string; |
| 218 | let staged: string | undefined; |
| 219 | if (typeof local === "string") { |
| 220 | localPath = local; |
| 221 | } else { |
| 222 | const stamp = yield* Effect.sync(() => crypto.randomUUID()); |
| 223 | staged = path.join(dir, `payload-${stamp}`); |
| 224 | yield* fs.writeFile(staged, local); |
| 225 | localPath = staged; |
| 226 | } |
| 227 | const slash = remote.lastIndexOf("/"); |
| 228 | if (slash > 0) { |
| 229 | const remoteDir = remote.slice(0, slash); |
| 230 | yield* exec(`mkdir -p ${JSON.stringify(remoteDir)}`); |
| 231 | } |
| 232 | const result = yield* runCommand({ |
| 233 | bin: "scp", |
| 234 | args: [...sshArgs(keyPath), localPath, `${dest}:${remote}`], |
| 235 | host: input.host, |
| 236 | command: `scp ${localPath} ${remote}`, |
| 237 | }); |
| 238 | if (staged !== undefined) { |
| 239 | yield* fs.remove(staged, { force: true }).pipe(Effect.ignore); |
| 240 | } |
| 241 | if (result.code !== 0) { |
| 242 | return yield* new SshError({ |
| 243 | message: `scp exited ${result.code}`, |
| 244 | host: input.host, |
| 245 | command: `scp ${remote}`, |
| 246 | code: result.code, |
| 247 | stderr: result.stderr, |
| 248 | }); |
| 249 | } |
| 250 | }).pipe(Effect.mapError(toSshError)); |
| 251 | |
| 252 | return { exec, scp, close } satisfies SshClient & { |
| 253 | close: Effect.Effect<void>; |
| 254 | }; |
| 255 | }); |
| 256 | |
| 257 | export const sshClientForServer = Effect.fn(function* ( |
| 258 | server: Server, |
| 259 | options?: SshOptions, |
| 260 | ) { |
| 261 | const host = ipv4Of(server); |
| 262 | if (host === undefined) { |
| 263 | return yield* new SshError({ |
| 264 | message: `Server '${server.LogicalId}' has no public IPv4 address`, |
| 265 | }); |
| 266 | } |
| 267 | const privateKey = |
| 268 | unwrapKey(options?.privateKey) ?? |
| 269 | unwrapKey((server as { privateKey?: unknown }).privateKey); |
| 270 | if (privateKey === undefined) { |
| 271 | return yield* new SshError({ |
| 272 | message: `Server '${server.LogicalId}' has no deploy SSH private key`, |
| 273 | host, |
| 274 | }); |
| 275 | } |
| 276 | return yield* openSshClient({ |
| 277 | host, |
| 278 | privateKey, |
| 279 | user: options?.user, |
| 280 | }).pipe( |
| 281 | Effect.mapError((error) => |
| 282 | error instanceof SshError |
| 283 | ? error |
| 284 | : new SshError({ |
| 285 | message: `ssh failed: ${String(error)}`, |
| 286 | host, |
| 287 | }), |
| 288 | ), |
| 289 | ); |
| 290 | }); |
| 291 | |
| 292 | export const SshLive = Layer.effect( |
| 293 | Ssh, |
| 294 | Effect.succeed( |
| 295 | Effect.fn(function* (server: Server, options?: SshOptions) { |
| 296 | const session = yield* sshClientForServer(server, options); |
| 297 | return { |
| 298 | exec: session.exec, |
| 299 | scp: session.scp, |
| 300 | }; |
| 301 | }), |
| 302 | ), |
| 303 | ); |