spinuptui 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/CHANGELOG.md +943 -0
  2. package/LICENSE +21 -0
  3. package/README.md +856 -0
  4. package/bin/spinuptui.js +13 -0
  5. package/package.json +55 -0
  6. package/src/api/client.ts +354 -0
  7. package/src/api/types.ts +206 -0
  8. package/src/config.ts +309 -0
  9. package/src/dev/devMode.ts +7 -0
  10. package/src/dev/fixtures.ts +238 -0
  11. package/src/dev/mockClient.ts +214 -0
  12. package/src/index.tsx +68 -0
  13. package/src/lib/appUpdate.ts +206 -0
  14. package/src/lib/awsSigV4.ts +133 -0
  15. package/src/lib/cloneDomains.ts +112 -0
  16. package/src/lib/cloneLog.ts +85 -0
  17. package/src/lib/dbBackup.ts +198 -0
  18. package/src/lib/dbSync.ts +253 -0
  19. package/src/lib/discover.ts +165 -0
  20. package/src/lib/dns.ts +144 -0
  21. package/src/lib/dnsCache.ts +94 -0
  22. package/src/lib/dnsQuery.ts +180 -0
  23. package/src/lib/dnsRecords.ts +411 -0
  24. package/src/lib/format.ts +106 -0
  25. package/src/lib/gitDeployKey.ts +0 -0
  26. package/src/lib/gitStatus.ts +37 -0
  27. package/src/lib/jobs.ts +32 -0
  28. package/src/lib/keychain.ts +49 -0
  29. package/src/lib/local.ts +116 -0
  30. package/src/lib/mediaFallback.ts +288 -0
  31. package/src/lib/open.ts +103 -0
  32. package/src/lib/phpEol.ts +151 -0
  33. package/src/lib/probe.ts +183 -0
  34. package/src/lib/providers.ts +284 -0
  35. package/src/lib/providersCache.ts +105 -0
  36. package/src/lib/releaseNotes.ts +67 -0
  37. package/src/lib/serverClone.ts +782 -0
  38. package/src/lib/serverCreate.ts +161 -0
  39. package/src/lib/siteDoctor.ts +260 -0
  40. package/src/lib/siteFingerprint.ts +108 -0
  41. package/src/lib/spinupweb.ts +33 -0
  42. package/src/lib/ssh.ts +669 -0
  43. package/src/lib/stack.ts +78 -0
  44. package/src/lib/stackCache.ts +113 -0
  45. package/src/lib/theme.ts +54 -0
  46. package/src/lib/ubuntuEol.ts +124 -0
  47. package/src/lib/uptimeKuma.ts +551 -0
  48. package/src/lib/vanitySite.ts +297 -0
  49. package/src/lib/wpInventory.ts +153 -0
  50. package/src/ui/App.tsx +265 -0
  51. package/src/ui/Details.tsx +403 -0
  52. package/src/ui/Explain.tsx +158 -0
  53. package/src/ui/Header.tsx +117 -0
  54. package/src/ui/Help.tsx +296 -0
  55. package/src/ui/List.tsx +95 -0
  56. package/src/ui/Onboarding.tsx +93 -0
  57. package/src/ui/Splash.tsx +51 -0
  58. package/src/ui/StatusBar.tsx +60 -0
  59. package/src/ui/components.tsx +392 -0
  60. package/src/ui/store.tsx +4253 -0
  61. package/src/ui/views/Browser.tsx +365 -0
  62. package/src/ui/views/CloneWizard.tsx +932 -0
  63. package/src/ui/views/Dashboard.tsx +149 -0
  64. package/src/ui/views/DbBackup.tsx +228 -0
  65. package/src/ui/views/DbSync.tsx +307 -0
  66. package/src/ui/views/Discover.tsx +271 -0
  67. package/src/ui/views/DnsInventory.tsx +580 -0
  68. package/src/ui/views/DnsRecords.tsx +562 -0
  69. package/src/ui/views/Events.tsx +98 -0
  70. package/src/ui/views/Forgotten.tsx +165 -0
  71. package/src/ui/views/GrantKey.tsx +504 -0
  72. package/src/ui/views/Health.tsx +322 -0
  73. package/src/ui/views/HttpsToggle.tsx +195 -0
  74. package/src/ui/views/KumaSite.tsx +571 -0
  75. package/src/ui/views/LocalLink.tsx +220 -0
  76. package/src/ui/views/MediaFallback.tsx +180 -0
  77. package/src/ui/views/NewServer.tsx +750 -0
  78. package/src/ui/views/PhpUpgrade.tsx +301 -0
  79. package/src/ui/views/ProviderConnect.tsx +527 -0
  80. package/src/ui/views/PurgeCache.tsx +186 -0
  81. package/src/ui/views/ReleaseNotes.tsx +84 -0
  82. package/src/ui/views/Search.tsx +448 -0
  83. package/src/ui/views/ServerActions.tsx +328 -0
  84. package/src/ui/views/Stacks.tsx +371 -0
  85. package/src/ui/views/SudoConnect.tsx +358 -0
  86. package/src/ui/views/VanityNewSite.tsx +407 -0
  87. package/src/ui/views/WpInventory.tsx +218 -0
  88. package/src/version.ts +8 -0
package/src/lib/ssh.ts ADDED
@@ -0,0 +1,669 @@
1
+ // Live server health over SSH.
2
+ //
3
+ // SpinupWP exposes no live metrics API, so the health view shells out to the
4
+ // local `ssh` client (using the user's existing keys/agent) and reads /proc,
5
+ // df, and ps on the server — exactly the data `htop` shows. All remote commands
6
+ // are strictly read-only.
7
+ //
8
+ // Auth is non-interactive (BatchMode): if `ssh user@ip` doesn't already work
9
+ // from the user's terminal, we fail fast with a clear hint instead of hanging.
10
+ // A persistent ControlMaster connection makes repeated polls fast.
11
+
12
+ import { hostname, homedir } from "node:os"
13
+ import { join } from "node:path"
14
+ import { existsSync, readFileSync, readdirSync } from "node:fs"
15
+ import { mkdir, chmod } from "node:fs/promises"
16
+ import { keysDir } from "../config.ts"
17
+ import type { Server, Site } from "../api/types.ts"
18
+
19
+ export interface CpuCore {
20
+ idx: number
21
+ pct: number
22
+ }
23
+
24
+ export interface DiskMount {
25
+ mount: string
26
+ total: number
27
+ used: number
28
+ pct: number
29
+ }
30
+
31
+ export interface ProcInfo {
32
+ pid: string
33
+ comm: string
34
+ cpu: number
35
+ mem: number
36
+ }
37
+
38
+ export interface HealthSnapshot {
39
+ host: string
40
+ uptimeSecs: number
41
+ load: [number, number, number]
42
+ cores: number
43
+ cpuPct: number
44
+ perCore: CpuCore[]
45
+ memTotal: number
46
+ memUsed: number
47
+ memAvailable: number
48
+ swapTotal: number
49
+ swapUsed: number
50
+ disks: DiskMount[]
51
+ processes: ProcInfo[]
52
+ takenAt: number
53
+ }
54
+
55
+ export type HealthResult =
56
+ | { ok: true; target: string; snapshot: HealthSnapshot }
57
+ | { ok: false; target: string; error: string }
58
+
59
+ // Resolve `user@host` for a server. Prefers an explicit config override, then a
60
+ // site's `site_user` on that server, then falls back to root.
61
+ export function resolveSshTarget(server: Server, sites: Site[], sshUser: string | null): string {
62
+ if (!server.ip_address) return ""
63
+ if (sshUser) return `${sshUser}@${server.ip_address}`
64
+ const site = sites.find((s) => s.server_id === server.id && s.site_user)
65
+ const user = site?.site_user || "root"
66
+ return `${user}@${server.ip_address}`
67
+ }
68
+
69
+ // One batched command that captures everything we render in a single round-trip.
70
+ // Two /proc/stat reads bracket a short sleep so we can compute live CPU%.
71
+ const REMOTE_SCRIPT = [
72
+ "echo ===HOST; hostname 2>/dev/null",
73
+ "echo ===UPTIME; cat /proc/uptime",
74
+ "echo ===LOAD; cat /proc/loadavg",
75
+ "echo ===NPROC; nproc 2>/dev/null || grep -c ^processor /proc/cpuinfo",
76
+ "echo ===STAT1; grep '^cpu' /proc/stat",
77
+ "sleep 0.75",
78
+ "echo ===STAT2; grep '^cpu' /proc/stat",
79
+ "echo ===MEM; cat /proc/meminfo",
80
+ "echo ===DF; df -B1 -x tmpfs -x devtmpfs -x squashfs -x overlay 2>/dev/null",
81
+ "echo ===PS; ps -eo pid,comm,pcpu,pmem --sort=-pcpu 2>/dev/null | head -n 9",
82
+ "echo ===END",
83
+ ].join("; ")
84
+
85
+ const SSH_OPTS = [
86
+ "-o", "BatchMode=yes",
87
+ "-o", "ConnectTimeout=7",
88
+ "-o", "StrictHostKeyChecking=accept-new",
89
+ "-o", "ControlMaster=auto",
90
+ "-o", "ControlPath=/tmp/spinup-cm-%r@%h:%p",
91
+ "-o", "ControlPersist=30s",
92
+ ]
93
+
94
+ // Why a server needs a reboot. SpinupWP's `reboot_required` boolean tracks
95
+ // Ubuntu's /var/run/reboot-required (written by unattended-upgrades), which a
96
+ // fleet-wide check confirmed 1:1; the companion .pkgs file lists the packages
97
+ // awaiting a restart. We surface that as accurate OS-level context — labeled as
98
+ // what it is, not as SpinupWP's internal logic. Read-only, like the health view.
99
+ export interface RebootInfo {
100
+ present: boolean // /var/run/reboot-required exists
101
+ packages: string[] // de-duplicated package names from .pkgs
102
+ kernel: boolean // any linux-image* package present (the security-relevant case)
103
+ }
104
+
105
+ export type RebootInfoResult =
106
+ | { ok: true; target: string; info: RebootInfo }
107
+ | { ok: false; target: string; error: string }
108
+
109
+ const REBOOT_SCRIPT = [
110
+ "echo ===PRESENT; test -e /var/run/reboot-required && echo yes || echo no",
111
+ "echo ===PKGS; cat /var/run/reboot-required.pkgs 2>/dev/null",
112
+ "echo ===END",
113
+ ].join("; ")
114
+
115
+ export async function fetchRebootInfo(
116
+ server: Server,
117
+ sites: Site[],
118
+ sshUser: string | null,
119
+ ): Promise<RebootInfoResult> {
120
+ const target = resolveSshTarget(server, sites, sshUser)
121
+ if (!target) return { ok: false, target: "(no IP)", error: "Server has no IP address." }
122
+
123
+ let proc: ReturnType<typeof Bun.spawn>
124
+ try {
125
+ proc = Bun.spawn(["ssh", ...SSH_OPTS, target, REBOOT_SCRIPT], { stdout: "pipe", stderr: "pipe", stdin: "ignore" })
126
+ } catch (err) {
127
+ return { ok: false, target, error: `Failed to launch ssh: ${(err as Error).message}` }
128
+ }
129
+ const timeout = setTimeout(() => {
130
+ try {
131
+ proc.kill()
132
+ } catch {
133
+ /* already gone */
134
+ }
135
+ }, 12000)
136
+ const exitCode = await proc.exited
137
+ clearTimeout(timeout)
138
+ const stdout = await new Response(proc.stdout as ReadableStream<Uint8Array>).text()
139
+ const stderr = await new Response(proc.stderr as ReadableStream<Uint8Array>).text()
140
+
141
+ if (exitCode !== 0 || !stdout.includes("===END")) {
142
+ const reason = stderr.trim().split("\n").slice(-2).join(" ") || `ssh exited with code ${exitCode}`
143
+ return { ok: false, target, error: reason }
144
+ }
145
+
146
+ const s = splitSections(stdout)
147
+ const present = (s.PRESENT?.[0] || "").trim() === "yes"
148
+ // .pkgs has duplicate lines in practice; de-dupe while preserving order.
149
+ const packages = [...new Set((s.PKGS || []).map((l) => l.trim()).filter(Boolean))]
150
+ const kernel = packages.some((p) => p.startsWith("linux-image"))
151
+ return { ok: true, target, info: { present, packages, kernel } }
152
+ }
153
+
154
+ export async function fetchServerHealth(
155
+ server: Server,
156
+ sites: Site[],
157
+ sshUser: string | null,
158
+ ): Promise<HealthResult> {
159
+ const target = resolveSshTarget(server, sites, sshUser)
160
+ if (!target) return { ok: false, target: "(no IP)", error: "Server has no IP address." }
161
+
162
+ let proc: ReturnType<typeof Bun.spawn>
163
+ try {
164
+ proc = Bun.spawn(["ssh", ...SSH_OPTS, target, REMOTE_SCRIPT], {
165
+ stdout: "pipe",
166
+ stderr: "pipe",
167
+ stdin: "ignore",
168
+ })
169
+ } catch (err) {
170
+ return { ok: false, target, error: `Failed to launch ssh: ${(err as Error).message}` }
171
+ }
172
+
173
+ // Guard against a hung connection beyond ssh's own ConnectTimeout.
174
+ const timeout = setTimeout(() => {
175
+ try {
176
+ proc.kill()
177
+ } catch {
178
+ /* already gone */
179
+ }
180
+ }, 12000)
181
+
182
+ const exitCode = await proc.exited
183
+ clearTimeout(timeout)
184
+ const stdout = await new Response(proc.stdout as ReadableStream<Uint8Array>).text()
185
+ const stderr = await new Response(proc.stderr as ReadableStream<Uint8Array>).text()
186
+
187
+ if (exitCode !== 0 || !stdout.includes("===END")) {
188
+ const reason = stderr.trim().split("\n").slice(-2).join(" ") || `ssh exited with code ${exitCode}`
189
+ return { ok: false, target, error: reason }
190
+ }
191
+
192
+ try {
193
+ return { ok: true, target, snapshot: parseHealth(stdout) }
194
+ } catch (err) {
195
+ return { ok: false, target, error: `Could not parse server output: ${(err as Error).message}` }
196
+ }
197
+ }
198
+
199
+ // Split the batched output into its labelled sections.
200
+ function splitSections(out: string): Record<string, string[]> {
201
+ const sections: Record<string, string[]> = {}
202
+ let current = ""
203
+ for (const rawLine of out.split("\n")) {
204
+ const line = rawLine.replace(/\r$/, "")
205
+ const m = line.match(/^===([A-Z0-9]+)$/)
206
+ if (m) {
207
+ current = m[1]
208
+ sections[current] = []
209
+ } else if (current) {
210
+ sections[current].push(line)
211
+ }
212
+ }
213
+ return sections
214
+ }
215
+
216
+ // Parse one /proc/stat cpu line into [total, idle].
217
+ function parseCpuLine(line: string): { total: number; idle: number } | null {
218
+ const parts = line.trim().split(/\s+/)
219
+ if (parts.length < 5) return null
220
+ const nums = parts.slice(1).map((n) => parseInt(n, 10) || 0)
221
+ const idle = (nums[3] || 0) + (nums[4] || 0) // idle + iowait
222
+ const total = nums.reduce((a, b) => a + b, 0)
223
+ return { total, idle }
224
+ }
225
+
226
+ function busyPct(a: { total: number; idle: number }, b: { total: number; idle: number }): number {
227
+ const dTotal = b.total - a.total
228
+ const dIdle = b.idle - a.idle
229
+ if (dTotal <= 0) return 0
230
+ return Math.min(100, Math.max(0, (1 - dIdle / dTotal) * 100))
231
+ }
232
+
233
+ // ---- Privileged write-over-SSH (sudo) -------------------------------------
234
+ //
235
+ // The app's first WRITE over SSH: drop Spinup's dedicated machine key into a
236
+ // site user's authorized_keys, via a per-server SUDO user. Everything above this
237
+ // line is strictly read-only; this section is the one place we mutate a server.
238
+ //
239
+ // See docs/2026-06-26_sudo-ssh-key-provisioning-spec.md for the why (the SpinupWP
240
+ // API has no SSH-key/sudo-user surface) and the clobber-safety analysis (an
241
+ // appended key survives SpinupWP's authorized_keys reconciliation as long as it
242
+ // isn't also a saved account key — hence a dedicated key NOT in the account).
243
+
244
+ // Path to Spinup's dedicated machine keypair (private + .pub).
245
+ function spinupKeyPaths(): { path: string; pub: string } {
246
+ const path = join(keysDir(), "spinup-tui")
247
+ return { path, pub: `${path}.pub` }
248
+ }
249
+
250
+ // Lazily generate (once) and return Spinup's dedicated ed25519 machine key. It is
251
+ // NEVER added to the SpinupWP account, so SpinupWP never manages/removes it; the
252
+ // public-key comment (`spinup-tui@<hostname>`) makes its origin obvious wherever
253
+ // the authorized_keys line is seen. No passphrase — it's an unattended identity.
254
+ export async function ensureSpinupKey(): Promise<{ path: string; pub: string; comment: string }> {
255
+ const { path, pub } = spinupKeyPaths()
256
+ const comment = `spinup-tui@${hostname()}`
257
+ if (existsSync(pub) && existsSync(path)) {
258
+ return { path, pub: readFileSync(pub, "utf8").trim(), comment }
259
+ }
260
+ await mkdir(keysDir(), { recursive: true })
261
+ const proc = Bun.spawn(
262
+ ["ssh-keygen", "-t", "ed25519", "-N", "", "-C", comment, "-f", path],
263
+ { stdout: "pipe", stderr: "pipe", stdin: "ignore" },
264
+ )
265
+ const code = await proc.exited
266
+ if (code !== 0) {
267
+ const err = await new Response(proc.stderr as ReadableStream<Uint8Array>).text()
268
+ throw new Error(`ssh-keygen failed (${code}): ${err.trim() || "could not generate the machine key"}`)
269
+ }
270
+ // The private key is a machine identity — restrict it to the owner.
271
+ try {
272
+ await chmod(path, 0o600)
273
+ } catch {
274
+ /* best-effort (e.g. filesystems without POSIX perms) */
275
+ }
276
+ return { path, pub: readFileSync(pub, "utf8").trim(), comment }
277
+ }
278
+
279
+ // SpinupWP serves each site from /sites/<domain>; the site user's home (and thus
280
+ // its .ssh/authorized_keys) lives there.
281
+ function siteHome(domain: string): string {
282
+ return `/sites/${domain}`
283
+ }
284
+
285
+ // Single-quote a value for safe embedding in a POSIX shell script.
286
+ function shQuote(v: string): string {
287
+ return `'${v.replace(/'/g, `'\\''`)}'`
288
+ }
289
+
290
+ // The idempotent remote script that ensures EACH given key line is present in the
291
+ // site user's authorized_keys (mode 600, owned siteuser:siteuser). `grep -qxF` makes
292
+ // re-running a no-op; it verifies every key landed before echoing ===GRANTED, so a
293
+ // partial write reports failure. Exported so the confirm overlay can show the exact
294
+ // remote command before firing.
295
+ export function buildGrantScript(siteUser: string, domain: string, pubkeys: string[]): string {
296
+ const U = shQuote(siteUser)
297
+ const H = shQuote(siteHome(domain))
298
+ const lines = [
299
+ `set -e`,
300
+ `U=${U}; H=${H}`,
301
+ `install -d -m 700 -o "$U" -g "$U" "$H/.ssh"`,
302
+ `touch "$H/.ssh/authorized_keys"`,
303
+ ]
304
+ // Append each key only if absent (idempotent), keyed off the exact line.
305
+ for (const k of pubkeys) {
306
+ const K = shQuote(k)
307
+ lines.push(`grep -qxF ${K} "$H/.ssh/authorized_keys" || printf '%s\\n' ${K} >> "$H/.ssh/authorized_keys"`)
308
+ }
309
+ lines.push(`chown "$U:$U" "$H/.ssh/authorized_keys"`)
310
+ lines.push(`chmod 600 "$H/.ssh/authorized_keys"`)
311
+ // Verify every key is present; bail (no ===GRANTED) if any is missing.
312
+ for (const k of pubkeys) {
313
+ lines.push(`grep -qxF ${shQuote(k)} "$H/.ssh/authorized_keys" || exit 7`)
314
+ }
315
+ lines.push(`echo ===GRANTED`)
316
+ return lines.join("\n")
317
+ }
318
+
319
+ // The reverse of buildGrantScript: remove EACH given key line from the site user's
320
+ // authorized_keys (exact-line match via `grep -vxF`), leaving every other key. Safe
321
+ // to re-run; verifies none remain before echoing ===REVOKED.
322
+ export function buildRevokeScript(siteUser: string, domain: string, pubkeys: string[]): string {
323
+ const U = shQuote(siteUser)
324
+ const H = shQuote(siteHome(domain))
325
+ const patternArgs = pubkeys.map((k) => shQuote(k)).join(" ")
326
+ const lines = [
327
+ `set -e`,
328
+ `U=${U}; H=${H}`,
329
+ `F="$H/.ssh/authorized_keys"`,
330
+ `if [ -f "$F" ]; then`,
331
+ ` P="$(mktemp)"`,
332
+ ` printf '%s\\n' ${patternArgs} > "$P"`,
333
+ ` grep -vxF -f "$P" "$F" > "$F.tmp" || true`,
334
+ ` mv "$F.tmp" "$F"`,
335
+ ` rm -f "$P"`,
336
+ ` chown "$U:$U" "$F"`,
337
+ ` chmod 600 "$F"`,
338
+ `fi`,
339
+ ]
340
+ // Verify each removed key is really gone; bail (no ===REVOKED) if any remains.
341
+ for (const k of pubkeys) {
342
+ lines.push(`[ -f "$F" ] && grep -qxF ${shQuote(k)} "$F" 2>/dev/null && exit 8 || true`)
343
+ }
344
+ lines.push(`echo ===REVOKED`)
345
+ return lines.join("\n")
346
+ }
347
+
348
+ // A non-interactive ssh command, optionally feeding a sudo password (then a
349
+ // script body) on stdin. We do NOT drive an interactive `sudo -i` shell: we run
350
+ // one shot `sudo -S -p '' bash -s`, so the password is read from stdin (-S) and
351
+ // never appears in argv (so not in the remote `ps`/history). When `password` is
352
+ // null the remote command runs without sudo (used for the read-only probe).
353
+ async function runSshStdin(
354
+ target: string,
355
+ port: number | null,
356
+ remoteCmd: string,
357
+ stdinPayload: string | null,
358
+ timeoutMs: number,
359
+ ): Promise<{ code: number; stdout: string; stderr: string }> {
360
+ const portOpt = port && port !== 22 ? ["-p", String(port)] : []
361
+ let proc: ReturnType<typeof Bun.spawn>
362
+ try {
363
+ proc = Bun.spawn(["ssh", ...SSH_OPTS, ...portOpt, target, remoteCmd], {
364
+ stdout: "pipe",
365
+ stderr: "pipe",
366
+ stdin: stdinPayload == null ? "ignore" : "pipe",
367
+ })
368
+ } catch (err) {
369
+ return { code: -1, stdout: "", stderr: `Failed to launch ssh: ${(err as Error).message}` }
370
+ }
371
+ if (stdinPayload != null && proc.stdin) {
372
+ const sink = proc.stdin as { write: (s: string) => void; end: () => void }
373
+ sink.write(stdinPayload)
374
+ sink.end()
375
+ }
376
+ const timer = setTimeout(() => {
377
+ try {
378
+ proc.kill()
379
+ } catch {
380
+ /* already gone */
381
+ }
382
+ }, timeoutMs)
383
+ const code = await proc.exited
384
+ clearTimeout(timer)
385
+ const stdout = await new Response(proc.stdout as ReadableStream<Uint8Array>).text()
386
+ const stderr = await new Response(proc.stderr as ReadableStream<Uint8Array>).text()
387
+ return { code, stdout, stderr }
388
+ }
389
+
390
+ // Pull a meaningful last line out of ssh/sudo stderr for surfacing to the user.
391
+ function lastErrorLine(stderr: string, fallback: string): string {
392
+ const line = stderr.trim().split("\n").map((l) => l.trim()).filter(Boolean).pop()
393
+ return line || fallback
394
+ }
395
+
396
+ export type GrantKeyResult =
397
+ | { ok: true; target: string }
398
+ | { ok: false; target: string; error: string }
399
+
400
+ // Shared runner for a privileged per-site authorized_keys op (grant or revoke).
401
+ // Probes `sudo -n true` first to fail cleanly (can't-sudo / can't-connect) BEFORE
402
+ // sending the secret; if a password is required it's fed on stdin to `sudo -S`.
403
+ // `build(siteUser)` produces the remote script (so the script is only built once
404
+ // site_user is validated); `marker` is the success token the script echoes.
405
+ async function runSudoSiteOp(
406
+ server: Server,
407
+ site: Site,
408
+ opts: { sudoUser: string; sudoPassword: string },
409
+ build: (siteUser: string) => string,
410
+ marker: string,
411
+ ): Promise<GrantKeyResult> {
412
+ const ip = server.ip_address
413
+ if (!ip) return { ok: false, target: "(no IP)", error: "Server has no IP address." }
414
+ const siteUser = site.site_user
415
+ if (!siteUser) return { ok: false, target: ip, error: "Site has no site_user — can't locate its authorized_keys." }
416
+ const target = `${opts.sudoUser}@${ip}`
417
+ const port = server.ssh_port ?? null
418
+
419
+ const probe = await runSshStdin(target, port, "sudo -n true 2>&1", null, 15000)
420
+ let needPassword = false
421
+ if (probe.code === 0) {
422
+ needPassword = false
423
+ } else if (probe.code === 255 || probe.code === -1) {
424
+ return { ok: false, target, error: lastErrorLine(probe.stdout + "\n" + probe.stderr, "Couldn't connect over SSH — is your key on the server for this user?") }
425
+ } else {
426
+ const out = (probe.stdout + " " + probe.stderr).toLowerCase()
427
+ if (/not in the sudoers|not allowed to run|may not run|unknown user|is not allowed/.test(out)) {
428
+ return { ok: false, target, error: `${opts.sudoUser} can't run sudo on this server.` }
429
+ }
430
+ needPassword = true
431
+ if (!opts.sudoPassword) return { ok: false, target, error: "A sudo password is required for this server." }
432
+ }
433
+
434
+ const script = build(siteUser)
435
+ const payload = needPassword ? `${opts.sudoPassword}\n${script}\n` : `${script}\n`
436
+ const res = await runSshStdin(target, port, "sudo -S -p '' bash -s", payload, 30000)
437
+ if (res.code !== 0 || !res.stdout.includes(marker)) {
438
+ const out = (res.stderr + " " + res.stdout).toLowerCase()
439
+ if (/incorrect password|sorry, try again|authentication failure/.test(out)) {
440
+ return { ok: false, target, error: "Sudo password was rejected." }
441
+ }
442
+ return { ok: false, target, error: lastErrorLine(res.stderr || res.stdout, `The remote command failed (ssh exit ${res.code}).`) }
443
+ }
444
+ return { ok: true, target: `${siteUser}@${ip}` }
445
+ }
446
+
447
+ // Append the given public keys to a site user's authorized_keys, via the server's
448
+ // sudo user. Idempotent (re-running never duplicates a line). The caller resolves
449
+ // which keys to deploy (machine key via ensureSpinupKey, personal via listPersonalKeys).
450
+ export async function grantSiteSshKey(
451
+ server: Server,
452
+ site: Site,
453
+ opts: { sudoUser: string; sudoPassword: string; pubkeys: string[] },
454
+ ): Promise<GrantKeyResult> {
455
+ const keys = opts.pubkeys.map((k) => k.trim()).filter(Boolean)
456
+ if (keys.length === 0) return { ok: false, target: `${opts.sudoUser}@${server.ip_address ?? "?"}`, error: "No keys selected to grant." }
457
+ return runSudoSiteOp(server, site, opts, (siteUser) => buildGrantScript(siteUser, site.domain, keys), "===GRANTED")
458
+ }
459
+
460
+ // Remove the given public keys from a site user's authorized_keys, via sudo. The
461
+ // reverse of grant; safe to re-run (removing an absent key is a no-op). Only the
462
+ // exact lines given are removed — other keys (incl. SpinupWP-managed ones) are left.
463
+ export async function revokeSiteSshKey(
464
+ server: Server,
465
+ site: Site,
466
+ opts: { sudoUser: string; sudoPassword: string; pubkeys: string[] },
467
+ ): Promise<GrantKeyResult> {
468
+ const keys = opts.pubkeys.map((k) => k.trim()).filter(Boolean)
469
+ if (keys.length === 0) return { ok: false, target: `${opts.sudoUser}@${server.ip_address ?? "?"}`, error: "No keys selected to remove." }
470
+ return runSudoSiteOp(server, site, opts, (siteUser) => buildRevokeScript(siteUser, site.domain, keys), "===REVOKED")
471
+ }
472
+
473
+ // A public key the user can choose to grant. The machine key (`spinup-tui`) plus any
474
+ // personal keys discovered on this machine. `line` is the full authorized_keys line.
475
+ export interface GrantableKey {
476
+ id: string // stable id for selection (the key body, base64)
477
+ kind: "machine" | "personal"
478
+ label: string // human label (comment or filename)
479
+ line: string // the full "ssh-… AAAA… comment" line
480
+ source: string // "spinup" | "~/.ssh/id_ed25519.pub" | "ssh-agent"
481
+ }
482
+
483
+ // The base64 body of a public-key line — the stable identity used to dedupe keys
484
+ // across the agent and ~/.ssh, and to match what SpinupWP keys off.
485
+ function keyBody(line: string): string {
486
+ const parts = line.trim().split(/\s+/)
487
+ return parts[1] ?? ""
488
+ }
489
+
490
+ function parsePubLine(line: string): { type: string; body: string; comment: string } | null {
491
+ const t = line.trim()
492
+ if (!t || !/^(ssh|ecdsa|sk-)/.test(t)) return null
493
+ const parts = t.split(/\s+/)
494
+ if (parts.length < 2) return null
495
+ return { type: parts[0], body: parts[1], comment: parts.slice(2).join(" ") }
496
+ }
497
+
498
+ // Discover the user's PERSONAL public keys to offer alongside the machine key:
499
+ // every ~/.ssh/*.pub plus whatever the ssh-agent currently holds (`ssh-add -L`),
500
+ // deduped by key body. These are the keys a human uses to log in as themselves.
501
+ export async function listPersonalKeys(): Promise<GrantableKey[]> {
502
+ const byBody = new Map<string, GrantableKey>()
503
+ const add = (line: string, label: string, source: string) => {
504
+ const parsed = parsePubLine(line)
505
+ if (!parsed) return
506
+ if (byBody.has(parsed.body)) return // first source wins (files are listed first)
507
+ byBody.set(parsed.body, {
508
+ id: parsed.body,
509
+ kind: "personal",
510
+ label: parsed.comment || label,
511
+ line: line.trim(),
512
+ source,
513
+ })
514
+ }
515
+
516
+ // ~/.ssh/*.pub
517
+ try {
518
+ const dir = join(homedir(), ".ssh")
519
+ for (const name of readdirSync(dir)) {
520
+ if (!name.endsWith(".pub")) continue
521
+ try {
522
+ add(readFileSync(join(dir, name), "utf8"), name, `~/.ssh/${name}`)
523
+ } catch {
524
+ /* unreadable — skip */
525
+ }
526
+ }
527
+ } catch {
528
+ /* no ~/.ssh — fine */
529
+ }
530
+
531
+ // ssh-agent (keys the user has loaded). Best-effort; ignore if no agent.
532
+ try {
533
+ const proc = Bun.spawn(["ssh-add", "-L"], { stdout: "pipe", stderr: "ignore", stdin: "ignore" })
534
+ const code = await proc.exited
535
+ if (code === 0) {
536
+ const out = await new Response(proc.stdout as ReadableStream<Uint8Array>).text()
537
+ for (const line of out.split("\n")) add(line, "ssh-agent key", "ssh-agent")
538
+ }
539
+ } catch {
540
+ /* no ssh-add / no agent — fine */
541
+ }
542
+
543
+ // ed25519 first (the modern default people actually use), then the rest in
544
+ // discovery order — so the first entry is the most likely "my key".
545
+ return [...byBody.values()].sort((a, b) => {
546
+ const rank = (k: GrantableKey) => (k.line.startsWith("ssh-ed25519") ? 0 : 1)
547
+ return rank(a) - rank(b)
548
+ })
549
+ }
550
+
551
+ export { keyBody }
552
+
553
+ export type SudoVerifyResult = { ok: true } | { ok: false; error: string }
554
+
555
+ // Validate a server's sudo credentials live (login as the sudo user works AND the
556
+ // password is accepted by sudo), so "arming" a server gives immediate pass/fail
557
+ // before any real privileged action fires. `sudo -S -p '' true` reads the password
558
+ // from stdin; passwordless sudo ignores stdin and still exits 0.
559
+ export async function verifySudo(server: Server, opts: { sudoUser: string; sudoPassword: string }): Promise<SudoVerifyResult> {
560
+ const ip = server.ip_address
561
+ if (!ip) return { ok: false, error: "Server has no IP address." }
562
+ const target = `${opts.sudoUser}@${ip}`
563
+ const port = server.ssh_port ?? null
564
+ const res = await runSshStdin(target, port, "sudo -S -p '' true", `${opts.sudoPassword}\n`, 15000)
565
+ if (res.code === 0) return { ok: true }
566
+ if (res.code === 255 || res.code === -1) {
567
+ return { ok: false, error: lastErrorLine(res.stderr || res.stdout, "Couldn't connect over SSH — is your key on the sudo user?") }
568
+ }
569
+ const out = (res.stderr + " " + res.stdout).toLowerCase()
570
+ if (/incorrect password|sorry, try again|authentication failure/.test(out)) return { ok: false, error: "Sudo password was rejected." }
571
+ if (/not in the sudoers|not allowed to run|may not run|unknown user|is not allowed/.test(out)) {
572
+ return { ok: false, error: `${opts.sudoUser} can't run sudo on this server.` }
573
+ }
574
+ return { ok: false, error: lastErrorLine(res.stderr || res.stdout, `Verification failed (ssh exit ${res.code}).`) }
575
+ }
576
+
577
+ function parseHealth(out: string): HealthSnapshot {
578
+ const s = splitSections(out)
579
+
580
+ const host = (s.HOST?.[0] || "").trim()
581
+ const uptimeSecs = parseFloat(s.UPTIME?.[0]?.trim().split(/\s+/)[0] || "0")
582
+ const loadParts = (s.LOAD?.[0] || "").trim().split(/\s+/)
583
+ const load: [number, number, number] = [
584
+ parseFloat(loadParts[0] || "0"),
585
+ parseFloat(loadParts[1] || "0"),
586
+ parseFloat(loadParts[2] || "0"),
587
+ ]
588
+ const cores = parseInt(s.NPROC?.[0]?.trim() || "1", 10) || 1
589
+
590
+ // CPU% from the two /proc/stat snapshots, keyed by cpu label (cpu, cpu0, …).
591
+ const stat1 = new Map<string, { total: number; idle: number }>()
592
+ const stat2 = new Map<string, { total: number; idle: number }>()
593
+ for (const line of s.STAT1 || []) {
594
+ const label = line.trim().split(/\s+/)[0]
595
+ const parsed = parseCpuLine(line)
596
+ if (label && parsed) stat1.set(label, parsed)
597
+ }
598
+ for (const line of s.STAT2 || []) {
599
+ const label = line.trim().split(/\s+/)[0]
600
+ const parsed = parseCpuLine(line)
601
+ if (label && parsed) stat2.set(label, parsed)
602
+ }
603
+ const agg1 = stat1.get("cpu")
604
+ const agg2 = stat2.get("cpu")
605
+ const cpuPct = agg1 && agg2 ? busyPct(agg1, agg2) : 0
606
+ const perCore: CpuCore[] = []
607
+ for (const [label, a] of stat1) {
608
+ if (label === "cpu") continue
609
+ const b = stat2.get(label)
610
+ if (!b) continue
611
+ perCore.push({ idx: parseInt(label.replace("cpu", ""), 10) || 0, pct: busyPct(a, b) })
612
+ }
613
+ perCore.sort((a, b) => a.idx - b.idx)
614
+
615
+ // Memory (values in /proc/meminfo are kB).
616
+ const mem: Record<string, number> = {}
617
+ for (const line of s.MEM || []) {
618
+ const m = line.match(/^(\w+):\s+(\d+)\s*kB/)
619
+ if (m) mem[m[1]] = parseInt(m[2], 10) * 1024
620
+ }
621
+ const memTotal = mem.MemTotal || 0
622
+ const memAvailable = mem.MemAvailable ?? mem.MemFree ?? 0
623
+ const memUsed = Math.max(0, memTotal - memAvailable)
624
+ const swapTotal = mem.SwapTotal || 0
625
+ const swapUsed = Math.max(0, swapTotal - (mem.SwapFree || 0))
626
+
627
+ // Disk mounts from `df -B1`.
628
+ const disks: DiskMount[] = []
629
+ for (const line of s.DF || []) {
630
+ const parts = line.trim().split(/\s+/)
631
+ if (parts.length < 6 || parts[0] === "Filesystem") continue
632
+ const total = parseInt(parts[1], 10)
633
+ const used = parseInt(parts[2], 10)
634
+ const mount = parts.slice(5).join(" ")
635
+ if (isNaN(total) || isNaN(used) || total <= 0) continue
636
+ disks.push({ mount, total, used, pct: (used / total) * 100 })
637
+ }
638
+ disks.sort((a, b) => b.pct - a.pct)
639
+
640
+ // Top processes from ps.
641
+ const processes: ProcInfo[] = []
642
+ for (const line of s.PS || []) {
643
+ const parts = line.trim().split(/\s+/)
644
+ if (parts.length < 4 || parts[0] === "PID") continue
645
+ const pid = parts[0]
646
+ const mem = parseFloat(parts[parts.length - 1])
647
+ const cpu = parseFloat(parts[parts.length - 2])
648
+ const comm = parts.slice(1, parts.length - 2).join(" ")
649
+ if (isNaN(cpu) || isNaN(mem)) continue
650
+ processes.push({ pid, comm, cpu, mem })
651
+ }
652
+
653
+ return {
654
+ host,
655
+ uptimeSecs,
656
+ load,
657
+ cores,
658
+ cpuPct,
659
+ perCore,
660
+ memTotal,
661
+ memUsed,
662
+ memAvailable,
663
+ swapTotal,
664
+ swapUsed,
665
+ disks,
666
+ processes,
667
+ takenAt: Date.now(),
668
+ }
669
+ }