spinuptui 1.0.0 → 1.1.1
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 +29 -1
- package/README.md +34 -10
- package/package.json +1 -1
- package/src/api/client.ts +65 -0
- package/src/api/types.ts +51 -2
- package/src/dev/mockClient.ts +56 -0
- package/src/index.tsx +28 -0
- package/src/lib/appUpdate.ts +8 -1
- package/src/lib/cliIncidents.ts +7 -0
- package/src/lib/cliSiblings.ts +181 -0
- package/src/lib/cliSsh.ts +45 -31
- package/src/lib/cloneSettings.ts +180 -0
- package/src/lib/serverClone.ts +33 -5
- package/src/lib/sshExecPolicy.ts +119 -7
- package/src/ui/Help.tsx +23 -1
- package/src/ui/store.tsx +79 -28
- package/src/ui/views/CloneWizard.tsx +24 -5
package/src/lib/cliSsh.ts
CHANGED
|
@@ -169,15 +169,17 @@ export async function resolveSshTargetInfo(
|
|
|
169
169
|
}
|
|
170
170
|
}
|
|
171
171
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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 {
|
|
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
|
-
},
|
|
204
|
+
}, PROBE_TIMEOUT_MS)
|
|
201
205
|
|
|
202
206
|
const exitCode = await proc.exited
|
|
203
207
|
clearTimeout(timeout)
|
|
204
|
-
|
|
208
|
+
if (exitCode === 0) return { status: "ok" }
|
|
205
209
|
|
|
206
|
-
|
|
207
|
-
|
|
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
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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:
|
|
231
|
-
message:
|
|
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
|
+
}
|
package/src/lib/serverClone.ts
CHANGED
|
@@ -757,6 +757,10 @@ export interface CrontabSyncResult {
|
|
|
757
757
|
ok: boolean
|
|
758
758
|
added: string[] // the lines appended to the dest crontab (empty = nothing to carry)
|
|
759
759
|
error?: string
|
|
760
|
+
// SpinupWP's server-level WP cron on each end, in minutes (see wpCronInterval):
|
|
761
|
+
// null = no managed WP cron job, undefined = that crontab wasn't read.
|
|
762
|
+
sourceWpCron?: number | null
|
|
763
|
+
destWpCron?: number | null
|
|
760
764
|
}
|
|
761
765
|
|
|
762
766
|
// The owner-added lines of a crontab: everything except SpinupWP's `#Ansible:`
|
|
@@ -778,6 +782,27 @@ export function ownerCrontabLines(crontab: string): string[] {
|
|
|
778
782
|
return out
|
|
779
783
|
}
|
|
780
784
|
|
|
785
|
+
// The interval, in minutes, of SpinupWP's managed WP cron job — the line after the
|
|
786
|
+
// `#Ansible:` marker. The API doesn't report it, so the crontab is the only place
|
|
787
|
+
// to read it. SpinupWP staggers the start minute per site, so every-5 is written
|
|
788
|
+
// "4,9,14,…,59 * * * *": the gap between the first two minutes is the interval, a
|
|
789
|
+
// single minute is hourly, "*" every minute. null = no managed WP cron job (or one
|
|
790
|
+
// in a shape we don't recognize). Exported for the test harness.
|
|
791
|
+
export function wpCronInterval(crontab: string): number | null {
|
|
792
|
+
const lines = crontab.split("\n").map((l) => l.replace(/\r$/, ""))
|
|
793
|
+
const at = lines.findIndex((l) => /^#Ansible:/.test(l))
|
|
794
|
+
const job = at >= 0 ? lines[at + 1] : undefined
|
|
795
|
+
if (!job || !/wp cron event run/.test(job)) return null
|
|
796
|
+
const [minute, hour] = job.trim().split(/\s+/)
|
|
797
|
+
if (hour !== "*" || !minute) return null
|
|
798
|
+
if (minute === "*") return 1
|
|
799
|
+
const step = minute.match(/^\*\/(\d+)$/)
|
|
800
|
+
if (step) return Number(step[1])
|
|
801
|
+
const mins = minute.split(",").map(Number)
|
|
802
|
+
if (mins.some((m) => !Number.isInteger(m))) return null
|
|
803
|
+
return mins.length === 1 ? 60 : mins[1]! - mins[0]!
|
|
804
|
+
}
|
|
805
|
+
|
|
781
806
|
const envName = (line: string): string | null => line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=/)?.[1] ?? null
|
|
782
807
|
|
|
783
808
|
export async function syncCrontab(
|
|
@@ -796,15 +821,18 @@ export async function syncCrontab(
|
|
|
796
821
|
const src = await read(source, spec.sourceSiteUser)
|
|
797
822
|
if (!src.ok) return { ok: false, added: [], error: src.stderr.trim() || "couldn't read the source crontab" }
|
|
798
823
|
const wanted = ownerCrontabLines(src.stdout)
|
|
799
|
-
|
|
824
|
+
const sourceWpCron = wpCronInterval(src.stdout)
|
|
800
825
|
|
|
826
|
+
// Read even when there's nothing to append: the caller compares WP cron intervals.
|
|
801
827
|
const dst = await read(dest, spec.destSiteUser)
|
|
802
|
-
if (!dst.ok) return { ok: false, added: [], error: dst.stderr.trim() || "couldn't read the destination crontab" }
|
|
828
|
+
if (!dst.ok) return { ok: false, added: [], error: dst.stderr.trim() || "couldn't read the destination crontab", sourceWpCron }
|
|
829
|
+
const destWpCron = wpCronInterval(dst.stdout)
|
|
830
|
+
if (wanted.length === 0) return { ok: true, added: [], sourceWpCron, destWpCron }
|
|
803
831
|
const have = new Set(dst.stdout.split("\n").map((l) => l.replace(/\r$/, "")))
|
|
804
832
|
const haveEnv = new Set([...have].map(envName).filter((n): n is string => n != null))
|
|
805
833
|
// An env line the dest already sets (SpinupWP's own PATH, typically) stays the dest's.
|
|
806
834
|
const added = wanted.filter((l) => !have.has(l) && !(envName(l) && haveEnv.has(envName(l)!)))
|
|
807
|
-
if (added.length === 0) return { ok: true, added: [] }
|
|
835
|
+
if (added.length === 0) return { ok: true, added: [], sourceWpCron, destWpCron }
|
|
808
836
|
|
|
809
837
|
const block = ["", `# Carried over from ${source.server.name} by the SpinupTUI clone`, ...added].join("\n") + "\n"
|
|
810
838
|
const b64 = Buffer.from(block).toString("base64")
|
|
@@ -812,8 +840,8 @@ export async function syncCrontab(
|
|
|
812
840
|
const t0 = Date.now()
|
|
813
841
|
const w = await exec(dest, script, 30_000)
|
|
814
842
|
onExec?.({ domain: spec.domain, stage: "config", host: dest.server.name, ok: w.ok, code: w.code, ms: Date.now() - t0, script, stdout: w.stdout, stderr: w.stderr })
|
|
815
|
-
if (!w.ok) return { ok: false, added: [], error: w.stderr.trim() || "couldn't write the destination crontab" }
|
|
816
|
-
return { ok: true, added }
|
|
843
|
+
if (!w.ok) return { ok: false, added: [], error: w.stderr.trim() || "couldn't write the destination crontab", sourceWpCron, destWpCron }
|
|
844
|
+
return { ok: true, added, sourceWpCron, destWpCron }
|
|
817
845
|
}
|
|
818
846
|
|
|
819
847
|
// ---- Files-only pull chain (non-WP sites: redirect shells, static/PHP sites) --
|
package/src/lib/sshExecPolicy.ts
CHANGED
|
@@ -8,13 +8,6 @@ const DANGEROUS_PATTERNS: RegExp[] = [
|
|
|
8
8
|
/\b(rm|mv|chmod|chown|reboot|shutdown|kill|truncate)\b/i,
|
|
9
9
|
/systemctl\s+(restart|stop|reload)\b/i,
|
|
10
10
|
/service\s+\S+\s+restart\b/i,
|
|
11
|
-
/wp\s+plugin\s+(activate|deactivate|install|update|delete)\b/i,
|
|
12
|
-
/wp\s+theme\s+(activate|delete|install)\b/i,
|
|
13
|
-
/wp\s+db\s+(query|import|reset)\b/i,
|
|
14
|
-
/wp\s+option\s+(update|delete|add)\b/i,
|
|
15
|
-
/wp\s+user\b/i,
|
|
16
|
-
/wp\s+core\s+(update|install)\b/i,
|
|
17
|
-
/wp\s+(rewrite\s+flush|cache\s+flush)\b/i,
|
|
18
11
|
/crontab\s+-[re]\b/i,
|
|
19
12
|
/sed\s+-i\b/i,
|
|
20
13
|
/\b(DROP\s+TABLE|INSERT\s+INTO|DELETE\s+FROM)\b/i,
|
|
@@ -32,12 +25,131 @@ function hasUnsafeRedirect(cmd: string): boolean {
|
|
|
32
25
|
return />/.test(stripped)
|
|
33
26
|
}
|
|
34
27
|
|
|
28
|
+
// redis-cli has a verb grammar a deny-list can't enumerate (CONFIG SET, FLUSHALL,
|
|
29
|
+
// CLIENT PAUSE, plain SET/DEL/EXPIRE, …), and generic patterns like /\bset\b/
|
|
30
|
+
// would collide with awk in ordinary log analysis. So it is allowlisted instead:
|
|
31
|
+
// every redis-cli in the command must name a known read, and anything else is
|
|
32
|
+
// denied — an unknown verb, an unknown flag (--pipe, --eval, --rdb, --cluster and
|
|
33
|
+
// --lru-test all write), or no verb at all (bare redis-cli takes commands on stdin).
|
|
34
|
+
// Deliberately left out though read-only: KEYS and MONITOR (both can stall a busy
|
|
35
|
+
// instance) and GET (dumps cached values into the log).
|
|
36
|
+
const REDIS_READ_VERBS = new Set([
|
|
37
|
+
"ping", "info", "dbsize", "lastsave", "time", "role",
|
|
38
|
+
"scan", "type", "ttl", "pttl", "exists", "strlen",
|
|
39
|
+
])
|
|
40
|
+
const REDIS_READ_SUBVERBS: Record<string, Set<string>> = {
|
|
41
|
+
config: new Set(["get"]),
|
|
42
|
+
memory: new Set(["doctor", "stats", "usage", "malloc-stats"]),
|
|
43
|
+
latency: new Set(["latest", "history", "doctor"]),
|
|
44
|
+
slowlog: new Set(["get", "len"]),
|
|
45
|
+
client: new Set(["list", "info"]),
|
|
46
|
+
}
|
|
47
|
+
const REDIS_VALUE_FLAGS = new Set(["-h", "-p", "-s", "-a", "-n", "-u", "--user", "--pass"])
|
|
48
|
+
const REDIS_BARE_FLAGS = new Set(["--no-auth-warning", "--raw", "--no-raw", "--tls", "-c", "-e", "-2", "-3"])
|
|
49
|
+
|
|
50
|
+
// Shared by the per-tool allowlists below. A segment is what follows the tool
|
|
51
|
+
// name up to the next shell operator.
|
|
52
|
+
const SEGMENT = "([^|;&()`<>\\n]*)"
|
|
53
|
+
// Drop a quote pair around a token, and a stray closing quote left by an
|
|
54
|
+
// enclosing `ssh host '…'` / `bash -c "…"`.
|
|
55
|
+
const clean = (t: string) => t.replace(/^(['"])(.*)\1$/, "$2").replace(/['"]+$/, "").toLowerCase()
|
|
56
|
+
// A token that opens a quote it doesn't close (`-a 'pw ping' flushall`) would
|
|
57
|
+
// shift which later token we read as the verb.
|
|
58
|
+
const hasOpenQuote = (t: string) => /['"]/.test(t) && !/^[^'"]*(['"])[^'"]*\1$/.test(t)
|
|
59
|
+
|
|
60
|
+
// Returns why the command is unsafe, or null. Looks at every `redis-cli` in the
|
|
61
|
+
// string, wherever it appears — including inside `bash -c '…'`, `sudo`, `xargs`,
|
|
62
|
+
// or a grep pattern — so a mention that isn't an invocation (`which redis-cli`)
|
|
63
|
+
// is denied too; that is the price of not having to recognise command position.
|
|
64
|
+
function unsafeRedisCli(cmd: string): string | null {
|
|
65
|
+
for (const m of cmd.matchAll(new RegExp(`\\bredis-cli\\b${SEGMENT}`, "g"))) {
|
|
66
|
+
const tokens = (m[1] ?? "").trim().split(/\s+/).filter(Boolean)
|
|
67
|
+
if (tokens.length === 1 && /^(-v|--version)$/.test(clean(tokens[0]!))) continue
|
|
68
|
+
let i = 0
|
|
69
|
+
for (; i < tokens.length; i++) {
|
|
70
|
+
const flag = clean(tokens[i]!)
|
|
71
|
+
if (!flag.startsWith("-")) break
|
|
72
|
+
if (REDIS_BARE_FLAGS.has(flag)) continue
|
|
73
|
+
if (!REDIS_VALUE_FLAGS.has(flag)) return `redis-cli option \`${flag}\` is not on the read-only allowlist`
|
|
74
|
+
if (hasOpenQuote(tokens[++i] ?? "")) return "redis-cli option value has quoting this guard can't read safely"
|
|
75
|
+
}
|
|
76
|
+
const verb = clean(tokens[i] ?? "")
|
|
77
|
+
if (!verb) return "redis-cli with no command reads commands from stdin"
|
|
78
|
+
if (REDIS_READ_VERBS.has(verb)) continue
|
|
79
|
+
const sub = clean(tokens[i + 1] ?? "")
|
|
80
|
+
if (REDIS_READ_SUBVERBS[verb]?.has(sub)) continue
|
|
81
|
+
return `redis-cli \`${[verb, sub].filter(Boolean).join(" ")}\` is not on the read-only allowlist`
|
|
82
|
+
}
|
|
83
|
+
return null
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// WP-CLI gets the same treatment: the deny patterns this replaces listed a dozen
|
|
87
|
+
// writes and missed the rest (`transient delete`, `search-replace`, `eval`, …),
|
|
88
|
+
// while blanket-denying reads like `wp user list`. Each entry is a full command
|
|
89
|
+
// path. Left out on purpose: `db query`/`eval`/`shell` (arbitrary SQL or PHP),
|
|
90
|
+
// `db export` (writes a file), `config get|list` (prints the DB password) and
|
|
91
|
+
// `cron test` (spawns a cron run).
|
|
92
|
+
const WP_READ_COMMANDS = new Set([
|
|
93
|
+
"core version", "core is-installed", "core verify-checksums", "core check-update",
|
|
94
|
+
"plugin list", "plugin get", "plugin status", "plugin is-active", "plugin is-installed", "plugin path", "plugin verify-checksums",
|
|
95
|
+
"theme list", "theme get", "theme status", "theme is-active", "theme is-installed", "theme path",
|
|
96
|
+
"option get", "option list", "transient get", "transient list", "cache type",
|
|
97
|
+
"user list", "user get", "user list-caps", "role list", "cap list",
|
|
98
|
+
"post list", "post get", "site list", "rewrite list",
|
|
99
|
+
"cron event list", "cron schedule list",
|
|
100
|
+
"db size", "db tables", "db prefix", "db check",
|
|
101
|
+
"config path", "config has", "maintenance-mode status", "maintenance-mode is-active",
|
|
102
|
+
"cli version", "cli info", "cli check-update",
|
|
103
|
+
"doctor list", "doctor check", "profile stage", "profile hook",
|
|
104
|
+
])
|
|
105
|
+
// Global flags that run code or send the command somewhere else. Per-command
|
|
106
|
+
// flags (--format, --field, --status, …) are too many to allowlist.
|
|
107
|
+
const WP_UNSAFE_FLAGS = /(^|\s)['"]?--(exec|require|ssh|http)\b/
|
|
108
|
+
|
|
109
|
+
// Unlike redis-cli, `wp` is only treated as an invocation when it stands alone
|
|
110
|
+
// and is followed by a word — a bare `wp` just prints help, and this keeps paths
|
|
111
|
+
// (`wp-content`, Bedrock's `web/wp`) from tripping it.
|
|
112
|
+
function unsafeWpCli(cmd: string): string | null {
|
|
113
|
+
// `wp 2>/dev/null plugin delete x` must not end the segment at the redirect.
|
|
114
|
+
const stripped = cmd.replace(/\d?>{1,2}\s*(\/dev\/null\b|&\d+)/g, "")
|
|
115
|
+
for (const m of stripped.matchAll(new RegExp(`(?<![\\w.$-])wp(?:-cli\\.phar)?(?=\\s)${SEGMENT}(.?)`, "gs"))) {
|
|
116
|
+
const segment = m[1] ?? ""
|
|
117
|
+
if (WP_UNSAFE_FLAGS.test(segment)) return "wp --exec/--require/--ssh/--http can run code or target another host"
|
|
118
|
+
const tokens = segment.trim().split(/\s+/).filter(Boolean)
|
|
119
|
+
let i = 0
|
|
120
|
+
for (; i < tokens.length && tokens[i]!.replace(/^['"]/, "").startsWith("-"); i++) {
|
|
121
|
+
if (hasOpenQuote(tokens[i]!)) return "wp option value has quoting this guard can't read safely"
|
|
122
|
+
}
|
|
123
|
+
const words: string[] = []
|
|
124
|
+
for (; i < tokens.length && words.length < 3; i++) {
|
|
125
|
+
const word = clean(tokens[i]!)
|
|
126
|
+
if (word.startsWith("-")) break
|
|
127
|
+
if (word) words.push(word)
|
|
128
|
+
}
|
|
129
|
+
if (words.length === 0) {
|
|
130
|
+
// `wp </dev/stdin plugin delete` — the command sits past a redirect we stopped at.
|
|
131
|
+
if (m[2] === "<" || m[2] === ">") return "wp followed by a redirect hides its command from this guard"
|
|
132
|
+
continue
|
|
133
|
+
}
|
|
134
|
+
if ([3, 2].some((n) => words.length >= n && WP_READ_COMMANDS.has(words.slice(0, n).join(" ")))) continue
|
|
135
|
+
return `wp \`${words.slice(0, 2).join(" ")}\` is not on the read-only allowlist`
|
|
136
|
+
}
|
|
137
|
+
return null
|
|
138
|
+
}
|
|
139
|
+
|
|
35
140
|
export interface SshCommandVerdict {
|
|
36
141
|
decision: "allow" | "deny"
|
|
37
142
|
reason: string
|
|
38
143
|
}
|
|
39
144
|
|
|
40
145
|
export function classifySshCommand(cmd: string): SshCommandVerdict {
|
|
146
|
+
const unsafeTool = unsafeRedisCli(cmd) ?? unsafeWpCli(cmd)
|
|
147
|
+
if (unsafeTool) {
|
|
148
|
+
return {
|
|
149
|
+
decision: "deny",
|
|
150
|
+
reason: `${unsafeTool} — spinuptui ssh-exec only runs read-only diagnostic commands.`,
|
|
151
|
+
}
|
|
152
|
+
}
|
|
41
153
|
if (DANGEROUS_PATTERNS.some((re) => re.test(cmd)) || hasUnsafeRedirect(cmd)) {
|
|
42
154
|
return {
|
|
43
155
|
decision: "deny",
|
package/src/ui/Help.tsx
CHANGED
|
@@ -129,6 +129,24 @@ function SectionBlock({ section }: { section: Section }) {
|
|
|
129
129
|
)
|
|
130
130
|
}
|
|
131
131
|
|
|
132
|
+
// Greedy word wrap for a shell command, so a line never breaks inside a flag.
|
|
133
|
+
// Continuation lines are indented two columns by the caller, hence `width - 2`.
|
|
134
|
+
function wrapAtSpaces(text: string, width: number): string[] {
|
|
135
|
+
const lines: string[] = []
|
|
136
|
+
let cur = ""
|
|
137
|
+
for (const word of text.split(" ")) {
|
|
138
|
+
const limit = lines.length === 0 ? width : width - 2
|
|
139
|
+
if (cur && cur.length + 1 + word.length > limit) {
|
|
140
|
+
lines.push(cur)
|
|
141
|
+
cur = word
|
|
142
|
+
} else {
|
|
143
|
+
cur = cur ? `${cur} ${word}` : word
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
if (cur) lines.push(cur)
|
|
147
|
+
return lines
|
|
148
|
+
}
|
|
149
|
+
|
|
132
150
|
function ShortcutColumn({ sections, width }: { sections: Section[]; width: number }) {
|
|
133
151
|
return (
|
|
134
152
|
<box style={{ flexDirection: "column", width, flexShrink: 0 }}>
|
|
@@ -194,7 +212,11 @@ function AboutColumn({
|
|
|
194
212
|
{installChannel() !== "git" ? (
|
|
195
213
|
<>
|
|
196
214
|
{line("Manual:")}
|
|
197
|
-
{
|
|
215
|
+
{/* Broken at spaces by hand: the package command is wider than the
|
|
216
|
+
About column, and OpenTUI's own wrap splits "--no-cache" at its hyphen. */}
|
|
217
|
+
{wrapAtSpaces(updateCommand() ?? "", width).map((l, i) => (
|
|
218
|
+
<text key={i} content={i === 0 ? l : ` ${l}`} fg={theme.text} wrapMode="none" />
|
|
219
|
+
))}
|
|
198
220
|
</>
|
|
199
221
|
) : (
|
|
200
222
|
<>
|