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.
- package/CHANGELOG.md +943 -0
- package/LICENSE +21 -0
- package/README.md +856 -0
- package/bin/spinuptui.js +13 -0
- package/package.json +55 -0
- package/src/api/client.ts +354 -0
- package/src/api/types.ts +206 -0
- package/src/config.ts +309 -0
- package/src/dev/devMode.ts +7 -0
- package/src/dev/fixtures.ts +238 -0
- package/src/dev/mockClient.ts +214 -0
- package/src/index.tsx +68 -0
- package/src/lib/appUpdate.ts +206 -0
- package/src/lib/awsSigV4.ts +133 -0
- package/src/lib/cloneDomains.ts +112 -0
- package/src/lib/cloneLog.ts +85 -0
- package/src/lib/dbBackup.ts +198 -0
- package/src/lib/dbSync.ts +253 -0
- package/src/lib/discover.ts +165 -0
- package/src/lib/dns.ts +144 -0
- package/src/lib/dnsCache.ts +94 -0
- package/src/lib/dnsQuery.ts +180 -0
- package/src/lib/dnsRecords.ts +411 -0
- package/src/lib/format.ts +106 -0
- package/src/lib/gitDeployKey.ts +0 -0
- package/src/lib/gitStatus.ts +37 -0
- package/src/lib/jobs.ts +32 -0
- package/src/lib/keychain.ts +49 -0
- package/src/lib/local.ts +116 -0
- package/src/lib/mediaFallback.ts +288 -0
- package/src/lib/open.ts +103 -0
- package/src/lib/phpEol.ts +151 -0
- package/src/lib/probe.ts +183 -0
- package/src/lib/providers.ts +284 -0
- package/src/lib/providersCache.ts +105 -0
- package/src/lib/releaseNotes.ts +67 -0
- package/src/lib/serverClone.ts +782 -0
- package/src/lib/serverCreate.ts +161 -0
- package/src/lib/siteDoctor.ts +260 -0
- package/src/lib/siteFingerprint.ts +108 -0
- package/src/lib/spinupweb.ts +33 -0
- package/src/lib/ssh.ts +669 -0
- package/src/lib/stack.ts +78 -0
- package/src/lib/stackCache.ts +113 -0
- package/src/lib/theme.ts +54 -0
- package/src/lib/ubuntuEol.ts +124 -0
- package/src/lib/uptimeKuma.ts +551 -0
- package/src/lib/vanitySite.ts +297 -0
- package/src/lib/wpInventory.ts +153 -0
- package/src/ui/App.tsx +265 -0
- package/src/ui/Details.tsx +403 -0
- package/src/ui/Explain.tsx +158 -0
- package/src/ui/Header.tsx +117 -0
- package/src/ui/Help.tsx +296 -0
- package/src/ui/List.tsx +95 -0
- package/src/ui/Onboarding.tsx +93 -0
- package/src/ui/Splash.tsx +51 -0
- package/src/ui/StatusBar.tsx +60 -0
- package/src/ui/components.tsx +392 -0
- package/src/ui/store.tsx +4253 -0
- package/src/ui/views/Browser.tsx +365 -0
- package/src/ui/views/CloneWizard.tsx +932 -0
- package/src/ui/views/Dashboard.tsx +149 -0
- package/src/ui/views/DbBackup.tsx +228 -0
- package/src/ui/views/DbSync.tsx +307 -0
- package/src/ui/views/Discover.tsx +271 -0
- package/src/ui/views/DnsInventory.tsx +580 -0
- package/src/ui/views/DnsRecords.tsx +562 -0
- package/src/ui/views/Events.tsx +98 -0
- package/src/ui/views/Forgotten.tsx +165 -0
- package/src/ui/views/GrantKey.tsx +504 -0
- package/src/ui/views/Health.tsx +322 -0
- package/src/ui/views/HttpsToggle.tsx +195 -0
- package/src/ui/views/KumaSite.tsx +571 -0
- package/src/ui/views/LocalLink.tsx +220 -0
- package/src/ui/views/MediaFallback.tsx +180 -0
- package/src/ui/views/NewServer.tsx +750 -0
- package/src/ui/views/PhpUpgrade.tsx +301 -0
- package/src/ui/views/ProviderConnect.tsx +527 -0
- package/src/ui/views/PurgeCache.tsx +186 -0
- package/src/ui/views/ReleaseNotes.tsx +84 -0
- package/src/ui/views/Search.tsx +448 -0
- package/src/ui/views/ServerActions.tsx +328 -0
- package/src/ui/views/Stacks.tsx +371 -0
- package/src/ui/views/SudoConnect.tsx +358 -0
- package/src/ui/views/VanityNewSite.tsx +407 -0
- package/src/ui/views/WpInventory.tsx +218 -0
- 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
|
+
}
|