1import * as Data from "effect/Data";
2import * as Effect from "effect/Effect";
3import * as FileSystem from "effect/FileSystem";
4import * as Layer from "effect/Layer";
5import * as Path from "effect/Path";
6import * as Redacted from "effect/Redacted";
7import type * as Scope from "effect/Scope";
8import * as Stream from "effect/Stream";
9import * as ChildProcess from "effect/process/ChildProcess";
10import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
11import * as Binding from "../Binding.ts";
12import type { Server } from "./Server.ts";
13
14export class SshError extends Data.TaggedError("Hetzner.SshError")<{
15 message: string;
16 host?: string;
17 command?: string;
18 code?: number;
19 stderr?: string;
20}> {}
21
22export interface SshExecResult {
23 stdout: string;
24 stderr: string;
25 code: number;
26}
27
28export 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
35export type SshServices =
36 | FileSystem.FileSystem
37 | Path.Path
38 | ChildProcessSpawner.ChildProcessSpawner
39 | Scope.Scope;
40
41export 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 */
71export 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
80export const Ssh = Binding.Service<Ssh>("Hetzner.Ssh");
81
82const 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
92const 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
97const 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
114const 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 */
158export 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
257export 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
292export 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);