spinuptui 0.28.0 → 1.1.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.
@@ -35,6 +35,13 @@ const KIND_FIELDS: Array<[keyof KumaMonitorRef, IncidentKind]> = [
35
35
  ["bypassId", "bypass"],
36
36
  ]
37
37
 
38
+ // Does this ref name at least one monitor `resolveIncidents` would query? A ref
39
+ // can outlive its monitors (removing one deletes its id, not the entry), so an
40
+ // entry's mere presence isn't the answer.
41
+ export function hasIncidentMonitors(ref: KumaMonitorRef | undefined): boolean {
42
+ return !!ref && KIND_FIELDS.some(([field]) => typeof ref[field] === "number")
43
+ }
44
+
38
45
  interface Target {
39
46
  domain: string
40
47
  kind: IncidentKind
@@ -0,0 +1,181 @@
1
+ // `spinuptui siblings <domain>` — every site sharing a server with this one,
2
+ // for external tooling (e.g. an incident-diagnostics agent handed one domain).
3
+ //
4
+ // The motivating case: a server's vanity host pages on `?healthz`, but the
5
+ // vanity site is an empty placeholder — the load is always a co-tenant, and
6
+ // site isolation means the vanity user can't read the neighbour's logs. The
7
+ // agent needs to know who else lives on the box, and which of them it can
8
+ // actually SSH into, before it can find the request-level cause. Answering that
9
+ // from the API takes two calls; discovering it by hand took weeks.
10
+
11
+ import type { AppConfig } from "../config.ts"
12
+ import type { Server, Site } from "../api/types.ts"
13
+ import type { SpinupWPClientLike } from "../api/client.ts"
14
+ import { ApiError } from "../api/client.ts"
15
+ import {
16
+ resolveSiteByDomain,
17
+ probeSshTarget,
18
+ type SshAccessReason,
19
+ type SshAccessCandidate,
20
+ type SshProbeStatus,
21
+ } from "./cliSsh.ts"
22
+ import { hasIncidentMonitors } from "./cliIncidents.ts"
23
+ import { resolveSiteSshTarget } from "./probe.ts"
24
+ import { isVanityPair } from "./vanitySite.ts"
25
+
26
+ // Why a co-tenant's logs may be out of reach. `ok` means this device's key is
27
+ // accepted for that site user; `permission_denied` is the one worth acting on
28
+ // (grant the key with K in the Browser view) and the usual reason a neighbour's
29
+ // access log can't be read.
30
+ export type SiblingSshStatus = SshProbeStatus
31
+
32
+ export interface SiblingSite {
33
+ domain: string
34
+ siteUser: string | null
35
+ sshTarget: string
36
+ isWordPress: boolean
37
+ phpVersion: string | null
38
+ pageCache: boolean | null
39
+ // The site living at the server's own hostname — a placeholder whose monitors
40
+ // report on the whole box, so its pages are rarely about itself.
41
+ isVanity: boolean
42
+ // Has Uptime Kuma monitors registered through SpinupTUI, so `spinuptui
43
+ // incidents <domain>` will return something for it.
44
+ monitored: boolean
45
+ // The domain that was asked about, so a caller can tell itself apart from the
46
+ // neighbours without re-matching on additional domains.
47
+ isQuery: boolean
48
+ // Only present with --probe.
49
+ ssh?: SiblingSshStatus
50
+ sshMessage?: string
51
+ }
52
+
53
+ export interface SiblingsServer {
54
+ id: number
55
+ name: string
56
+ ip: string | null
57
+ // Needed alongside each site's sshTarget; null means the default, 22.
58
+ sshPort: number | null
59
+ provider: string | null
60
+ size: string | null
61
+ ubuntuVersion: string | null
62
+ }
63
+
64
+ export type SiblingsResult =
65
+ | {
66
+ ok: true
67
+ domain: string
68
+ primaryDomain: string
69
+ server: SiblingsServer
70
+ siteCount: number
71
+ sites: SiblingSite[]
72
+ }
73
+ | {
74
+ ok: false
75
+ domain: string
76
+ reason: SshAccessReason
77
+ message: string
78
+ remedy?: string
79
+ candidates?: SshAccessCandidate[]
80
+ }
81
+
82
+ // Every probe hits the same sshd, so they're capped rather than all fired at
83
+ // once: OpenSSH's default MaxStartups (10:30:100) starts dropping connections
84
+ // that are still authenticating past 10, which would turn a big server's
85
+ // result into random connection failures. A small burst of refused logins is
86
+ // also gentler on any fail2ban-style guard watching that sshd.
87
+ const PROBE_CONCURRENCY = 4
88
+
89
+ async function mapLimit<T, R>(items: T[], limit: number, fn: (item: T) => Promise<R>): Promise<R[]> {
90
+ const out = new Array<R>(items.length)
91
+ let next = 0
92
+ const worker = async () => {
93
+ while (next < items.length) {
94
+ const i = next++
95
+ out[i] = await fn(items[i]!)
96
+ }
97
+ }
98
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker))
99
+ return out
100
+ }
101
+
102
+ function describe(site: Site, server: Server, cfg: AppConfig, queryId: number): SiblingSite {
103
+ return {
104
+ domain: site.domain,
105
+ siteUser: site.site_user,
106
+ sshTarget: resolveSiteSshTarget(site, server, cfg.sshUser),
107
+ isWordPress: site.is_wordpress,
108
+ phpVersion: site.php_version,
109
+ pageCache: site.page_cache ? site.page_cache.enabled : null,
110
+ isVanity: isVanityPair(site.domain, server.name),
111
+ monitored: hasIncidentMonitors(cfg.kumaMonitors[site.domain]),
112
+ isQuery: site.id === queryId,
113
+ }
114
+ }
115
+
116
+ export async function resolveSiblings(
117
+ domain: string,
118
+ client: SpinupWPClientLike,
119
+ cfg: AppConfig,
120
+ opts?: { server?: string | null; probe?: boolean },
121
+ ): Promise<SiblingsResult> {
122
+ const resolved = await resolveSiteByDomain(domain, client, cfg, { server: opts?.server ?? null })
123
+ if (!resolved.ok) return resolved.result
124
+
125
+ const { site, server } = resolved
126
+
127
+ let siteList: Site[]
128
+ try {
129
+ siteList = await client.listSites(server.id)
130
+ } catch (err) {
131
+ if (err instanceof ApiError && err.status === 401) {
132
+ return {
133
+ ok: false,
134
+ domain,
135
+ reason: "token_rejected",
136
+ message: "Saved API token was rejected (401) — it may be expired or revoked.",
137
+ remedy: "Run `spinuptui login` to save a fresh token.",
138
+ }
139
+ }
140
+ const message = err instanceof ApiError ? err.message : `Unexpected error: ${(err as Error).message}`
141
+ return { ok: false, domain, reason: "api_error", message }
142
+ }
143
+
144
+ // The vanity host first, then the queried site, then alphabetically: a reader
145
+ // scanning the list sees what the box calls itself before its tenants.
146
+ const sorted = [...siteList].sort((a, b) => {
147
+ const rank = (s: Site) =>
148
+ isVanityPair(s.domain, server.name) ? 0 : s.id === site.id ? 1 : 2
149
+ return rank(a) - rank(b) || a.domain.localeCompare(b.domain)
150
+ })
151
+
152
+ const sites = sorted.map((s) => describe(s, server, cfg, site.id))
153
+ const port = server.ssh_port && server.ssh_port !== 22 ? server.ssh_port : null
154
+
155
+ // resolveSiteByDomain has already refused a server with no IP, so every
156
+ // sshTarget here is a real user@ip.
157
+ if (opts?.probe) {
158
+ const probes = await mapLimit(sites, PROBE_CONCURRENCY, (s) => probeSshTarget(s.sshTarget, port))
159
+ probes.forEach((p, i) => {
160
+ sites[i]!.ssh = p.status
161
+ if (p.message) sites[i]!.sshMessage = p.message
162
+ })
163
+ }
164
+
165
+ return {
166
+ ok: true,
167
+ domain,
168
+ primaryDomain: site.domain,
169
+ server: {
170
+ id: server.id,
171
+ name: server.name,
172
+ ip: server.ip_address,
173
+ sshPort: port,
174
+ provider: server.provider_name,
175
+ size: server.size,
176
+ ubuntuVersion: server.ubuntu_version,
177
+ },
178
+ siteCount: sites.length,
179
+ sites,
180
+ }
181
+ }
package/src/lib/cliSsh.ts CHANGED
@@ -169,15 +169,17 @@ export async function resolveSshTargetInfo(
169
169
  }
170
170
  }
171
171
 
172
- export async function resolveSshAccess(
173
- domain: string,
174
- client: SpinupWPClientLike,
175
- cfg: AppConfig,
176
- opts?: { server?: string | null },
177
- ): Promise<SshAccessResult> {
178
- const resolution = await resolveSshTargetInfo(domain, client, cfg, opts)
179
- if (!resolution.ok) return resolution.result
180
- const { primaryDomain, sshTarget: target, port, server: serverName } = resolution.info
172
+ // One BatchMode `ssh … true` against an already-resolved target, classified.
173
+ // Shared by `ssh` (one domain) and `siblings --probe` (every site on a server)
174
+ // so both read a refused key, an unreachable box and anything else the same way.
175
+ export type SshProbeStatus = "ok" | "permission_denied" | "connection_failed" | "ssh_error"
176
+
177
+ const PROBE_TIMEOUT_MS = 15_000
178
+
179
+ export async function probeSshTarget(
180
+ target: string,
181
+ port: number | null,
182
+ ): Promise<{ status: SshProbeStatus; message?: string }> {
181
183
  const portOpt = port ? ["-p", String(port)] : []
182
184
 
183
185
  let proc: ReturnType<typeof Bun.spawn>
@@ -188,46 +190,58 @@ export async function resolveSshAccess(
188
190
  stdin: "ignore",
189
191
  })
190
192
  } catch (err) {
191
- return { ok: false, domain, reason: "ssh_error", message: `Failed to launch ssh: ${(err as Error).message}` }
193
+ return { status: "ssh_error", message: `Failed to launch ssh: ${(err as Error).message}` }
192
194
  }
193
195
 
196
+ let timedOut = false
194
197
  const timeout = setTimeout(() => {
198
+ timedOut = true
195
199
  try {
196
200
  proc.kill()
197
201
  } catch {
198
202
  /* already gone */
199
203
  }
200
- }, 15000)
204
+ }, PROBE_TIMEOUT_MS)
201
205
 
202
206
  const exitCode = await proc.exited
203
207
  clearTimeout(timeout)
204
- const stderr = await new Response(proc.stderr as ReadableStream<Uint8Array>).text()
208
+ if (exitCode === 0) return { status: "ok" }
205
209
 
206
- if (exitCode === 0) {
207
- return { ok: true, domain, primaryDomain, sshTarget: target, port, server: serverName }
210
+ // Killed by our own timer: ssh never got to say why, so say it here rather
211
+ // than reporting the kill signal's exit code as an unexplained ssh error.
212
+ if (timedOut) {
213
+ return { status: "connection_failed", message: `ssh did not finish within ${PROBE_TIMEOUT_MS / 1000}s.` }
208
214
  }
209
215
 
210
- if (/permission denied/i.test(stderr)) {
211
- return {
212
- ok: false,
213
- domain,
214
- reason: "permission_denied",
215
- message: stderr.trim().split("\n").slice(-2).join(" ") || "Permission denied.",
216
- remedy: GRANT_KEY_REMEDY,
217
- }
218
- }
216
+ const stderr = await new Response(proc.stderr as ReadableStream<Uint8Array>).text()
217
+ const tail = stderr.trim().split("\n").slice(-2).join(" ")
218
+
219
+ if (/permission denied/i.test(stderr)) return { status: "permission_denied", message: tail || "Permission denied." }
219
220
  if (/(connection timed out|operation timed out|connection refused|could not resolve)/i.test(stderr)) {
220
- return {
221
- ok: false,
222
- domain,
223
- reason: "connection_failed",
224
- message: stderr.trim().split("\n").slice(-2).join(" ") || `ssh exited with code ${exitCode}`,
225
- }
221
+ return { status: "connection_failed", message: tail || `ssh exited with code ${exitCode}` }
222
+ }
223
+ return { status: "ssh_error", message: tail || `ssh exited with code ${exitCode}` }
224
+ }
225
+
226
+ export async function resolveSshAccess(
227
+ domain: string,
228
+ client: SpinupWPClientLike,
229
+ cfg: AppConfig,
230
+ opts?: { server?: string | null },
231
+ ): Promise<SshAccessResult> {
232
+ const resolution = await resolveSshTargetInfo(domain, client, cfg, opts)
233
+ if (!resolution.ok) return resolution.result
234
+ const { primaryDomain, sshTarget: target, port, server: serverName } = resolution.info
235
+
236
+ const probe = await probeSshTarget(target, port)
237
+ if (probe.status === "ok") {
238
+ return { ok: true, domain, primaryDomain, sshTarget: target, port, server: serverName }
226
239
  }
227
240
  return {
228
241
  ok: false,
229
242
  domain,
230
- reason: "ssh_error",
231
- message: stderr.trim().split("\n").slice(-2).join(" ") || `ssh exited with code ${exitCode}`,
243
+ reason: probe.status,
244
+ message: probe.message ?? "ssh failed.",
245
+ ...(probe.status === "permission_denied" ? { remedy: GRANT_KEY_REMEDY } : {}),
232
246
  }
233
247
  }
@@ -0,0 +1,180 @@
1
+ // Carry a site's SpinupWP settings onto its clone: page cache (on/off, duration,
2
+ // exclusions), the nginx security toggles, path redirects, and the server-level WP
3
+ // cron interval. The create payload can only say "page cache on", so without this
4
+ // a clone comes out with SpinupWP's defaults for everything else. Each setting is
5
+ // compared first and only a difference is written, so a retry re-runs it safely.
6
+ //
7
+ // Not carried here: basic auth (the password can't be read from the API — the
8
+ // result flags it instead) and backups (stored per DOMAIN in the bucket, so a clone
9
+ // backing up before DNS cutover would write over production's files; they're
10
+ // applied at cutover).
11
+
12
+ import type { SpinupWPClientLike } from "../api/client.ts"
13
+ import { WP_CRON_INTERVALS, type NginxSettings, type PageCachePayload, type PageCacheSettings, type PathRedirect, type Site } from "../api/types.ts"
14
+ import { pollEvent } from "./cloneDomains.ts"
15
+
16
+ export interface SettingsCarryResult {
17
+ carried: string[] // short labels for what was written ("page cache", "3 redirects", …)
18
+ failed: { what: string; error: string }[]
19
+ basicAuthMissing: boolean // the source is password-protected, the clone isn't
20
+ }
21
+
22
+ type Log = (entry: Record<string, unknown>) => void
23
+
24
+ // Page-cache exclusion lists read back as one pipe-joined regex but are written one
25
+ // entry per line. Split on top-level pipes only, so an alternation inside a group
26
+ // or class — "(a|b)", "[|]" — stays one entry. Exported for the test harness.
27
+ export function splitCacheList(joined: string): string[] {
28
+ const out: string[] = []
29
+ let depth = 0
30
+ let cur = ""
31
+ for (let i = 0; i < joined.length; i++) {
32
+ const ch = joined[i]!
33
+ if (ch === "\\") {
34
+ cur += ch + (joined[i + 1] ?? "")
35
+ i++
36
+ continue
37
+ }
38
+ if (ch === "(" || ch === "[" || ch === "{") depth++
39
+ else if ((ch === ")" || ch === "]" || ch === "}") && depth > 0) depth--
40
+ if (ch === "|" && depth === 0) {
41
+ out.push(cur)
42
+ cur = ""
43
+ continue
44
+ }
45
+ cur += ch
46
+ }
47
+ out.push(cur)
48
+ return out.filter((e) => e !== "")
49
+ }
50
+
51
+ const CACHE_LISTS = ["path_exclusions", "cookie_exclusions", "ignored_query_params"] as const
52
+
53
+ // What to send so the dest's page cache matches the source's: only the fields that
54
+ // differ, and never a list the source leaves unset (null reads as "SpinupWP's
55
+ // default", which the dest already has).
56
+ function pageCacheDiff(src: PageCacheSettings, dst: PageCacheSettings | undefined): PageCachePayload {
57
+ const out: PageCachePayload = {}
58
+ if (src.duration != null && src.duration_unit != null && (src.duration !== dst?.duration || src.duration_unit !== dst?.duration_unit)) {
59
+ out.duration = src.duration
60
+ out.duration_unit = src.duration_unit
61
+ }
62
+ for (const k of CACHE_LISTS) {
63
+ const v = src[k]
64
+ if (v != null && v !== dst?.[k]) out[k] = splitCacheList(v).join("\n")
65
+ }
66
+ return out
67
+ }
68
+
69
+ // Wait for each event and turn the outcome into an error string (undefined = done).
70
+ async function settle(client: SpinupWPClientLike, eventIds: number[]): Promise<string | undefined> {
71
+ for (const id of eventIds) {
72
+ const r = await pollEvent(client, id)
73
+ if (r.outcome === "failed") return `event ${id} failed on SpinupWP`
74
+ if (r.outcome === "timeout") return `gave up waiting for event ${id}${r.lastStatus ? ` (still "${r.lastStatus}")` : ""} — retry the site to re-check`
75
+ }
76
+ return undefined
77
+ }
78
+
79
+ const redirectKey = (r: Pick<PathRedirect, "from" | "to">) => `${r.from}\u0000${r.to}`
80
+
81
+ export async function carrySiteSettings(
82
+ client: SpinupWPClientLike,
83
+ sourceSiteId: number,
84
+ destSiteId: number,
85
+ wpCron: { source?: number | null; dest?: number | null },
86
+ log?: Log,
87
+ ): Promise<SettingsCarryResult> {
88
+ const result: SettingsCarryResult = { carried: [], failed: [], basicAuthMissing: false }
89
+ let src: Site
90
+ let dst: Site
91
+ try {
92
+ ;[src, dst] = await Promise.all([client.getSite(sourceSiteId), client.getSite(destSiteId)])
93
+ } catch (err) {
94
+ result.failed.push({ what: "settings", error: `couldn't read the sites: ${(err as Error).message}` })
95
+ return result
96
+ }
97
+
98
+ // Each step records its own outcome; one failing never stops the rest.
99
+ const step = async (what: string, run: () => Promise<number[] | null>) => {
100
+ try {
101
+ const events = await run()
102
+ if (events == null) return // nothing to change
103
+ const error = await settle(client, events)
104
+ if (error) {
105
+ result.failed.push({ what, error })
106
+ log?.({ event: "settings-carry", what, ok: false, error })
107
+ } else {
108
+ result.carried.push(what)
109
+ log?.({ event: "settings-carry", what, ok: true })
110
+ }
111
+ } catch (err) {
112
+ const error = (err as Error).message
113
+ result.failed.push({ what, error })
114
+ log?.({ event: "settings-carry", what, ok: false, error })
115
+ }
116
+ }
117
+
118
+ // Page cache
119
+ const sc = src.page_cache
120
+ if (sc) {
121
+ await step("page cache", async () => {
122
+ if (!sc.enabled) return dst.page_cache?.enabled ? [(await client.disablePageCache(destSiteId)).event_id] : null
123
+ const diff = pageCacheDiff(sc, dst.page_cache)
124
+ if (!dst.page_cache?.enabled) return [(await client.enablePageCache(destSiteId, diff)).event_id]
125
+ return Object.keys(diff).length > 0 ? [(await client.updatePageCache(destSiteId, diff)).event_id] : null
126
+ })
127
+ }
128
+
129
+ // Nginx security toggles (public_folder is set at create and handled by the pull)
130
+ const sn = src.nginx ?? {}
131
+ const nginx: NginxSettings = {}
132
+ for (const k of ["uploads_directory_protected", "xmlrpc_protected", "subdirectory_rewrite_in_place"] as const) {
133
+ if (sn[k] != null && sn[k] !== dst.nginx?.[k]) nginx[k] = sn[k]
134
+ }
135
+ if (Object.keys(nginx).length > 0) {
136
+ await step("security settings", async () => (await client.updateNginx(destSiteId, nginx)).event_ids)
137
+ }
138
+
139
+ // Path redirects: add the source's that the dest lacks. The dest's own extras
140
+ // are left alone (a clone shouldn't delete anything).
141
+ let srcRedirects: PathRedirect[] = []
142
+ try {
143
+ srcRedirects = await client.listPathRedirects(sourceSiteId)
144
+ } catch (err) {
145
+ result.failed.push({ what: "redirects", error: `couldn't list the source's redirects: ${(err as Error).message}` })
146
+ }
147
+ if (srcRedirects.length > 0) {
148
+ let have = new Set<string>()
149
+ try {
150
+ have = new Set((await client.listPathRedirects(destSiteId)).map(redirectKey))
151
+ } catch {
152
+ /* add them all; a duplicate is rejected per redirect and reported */
153
+ }
154
+ const missing = srcRedirects.filter((r) => !have.has(redirectKey(r)))
155
+ if (missing.length > 0) {
156
+ await step(`${missing.length} redirect${missing.length === 1 ? "" : "s"}`, async () => {
157
+ const events: number[] = []
158
+ for (const r of missing) events.push((await client.addPathRedirect(destSiteId, { from: r.from, to: r.to, type: r.type })).event_id)
159
+ return events
160
+ })
161
+ }
162
+ }
163
+
164
+ // WP cron: both ends' intervals come from their crontabs (undefined = unread,
165
+ // null = no managed job recognized). Only an interval is ever carried: a source
166
+ // with no recognizable job might have WP cron off, or a line shape we don't
167
+ // parse, and switching the clone's off on a guess is worse than leaving it.
168
+ const want = wpCron.source
169
+ const have = wpCron.dest
170
+ if (want != null && have !== undefined && want !== have) {
171
+ if (!(WP_CRON_INTERVALS as readonly number[]).includes(want)) {
172
+ result.failed.push({ what: "WP cron", error: `the source runs WP cron every ${want} min, which SpinupWP's API doesn't offer` })
173
+ } else {
174
+ await step(`WP cron every ${want} min`, async () => [(have == null ? await client.enableWpCron(destSiteId, want) : await client.updateWpCron(destSiteId, want)).event_id])
175
+ }
176
+ }
177
+
178
+ result.basicAuthMissing = !!src.basic_auth?.enabled && !dst.basic_auth?.enabled
179
+ return result
180
+ }