@huynle/hub 0.7.1 → 0.7.3
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 +87 -0
- package/Dockerfile +3 -0
- package/README.md +148 -11
- package/package.json +3 -1
- package/packages/claude-code/package.json +1 -1
- package/packages/claude-code/src/main.ts +4 -4
- package/packages/claude-code/src/wrapper.ts +3 -2
- package/packages/cli/dist/hub-cli.js +19334 -7849
- package/packages/cli/package.json +11 -0
- package/packages/cli/src/acting.ts +70 -0
- package/packages/cli/src/args.ts +91 -0
- package/packages/cli/src/bootstrap/adapters/archive-stream.ts +276 -0
- package/packages/cli/src/bootstrap/adapters/claude/README.md +63 -0
- package/packages/cli/src/bootstrap/adapters/claude/adapter.ts +250 -0
- package/packages/cli/src/bootstrap/adapters/claude/index.ts +42 -0
- package/packages/cli/src/bootstrap/adapters/claude/node.ts +99 -0
- package/packages/cli/src/bootstrap/adapters/claude/openpgp.ts +221 -0
- package/packages/cli/src/bootstrap/adapters/claude/ports.ts +44 -0
- package/packages/cli/src/bootstrap/adapters/claude/source.ts +160 -0
- package/packages/cli/src/bootstrap/adapters/claude/verify.ts +165 -0
- package/packages/cli/src/bootstrap/adapters/codex/archive.ts +103 -0
- package/packages/cli/src/bootstrap/adapters/codex/index.ts +60 -0
- package/packages/cli/src/bootstrap/adapters/codex/plan.ts +129 -0
- package/packages/cli/src/bootstrap/adapters/codex/sigstore.ts +298 -0
- package/packages/cli/src/bootstrap/adapters/codex/source.ts +102 -0
- package/packages/cli/src/bootstrap/adapters/codex/verify.ts +298 -0
- package/packages/cli/src/bootstrap/adapters/inflate.ts +433 -0
- package/packages/cli/src/bootstrap/adapters/node-services/control.ts +392 -0
- package/packages/cli/src/bootstrap/adapters/node-services/index.ts +23 -0
- package/packages/cli/src/bootstrap/adapters/node-services/launch.ts +223 -0
- package/packages/cli/src/bootstrap/adapters/node-services/plan.ts +222 -0
- package/packages/cli/src/bootstrap/adapters/node-services/render.ts +183 -0
- package/packages/cli/src/bootstrap/adapters/node-services/validate.ts +197 -0
- package/packages/cli/src/bootstrap/adapters/opencode/README.md +204 -0
- package/packages/cli/src/bootstrap/adapters/opencode/apply.ts +205 -0
- package/packages/cli/src/bootstrap/adapters/opencode/archive.ts +91 -0
- package/packages/cli/src/bootstrap/adapters/opencode/config.ts +212 -0
- package/packages/cli/src/bootstrap/adapters/opencode/index.ts +81 -0
- package/packages/cli/src/bootstrap/adapters/opencode/plan.ts +266 -0
- package/packages/cli/src/bootstrap/adapters/opencode/verify.ts +208 -0
- package/packages/cli/src/bootstrap/adapters/readiness.ts +19 -0
- package/packages/cli/src/bootstrap/ansible/index.ts +2 -0
- package/packages/cli/src/bootstrap/ansible/render.ts +279 -0
- package/packages/cli/src/bootstrap/contracts/actions.ts +470 -0
- package/packages/cli/src/bootstrap/contracts/artifact.ts +323 -0
- package/packages/cli/src/bootstrap/contracts/canonical.ts +54 -0
- package/packages/cli/src/bootstrap/contracts/credentials.ts +108 -0
- package/packages/cli/src/bootstrap/contracts/discovery.ts +215 -0
- package/packages/cli/src/bootstrap/contracts/envelope.ts +143 -0
- package/packages/cli/src/bootstrap/contracts/exec.ts +74 -0
- package/packages/cli/src/bootstrap/contracts/harness.ts +202 -0
- package/packages/cli/src/bootstrap/contracts/index.ts +28 -0
- package/packages/cli/src/bootstrap/contracts/journal.ts +198 -0
- package/packages/cli/src/bootstrap/contracts/paths.ts +91 -0
- package/packages/cli/src/bootstrap/contracts/plan.ts +155 -0
- package/packages/cli/src/bootstrap/contracts/platform.ts +62 -0
- package/packages/cli/src/bootstrap/contracts/preview.ts +190 -0
- package/packages/cli/src/bootstrap/contracts/profile.ts +105 -0
- package/packages/cli/src/bootstrap/contracts/secrets.ts +52 -0
- package/packages/cli/src/bootstrap/contracts/validate.ts +212 -0
- package/packages/cli/src/bootstrap/discovery/discover.ts +146 -0
- package/packages/cli/src/bootstrap/discovery/executables.ts +134 -0
- package/packages/cli/src/bootstrap/discovery/host.ts +96 -0
- package/packages/cli/src/bootstrap/discovery/index.ts +6 -0
- package/packages/cli/src/bootstrap/discovery/node-probe.ts +97 -0
- package/packages/cli/src/bootstrap/discovery/probe.ts +41 -0
- package/packages/cli/src/bootstrap/discovery/util.ts +13 -0
- package/packages/cli/src/bootstrap/enrollment/enroll.ts +226 -0
- package/packages/cli/src/bootstrap/enrollment/index.ts +7 -0
- package/packages/cli/src/bootstrap/enrollment/policy.ts +54 -0
- package/packages/cli/src/bootstrap/enrollment/result.ts +63 -0
- package/packages/cli/src/bootstrap/enrollment/status.ts +119 -0
- package/packages/cli/src/bootstrap/enrollment/store.ts +173 -0
- package/packages/cli/src/bootstrap/preview/index.ts +5 -0
- package/packages/cli/src/bootstrap/preview/links.ts +31 -0
- package/packages/cli/src/bootstrap/preview/plan.ts +242 -0
- package/packages/cli/src/bootstrap/preview/preview.ts +104 -0
- package/packages/cli/src/bootstrap/preview/render.ts +30 -0
- package/packages/cli/src/bootstrap/profiles/audit.ts +87 -0
- package/packages/cli/src/bootstrap/profiles/catalog.ts +49 -0
- package/packages/cli/src/bootstrap/profiles/desired.ts +96 -0
- package/packages/cli/src/bootstrap/profiles/drift.ts +286 -0
- package/packages/cli/src/bootstrap/profiles/history.ts +263 -0
- package/packages/cli/src/bootstrap/profiles/index.ts +11 -0
- package/packages/cli/src/bootstrap/profiles/upgrade.ts +276 -0
- package/packages/cli/src/bootstrap/profiles/versions.ts +50 -0
- package/packages/cli/src/bootstrap/release/README.md +115 -0
- package/packages/cli/src/bootstrap/release/assets.ts +39 -0
- package/packages/cli/src/bootstrap/release/cli.ts +332 -0
- package/packages/cli/src/bootstrap/release/harness-pins.json +94 -0
- package/packages/cli/src/bootstrap/release/manifest.ts +118 -0
- package/packages/cli/src/bootstrap/release/pins.ts +192 -0
- package/packages/cli/src/bootstrap/release/text-assets.d.ts +9 -0
- package/packages/cli/src/bootstrap/release/trust-root.json +13 -0
- package/packages/cli/src/bootstrap/release/trust-root.ts +39 -0
- package/packages/cli/src/bootstrap/release/verify.ts +88 -0
- package/packages/cli/src/bootstrap/transaction/builtin.ts +312 -0
- package/packages/cli/src/bootstrap/transaction/engine.ts +994 -0
- package/packages/cli/src/bootstrap/transaction/executor.ts +112 -0
- package/packages/cli/src/bootstrap/transaction/fs.ts +194 -0
- package/packages/cli/src/bootstrap/transaction/index.ts +12 -0
- package/packages/cli/src/bootstrap/transaction/json-merge.ts +403 -0
- package/packages/cli/src/bootstrap/transaction/lock.ts +132 -0
- package/packages/cli/src/bootstrap/transaction/resolve.ts +48 -0
- package/packages/cli/src/bootstrap/transaction/result.ts +60 -0
- package/packages/cli/src/bootstrap/transaction/state.ts +88 -0
- package/packages/cli/src/bootstrap/transaction/store.ts +189 -0
- package/packages/cli/src/bootstrap/transaction/stream.ts +254 -0
- package/packages/cli/src/cli.ts +814 -0
- package/packages/cli/src/credentials.ts +75 -0
- package/packages/cli/src/decisions.ts +106 -0
- package/packages/cli/src/format.ts +185 -0
- package/packages/cli/src/grants.ts +105 -0
- package/packages/cli/src/http.ts +86 -0
- package/packages/cli/src/launches.ts +157 -0
- package/packages/cli/src/login.ts +160 -0
- package/packages/cli/src/main.ts +63 -0
- package/packages/cli/src/mcp.ts +879 -0
- package/packages/cli/src/messaging.ts +108 -0
- package/packages/cli/src/node.ts +279 -0
- package/packages/cli/src/openshell.ts +166 -0
- package/packages/cli/src/setup/commands.ts +809 -0
- package/packages/cli/src/setup/contributors.ts +171 -0
- package/packages/cli/src/setup/dispatch.ts +74 -0
- package/packages/cli/src/setup/launch-driver.ts +109 -0
- package/packages/cli/src/setup/runtime.ts +456 -0
- package/packages/cli/src/setup/spoke-probe.ts +120 -0
- package/packages/cli/src/setup/state.ts +244 -0
- package/packages/cli/src/setup/user-bin.ts +39 -0
- package/packages/cli/src/spoke-services.ts +109 -0
- package/packages/cli/src/watch.ts +28 -0
- package/packages/codex/package.json +1 -1
- package/packages/codex/src/main.ts +3 -2
- package/packages/codex/src/spoke.ts +4 -1
- package/packages/codex/src/wrapper.ts +7 -3
- package/packages/node/package.json +1 -1
- package/packages/node/src/harnesses.ts +39 -4
- package/packages/node/src/run.ts +1 -1
- package/packages/preset/package.json +1 -1
- package/packages/protocol/package.json +1 -1
- package/packages/protocol/src/harness-events.ts +2 -2
- package/packages/protocol/src/index.ts +70 -7
- package/packages/runtime-openshell/package.json +1 -1
- package/packages/runtime-sdk/package.json +1 -1
- package/packages/sdk/package.json +1 -1
- package/packages/sdk/src/client.ts +55 -4
- package/packages/sdk/src/errors.ts +1 -0
- package/packages/sdk/src/sse.ts +4 -1
- package/packages/sdk/src/types.ts +59 -0
- package/packages/spoke-sdk/package.json +1 -1
- package/packages/spoke-sdk/src/enroll.ts +26 -4
- package/src/central/agent/app-tokens.ts +27 -17
- package/src/central/agent/archive-inactive.ts +66 -0
- package/src/central/agent/directory-routes.ts +155 -12
- package/src/central/agent/directory.ts +1 -1
- package/src/central/agent/errors.ts +2 -0
- package/src/central/agent/event-cursor.ts +49 -0
- package/src/central/agent/event-writers.ts +181 -0
- package/src/central/agent/events-routes.ts +148 -0
- package/src/central/agent/events.ts +277 -0
- package/src/central/agent/names.ts +1 -1
- package/src/central/agent/read-routes.ts +32 -36
- package/src/central/agent/routes.ts +14 -11
- package/src/central/agent/watch.ts +65 -174
- package/src/central/agent/webhook-routes.ts +10 -2
- package/src/central/agent/webhooks.ts +49 -5
- package/src/central/auth.ts +127 -1
- package/src/central/backend/index.ts +3 -0
- package/src/central/backend/types.ts +2 -0
- package/src/central/http.ts +116 -32
- package/src/central/mcp-http.ts +116 -0
- package/src/central/push.ts +14 -1
- package/src/central/registry.ts +34 -5
- package/src/central/store.ts +60 -0
- package/src/config.ts +70 -7
- package/src/index.ts +90 -91
- package/src/spoke/launchers.ts +109 -3
- package/src/spoke/spoke.ts +8 -4
- package/src/spoke/timeline.ts +59 -3
- package/src/spoke/tracker.ts +59 -6
- package/src/status.ts +97 -6
- package/web/dist/apple-touch-icon.png +0 -0
- package/web/dist/assets/index-Dm8OmkaO.css +1 -0
- package/web/dist/assets/index-FUJbj_Du.js +20 -0
- package/web/dist/favicon.ico +0 -0
- package/web/dist/icon-192.png +0 -0
- package/web/dist/icon-512.png +0 -0
- package/web/dist/icon-maskable-512.png +0 -0
- package/web/dist/icon.svg +5 -5
- package/web/dist/index.html +10 -4
- package/web/dist/manifest.webmanifest +2 -2
- package/web/dist/sw.js +4 -1
- package/web/dist/assets/index-D9zMbxSg.css +0 -1
- package/web/dist/assets/index-db1z4duA.js +0 -20
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
// `machine enroll`: the canonical device-code enrollment of this machine. It needs no harness. It reuses the spoke
|
|
2
|
+
// SDK's flow (POST /spoke/enroll, then /spoke/enroll/poll until the user approves on the hub) and stores the one
|
|
3
|
+
// machine identity every spoke and `hub node` here share (ConfigCredentialStore, the spoke's config.json).
|
|
4
|
+
//
|
|
5
|
+
// Policy:
|
|
6
|
+
// - The hub URL is https (plain http only for a loopback hub); redirects are refused, never followed.
|
|
7
|
+
// - The approval link shown is on the hub's own origin (approvalURL).
|
|
8
|
+
// - The wait is bounded by the hub's expiry and by maxWaitMs; `signal` cancels it.
|
|
9
|
+
// - The hub hands out the credential once: the store is checked before the code is redeemed, an approving poll in
|
|
10
|
+
// flight is never aborted, a failed save is retried, and when it still fails the credential stays in memory
|
|
11
|
+
// only (retryPersist) and the output names the machine ID so the user can revoke it. It is never written
|
|
12
|
+
// anywhere else and never printed.
|
|
13
|
+
// - v1.1: the outcomes have their own codes: enrollment_denied (exit 5), enrollment_expired and cancelled (exit 4),
|
|
14
|
+
// the same exits as before.
|
|
15
|
+
import { hostname } from "node:os"
|
|
16
|
+
import type { EnrollStart } from "../../../../protocol/src/index.ts"
|
|
17
|
+
import { EnrollmentRefused, pollEnrollment, startEnrollment } from "../../../../spoke-sdk/src/enroll.ts"
|
|
18
|
+
import type { CredentialStore, MachineCredential, MachineEnrollment } from "../contracts/index.ts"
|
|
19
|
+
import { approvalURL, checkServerURL, isUserCode } from "./policy.ts"
|
|
20
|
+
import { failure, line, success, type MachineResult } from "./result.ts"
|
|
21
|
+
import { CredentialStoreError, type SharingMode, type StoreContents } from "./store.ts"
|
|
22
|
+
import { probeCredential, statusOf, storeFailure } from "./status.ts"
|
|
23
|
+
|
|
24
|
+
/** The longest a single enrollment waits for approval, whatever the hub says (the hub's own expiry is 10 minutes). */
|
|
25
|
+
export const ENROLL_MAX_WAIT_MS = 15 * 60_000
|
|
26
|
+
const MIN_INTERVAL_MS = 500
|
|
27
|
+
const MAX_INTERVAL_MS = 30_000
|
|
28
|
+
const PERSIST_ATTEMPTS = 3
|
|
29
|
+
const PERSIST_DELAY_MS = 250
|
|
30
|
+
const MACHINE_NAME = /^[A-Za-z0-9._ -]{1,64}$/
|
|
31
|
+
const COMMAND = "machine enroll"
|
|
32
|
+
|
|
33
|
+
/** What to show the user while waiting: the code to confirm and where to approve it. Never a credential. */
|
|
34
|
+
export interface EnrollPrompt {
|
|
35
|
+
readonly userCode: string
|
|
36
|
+
readonly approveURL: string
|
|
37
|
+
readonly expiresAt: number
|
|
38
|
+
/** A local hub approved this first machine by itself. */
|
|
39
|
+
readonly autoApproved: boolean
|
|
40
|
+
/** Set when the hub's own approval link was not used. */
|
|
41
|
+
readonly warning?: string
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export type EnrollStore = CredentialStore & { preflight?(): Promise<void>; inspect?(): StoreContents; sharingMode?(): SharingMode; enableSharing?(): boolean }
|
|
45
|
+
|
|
46
|
+
/** Why an enrolled machine's OpenCode spoke stays disconnected: the user's explicit `mode: "off"`. */
|
|
47
|
+
export function sharingOffWarning(location: string): string {
|
|
48
|
+
return `sharing is off for this machine (mode is "off" in ${location}), so the OpenCode spoke will not connect; turn it on with /hub → Mode in OpenCode, or set "mode" to "always"`
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The sharing notes of an enrolled machine. With `repair`, a config with no `mode` at all (written by an older
|
|
53
|
+
* hubctl) gets the default; an explicit mode is never changed. Best effort: a store that cannot say adds nothing.
|
|
54
|
+
*/
|
|
55
|
+
function sharingNotes(store: EnrollStore, repair: boolean): string[] {
|
|
56
|
+
try {
|
|
57
|
+
if (repair && store.enableSharing?.()) return [`turned sharing on for this machine (mode "always" in ${store.location}), so the OpenCode spoke connects`]
|
|
58
|
+
return store.sharingMode?.() === "off" ? [sharingOffWarning(store.location)] : []
|
|
59
|
+
} catch {
|
|
60
|
+
return []
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface MachineEnrollOptions {
|
|
65
|
+
readonly server: string
|
|
66
|
+
/** Default: defaultMachineName(hostname()). */
|
|
67
|
+
readonly machineName?: string
|
|
68
|
+
readonly store: EnrollStore
|
|
69
|
+
/** Replace an existing machine identity (otherwise enrolling twice is a no-op, and another hub is refused). */
|
|
70
|
+
readonly reenroll?: boolean
|
|
71
|
+
/** Check the new credential with the hub afterwards (default true). */
|
|
72
|
+
readonly verify?: boolean
|
|
73
|
+
readonly signal?: AbortSignal
|
|
74
|
+
readonly fetch?: typeof fetch
|
|
75
|
+
readonly now?: () => number
|
|
76
|
+
readonly onPrompt?: (prompt: EnrollPrompt) => void
|
|
77
|
+
readonly maxWaitMs?: number
|
|
78
|
+
/** Floor of the poll interval the hub asks for (default 500 ms). */
|
|
79
|
+
readonly minIntervalMs?: number
|
|
80
|
+
readonly persistAttempts?: number
|
|
81
|
+
readonly persistDelayMs?: number
|
|
82
|
+
readonly probeTimeoutMs?: number
|
|
83
|
+
/** The device-code flow (default: the spoke SDK's, with redirects refused and redeem-safe polling). */
|
|
84
|
+
readonly enrollment?: MachineEnrollment
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface MachineEnrollResult extends MachineResult {
|
|
88
|
+
/**
|
|
89
|
+
* Only when the hub approved the machine but its credential could not be saved: saves it again (no new code is
|
|
90
|
+
* redeemed). The credential lives in this closure only; it is not part of the result's data.
|
|
91
|
+
*/
|
|
92
|
+
readonly retryPersist?: () => Promise<MachineEnrollResult>
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** A machine name the hub and the status contract accept, from a hostname. */
|
|
96
|
+
export function defaultMachineName(host: string): string {
|
|
97
|
+
const name = host
|
|
98
|
+
.replace(/\.local$/i, "")
|
|
99
|
+
.replace(/[^A-Za-z0-9._ -]/g, "-")
|
|
100
|
+
.trim()
|
|
101
|
+
.slice(0, 64)
|
|
102
|
+
return name || "machine"
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** The spoke SDK's device-code flow under the enrollment policy. */
|
|
106
|
+
export function deviceCodeEnrollment(options: { fetch?: typeof fetch; now?: () => number } = {}): MachineEnrollment {
|
|
107
|
+
const base = { ...(options.fetch ? { fetch: options.fetch } : {}), ...(options.now ? { now: options.now } : {}), redirect: "error" as const }
|
|
108
|
+
return {
|
|
109
|
+
start: (server, machineName, signal) => startEnrollment(server, machineName, { ...base, ...(signal ? { signal } : {}) }),
|
|
110
|
+
poll: (server, start, signal) => pollEnrollment(server, start, { ...base, ...(signal ? { signal } : {}), keepRedeemed: true }),
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
115
|
+
const errnoOf = (cause: unknown) => (cause instanceof CredentialStoreError ? cause.errno : (cause as NodeJS.ErrnoException | undefined)?.code)
|
|
116
|
+
|
|
117
|
+
function validStart(value: EnrollStart): boolean {
|
|
118
|
+
return isUserCode(value.userCode) && Number.isFinite(value.expiresAt) && Number.isFinite(value.intervalMs)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export async function machineEnroll(options: MachineEnrollOptions): Promise<MachineEnrollResult> {
|
|
122
|
+
const now = options.now ?? Date.now
|
|
123
|
+
const { store, signal } = options
|
|
124
|
+
const server = checkServerURL(options.server)
|
|
125
|
+
if (!server) {
|
|
126
|
+
return failure(COMMAND, "usage", `The hub URL must be https (plain http only for a loopback hub), without user info, query or fragment: ${line(String(options.server), 200)}`)
|
|
127
|
+
}
|
|
128
|
+
const machineName = options.machineName ?? defaultMachineName(hostname())
|
|
129
|
+
if (!MACHINE_NAME.test(machineName)) return failure(COMMAND, "usage", "The machine name must be 1–64 letters, digits, spaces, dots, dashes or underscores")
|
|
130
|
+
const cancelled = () => failure(COMMAND, "cancelled", "Enrollment cancelled; nothing was stored", { hint: "Run hubctl machine enroll again" })
|
|
131
|
+
if (signal?.aborted) return cancelled()
|
|
132
|
+
|
|
133
|
+
let existing: MachineCredential | undefined
|
|
134
|
+
try {
|
|
135
|
+
existing = await store.read()
|
|
136
|
+
} catch (cause) {
|
|
137
|
+
return storeFailure(COMMAND, cause, store.location)
|
|
138
|
+
}
|
|
139
|
+
if (existing && !options.reenroll) {
|
|
140
|
+
if (checkServerURL(existing.server) !== server) {
|
|
141
|
+
return failure(COMMAND, "needs_user", `This machine is already enrolled with ${line(existing.server, 200)} as ${existing.machineID}`, {
|
|
142
|
+
hint: `Pass --reenroll to replace its identity with a new one from ${server}`,
|
|
143
|
+
})
|
|
144
|
+
}
|
|
145
|
+
const probe = options.verify === false ? undefined : await probeCredential(existing, probeOptions(options))
|
|
146
|
+
const status = statusOf(existing, probe?.reachable ? probe : undefined, now())
|
|
147
|
+
if (status.state === "revoked") return failure(COMMAND, "revoked", "The hub rejected this machine's stored credential", { data: status, hint: "Pass --reenroll to enroll it again" })
|
|
148
|
+
return success(COMMAND, status, [`already enrolled as ${existing.machineID}; pass --reenroll to replace this machine's identity`, ...sharingNotes(store, true)])
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
try {
|
|
152
|
+
await store.preflight?.()
|
|
153
|
+
} catch (cause) {
|
|
154
|
+
return storeFailure(COMMAND, cause, store.location)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const flow = options.enrollment ?? deviceCodeEnrollment({ ...(options.fetch ? { fetch: options.fetch } : {}), now })
|
|
158
|
+
let start: EnrollStart
|
|
159
|
+
try {
|
|
160
|
+
start = await flow.start(server, machineName, signal)
|
|
161
|
+
} catch (cause) {
|
|
162
|
+
if (signal?.aborted) return cancelled()
|
|
163
|
+
if (cause instanceof EnrollmentRefused) return failure(COMMAND, "internal", `${server} redirected the enrollment request; enrollment does not follow redirects`, { hint: "Use the hub's final https URL" })
|
|
164
|
+
const message = cause instanceof Error ? cause.message : ""
|
|
165
|
+
if (/invalid enrollment reply/i.test(message)) return failure(COMMAND, "internal", `${server} sent an invalid enrollment reply`)
|
|
166
|
+
return failure(COMMAND, "offline", `Could not start enrollment with ${server}${/HTTP \d{3}/.test(message) ? ` (${/HTTP \d{3}/.exec(message)![0]})` : ""}`)
|
|
167
|
+
}
|
|
168
|
+
if (!validStart(start)) return failure(COMMAND, "internal", `${server} sent an invalid enrollment reply`)
|
|
169
|
+
|
|
170
|
+
const link = approvalURL(server, start.approveURL, start.userCode)
|
|
171
|
+
const warnings = link.warning ? [link.warning] : []
|
|
172
|
+
const expiresAt = Math.min(start.expiresAt, now() + (options.maxWaitMs ?? ENROLL_MAX_WAIT_MS))
|
|
173
|
+
options.onPrompt?.({ userCode: start.userCode, approveURL: link.url, expiresAt, autoApproved: start.autoApproved === true, ...(link.warning ? { warning: link.warning } : {}) })
|
|
174
|
+
|
|
175
|
+
const intervalMs = Math.min(Math.max(start.intervalMs, options.minIntervalMs ?? MIN_INTERVAL_MS), MAX_INTERVAL_MS)
|
|
176
|
+
let outcome: Awaited<ReturnType<MachineEnrollment["poll"]>>
|
|
177
|
+
try {
|
|
178
|
+
outcome = await flow.poll(server, { ...start, expiresAt, intervalMs }, signal)
|
|
179
|
+
} catch (cause) {
|
|
180
|
+
if (cause instanceof EnrollmentRefused) return failure(COMMAND, "internal", `${server} redirected the enrollment poll; enrollment does not follow redirects`, { warnings })
|
|
181
|
+
return failure(COMMAND, "offline", `Enrollment with ${server} failed`, { warnings })
|
|
182
|
+
}
|
|
183
|
+
if (outcome.status === "cancelled") return cancelled()
|
|
184
|
+
if (outcome.status === "denied") return failure(COMMAND, "enrollment_denied", `The enrollment of ${machineName} was denied on the hub`, { warnings })
|
|
185
|
+
if (outcome.status !== "approved") return failure(COMMAND, "enrollment_expired", "The enrollment code expired before it was approved; nothing was stored", { hint: "Run hubctl machine enroll again and approve the code on the hub", warnings })
|
|
186
|
+
|
|
187
|
+
const credential: MachineCredential = { server, machineID: outcome.machineID, machineName, token: outcome.machineToken }
|
|
188
|
+
return persist(credential, options, warnings, options.persistAttempts ?? PERSIST_ATTEMPTS)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const probeOptions = (options: MachineEnrollOptions) => ({
|
|
192
|
+
...(options.fetch ? { fetch: options.fetch } : {}),
|
|
193
|
+
...(options.signal ? { signal: options.signal } : {}),
|
|
194
|
+
...(options.probeTimeoutMs ? { timeoutMs: options.probeTimeoutMs } : {}),
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
/** Saves a redeemed credential (retrying), then reports its status. Never aborted: the code cannot be redeemed again. */
|
|
198
|
+
async function persist(credential: MachineCredential, options: MachineEnrollOptions, warnings: string[], attempts: number): Promise<MachineEnrollResult> {
|
|
199
|
+
const now = options.now ?? Date.now
|
|
200
|
+
let last: unknown
|
|
201
|
+
for (let attempt = 1; attempt <= Math.max(1, attempts); attempt++) {
|
|
202
|
+
try {
|
|
203
|
+
await options.store.write(credential)
|
|
204
|
+
last = undefined
|
|
205
|
+
break
|
|
206
|
+
} catch (cause) {
|
|
207
|
+
last = cause
|
|
208
|
+
if (attempt < attempts) await sleep(options.persistDelayMs ?? PERSIST_DELAY_MS)
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
if (last !== undefined) {
|
|
212
|
+
const errno = errnoOf(last)
|
|
213
|
+
const result = failure(COMMAND, "internal", `The hub approved this machine as ${credential.machineID}, but its credential could not be saved to ${options.store.location}${errno ? ` (${errno})` : ""}`, {
|
|
214
|
+
hint: `Fix the problem and retry while this command runs; otherwise revoke machine ${credential.machineID} on the hub and run hubctl machine enroll again`,
|
|
215
|
+
warnings,
|
|
216
|
+
})
|
|
217
|
+
return { ...result, retryPersist: () => persist(credential, options, warnings, 1) }
|
|
218
|
+
}
|
|
219
|
+
warnings = [...warnings, ...sharingNotes(options.store, false)]
|
|
220
|
+
if (options.verify === false) return success(COMMAND, statusOf(credential, undefined, now()), warnings)
|
|
221
|
+
const probe = await probeCredential(credential, probeOptions(options))
|
|
222
|
+
if (probe && !probe.reachable) return success(COMMAND, statusOf(credential, undefined, now()), [...warnings, `enrolled, but not verified: ${probe.detail}`])
|
|
223
|
+
const status = statusOf(credential, probe, now())
|
|
224
|
+
if (status.state === "revoked") return failure(COMMAND, "revoked", "The hub rejected the credential it just issued", { data: status, warnings })
|
|
225
|
+
return success(COMMAND, status, warnings)
|
|
226
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// Canonical machine enrollment (`hubctl machine enroll`, `hubctl machine status`): the device-code flow of the
|
|
2
|
+
// spoke SDK, the config.json credential store, and secret-free status. See docs/bootstrap-contracts.md §3.8.
|
|
3
|
+
export * from "./store.ts"
|
|
4
|
+
export * from "./policy.ts"
|
|
5
|
+
export * from "./result.ts"
|
|
6
|
+
export * from "./status.ts"
|
|
7
|
+
export * from "./enroll.ts"
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// The network policy of machine enrollment: which hub URLs a machine credential may be sent to, and which approval
|
|
2
|
+
// link may be shown to the user.
|
|
3
|
+
|
|
4
|
+
const LOOPBACK = new Set(["127.0.0.1", "localhost", "[::1]"])
|
|
5
|
+
const MAX_URL = 2048
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The normalized hub URL (origin + path, no trailing slash), or undefined when it is not allowed: https only, plain
|
|
9
|
+
* http only for a loopback hub (a local hub, and the tests); no user info, query, fragment or whitespace. Matches the
|
|
10
|
+
* contract's hubURL check, and what src/config.ts normalize() keeps.
|
|
11
|
+
*/
|
|
12
|
+
export function checkServerURL(value: unknown): string | undefined {
|
|
13
|
+
if (typeof value !== "string" || value.length === 0 || value.length > MAX_URL) return undefined
|
|
14
|
+
if (/[\s?#]/.test(value)) return undefined
|
|
15
|
+
let url: URL
|
|
16
|
+
try {
|
|
17
|
+
url = new URL(value)
|
|
18
|
+
} catch {
|
|
19
|
+
return undefined
|
|
20
|
+
}
|
|
21
|
+
if (url.username || url.password) return undefined
|
|
22
|
+
if (!(url.protocol === "https:" || (url.protocol === "http:" && LOOPBACK.has(url.hostname)))) return undefined
|
|
23
|
+
if (!/^https?:\/\/[^\s/]+/.test(value)) return undefined
|
|
24
|
+
return `${url.origin}${url.pathname.replace(/\/+$/, "")}`
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const isLoopbackServer = (server: string): boolean => {
|
|
28
|
+
try {
|
|
29
|
+
return LOOPBACK.has(new URL(server).hostname)
|
|
30
|
+
} catch {
|
|
31
|
+
return false
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const USER_CODE = /^[A-Za-z0-9-]{4,32}$/
|
|
36
|
+
|
|
37
|
+
export const isUserCode = (value: unknown): value is string => typeof value === "string" && USER_CODE.test(value)
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The approval link to show. The hub's own link is used only when it is on the hub's origin (scheme, host and port)
|
|
41
|
+
* and passes the URL policy: anything else could send the user (and their hub login) to a phishing page. Otherwise
|
|
42
|
+
* the link is rebuilt on the hub URL, with a warning.
|
|
43
|
+
*/
|
|
44
|
+
export function approvalURL(server: string, approveURL: unknown, userCode: string): { url: string; warning?: string } {
|
|
45
|
+
const own = `${server}/approve?code=${encodeURIComponent(userCode)}`
|
|
46
|
+
if (typeof approveURL !== "string" || approveURL.length > MAX_URL || /\s/.test(approveURL)) return { url: own, warning: "the hub sent no usable approval link; showing one on the hub URL" }
|
|
47
|
+
try {
|
|
48
|
+
const link = new URL(approveURL)
|
|
49
|
+
const base = new URL(server)
|
|
50
|
+
const allowed = checkServerURL(`${link.origin}${link.pathname}`) !== undefined && !link.username && !link.password
|
|
51
|
+
if (allowed && link.origin === base.origin) return { url: link.href }
|
|
52
|
+
} catch {}
|
|
53
|
+
return { url: own, warning: "the hub's approval link is on another origin; showing one on the hub URL instead" }
|
|
54
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// The output of `machine enroll` / `machine status`: one hub.bootstrap.result envelope (for --json) and its exit
|
|
2
|
+
// code, plus a text rendering for people. Every envelope is validated before it is returned: one that fails (for
|
|
3
|
+
// instance because a credential slipped into it) is withheld and replaced by an internal error.
|
|
4
|
+
import { ENVELOPE_SCHEMA, exitFor, validateEnvelope, type EnrollmentStatus, type ErrorCode, type ResultEnvelope } from "../contracts/index.ts"
|
|
5
|
+
|
|
6
|
+
export type MachineCommand = "machine enroll" | "machine status"
|
|
7
|
+
|
|
8
|
+
export interface MachineResult<T = EnrollmentStatus> {
|
|
9
|
+
readonly envelope: ResultEnvelope<T>
|
|
10
|
+
/** The process exit code (contracts envelope.ts exitFor; 0 on success). */
|
|
11
|
+
readonly exit: number
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const clip = (s: string, max: number) => (s.length <= max ? s : `${s.slice(0, max - 1)}…`)
|
|
15
|
+
/** One line, at most `max` characters (contract text fields are single-line). */
|
|
16
|
+
export const line = (s: string, max = 200) => clip(s.replace(/[\u0000-\u001f\u007f]+/g, " ").trim(), max)
|
|
17
|
+
|
|
18
|
+
function checked<T>(envelope: ResultEnvelope<T>): ResultEnvelope<T> {
|
|
19
|
+
if (validateEnvelope(envelope).ok) return envelope
|
|
20
|
+
return { schema: ENVELOPE_SCHEMA, version: 1, command: envelope.command, ok: false, error: { code: "internal", message: "The result failed validation and was withheld" } }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function success<T>(command: MachineCommand, data: T, warnings: readonly string[] = []): MachineResult<T> {
|
|
24
|
+
const envelope = checked<T>({ schema: ENVELOPE_SCHEMA, version: 1, command, ok: true, data, ...(warnings.length ? { warnings: warnings.map((w) => line(w, 500)) } : {}) })
|
|
25
|
+
return { envelope, exit: envelope.ok ? 0 : exitFor(envelope.error!.code) }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function failure<T = EnrollmentStatus>(
|
|
29
|
+
command: MachineCommand,
|
|
30
|
+
code: ErrorCode,
|
|
31
|
+
message: string,
|
|
32
|
+
extra: { hint?: string; data?: T; warnings?: readonly string[] } = {},
|
|
33
|
+
): MachineResult<T> {
|
|
34
|
+
const envelope = checked<T>({
|
|
35
|
+
schema: ENVELOPE_SCHEMA,
|
|
36
|
+
version: 1,
|
|
37
|
+
command,
|
|
38
|
+
ok: false,
|
|
39
|
+
...(extra.data !== undefined ? { data: extra.data } : {}),
|
|
40
|
+
error: { code, message: line(message, 500), ...(extra.hint ? { hint: line(extra.hint, 500) } : {}) },
|
|
41
|
+
...(extra.warnings?.length ? { warnings: extra.warnings.map((w) => line(w, 500)) } : {}),
|
|
42
|
+
})
|
|
43
|
+
return { envelope, exit: exitFor(envelope.error!.code) }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const isStatus = (v: unknown): v is EnrollmentStatus => typeof v === "object" && v !== null && (v as { schema?: unknown }).schema === "hub.enrollment-status"
|
|
47
|
+
|
|
48
|
+
/** Text for people: the machine's state, then any error, hint and warnings. */
|
|
49
|
+
export function renderMachineResult(envelope: ResultEnvelope): string {
|
|
50
|
+
const lines: string[] = []
|
|
51
|
+
if (isStatus(envelope.data)) {
|
|
52
|
+
const s = envelope.data
|
|
53
|
+
const who = s.machineID ? ` as ${s.machineID}${s.machineName ? ` (${s.machineName})` : ""}` : ""
|
|
54
|
+
lines.push(s.server ? `machine: ${s.state}, enrolled with ${s.server}${who}` : `machine: ${s.state}`)
|
|
55
|
+
if (s.detail) lines.push(` ${s.detail}`)
|
|
56
|
+
}
|
|
57
|
+
if (envelope.error) {
|
|
58
|
+
lines.push(`error: ${envelope.error.message}`)
|
|
59
|
+
if (envelope.error.hint) lines.push(`hint: ${envelope.error.hint}`)
|
|
60
|
+
}
|
|
61
|
+
for (const w of envelope.warnings ?? []) lines.push(`warning: ${w}`)
|
|
62
|
+
return `${lines.join("\n")}\n`
|
|
63
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// `machine status`: what the credential store holds and, unless told not to, whether the hub still accepts it.
|
|
2
|
+
// The check is a GET /spoke/ws with the machine token and no WebSocket upgrade: the hub authenticates before it
|
|
3
|
+
// upgrades, so it answers 426 (Expected a WebSocket upgrade) to a token it accepts and 401 to one it rejects. No
|
|
4
|
+
// central change is needed; nothing is opened. The token goes only to a hub URL that passes checkServerURL, never
|
|
5
|
+
// across a redirect, and never into the output. v1.1: a credential already stored for a plain-http hub that is not on
|
|
6
|
+
// loopback is reported as `insecure`, with a warning, and never sent there (a new enrollment to it stays refused).
|
|
7
|
+
import { enrollmentStatusFrom, isInsecureHubURL, validateEnrollmentStatus, type CredentialProbe, type CredentialStore, type EnrollmentStatus, type MachineCredential } from "../contracts/index.ts"
|
|
8
|
+
import { checkServerURL } from "./policy.ts"
|
|
9
|
+
import { failure, line, success, type MachineResult } from "./result.ts"
|
|
10
|
+
import { CredentialStoreError, type StoreContents } from "./store.ts"
|
|
11
|
+
|
|
12
|
+
export const PROBE_TIMEOUT_MS = 10_000
|
|
13
|
+
|
|
14
|
+
export interface ProbeOptions {
|
|
15
|
+
readonly fetch?: typeof fetch
|
|
16
|
+
readonly signal?: AbortSignal
|
|
17
|
+
readonly timeoutMs?: number
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface ProbeResult extends CredentialProbe {
|
|
21
|
+
/** One line for people (HTTP status or error kind); never the response body or a credential. */
|
|
22
|
+
readonly detail: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function reason(cause: unknown, signal?: AbortSignal): string {
|
|
26
|
+
if (signal?.aborted) return "cancelled"
|
|
27
|
+
const e = cause as { name?: string; code?: string } | undefined
|
|
28
|
+
if (e?.name === "TimeoutError") return "timed out"
|
|
29
|
+
return e?.code ?? e?.name ?? "error"
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Asks the hub whether it accepts the credential. Undefined: not checked (the hub URL fails the policy). */
|
|
33
|
+
export async function probeCredential(credential: MachineCredential, options: ProbeOptions = {}): Promise<ProbeResult | undefined> {
|
|
34
|
+
const server = checkServerURL(credential.server)
|
|
35
|
+
if (!server) return undefined
|
|
36
|
+
const doFetch = options.fetch ?? fetch
|
|
37
|
+
const signals = [AbortSignal.timeout(options.timeoutMs ?? PROBE_TIMEOUT_MS), ...(options.signal ? [options.signal] : [])]
|
|
38
|
+
let response: Response
|
|
39
|
+
try {
|
|
40
|
+
response = await doFetch(`${server}/spoke/ws`, {
|
|
41
|
+
method: "GET",
|
|
42
|
+
headers: { authorization: `Bearer ${credential.token}` },
|
|
43
|
+
redirect: "manual",
|
|
44
|
+
signal: AbortSignal.any(signals),
|
|
45
|
+
})
|
|
46
|
+
} catch (cause) {
|
|
47
|
+
return { reachable: false, detail: `could not reach ${server}: ${reason(cause, options.signal)}` }
|
|
48
|
+
}
|
|
49
|
+
void response.body?.cancel().catch(() => {})
|
|
50
|
+
if (response.status === 426) return { reachable: true, accepted: true, detail: `${server} accepts this machine` }
|
|
51
|
+
if (response.status === 401 || response.status === 403) return { reachable: true, accepted: false, detail: `${server} rejected the machine credential (revoked or unknown)` }
|
|
52
|
+
if (response.type === "opaqueredirect" || (response.status >= 300 && response.status < 400)) {
|
|
53
|
+
return { reachable: false, detail: `${server} redirected the check (HTTP ${response.status}); redirects are not followed` }
|
|
54
|
+
}
|
|
55
|
+
return { reachable: false, detail: `${server} could not check the credential (HTTP ${response.status})` }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** A store that can also tell an incomplete entry (a token without a machine ID) from an empty one. */
|
|
59
|
+
export type InspectableStore = CredentialStore & { inspect?(): StoreContents }
|
|
60
|
+
|
|
61
|
+
export interface MachineStatusOptions extends ProbeOptions {
|
|
62
|
+
readonly store: InspectableStore
|
|
63
|
+
/** Check the credential with the hub (default true); false reports `configured` without any network access. */
|
|
64
|
+
readonly verify?: boolean
|
|
65
|
+
readonly now?: () => number
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const COMMAND = "machine status"
|
|
69
|
+
const MACHINE_NAME = /^[A-Za-z0-9._ -]{1,64}$/
|
|
70
|
+
|
|
71
|
+
export function storeFailure(command: "machine status" | "machine enroll", cause: unknown, location: string): MachineResult {
|
|
72
|
+
if (cause instanceof CredentialStoreError && (cause.reason === "not_object" || cause.reason === "symlink")) {
|
|
73
|
+
return failure(command, "conflict", cause.message, { hint: `Fix ${location} by hand; hubctl does not replace it` })
|
|
74
|
+
}
|
|
75
|
+
return failure(command, "internal", cause instanceof CredentialStoreError ? cause.message : `Cannot read ${location}`)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The secret-free status of a credential, with an optional probe result. Drops a machine name the contract refuses. */
|
|
79
|
+
export function statusOf(credential: MachineCredential | undefined, probe: ProbeResult | undefined, now: number, detail?: string): EnrollmentStatus {
|
|
80
|
+
const named = credential?.machineName && !MACHINE_NAME.test(credential.machineName) ? { ...credential, machineName: undefined } : credential
|
|
81
|
+
const status = enrollmentStatusFrom(named, probe, now)
|
|
82
|
+
const text = detail ?? probe?.detail
|
|
83
|
+
return text ? { ...status, detail: line(text) } : status
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export async function machineStatus(options: MachineStatusOptions): Promise<MachineResult> {
|
|
87
|
+
const now = options.now ?? Date.now
|
|
88
|
+
const { store } = options
|
|
89
|
+
let credential: MachineCredential | undefined
|
|
90
|
+
let contents: StoreContents | undefined
|
|
91
|
+
try {
|
|
92
|
+
credential = await store.read()
|
|
93
|
+
contents = store.inspect?.()
|
|
94
|
+
} catch (cause) {
|
|
95
|
+
return storeFailure(COMMAND, cause, store.location)
|
|
96
|
+
}
|
|
97
|
+
if (!credential) {
|
|
98
|
+
const detail = contents?.state === "incomplete" ? `${store.location} has no machine ID for its credential; run hubctl machine enroll --reenroll` : undefined
|
|
99
|
+
return success(COMMAND, statusOf(undefined, undefined, now(), detail))
|
|
100
|
+
}
|
|
101
|
+
if (isInsecureHubURL(credential.server)) {
|
|
102
|
+
const status = statusOf(credential, undefined, now())
|
|
103
|
+
if (!validateEnrollmentStatus(status).ok) return failure(COMMAND, "internal", `${store.location} holds a machine identity this hubctl cannot report`)
|
|
104
|
+
return success(COMMAND, status, [
|
|
105
|
+
`the stored hub URL ${line(credential.server, 300)} is plain http and not on loopback, so the credential was not checked (it is never sent over plain http); enroll again with an https hub URL: hubctl machine enroll --server https://<hub> --reenroll`,
|
|
106
|
+
])
|
|
107
|
+
}
|
|
108
|
+
if (!checkServerURL(credential.server)) {
|
|
109
|
+
return failure(COMMAND, "invalid", `The stored hub URL ${line(credential.server, 300)} is not https (plain http is allowed only for a loopback hub), so it was not checked`, {
|
|
110
|
+
hint: "Enroll again with an https hub URL: hubctl machine enroll --server https://<hub> --reenroll",
|
|
111
|
+
})
|
|
112
|
+
}
|
|
113
|
+
const probe = options.verify === false ? undefined : await probeCredential(credential, options)
|
|
114
|
+
const status = statusOf(credential, probe, now())
|
|
115
|
+
if (!validateEnrollmentStatus(status).ok) return failure(COMMAND, "internal", `${store.location} holds a machine identity this hubctl cannot report`)
|
|
116
|
+
if (status.state === "revoked") return failure(COMMAND, "revoked", "The hub rejected this machine's credential", { data: status, hint: "Enroll the machine again: hubctl machine enroll --reenroll" })
|
|
117
|
+
if (status.state === "offline") return failure(COMMAND, "offline", `Could not check the credential with ${credential.server}`, { data: status })
|
|
118
|
+
return success(COMMAND, status)
|
|
119
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
// The machine credential store: the spoke's config.json (src/config.ts), the one file every spoke and `hub node` on
|
|
2
|
+
// this machine read their identity from. Writes merge into the raw JSON (every key the file holds survives, I4),
|
|
3
|
+
// atomically (private temp file + rename) with mode 0600 in a 0700 directory. A symlinked config.json or config
|
|
4
|
+
// directory is refused: a machine token must never be written into a dotfiles repository (T23).
|
|
5
|
+
import { randomBytes } from "node:crypto"
|
|
6
|
+
import { closeSync, lstatSync, mkdirSync, openSync, rmSync } from "node:fs"
|
|
7
|
+
import { hostname } from "node:os"
|
|
8
|
+
import path from "node:path"
|
|
9
|
+
import { configPath, normalize, prepareConfigPath, readRawConfig, updateRawConfig, type Mode } from "../../../../../src/config.ts"
|
|
10
|
+
import type { CredentialStore, MachineCredential } from "../contracts/index.ts"
|
|
11
|
+
|
|
12
|
+
export type CredentialStoreFailure = "symlink" | "not_object" | "invalid" | "io"
|
|
13
|
+
|
|
14
|
+
/** Why the store refused or failed. The message names the file and the reason, never a credential. */
|
|
15
|
+
export class CredentialStoreError extends Error {
|
|
16
|
+
override readonly name = "CredentialStoreError"
|
|
17
|
+
constructor(
|
|
18
|
+
readonly reason: CredentialStoreFailure,
|
|
19
|
+
message: string,
|
|
20
|
+
/** The errno code of an I/O failure (EACCES, ENOSPC, …). */
|
|
21
|
+
readonly errno?: string,
|
|
22
|
+
) {
|
|
23
|
+
super(message)
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The spoke's sharing mode in config.json: `unset` when the file has no `mode` key (the spoke then treats it as
|
|
29
|
+
* "off"), else what the spoke reads (a value it does not understand reads as "off").
|
|
30
|
+
*/
|
|
31
|
+
export type SharingMode = Mode | "unset"
|
|
32
|
+
|
|
33
|
+
/** The mode an enrollment turns on when the user never chose one: the OpenCode spoke connects only when it is not "off". */
|
|
34
|
+
export const DEFAULT_SHARING: Mode = "always"
|
|
35
|
+
|
|
36
|
+
/** What the file holds, without the credential: `incomplete` is a token without a machine ID (enroll again). */
|
|
37
|
+
export type StoreContents = { readonly state: "empty" | "credential" | "incomplete" }
|
|
38
|
+
|
|
39
|
+
const errnoOf = (cause: unknown) => (cause as NodeJS.ErrnoException | undefined)?.code
|
|
40
|
+
|
|
41
|
+
function ioError(file: string, doing: string, cause: unknown): CredentialStoreError {
|
|
42
|
+
if (cause instanceof CredentialStoreError) return cause
|
|
43
|
+
const errno = errnoOf(cause)
|
|
44
|
+
return new CredentialStoreError("io", `Cannot ${doing} ${file}${errno ? ` (${errno})` : ""}`, errno)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const isLink = (p: string) => {
|
|
48
|
+
try {
|
|
49
|
+
return lstatSync(p).isSymbolicLink()
|
|
50
|
+
} catch {
|
|
51
|
+
return false
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export class ConfigCredentialStore implements CredentialStore {
|
|
56
|
+
readonly location: string
|
|
57
|
+
|
|
58
|
+
constructor(file: string) {
|
|
59
|
+
this.location = file
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The raw config object ({} when there is none); throws a CredentialStoreError when it is not a JSON object. */
|
|
63
|
+
private raw(): Record<string, unknown> {
|
|
64
|
+
try {
|
|
65
|
+
return readRawConfig(this.location) ?? {}
|
|
66
|
+
} catch (cause) {
|
|
67
|
+
if (errnoOf(cause)) throw ioError(this.location, "read", cause)
|
|
68
|
+
throw new CredentialStoreError("not_object", `${this.location} is not a JSON object; fix or remove it first`)
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
private refuseLinks(): void {
|
|
73
|
+
for (const p of [path.dirname(this.location), this.location]) {
|
|
74
|
+
if (isLink(p)) throw new CredentialStoreError("symlink", `${p} is a symlink; hubctl never writes a machine credential through one (keep config.json out of dotfiles)`)
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Checks, before a one-time enrollment code is redeemed, that write() can succeed: no symlink, a JSON object (or
|
|
80
|
+
* no file), and a writable private directory (created 0700 if missing). Leaves no file behind.
|
|
81
|
+
*/
|
|
82
|
+
async preflight(): Promise<void> {
|
|
83
|
+
this.refuseLinks()
|
|
84
|
+
this.raw()
|
|
85
|
+
const dir = path.dirname(this.location)
|
|
86
|
+
const probe = path.join(dir, `.${path.basename(this.location)}.${process.pid}.${randomBytes(6).toString("hex")}.probe`)
|
|
87
|
+
try {
|
|
88
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 })
|
|
89
|
+
closeSync(openSync(probe, "wx", 0o600))
|
|
90
|
+
} catch (cause) {
|
|
91
|
+
throw ioError(dir, "write to", cause)
|
|
92
|
+
} finally {
|
|
93
|
+
rmSync(probe, { force: true })
|
|
94
|
+
}
|
|
95
|
+
this.refuseLinks()
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
inspect(): StoreContents {
|
|
99
|
+
const config = normalize(this.raw(), hostname())
|
|
100
|
+
if (!config.token) return { state: "empty" }
|
|
101
|
+
return { state: config.machineID ? "credential" : "incomplete" }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
sharingMode(): SharingMode {
|
|
105
|
+
const raw = this.raw()
|
|
106
|
+
return "mode" in raw ? normalize({ mode: raw.mode }, "x").mode : "unset"
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Turns sharing on (`mode: "always"`) when config.json has no `mode` at all, so an enrolled machine's spoke
|
|
111
|
+
* connects. An explicit value, "off" included, is the user's and is never changed. True when it wrote.
|
|
112
|
+
*/
|
|
113
|
+
enableSharing(): boolean {
|
|
114
|
+
this.refuseLinks()
|
|
115
|
+
if ("mode" in this.raw()) return false
|
|
116
|
+
try {
|
|
117
|
+
updateRawConfig(this.location, (raw) => ("mode" in raw ? raw : { ...raw, mode: DEFAULT_SHARING }))
|
|
118
|
+
} catch (cause) {
|
|
119
|
+
throw ioError(this.location, "write", cause)
|
|
120
|
+
}
|
|
121
|
+
return true
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async read(): Promise<MachineCredential | undefined> {
|
|
125
|
+
const raw = this.raw()
|
|
126
|
+
const config = normalize(raw, hostname())
|
|
127
|
+
if (!config.token || !config.machineID) return undefined
|
|
128
|
+
const machineName = typeof raw.machineName === "string" && raw.machineName ? raw.machineName : undefined
|
|
129
|
+
return { server: config.server, machineID: config.machineID, ...(machineName ? { machineName } : {}), token: config.token }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async write(credential: MachineCredential): Promise<void> {
|
|
133
|
+
const server = credential.server.replace(/\/+$/, "")
|
|
134
|
+
// normalize() falls back to the default server for a URL it cannot keep; the token must never end up paired with it.
|
|
135
|
+
if (normalize({ server }, "x").server !== server) throw new CredentialStoreError("invalid", `Refusing to store a credential for an unusable server URL`)
|
|
136
|
+
if (!credential.token || !credential.machineID) throw new CredentialStoreError("invalid", "Refusing to store an incomplete credential")
|
|
137
|
+
this.refuseLinks()
|
|
138
|
+
this.raw()
|
|
139
|
+
try {
|
|
140
|
+
updateRawConfig(this.location, (raw) => ({
|
|
141
|
+
...raw,
|
|
142
|
+
server,
|
|
143
|
+
machineID: credential.machineID,
|
|
144
|
+
...(credential.machineName ? { machineName: credential.machineName } : {}),
|
|
145
|
+
token: credential.token,
|
|
146
|
+
// An enrolled machine shares by default (the /hub dialog asks; hubctl cannot): only when the user never chose.
|
|
147
|
+
...("mode" in raw ? {} : { mode: DEFAULT_SHARING }),
|
|
148
|
+
}))
|
|
149
|
+
} catch (cause) {
|
|
150
|
+
throw ioError(this.location, "write", cause)
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async clear(): Promise<void> {
|
|
155
|
+
this.refuseLinks()
|
|
156
|
+
const raw = this.raw()
|
|
157
|
+
if (!("token" in raw) && !("machineID" in raw)) return
|
|
158
|
+
try {
|
|
159
|
+
updateRawConfig(this.location, ({ token: _token, machineID: _machineID, ...rest }) => rest)
|
|
160
|
+
} catch (cause) {
|
|
161
|
+
throw ioError(this.location, "write", cause)
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* The machine's credential store: $HUB_SPOKE_CONFIG, else ~/.config/hub/config.json (the file the spoke and
|
|
168
|
+
* `hub node` read). With `migrate` (for enroll), a legacy config is copied to the default location first, as the
|
|
169
|
+
* spoke does; status reads without side effects.
|
|
170
|
+
*/
|
|
171
|
+
export function credentialStoreFor(env: Readonly<Record<string, string | undefined>>, home: string, options: { migrate?: boolean } = {}): ConfigCredentialStore {
|
|
172
|
+
return new ConfigCredentialStore(options.migrate ? prepareConfigPath(env, home) : configPath(env, home))
|
|
173
|
+
}
|