spinuptui 0.20.1 → 0.21.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 +65 -1
- package/README.md +29 -11
- package/package.json +1 -1
- package/src/config.ts +3 -0
- package/src/index.tsx +44 -1
- package/src/lib/cliIncidents.ts +126 -0
- package/src/lib/cliSsh.ts +161 -0
- package/src/lib/probe.ts +1 -1
- package/src/lib/ssh.ts +46 -0
- package/src/lib/uptimeKuma.ts +99 -3
- package/src/lib/vanitySite.ts +74 -0
- package/src/ui/Header.tsx +10 -2
- package/src/ui/store.tsx +123 -6
- package/src/ui/views/KumaSite.tsx +508 -127
package/CHANGELOG.md
CHANGED
|
@@ -11,6 +11,69 @@ versions; such changes are called out here.
|
|
|
11
11
|
|
|
12
12
|
## [Unreleased]
|
|
13
13
|
|
|
14
|
+
## [0.21.0] - 2026-07-09
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
- **`spinuptui ssh <domain>`** — non-interactive CLI subcommand for external
|
|
18
|
+
tooling (e.g. an incident-diagnostics agent that's handed only a domain).
|
|
19
|
+
Resolves the domain to its site/server, builds the SSH target, and runs a
|
|
20
|
+
live connectivity probe, returning JSON with a classified failure reason
|
|
21
|
+
(site not found, ambiguous domain match, server has no IP, key not
|
|
22
|
+
granted, token rejected, etc.) when it can't connect.
|
|
23
|
+
- **`spinuptui incidents <domain>` / `--all`** — surfaces Uptime Kuma's
|
|
24
|
+
down/up incident history as JSON, scoped to sites SpinupTUI manages
|
|
25
|
+
monitoring for. `--all` sweeps every such site over a single Kuma
|
|
26
|
+
connection; `--hours` sets the lookback window (default 24). Pairs with
|
|
27
|
+
`spinuptui ssh` for an external agent's discover → access → report loop.
|
|
28
|
+
- **Server-wide PHP-fatal sentinel monitor.** Fixes a real monitoring blind
|
|
29
|
+
spot: SpinupWP's page cache keeps serving `/` even while PHP is fataling on
|
|
30
|
+
anything the cache doesn't already have, so the existing health/fingerprint
|
|
31
|
+
monitors can sleep through a real outage (confirmed against a 2026-07-07
|
|
32
|
+
incident that went undetected for 54 of its 68 minutes). A root-level cron
|
|
33
|
+
(one per server, added via the monitoring overlay's repair flow once sudo
|
|
34
|
+
is connected — same place the Redis sentinel is set up) tails every site's
|
|
35
|
+
error/debug logs each minute for new `PHP Fatal error` lines and reports
|
|
36
|
+
up/down to a single new Kuma push monitor, with the actually-affected
|
|
37
|
+
site domain(s) carried in the down message. Surfaces automatically in
|
|
38
|
+
`spinuptui incidents --all`.
|
|
39
|
+
- **Cache-bypass monitor, opt-in per site.**
|
|
40
|
+
Closes the other half of the page-cache blind spot: a wedged PHP-FPM pool
|
|
41
|
+
that never logs a fatal is invisible to both the plain health monitor and
|
|
42
|
+
the new fatal sentinel above, since nginx serves the cache without ever
|
|
43
|
+
touching PHP-FPM. This registers an ordinary Kuma HTTP monitor with a
|
|
44
|
+
`Cookie: wordpress_no_cache=1` header — the same page-cache bypass
|
|
45
|
+
`siteDoctor.ts` already uses — so it actually exercises PHP on every check
|
|
46
|
+
(verified live: `fastcgi-cache-bypass-reason: COOKIE`). Deliberately opt-in
|
|
47
|
+
(not automatic) and defaults to a 1h check window (30m/1h/2h/4h/6h
|
|
48
|
+
choices), since forcing a real render costs the site something, unlike the
|
|
49
|
+
cache-served health/fingerprint checks — meant for sites with an actual
|
|
50
|
+
history of this failure mode, not the whole fleet.
|
|
51
|
+
|
|
52
|
+
### Changed
|
|
53
|
+
- **Site/server monitoring overlay rebuilt as a full-screen two-pane browser**
|
|
54
|
+
(`m` in the sites pane), replacing the small centered popover. A narrow
|
|
55
|
+
left list of the site's monitor kinds (with a live status dot) sits next
|
|
56
|
+
to a wide detail pane showing whichever one is selected — its one-line
|
|
57
|
+
definition, a longer explanation of the actual mechanism, live status, and
|
|
58
|
+
how to act on it. Grew out of real confusion testing the popover version:
|
|
59
|
+
six monitor kinds across two site contexts didn't fit legibly in a 68-col
|
|
60
|
+
box, and a separate glossary overlay left the explanation disconnected
|
|
61
|
+
from live status. Also **collapses the per-kind action keys into one `a`**
|
|
62
|
+
that acts on whichever monitor is currently highlighted (front page
|
|
63
|
+
selected → calibration picker; cache bypass selected → its picker;
|
|
64
|
+
anything else → register/repair) — fewer keys to remember now that the
|
|
65
|
+
detail pane shows which monitor you're acting on. The overlay also gained:
|
|
66
|
+
a passive "Alerts: …" header line (fetched once per open, not polled) so
|
|
67
|
+
notification wiring is visible without pressing `n`; `o` to deep-link
|
|
68
|
+
straight to the selected monitor's own page in Uptime Kuma; and `x` to
|
|
69
|
+
remove the front-page or cache-bypass monitor (confirm-gated) — scoped to
|
|
70
|
+
just those two kinds, since the other four re-derive themselves
|
|
71
|
+
automatically on the next repair and would silently reappear.
|
|
72
|
+
- **The header's fleet-wide "N monitors down" badge now names the domain(s)**
|
|
73
|
+
(falling back to a bare count once there are too many to list) and points
|
|
74
|
+
at the Search tab to find them, instead of an unactionable number with no
|
|
75
|
+
way to tell which site it means.
|
|
76
|
+
|
|
14
77
|
## [0.20.1] - 2026-07-08
|
|
15
78
|
|
|
16
79
|
### Changed
|
|
@@ -996,7 +1059,8 @@ Initial tagged release.
|
|
|
996
1059
|
### Notes
|
|
997
1060
|
- Read-only release: works with a SpinupWP **Read Only** API token.
|
|
998
1061
|
|
|
999
|
-
[Unreleased]: https://github.com/mwender/spinupwp-tui/compare/v0.
|
|
1062
|
+
[Unreleased]: https://github.com/mwender/spinupwp-tui/compare/v0.21.0...HEAD
|
|
1063
|
+
[0.21.0]: https://github.com/mwender/spinupwp-tui/compare/v0.20.1...v0.21.0
|
|
1000
1064
|
[0.20.1]: https://github.com/mwender/spinupwp-tui/compare/v0.20.0...v0.20.1
|
|
1001
1065
|
[0.20.0]: https://github.com/mwender/spinupwp-tui/compare/v0.19.1...v0.20.0
|
|
1002
1066
|
[0.19.1]: https://github.com/mwender/spinupwp-tui/compare/v0.19.0...v0.19.1
|
package/README.md
CHANGED
|
@@ -65,12 +65,19 @@
|
|
|
65
65
|
[how it works](docs/clone-wizard-explained.md)
|
|
66
66
|
- **Privileged writes over SSH** (`S` / `K`) — grant or revoke SSH keys directly,
|
|
67
67
|
since the API can't. → [docs/privileged-ssh-writes.md](docs/privileged-ssh-writes.md)
|
|
68
|
-
- **
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
→ [docs/site-monitoring.md](docs/site-monitoring.md)
|
|
68
|
+
- **Site & server monitoring** (`m`) — a two-pane browser of a site's Uptime
|
|
69
|
+
Kuma monitors (health, front-page fingerprint, an opt-in cache-bypass
|
|
70
|
+
check, and, for vanity/server sites, load/Redis/PHP-fatal sentinels), each
|
|
71
|
+
with a live status dot, an in-context explanation, and one action key to
|
|
72
|
+
register/recalibrate/remove it — plus a cache/outage doctor and alert
|
|
73
|
+
wiring. → [docs/site-monitoring.md](docs/site-monitoring.md) ·
|
|
74
|
+
[docs/uptime-kuma.md](docs/uptime-kuma.md)
|
|
75
|
+
- **Non-interactive CLI subcommands for external tooling** — `spinuptui ssh <domain>`
|
|
76
|
+
resolves a domain to a live SSH target with a live connectivity probe;
|
|
77
|
+
`spinuptui incidents <domain>` / `--all` surfaces Uptime Kuma's down/up
|
|
78
|
+
history as JSON. Built so another agent/script can go from just a domain to
|
|
79
|
+
"what's wrong and how do I get in" with no manual lookup.
|
|
80
|
+
→ [CLI subcommands](#cli-subcommands)
|
|
74
81
|
- **Completion toasts** — a non-focus-stealing toast when a background write
|
|
75
82
|
(PHP upgrade, reboot, DNS resolve, …) finishes.
|
|
76
83
|
- **Release notes** — an in-app "what's new" after every update, sourced
|
|
@@ -156,6 +163,15 @@ to the version in the header (and in the `?` About panel).
|
|
|
156
163
|
spinuptui Launch the dashboard
|
|
157
164
|
spinuptui login Set or update your saved API token
|
|
158
165
|
spinuptui where Show the config path and which source the token came from
|
|
166
|
+
spinuptui ssh <domain> Print SSH access info for a site as JSON (resolves the
|
|
167
|
+
site's server, builds its SSH target, and runs a live
|
|
168
|
+
connectivity probe — for external tooling, e.g. an
|
|
169
|
+
incident-diagnostics agent handed only a domain)
|
|
170
|
+
spinuptui incidents <domain> | --all [--hours N] Print Uptime Kuma down/up
|
|
171
|
+
incidents as JSON, scoped to sites SpinupTUI manages
|
|
172
|
+
monitoring for (config.json's kumaMonitors) — --all
|
|
173
|
+
sweeps every such site in one Kuma connection, --hours
|
|
174
|
+
sets the lookback window (default 24)
|
|
159
175
|
spinuptui --version Print the version
|
|
160
176
|
spinuptui --help Show help
|
|
161
177
|
```
|
|
@@ -232,8 +248,7 @@ These can be set in `config.json` or via an environment variable:
|
|
|
232
248
|
| `u` | Upgrade a site's PHP version (Servers / Stacks / Search; needs a Read/Write token) |
|
|
233
249
|
| `H` | Enable / disable HTTPS on a site (Servers / Stacks / Search; needs a Read/Write token) |
|
|
234
250
|
| `P` | Purge a site's page cache + object cache (Servers / Stacks / Search; needs a Read/Write token) |
|
|
235
|
-
| `m` | Site monitoring
|
|
236
|
-
| `M` | Site monitoring overlay — add monitors, `f` front-page check, `d` site doctor, `n` alert wiring, vanity refresh/rotation (Servers / Stacks / Search) |
|
|
251
|
+
| `m` / `M` | Site/server monitoring — a two-pane browser of this site's Uptime Kuma monitors (`M` is an alias, since lowercase `m` is taken in Search/Stacks). Inside: `↑`/`↓` selects a monitor, `a` registers/recalibrates/repairs it (Front page and Cache bypass open a check-window picker), `x` removes Front page or Cache bypass, `o` opens the selected monitor in Kuma, `d` runs the site doctor, `n` shows/edits alert wiring, and vanity sites add `R`/`r` for page refresh/secret rotation (Servers / Stacks / Search) |
|
|
237
252
|
| `a` | Server actions: reboot / restart a service (Servers / Search; needs a Read/Write token) |
|
|
238
253
|
| `c` | Create a new server (Servers tab; needs a Read/Write token) |
|
|
239
254
|
| `V` | Add a vanity site at the server's own hostname — DNS + site + HTTPS + SSH-key handoff (Servers tab; offered when no hostname site exists; needs a Read/Write token) |
|
|
@@ -286,9 +301,12 @@ Needs a Read/Write token. Full details: [docs/server-actions.md](docs/server-act
|
|
|
286
301
|
|
|
287
302
|
## Site monitoring
|
|
288
303
|
|
|
289
|
-
`
|
|
290
|
-
|
|
291
|
-
|
|
304
|
+
`m` on a site opens a full-screen two-pane monitor browser — health,
|
|
305
|
+
front-page fingerprint, an opt-in cache-bypass check, and (vanity/server
|
|
306
|
+
sites) load/Redis/PHP-fatal sentinels, each with live status, an in-context
|
|
307
|
+
explanation, and a single action key to register/recalibrate/remove it —
|
|
308
|
+
plus a cache/outage doctor and Kuma alert wiring, all against your own
|
|
309
|
+
Uptime Kuma instance. Full details:
|
|
292
310
|
[docs/site-monitoring.md](docs/site-monitoring.md) ·
|
|
293
311
|
[docs/uptime-kuma.md](docs/uptime-kuma.md).
|
|
294
312
|
|
package/package.json
CHANGED
package/src/config.ts
CHANGED
|
@@ -116,6 +116,9 @@ export interface KumaMonitorRef {
|
|
|
116
116
|
pushToken?: string
|
|
117
117
|
redisId?: number // "{server} redis" push monitor fed by the cron's redis-cli ping (vanity/server domains)
|
|
118
118
|
redisToken?: string
|
|
119
|
+
fatalId?: number // "{server} php-fatal" server-wide sentinel fed by the root cron's error/debug.log tail (vanity/server domains)
|
|
120
|
+
fatalToken?: string
|
|
121
|
+
bypassId?: number // "{domain} cache-bypass" opt-in http monitor with a page-cache-bypass Cookie header (per-site, no token — Kuma polls it directly)
|
|
119
122
|
fingerprintId?: number // keyword monitor asserting the front page serves its own template
|
|
120
123
|
// What fingerprint calibration derived (lib/siteFingerprint.ts) — shown in the
|
|
121
124
|
// overlay and kept so a recalibration can explain what it's replacing.
|
package/src/index.tsx
CHANGED
|
@@ -12,9 +12,13 @@ import { StoreProvider } from "./ui/store.tsx"
|
|
|
12
12
|
import { App } from "./ui/App.tsx"
|
|
13
13
|
import { Onboarding } from "./ui/Onboarding.tsx"
|
|
14
14
|
import { isDevMode } from "./dev/devMode.ts"
|
|
15
|
+
import { SpinupWPClient } from "./api/client.ts"
|
|
16
|
+
import { resolveSshAccess } from "./lib/cliSsh.ts"
|
|
17
|
+
import { resolveIncidents } from "./lib/cliIncidents.ts"
|
|
15
18
|
|
|
16
19
|
const args = process.argv.slice(2)
|
|
17
|
-
const
|
|
20
|
+
const positionals = args.filter((a) => !a.startsWith("-"))
|
|
21
|
+
const command = positionals[0]
|
|
18
22
|
|
|
19
23
|
if (args.includes("-h") || args.includes("--help") || command === "help") {
|
|
20
24
|
const cfg = loadConfig()
|
|
@@ -24,6 +28,9 @@ Usage:
|
|
|
24
28
|
spinuptui Launch the dashboard
|
|
25
29
|
spinuptui login Set or update your saved API token
|
|
26
30
|
spinuptui where Print the config file path and token source
|
|
31
|
+
spinuptui ssh <domain> Print SSH access info for a site (JSON)
|
|
32
|
+
spinuptui incidents <domain> | --all [--hours N] Print Uptime Kuma
|
|
33
|
+
down/up incidents for a site or the whole fleet (JSON)
|
|
27
34
|
spinuptui --version Print the version
|
|
28
35
|
spinuptui --help Show this help
|
|
29
36
|
|
|
@@ -48,6 +55,42 @@ if (command === "where") {
|
|
|
48
55
|
process.exit(0)
|
|
49
56
|
}
|
|
50
57
|
|
|
58
|
+
if (command === "ssh") {
|
|
59
|
+
const domain = positionals[1]
|
|
60
|
+
if (!domain) {
|
|
61
|
+
console.error(JSON.stringify({ ok: false, reason: "usage", message: "Usage: spinuptui ssh <domain>" }))
|
|
62
|
+
process.exit(1)
|
|
63
|
+
}
|
|
64
|
+
const cfg = loadConfig()
|
|
65
|
+
const client = new SpinupWPClient(cfg)
|
|
66
|
+
const result = await resolveSshAccess(domain, client, cfg)
|
|
67
|
+
console.log(JSON.stringify(result))
|
|
68
|
+
process.exit(result.ok ? 0 : 1)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (command === "incidents") {
|
|
72
|
+
const rest = args.slice(1)
|
|
73
|
+
const hoursIdx = rest.indexOf("--hours")
|
|
74
|
+
const hours = hoursIdx !== -1 ? Number(rest[hoursIdx + 1]) : 24
|
|
75
|
+
const allFlag = rest.includes("--all")
|
|
76
|
+
const domainArg = rest.find((a, i) => !a.startsWith("-") && !(hoursIdx !== -1 && i === hoursIdx + 1))
|
|
77
|
+
|
|
78
|
+
if ((!domainArg && !allFlag) || (domainArg && allFlag) || !Number.isFinite(hours) || hours <= 0) {
|
|
79
|
+
console.error(
|
|
80
|
+
JSON.stringify({
|
|
81
|
+
ok: false,
|
|
82
|
+
reason: "usage",
|
|
83
|
+
message: "Usage: spinuptui incidents <domain> | spinuptui incidents --all [--hours N]",
|
|
84
|
+
}),
|
|
85
|
+
)
|
|
86
|
+
process.exit(1)
|
|
87
|
+
}
|
|
88
|
+
const cfg = loadConfig()
|
|
89
|
+
const result = await resolveIncidents(cfg, { domain: domainArg, hours })
|
|
90
|
+
console.log(JSON.stringify(result))
|
|
91
|
+
process.exit(result.ok ? 0 : 1)
|
|
92
|
+
}
|
|
93
|
+
|
|
51
94
|
// `spinuptui login` forces the onboarding wizard even when a token already exists,
|
|
52
95
|
// so the token can be (re)saved to the config file for global use.
|
|
53
96
|
const forceLogin = command === "login"
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// `spinuptui incidents <domain>` / `spinuptui incidents --all` — surfaces
|
|
2
|
+
// Uptime Kuma's down/up incident history for the fleet SpinupTUI itself
|
|
3
|
+
// manages monitoring for, for external tooling (e.g. an incident-diagnostics
|
|
4
|
+
// agent polling on its own schedule). Scoped to config.kumaMonitors, not
|
|
5
|
+
// Kuma's full live monitor list — a monitor added by hand in Kuma with no
|
|
6
|
+
// corresponding SpinupWP site would have nowhere actionable for a caller to
|
|
7
|
+
// go, so it's deliberately excluded.
|
|
8
|
+
|
|
9
|
+
import type { AppConfig, KumaMonitorRef } from "../config.ts"
|
|
10
|
+
import { withKuma, KumaError, type KumaBeat } from "./uptimeKuma.ts"
|
|
11
|
+
|
|
12
|
+
export type IncidentKind = "health" | "push" | "redis" | "fingerprint" | "fatal" | "bypass"
|
|
13
|
+
export type IncidentStatus = "down" | "up" | "pending" | "maintenance"
|
|
14
|
+
|
|
15
|
+
export interface IncidentEvent {
|
|
16
|
+
domain: string
|
|
17
|
+
kind: IncidentKind
|
|
18
|
+
status: IncidentStatus
|
|
19
|
+
time: string
|
|
20
|
+
message: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type IncidentsResult =
|
|
24
|
+
| { ok: true; windowHours: number; events: IncidentEvent[] }
|
|
25
|
+
| { ok: false; reason: "kuma_not_configured" | "no_monitors_registered" | "kuma_error"; message: string }
|
|
26
|
+
|
|
27
|
+
const STATUS_LABEL: Record<number, IncidentStatus> = { 0: "down", 1: "up", 2: "pending", 3: "maintenance" }
|
|
28
|
+
|
|
29
|
+
const KIND_FIELDS: Array<[keyof KumaMonitorRef, IncidentKind]> = [
|
|
30
|
+
["healthId", "health"],
|
|
31
|
+
["pushId", "push"],
|
|
32
|
+
["redisId", "redis"],
|
|
33
|
+
["fingerprintId", "fingerprint"],
|
|
34
|
+
["fatalId", "fatal"],
|
|
35
|
+
["bypassId", "bypass"],
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
interface Target {
|
|
39
|
+
domain: string
|
|
40
|
+
kind: IncidentKind
|
|
41
|
+
id: number
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function resolveIncidents(
|
|
45
|
+
cfg: AppConfig,
|
|
46
|
+
opts: { domain?: string; hours: number },
|
|
47
|
+
): Promise<IncidentsResult> {
|
|
48
|
+
if (!cfg.uptimeKuma) {
|
|
49
|
+
return {
|
|
50
|
+
ok: false,
|
|
51
|
+
reason: "kuma_not_configured",
|
|
52
|
+
message: "No Uptime Kuma connection configured in SpinupTUI.",
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const domains = opts.domain ? [opts.domain] : Object.keys(cfg.kumaMonitors)
|
|
57
|
+
const targets: Target[] = []
|
|
58
|
+
for (const domain of domains) {
|
|
59
|
+
const ref = cfg.kumaMonitors[domain]
|
|
60
|
+
if (!ref) continue
|
|
61
|
+
for (const [field, kind] of KIND_FIELDS) {
|
|
62
|
+
const id = ref[field]
|
|
63
|
+
if (typeof id === "number") targets.push({ domain, kind, id })
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (targets.length === 0) {
|
|
68
|
+
return {
|
|
69
|
+
ok: false,
|
|
70
|
+
reason: "no_monitors_registered",
|
|
71
|
+
message: opts.domain
|
|
72
|
+
? `No Kuma monitors registered for "${opts.domain}" in SpinupTUI.`
|
|
73
|
+
: "No Kuma monitors registered for any site in SpinupTUI.",
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
try {
|
|
78
|
+
const { result } = await withKuma(cfg.uptimeKuma, async (kuma) => {
|
|
79
|
+
const perTarget = await Promise.all(
|
|
80
|
+
targets.map(async (t) => {
|
|
81
|
+
try {
|
|
82
|
+
return { t, beats: await kuma.getMonitorBeats(t.id, opts.hours) }
|
|
83
|
+
} catch {
|
|
84
|
+
return { t, beats: [] as KumaBeat[] }
|
|
85
|
+
}
|
|
86
|
+
}),
|
|
87
|
+
)
|
|
88
|
+
const events: IncidentEvent[] = []
|
|
89
|
+
for (const { t, beats } of perTarget) {
|
|
90
|
+
for (const b of beats) {
|
|
91
|
+
if (!b.important) continue
|
|
92
|
+
const status = STATUS_LABEL[b.status] ?? "pending"
|
|
93
|
+
const message = b.msg || ""
|
|
94
|
+
// The fatal sentinel is registered under the server's vanity domain,
|
|
95
|
+
// not the site that's actually broken — a down beat pushed BY OUR
|
|
96
|
+
// OWN cron carries the real, comma-separated affected domain(s) as
|
|
97
|
+
// msg instead. But Kuma can ALSO generate its own synthetic down
|
|
98
|
+
// beats on this same push monitor (e.g. "No heartbeat in the time
|
|
99
|
+
// window" if the cron itself stops beating) — those are an
|
|
100
|
+
// infrastructure signal about the server, not a parseable domain
|
|
101
|
+
// list, and must NOT be mistaken for one (confirmed live: this
|
|
102
|
+
// exact message has no commas and isn't a hostname). Only messages
|
|
103
|
+
// that parse cleanly as a comma-separated list of hostname-shaped
|
|
104
|
+
// tokens get split into per-domain events; anything else — Kuma's
|
|
105
|
+
// own wording included — stays tagged to the vanity domain like
|
|
106
|
+
// every other kind.
|
|
107
|
+
const domainList = message.split(",").map((d) => d.trim())
|
|
108
|
+
const looksLikeDomainList = domainList.every((d) => /^[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?)+$/.test(d))
|
|
109
|
+
if (t.kind === "fatal" && status === "down" && looksLikeDomainList) {
|
|
110
|
+
for (const domain of domainList) {
|
|
111
|
+
events.push({ domain, kind: t.kind, status, time: b.time, message })
|
|
112
|
+
}
|
|
113
|
+
continue
|
|
114
|
+
}
|
|
115
|
+
events.push({ domain: t.domain, kind: t.kind, status, time: b.time, message })
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
events.sort((a, b) => a.time.localeCompare(b.time))
|
|
119
|
+
return events
|
|
120
|
+
})
|
|
121
|
+
return { ok: true, windowHours: opts.hours, events: result }
|
|
122
|
+
} catch (err) {
|
|
123
|
+
const message = err instanceof KumaError ? err.message : `Unexpected error: ${(err as Error).message}`
|
|
124
|
+
return { ok: false, reason: "kuma_error", message }
|
|
125
|
+
}
|
|
126
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// `spinuptui ssh <domain>` — non-interactive SSH access lookup for external
|
|
2
|
+
// tooling (e.g. an incident-diagnostics agent that's handed only a domain).
|
|
3
|
+
// Resolves domain -> site -> server -> SSH target, then runs a live
|
|
4
|
+
// connectivity probe (BatchMode, same as probeSite) so the result is a
|
|
5
|
+
// trustworthy go/no-go rather than a guess.
|
|
6
|
+
|
|
7
|
+
import type { AppConfig } from "../config.ts"
|
|
8
|
+
import type { SpinupWPClientLike } from "../api/client.ts"
|
|
9
|
+
import { ApiError } from "../api/client.ts"
|
|
10
|
+
import { resolveSiteSshTarget, SSH_OPTS } from "./probe.ts"
|
|
11
|
+
|
|
12
|
+
export type SshAccessReason =
|
|
13
|
+
| "no_token"
|
|
14
|
+
| "token_rejected"
|
|
15
|
+
| "site_not_found"
|
|
16
|
+
| "multiple_matches"
|
|
17
|
+
| "server_has_no_ip"
|
|
18
|
+
| "permission_denied"
|
|
19
|
+
| "connection_failed"
|
|
20
|
+
| "ssh_error"
|
|
21
|
+
| "api_error"
|
|
22
|
+
|
|
23
|
+
export interface SshAccessCandidate {
|
|
24
|
+
siteId: number
|
|
25
|
+
serverId: number
|
|
26
|
+
server: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type SshAccessResult =
|
|
30
|
+
| { ok: true; domain: string; primaryDomain: string; sshTarget: string; port: number | null; server: string }
|
|
31
|
+
| {
|
|
32
|
+
ok: false
|
|
33
|
+
domain: string
|
|
34
|
+
reason: SshAccessReason
|
|
35
|
+
message: string
|
|
36
|
+
remedy?: string
|
|
37
|
+
candidates?: SshAccessCandidate[]
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const GRANT_KEY_REMEDY =
|
|
41
|
+
"Run `spinuptui`, select this site in the Browser view, and press K to grant this device's key (sudo must be connected on the server first — press S)."
|
|
42
|
+
|
|
43
|
+
export async function resolveSshAccess(
|
|
44
|
+
domain: string,
|
|
45
|
+
client: SpinupWPClientLike,
|
|
46
|
+
cfg: AppConfig,
|
|
47
|
+
): Promise<SshAccessResult> {
|
|
48
|
+
if (!cfg.token) {
|
|
49
|
+
return { ok: false, domain, reason: "no_token", message: "No API token configured. Run `spinuptui login`." }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
let site
|
|
53
|
+
let server
|
|
54
|
+
try {
|
|
55
|
+
const sites = await client.listSites()
|
|
56
|
+
const matches = sites.filter(
|
|
57
|
+
(s) => s.domain === domain || s.additional_domains?.some((a) => a.domain === domain),
|
|
58
|
+
)
|
|
59
|
+
if (matches.length === 0) {
|
|
60
|
+
return {
|
|
61
|
+
ok: false,
|
|
62
|
+
domain,
|
|
63
|
+
reason: "site_not_found",
|
|
64
|
+
message: `No site matching "${domain}" found in this SpinupWP account.`,
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
if (matches.length > 1) {
|
|
68
|
+
const candidates: SshAccessCandidate[] = await Promise.all(
|
|
69
|
+
matches.map(async (s) => {
|
|
70
|
+
const srv = await client.getServer(s.server_id)
|
|
71
|
+
return { siteId: s.id, serverId: s.server_id, server: srv.name }
|
|
72
|
+
}),
|
|
73
|
+
)
|
|
74
|
+
return {
|
|
75
|
+
ok: false,
|
|
76
|
+
domain,
|
|
77
|
+
reason: "multiple_matches",
|
|
78
|
+
message: `"${domain}" matches ${matches.length} sites in this account — cannot pick one automatically.`,
|
|
79
|
+
candidates,
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
site = matches[0]
|
|
83
|
+
server = await client.getServer(site.server_id)
|
|
84
|
+
} catch (err) {
|
|
85
|
+
if (err instanceof ApiError && err.status === 401) {
|
|
86
|
+
return {
|
|
87
|
+
ok: false,
|
|
88
|
+
domain,
|
|
89
|
+
reason: "token_rejected",
|
|
90
|
+
message: "Saved API token was rejected (401) — it may be expired or revoked.",
|
|
91
|
+
remedy: "Run `spinuptui login` to save a fresh token.",
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
const message = err instanceof ApiError ? err.message : `Unexpected error: ${(err as Error).message}`
|
|
95
|
+
return { ok: false, domain, reason: "api_error", message }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (!server.ip_address) {
|
|
99
|
+
return {
|
|
100
|
+
ok: false,
|
|
101
|
+
domain,
|
|
102
|
+
reason: "server_has_no_ip",
|
|
103
|
+
message: `Server "${server.name}" has no IP address on file.`,
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const target = resolveSiteSshTarget(site, server, cfg.sshUser)
|
|
108
|
+
const port = server.ssh_port && server.ssh_port !== 22 ? server.ssh_port : null
|
|
109
|
+
const portOpt = port ? ["-p", String(port)] : []
|
|
110
|
+
|
|
111
|
+
let proc: ReturnType<typeof Bun.spawn>
|
|
112
|
+
try {
|
|
113
|
+
proc = Bun.spawn(["ssh", ...SSH_OPTS, ...portOpt, target, "true"], {
|
|
114
|
+
stdout: "pipe",
|
|
115
|
+
stderr: "pipe",
|
|
116
|
+
stdin: "ignore",
|
|
117
|
+
})
|
|
118
|
+
} catch (err) {
|
|
119
|
+
return { ok: false, domain, reason: "ssh_error", message: `Failed to launch ssh: ${(err as Error).message}` }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const timeout = setTimeout(() => {
|
|
123
|
+
try {
|
|
124
|
+
proc.kill()
|
|
125
|
+
} catch {
|
|
126
|
+
/* already gone */
|
|
127
|
+
}
|
|
128
|
+
}, 15000)
|
|
129
|
+
|
|
130
|
+
const exitCode = await proc.exited
|
|
131
|
+
clearTimeout(timeout)
|
|
132
|
+
const stderr = await new Response(proc.stderr as ReadableStream<Uint8Array>).text()
|
|
133
|
+
|
|
134
|
+
if (exitCode === 0) {
|
|
135
|
+
return { ok: true, domain, primaryDomain: site.domain, sshTarget: target, port, server: server.name }
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (/permission denied/i.test(stderr)) {
|
|
139
|
+
return {
|
|
140
|
+
ok: false,
|
|
141
|
+
domain,
|
|
142
|
+
reason: "permission_denied",
|
|
143
|
+
message: stderr.trim().split("\n").slice(-2).join(" ") || "Permission denied.",
|
|
144
|
+
remedy: GRANT_KEY_REMEDY,
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
if (/(connection timed out|operation timed out|connection refused|could not resolve)/i.test(stderr)) {
|
|
148
|
+
return {
|
|
149
|
+
ok: false,
|
|
150
|
+
domain,
|
|
151
|
+
reason: "connection_failed",
|
|
152
|
+
message: stderr.trim().split("\n").slice(-2).join(" ") || `ssh exited with code ${exitCode}`,
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return {
|
|
156
|
+
ok: false,
|
|
157
|
+
domain,
|
|
158
|
+
reason: "ssh_error",
|
|
159
|
+
message: stderr.trim().split("\n").slice(-2).join(" ") || `ssh exited with code ${exitCode}`,
|
|
160
|
+
}
|
|
161
|
+
}
|
package/src/lib/probe.ts
CHANGED
|
@@ -60,7 +60,7 @@ export function resolveSiteSshTarget(site: Site, server: Server | undefined, ssh
|
|
|
60
60
|
return `${user}@${ip}`
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
-
const SSH_OPTS = [
|
|
63
|
+
export const SSH_OPTS = [
|
|
64
64
|
"-o", "BatchMode=yes",
|
|
65
65
|
"-o", "ConnectTimeout=7",
|
|
66
66
|
"-o", "StrictHostKeyChecking=accept-new",
|
package/src/lib/ssh.ts
CHANGED
|
@@ -444,6 +444,52 @@ async function runSudoSiteOp(
|
|
|
444
444
|
return { ok: true, target: `${siteUser}@${ip}` }
|
|
445
445
|
}
|
|
446
446
|
|
|
447
|
+
export type SudoServerResult = { ok: true } | { ok: false; error: string }
|
|
448
|
+
|
|
449
|
+
// Same probe → maybe-password → `sudo -S -p '' bash -s` idiom as runSudoSiteOp,
|
|
450
|
+
// but scoped to the SERVER as root — no Site/site_user involved. Used for ops
|
|
451
|
+
// that need to read/write across every site's directory (e.g. the fatal-log
|
|
452
|
+
// monitor's cron, which globs /sites/* — a regular site user can't even list
|
|
453
|
+
// that directory, confirmed live).
|
|
454
|
+
export async function runSudoServerOp(
|
|
455
|
+
server: Server,
|
|
456
|
+
opts: { sudoUser: string; sudoPassword: string },
|
|
457
|
+
build: () => string,
|
|
458
|
+
marker: string,
|
|
459
|
+
): Promise<SudoServerResult> {
|
|
460
|
+
const ip = server.ip_address
|
|
461
|
+
if (!ip) return { ok: false, error: "Server has no IP address." }
|
|
462
|
+
const target = `${opts.sudoUser}@${ip}`
|
|
463
|
+
const port = server.ssh_port ?? null
|
|
464
|
+
|
|
465
|
+
const probe = await runSshStdin(target, port, "sudo -n true 2>&1", null, 15000)
|
|
466
|
+
let needPassword = false
|
|
467
|
+
if (probe.code === 0) {
|
|
468
|
+
needPassword = false
|
|
469
|
+
} else if (probe.code === 255 || probe.code === -1) {
|
|
470
|
+
return { ok: false, error: lastErrorLine(probe.stdout + "\n" + probe.stderr, "Couldn't connect over SSH — is your key on the server for this user?") }
|
|
471
|
+
} else {
|
|
472
|
+
const out = (probe.stdout + " " + probe.stderr).toLowerCase()
|
|
473
|
+
if (/not in the sudoers|not allowed to run|may not run|unknown user|is not allowed/.test(out)) {
|
|
474
|
+
return { ok: false, error: `${opts.sudoUser} can't run sudo on this server.` }
|
|
475
|
+
}
|
|
476
|
+
needPassword = true
|
|
477
|
+
if (!opts.sudoPassword) return { ok: false, error: "A sudo password is required for this server." }
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
const script = build()
|
|
481
|
+
const payload = needPassword ? `${opts.sudoPassword}\n${script}\n` : `${script}\n`
|
|
482
|
+
const res = await runSshStdin(target, port, "sudo -S -p '' bash -s", payload, 30000)
|
|
483
|
+
if (res.code !== 0 || !res.stdout.includes(marker)) {
|
|
484
|
+
const out = (res.stderr + " " + res.stdout).toLowerCase()
|
|
485
|
+
if (/incorrect password|sorry, try again|authentication failure/.test(out)) {
|
|
486
|
+
return { ok: false, error: "Sudo password was rejected." }
|
|
487
|
+
}
|
|
488
|
+
return { ok: false, error: lastErrorLine(res.stderr || res.stdout, `The remote command failed (ssh exit ${res.code}).`) }
|
|
489
|
+
}
|
|
490
|
+
return { ok: true }
|
|
491
|
+
}
|
|
492
|
+
|
|
447
493
|
// Append the given public keys to a site user's authorized_keys, via the server's
|
|
448
494
|
// sudo user. Idempotent (re-running never duplicates a line). The caller resolves
|
|
449
495
|
// which keys to deploy (machine key via ensureSpinupKey, personal via listPersonalKeys).
|