warpmetal 0.8.7 → 0.8.9
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/README.md +36 -11
- package/package.json +2 -2
- package/skills/warpmetal/SKILL.md +23 -10
- package/skills/warpmetal/references/cli-reference.md +15 -2
- package/skills/warpmetal/references/payments.md +7 -3
- package/skills/warpmetal/references/runtime.md +15 -3
- package/skills/warpmetal/references/safety.md +12 -3
- package/src/cli.js +41 -5
- package/src/host-trust.js +524 -0
- package/src/installer.js +12 -12
- package/src/state.js +54 -1
package/README.md
CHANGED
|
@@ -103,6 +103,19 @@ key does not encode a login username: use `root@<server-ip>` for an owner shell
|
|
|
103
103
|
and `--ssh-user root` for runtime installation, not a distribution convention
|
|
104
104
|
such as `ubuntu`.
|
|
105
105
|
|
|
106
|
+
Starting in CLI 0.8.8, `runtime install --confirm INSTALL` establishes managed
|
|
107
|
+
SSH host trust without requiring access to the VPS provider. For a server trust
|
|
108
|
+
epoch with no pin, the CLI runs only a harmless `ssh true` using the exact owner
|
|
109
|
+
key, trusts the first observed Ed25519 host key, stores it under the private
|
|
110
|
+
WarpMetal state directory, and immediately reconnects with strict checking. It
|
|
111
|
+
requests the Runtime bootstrap only after that strict replay succeeds. Every
|
|
112
|
+
later SSH and SCP operation must match the same pin; a changed key is never
|
|
113
|
+
silently accepted or overwritten.
|
|
114
|
+
|
|
115
|
+
The private WarpMetal state directory is the local trust domain. Back it up
|
|
116
|
+
with the owner key: deleting it also deletes the durable pin, so the next
|
|
117
|
+
install is a new first-use decision rather than a verified continuation.
|
|
118
|
+
|
|
106
119
|
Pass `--json` for structured, secret-redacted output. Use
|
|
107
120
|
`WARPMETAL_API_URL` for an alternate API origin and `WARPMETAL_HOME` for an
|
|
108
121
|
alternate state directory.
|
|
@@ -143,11 +156,15 @@ receipt finality while provisioning or renewal proceeds.
|
|
|
143
156
|
|
|
144
157
|
For an interactive initial purchase, `checkout challenge` may also return a
|
|
145
158
|
short-lived `humanCheckout` object. Its `url` and `qrPayload` are the same
|
|
146
|
-
`https://pay.x402api.com/c/...` bearer capability for the exact charge
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
159
|
+
`https://pay.x402api.com/c/...` bearer capability for the exact charge.
|
|
160
|
+
Present the exact URL as a clickable x402api handoff. If a QR is useful for
|
|
161
|
+
another device, encode only the identical `qrPayload` and explain that
|
|
162
|
+
scanning opens the hosted checkout without authorizing payment. x402api owns
|
|
163
|
+
any wallet selection or wallet-specific opening QR inside that page; never
|
|
164
|
+
synthesize a wallet link or encode the WarpMetal recipient address. The buyer
|
|
165
|
+
needs only the advertised USDC/USDT balance because x402api sponsors the native
|
|
166
|
+
gas. This is an alternative to the agent-wallet workflow, not a second
|
|
167
|
+
payment. After browser payment, run the exact
|
|
151
168
|
`humanCheckout.afterPayment.argv` status command. When the ready result asks
|
|
152
169
|
for `ask_human_for_notification_email`, ask the owner and add the optional
|
|
153
170
|
lifecycle-notification address they provide. Autonomous purchases and renewals
|
|
@@ -300,8 +317,9 @@ compatible external signer.
|
|
|
300
317
|
Wallet key management and signing remain outside this package.
|
|
301
318
|
- Destructive or state-changing commands require explicit confirmations and
|
|
302
319
|
generate idempotency keys by default.
|
|
303
|
-
- Runtime bootstrap credentials remain memory-only
|
|
304
|
-
|
|
320
|
+
- Runtime bootstrap credentials remain memory-only and are requested only after
|
|
321
|
+
the first host key has been pinned and strictly reverified. Signed supervisor
|
|
322
|
+
bundles are checksum- and signature-verified before OpenSSH uploads them.
|
|
305
323
|
- Each agent gets a distinct SSH key forced into exactly one sandbox. Token-free
|
|
306
324
|
connection profiles pin the VPS host key and contain no owner credential or
|
|
307
325
|
private-key material.
|
|
@@ -315,10 +333,10 @@ compatible external signer.
|
|
|
315
333
|
the observed digest and generation both match the accepted target.
|
|
316
334
|
- Guarded reload powers the server off first. Runtime-enabled reload requires a
|
|
317
335
|
second acknowledgment, after which the CLI guides supervisor reinstall and
|
|
318
|
-
pinned connection-profile refresh.
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
are never described as recoverable.
|
|
336
|
+
pinned connection-profile refresh. Only a locally recorded reload operation
|
|
337
|
+
that succeeds and reports an owner-host-key refresh opens one new managed
|
|
338
|
+
trust epoch; failed or ambiguous reloads retain the old pin and never permit
|
|
339
|
+
replacement. Erased workspaces are never described as recoverable.
|
|
322
340
|
|
|
323
341
|
## Agent Runtime example
|
|
324
342
|
|
|
@@ -349,6 +367,13 @@ warpmetal sandbox access keygen \
|
|
|
349
367
|
--json
|
|
350
368
|
```
|
|
351
369
|
|
|
370
|
+
On the first install in a server trust epoch, JSON output includes
|
|
371
|
+
`hostKeyTrust.state: "trusted_first_use"` and the safe Ed25519 fingerprint.
|
|
372
|
+
Later installs report `"matched"`. First-use trust protects continuity after
|
|
373
|
+
that observation, but it cannot detect an active attacker on the first
|
|
374
|
+
connection. Provider-console host-key pre-enrollment remains an optional
|
|
375
|
+
higher-assurance alternative when it is available.
|
|
376
|
+
|
|
352
377
|
WarpMetal CLI v0.8.7 with Agent Runtime v0.1.25 or newer can enable the narrowly
|
|
353
378
|
scoped AppArmor exception required by a verified workload that creates an inner
|
|
354
379
|
Bubblewrap PID namespace and private `/proc`:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "warpmetal",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.9",
|
|
4
4
|
"description": "Agent-safe CLI and skill for purchasing, renewing, and managing WarpMetal VPS servers",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
"node": ">=20.0.0"
|
|
17
17
|
},
|
|
18
18
|
"scripts": {
|
|
19
|
-
"check": "node --check bin/warpmetal.js && node --check src/api.js && node --check src/args.js && node --check src/cli.js && node --check src/connection.js && node --check src/errors.js && node --check src/install-skill.js && node --check src/installer.js && node --check src/payment.js && node --check src/runtime.js && node --check src/ssh.js && node --check src/state.js && node --check src/version.js",
|
|
19
|
+
"check": "node --check bin/warpmetal.js && node --check src/api.js && node --check src/args.js && node --check src/cli.js && node --check src/connection.js && node --check src/errors.js && node --check src/host-trust.js && node --check src/install-skill.js && node --check src/installer.js && node --check src/payment.js && node --check src/runtime.js && node --check src/ssh.js && node --check src/state.js && node --check src/version.js",
|
|
20
20
|
"plugin:check": "node --test test/plugin.test.js",
|
|
21
21
|
"test": "node --test",
|
|
22
22
|
"prepack": "npm run check && npm test"
|
|
@@ -90,11 +90,14 @@ open either file.
|
|
|
90
90
|
|
|
91
91
|
In an interactive initial purchase, the result may also contain
|
|
92
92
|
`humanCheckout`. Offer it as an alternative to the local agent wallet. Show
|
|
93
|
-
`humanCheckout.url` as clickable text
|
|
94
|
-
`humanCheckout.qrPayload` URL as
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
93
|
+
`humanCheckout.url` as clickable x402api text. If another-device acquisition
|
|
94
|
+
is useful, render only the identical `humanCheckout.qrPayload` URL as a QR and
|
|
95
|
+
explain that scanning opens the hosted checkout without authorizing payment.
|
|
96
|
+
x402api owns any wallet selection and wallet-specific opening QR inside that
|
|
97
|
+
page; never synthesize a wallet link or render the recipient address as this
|
|
98
|
+
QR. The URL is an expiring bearer capability, so do not log, save, or send it
|
|
99
|
+
anywhere except to the buyer who requested this purchase. If the buyer uses it,
|
|
100
|
+
do not authorize or submit through the agent wallet. Run the
|
|
98
101
|
exact `humanCheckout.afterPayment.argv` command, wait for the server to become
|
|
99
102
|
ready, then follow `ask_human_for_notification_email`: ask the owner for the
|
|
100
103
|
optional lifecycle-notification address and add only the address they provide.
|
|
@@ -266,11 +269,13 @@ warpmetal server reload \
|
|
|
266
269
|
```
|
|
267
270
|
|
|
268
271
|
The CLI requires the recovery owner credential so it can keep polling after
|
|
269
|
-
reload revokes SSH-derived access tokens.
|
|
270
|
-
only
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
272
|
+
reload revokes SSH-derived access tokens. CLI 0.8.8 records a new SSH trust
|
|
273
|
+
epoch only when that exact local reload operation succeeds and reports that the
|
|
274
|
+
owner host key needs refresh. The next Runtime install may trust the first
|
|
275
|
+
observed Ed25519 host key once in that epoch, then must reconnect strictly
|
|
276
|
+
before requesting bootstrap. Failed or ambiguous reloads retain the prior pin;
|
|
277
|
+
never bypass a mismatch or delete a pin to force a retry. A provider-console
|
|
278
|
+
pre-seed is the optional higher-assurance path. Then wait for grants to become applied and refresh
|
|
274
279
|
every connection profile with `sandbox access refresh --confirm REFRESH`
|
|
275
280
|
before connecting. Do not fall back to raw API calls for deletion, networking,
|
|
276
281
|
or another unsupported mutation.
|
|
@@ -283,6 +288,14 @@ sizes. Use `--runtime-file` to include sandbox intent in an unpaid order, or
|
|
|
283
288
|
`warpmetal runtime enable` after the VPS is ready. Supervisor installation is
|
|
284
289
|
separate and requires approval plus `--confirm INSTALL`.
|
|
285
290
|
|
|
291
|
+
With CLI 0.8.8, that confirmation also authorizes managed trust on first use
|
|
292
|
+
when the exact server trust epoch has no pin. The CLI performs only an owner-
|
|
293
|
+
key-authenticated `ssh true`, pins the first observed Ed25519 key, immediately
|
|
294
|
+
reconnects strictly, and requests bootstrap only afterward. Later connections
|
|
295
|
+
must match. Explain that TOFU cannot detect an active attacker on the first
|
|
296
|
+
connection; never use `ssh-keyscan`, accept a mismatch, or expose a generic pin
|
|
297
|
+
reset.
|
|
298
|
+
|
|
286
299
|
For any verified workload that creates an inner Bubblewrap PID namespace and
|
|
287
300
|
private `/proc`, CLI 0.8.7 with Runtime 0.1.25 or newer may add
|
|
288
301
|
`--nested-private-procfs enable` to the approved Runtime install. Planning with
|
|
@@ -75,8 +75,11 @@ workflow.
|
|
|
75
75
|
For interactive initial purchases, the response may additionally include
|
|
76
76
|
`humanCheckout.url`, the identical `qrPayload`, `expiresAt`, and an exact
|
|
77
77
|
`afterPayment.argv` command. The URL is a short-lived bearer capability for the
|
|
78
|
-
same charge, not a recipient address.
|
|
79
|
-
|
|
78
|
+
same charge, not a recipient address. Present the URL as a clickable x402api
|
|
79
|
+
handoff. An optional QR contains that exact web link for another-device
|
|
80
|
+
acquisition; scanning it does not authorize payment, and x402api owns any
|
|
81
|
+
wallet-specific choices inside the hosted page. If the buyer pays there, do not
|
|
82
|
+
submit an agent-wallet artifact; run the returned status command and,
|
|
80
83
|
after ready, follow `ask_human_for_notification_email` to offer lifecycle
|
|
81
84
|
notices. The CLI does not persist the hosted URL. Renewal commands never expose
|
|
82
85
|
or use this interactive option.
|
|
@@ -232,6 +235,16 @@ warpmetal sandbox delete \
|
|
|
232
235
|
--server <serverId> --sandbox <sandboxId> --confirm DELETE [--wait] --json
|
|
233
236
|
```
|
|
234
237
|
|
|
238
|
+
CLI 0.8.8 manages owner-facing VPS host trust during `runtime install`. With no
|
|
239
|
+
pin for the exact server trust epoch, the confirmed install performs one
|
|
240
|
+
harmless owner-key-authenticated SSH connection, trusts the first observed
|
|
241
|
+
Ed25519 host key, atomically pins it, and immediately reconnects strictly before
|
|
242
|
+
requesting bootstrap. JSON reports `hostKeyTrust.state` as
|
|
243
|
+
`trusted_first_use` or `matched` plus the safe fingerprint. Every later SSH and
|
|
244
|
+
SCP operation is strict; changed keys, malformed pins, and failed or ambiguous
|
|
245
|
+
reloads never replace trust. This TOFU step cannot detect an active attacker on
|
|
246
|
+
the first connection. Provider-console pre-enrollment is optional and stronger.
|
|
247
|
+
|
|
235
248
|
The nested-private-procfs action requires CLI 0.8.7 and Runtime 0.1.25 or
|
|
236
249
|
newer. It defaults to `preserve`. `enable` is a host-level opt-in for any
|
|
237
250
|
verified workload that creates an inner Bubblewrap PID namespace and private
|
|
@@ -125,9 +125,13 @@ argv arrays returned under `paymentWorkflow`:
|
|
|
125
125
|
|
|
126
126
|
For an interactive initial purchase, an optional `humanCheckout` object offers
|
|
127
127
|
a second presentation path for the same charge. Its `url` and `qrPayload` must
|
|
128
|
-
be identical hosted-checkout URLs
|
|
129
|
-
|
|
130
|
-
|
|
128
|
+
be identical hosted-checkout URLs. Present `url` as the clickable x402api
|
|
129
|
+
handoff. If a QR is useful for another device, render only the exact
|
|
130
|
+
`qrPayload` and explain that scanning opens the checkout without authorizing
|
|
131
|
+
payment. x402api owns wallet selection and any wallet-specific opening QR
|
|
132
|
+
inside that page; never synthesize a wallet link or render a recipient address.
|
|
133
|
+
If the buyer completes this path, skip agent-wallet authorization and run
|
|
134
|
+
`humanCheckout.afterPayment.argv`. Continue bounded
|
|
131
135
|
status polling until ready, then ask for the optional lifecycle-notification
|
|
132
136
|
email when the result returns `ask_human_for_notification_email`. The hosted
|
|
133
137
|
URL expires at `humanCheckout.expiresAt`, is not persisted by the CLI, and is
|
|
@@ -65,9 +65,21 @@ warpmetal runtime get --server <serverId> --wait --json
|
|
|
65
65
|
```
|
|
66
66
|
|
|
67
67
|
Ask before installation. Pass the owner key path without reading the file.
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
68
|
+
CLI 0.8.8 first validates that the public half matches the server's ordering-key
|
|
69
|
+
fingerprint. When that server trust epoch has no pin, it runs only an owner-key-
|
|
70
|
+
authenticated `ssh true`, trusts the first observed Ed25519 host key, publishes
|
|
71
|
+
it atomically under the private WarpMetal state directory, and immediately
|
|
72
|
+
reconnects strictly. Only then does the CLI request the one-time bootstrap. It
|
|
73
|
+
holds that bootstrap only in memory, verifies the signed artifact, uploads it
|
|
74
|
+
through OpenSSH without a shell-enabled local spawn, and does not print or store
|
|
75
|
+
the bootstrap. Every later SSH/SCP operation uses the same strict pin; a
|
|
76
|
+
mismatch never overwrites it.
|
|
77
|
+
|
|
78
|
+
First-use trust provides continuity after the first observation but cannot
|
|
79
|
+
detect an active attacker on that connection. Provider-console pre-enrollment
|
|
80
|
+
is an optional higher-assurance alternative, not a requirement. Only a locally
|
|
81
|
+
recorded successful reload that reports an owner-host-key refresh creates one
|
|
82
|
+
new operation-bound trust epoch; failed or ambiguous reloads do not.
|
|
71
83
|
|
|
72
84
|
`--nested-private-procfs` is supported by `warpmetal` CLI 0.8.7 with Agent
|
|
73
85
|
Runtime 0.1.25 or newer. Its default is `preserve`, which makes no AppArmor
|
|
@@ -99,9 +99,18 @@ the supervisor identity is revoked, desired sandboxes are recreated empty
|
|
|
99
99
|
after reinstall, and all pinned profiles require refresh. Use only the guarded
|
|
100
100
|
CLI command and stop on `manual_review`. Reload invalidates the prior
|
|
101
101
|
owner-facing SSH host-key trust decision; the provider may rotate or preserve
|
|
102
|
-
the key.
|
|
103
|
-
|
|
104
|
-
|
|
102
|
+
the key. CLI 0.8.8 opens one new managed trust epoch only after the exact local
|
|
103
|
+
reload operation succeeds and reports that refresh is required. The first
|
|
104
|
+
owner-key-authenticated connection may pin the observed Ed25519 key once, then
|
|
105
|
+
all connections are strict. Failed or ambiguous reloads never advance trust.
|
|
106
|
+
Never use `ssh-keyscan`, disable host-key checking, accept a mismatch, or delete
|
|
107
|
+
a pin as a generic reset. Provider-console pre-enrollment remains the optional
|
|
108
|
+
higher-assurance alternative; TOFU cannot detect an active attacker on the
|
|
109
|
+
first connection.
|
|
110
|
+
|
|
111
|
+
The private WarpMetal state directory is the local trust domain. Deleting it
|
|
112
|
+
removes the durable pin and makes the next install a new first-use decision;
|
|
113
|
+
never present that as continuity with the prior server identity.
|
|
105
114
|
|
|
106
115
|
An order preparation is unpaid but consumes a limited prepared-order slot.
|
|
107
116
|
Confirm the plan, hostname, OS, and public key before preparing it.
|
package/src/cli.js
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
import { WarpMetalClient } from "./api.js";
|
|
13
13
|
import { connectionProfile, writeConnectionProfile } from "./connection.js";
|
|
14
14
|
import { CliError, toErrorMessage } from "./errors.js";
|
|
15
|
+
import { establishHostTrust } from "./host-trust.js";
|
|
15
16
|
import { installSkill } from "./install-skill.js";
|
|
16
17
|
import { installRuntime } from "./installer.js";
|
|
17
18
|
import {
|
|
@@ -142,6 +143,16 @@ inner Bubblewrap PID namespace and private /proc. Planning, coding, and QA are
|
|
|
142
143
|
common examples. GitHub access, an AI CLI, and subagent delegation alone do not
|
|
143
144
|
require it. The capability is host-scoped, not per-sandbox.
|
|
144
145
|
|
|
146
|
+
SSH host trust (CLI 0.8.8+):
|
|
147
|
+
runtime install authenticates with the exact owner key and trusts the first
|
|
148
|
+
observed Ed25519 host key once for that server trust epoch. It pins the key
|
|
149
|
+
before requesting a Runtime bootstrap, immediately reconnects strictly, and
|
|
150
|
+
requires that same pin thereafter. A changed key is never accepted or
|
|
151
|
+
overwritten. A successful recorded server reload may create one new trust
|
|
152
|
+
epoch; failed or ambiguous reloads do not. First-use trust cannot detect an
|
|
153
|
+
active attacker on the first connection. Provider-console pre-enrollment is
|
|
154
|
+
the optional higher-assurance alternative.
|
|
155
|
+
|
|
145
156
|
Credential environment variables:
|
|
146
157
|
WARPMETAL_OWNER_TOKEN Recovery/bootstrap credential for one explicit command
|
|
147
158
|
WARPMETAL_ACCESS_TOKEN Short-lived SSH-derived credential for one explicit command
|
|
@@ -876,7 +887,7 @@ async function handleCheckoutChallenge(client, store, options, context) {
|
|
|
876
887
|
stringOption(options, "request-envelope-out"),
|
|
877
888
|
);
|
|
878
889
|
const directCheckoutInstructions = safe.humanCheckout
|
|
879
|
-
? `\
|
|
890
|
+
? `\nHosted human checkout (optional, expires ${safe.humanCheckout.expiresAt}): ${safe.humanCheckout.url}\nOpen this exact x402api link. If another-device acquisition is useful, encode only the returned QR URL; scanning opens the checkout and does not authorize payment. x402api owns wallet selection and explicit approval. After payment, continue with: ${shellCommand(safe.humanCheckout.afterPayment.argv)}. When provisioning is ready, ask the owner for the optional lifecycle-notification email before adding it.`
|
|
880
891
|
: "";
|
|
881
892
|
const paymentInstructions = safe.paymentWorkflow
|
|
882
893
|
? `\nWallet package: ${safe.paymentWorkflow.signerPackage.spec} (Node ${safe.paymentWorkflow.signerNodeRequirement})\nInstall: ${shellCommand(safe.paymentWorkflow.signerPackage.install.argv)}\nVerify: ${shellCommand(safe.paymentWorkflow.signerContract.probe.argv)}\n${walletWorkflowInstructions(safe.paymentWorkflow)}\nRequest envelope: ${safe.paymentWorkflow.requestEnvelopePath}\nAuthorize only after selecting/funding one wallet: ${shellCommand(safe.paymentWorkflow.authorize.argv)}\nSubmit with WarpMetal: ${shellCommand(safe.paymentWorkflow.submit.argv)}`
|
|
@@ -1939,6 +1950,7 @@ async function applyReloadResult(
|
|
|
1939
1950
|
operation,
|
|
1940
1951
|
pendingIdentityId,
|
|
1941
1952
|
) {
|
|
1953
|
+
await store.applyReloadHostTrust(serverId, operation);
|
|
1942
1954
|
if (operation?.state !== "succeeded") return;
|
|
1943
1955
|
if (pendingIdentityId) {
|
|
1944
1956
|
await store.bindIdentity(pendingIdentityId, undefined, serverId);
|
|
@@ -2052,10 +2064,12 @@ async function handleServerReload(client, store, options, context) {
|
|
|
2052
2064
|
context.json,
|
|
2053
2065
|
`Reload operation ${operationId}: ${operation?.state}.` +
|
|
2054
2066
|
(operation?.state === "succeeded" &&
|
|
2055
|
-
operation.result?.reloadImpact?.
|
|
2056
|
-
?
|
|
2067
|
+
operation.result?.reloadImpact?.ownerKnownHostsNeedRefresh
|
|
2068
|
+
? operation.result?.reloadImpact?.agentRuntimeAffected
|
|
2069
|
+
? " The next Runtime install will establish the operation-bound owner SSH trust epoch, then reinstall Agent Runtime and refresh every sandbox connection profile."
|
|
2070
|
+
: " The next Runtime install will establish the operation-bound owner SSH trust epoch before reconnecting."
|
|
2057
2071
|
: operation?.state === "succeeded"
|
|
2058
|
-
? "
|
|
2072
|
+
? " WarpMetal did not report a host-key refresh; the existing managed pin remains required."
|
|
2059
2073
|
: ""),
|
|
2060
2074
|
);
|
|
2061
2075
|
if (operation?.state === "manual_review") return 6;
|
|
@@ -2170,6 +2184,20 @@ async function handleRuntimeInstall(client, store, options, context) {
|
|
|
2170
2184
|
serverId,
|
|
2171
2185
|
identity,
|
|
2172
2186
|
);
|
|
2187
|
+
const server = (await client.getServer(serverId, issued.accessToken)).data
|
|
2188
|
+
?.task;
|
|
2189
|
+
const trustEpoch = await store.hostTrustEpoch(serverId);
|
|
2190
|
+
const hostKeyTrust = await establishHostTrust({
|
|
2191
|
+
stateDirectory: store.directory,
|
|
2192
|
+
serverId,
|
|
2193
|
+
trustEpoch,
|
|
2194
|
+
server,
|
|
2195
|
+
identityPath: identity,
|
|
2196
|
+
sshUser,
|
|
2197
|
+
reinspectServer: async () =>
|
|
2198
|
+
(await client.getServer(serverId, issued.accessToken)).data?.task,
|
|
2199
|
+
spawnImpl: context.spawnImpl,
|
|
2200
|
+
});
|
|
2173
2201
|
const key =
|
|
2174
2202
|
stringOption(options, "idempotency-key") ||
|
|
2175
2203
|
idempotencyKey("runtime-bootstrap");
|
|
@@ -2184,11 +2212,19 @@ async function handleRuntimeInstall(client, store, options, context) {
|
|
|
2184
2212
|
token: issued.accessToken,
|
|
2185
2213
|
identity,
|
|
2186
2214
|
sshUser,
|
|
2215
|
+
knownHostsFile: hostKeyTrust.knownHostsFile,
|
|
2216
|
+
trustedPublicIp: hostKeyTrust.publicIp,
|
|
2187
2217
|
bootstrap: bootstrap.data,
|
|
2188
2218
|
nestedPrivateProcfs,
|
|
2189
2219
|
fetchImpl: context.fetchImpl,
|
|
2190
2220
|
spawnImpl: context.spawnImpl,
|
|
2191
2221
|
});
|
|
2222
|
+
safe.hostKeyTrust = {
|
|
2223
|
+
state: hostKeyTrust.state,
|
|
2224
|
+
algorithm: hostKeyTrust.algorithm,
|
|
2225
|
+
fingerprint: hostKeyTrust.fingerprint,
|
|
2226
|
+
trustEpoch: hostKeyTrust.trustEpoch,
|
|
2227
|
+
};
|
|
2192
2228
|
let runtimeResult;
|
|
2193
2229
|
if (booleanOption(options, "wait")) {
|
|
2194
2230
|
runtimeResult = await pollRuntime(
|
|
@@ -2204,7 +2240,7 @@ async function handleRuntimeInstall(client, store, options, context) {
|
|
|
2204
2240
|
context.stdout,
|
|
2205
2241
|
safe,
|
|
2206
2242
|
context.json,
|
|
2207
|
-
`Installed Agent Runtime ${safe.supervisorVersion} on ${serverId}${safe.runtime ? `: ${safe.runtime.state}` : "."}
|
|
2243
|
+
`Installed Agent Runtime ${safe.supervisorVersion} on ${serverId}${safe.runtime ? `: ${safe.runtime.state}` : "."} SSH host key ${safe.hostKeyTrust.state === "trusted_first_use" ? "trusted on first use" : "matched"} (${safe.hostKeyTrust.fingerprint}).`,
|
|
2208
2244
|
);
|
|
2209
2245
|
return safe.runtime?.state === "degraded" ? 5 : 0;
|
|
2210
2246
|
}
|
|
@@ -0,0 +1,524 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { spawn as nodeSpawn } from "node:child_process";
|
|
3
|
+
import {
|
|
4
|
+
chmod,
|
|
5
|
+
link,
|
|
6
|
+
lstat,
|
|
7
|
+
mkdir,
|
|
8
|
+
open,
|
|
9
|
+
readFile,
|
|
10
|
+
rm,
|
|
11
|
+
unlink,
|
|
12
|
+
} from "node:fs/promises";
|
|
13
|
+
import { isIP } from "node:net";
|
|
14
|
+
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
15
|
+
|
|
16
|
+
import { CliError } from "./errors.js";
|
|
17
|
+
import { readSshPublicKey, sshFingerprint } from "./ssh.js";
|
|
18
|
+
|
|
19
|
+
const SERVER_ID = /^srv_[A-Za-z0-9_-]{8,60}$/;
|
|
20
|
+
const TRUST_EPOCH = /^(?:initial|reload-[A-Za-z0-9_-]{8,128})$/;
|
|
21
|
+
|
|
22
|
+
function currentUid() {
|
|
23
|
+
return typeof process.getuid === "function" ? process.getuid() : undefined;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function exactMode(metadata) {
|
|
27
|
+
return metadata.mode & 0o777;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function requirePrivateDirectory(path) {
|
|
31
|
+
const metadata = await lstat(path);
|
|
32
|
+
const uid = currentUid();
|
|
33
|
+
if (
|
|
34
|
+
!metadata.isDirectory() ||
|
|
35
|
+
metadata.isSymbolicLink() ||
|
|
36
|
+
(process.platform !== "win32" && exactMode(metadata) !== 0o700) ||
|
|
37
|
+
(uid !== undefined && metadata.uid !== uid)
|
|
38
|
+
) {
|
|
39
|
+
throw new CliError("The managed SSH trust directory is not private.", {
|
|
40
|
+
exitCode: 4,
|
|
41
|
+
code: "host_trust_directory_unsafe",
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function ensurePrivateDirectory(path) {
|
|
47
|
+
let created = false;
|
|
48
|
+
try {
|
|
49
|
+
await mkdir(path, { mode: 0o700 });
|
|
50
|
+
created = true;
|
|
51
|
+
} catch (error) {
|
|
52
|
+
if (error?.code !== "EEXIST") throw error;
|
|
53
|
+
}
|
|
54
|
+
if (created && process.platform !== "win32") await chmod(path, 0o700);
|
|
55
|
+
await requirePrivateDirectory(path);
|
|
56
|
+
if (created) await syncPath(dirname(path));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function syncPath(path) {
|
|
60
|
+
const handle = await open(path, "r");
|
|
61
|
+
try {
|
|
62
|
+
await handle.sync();
|
|
63
|
+
} finally {
|
|
64
|
+
await handle.close();
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function requirePrivateFile(path, label) {
|
|
69
|
+
const metadata = await lstat(path);
|
|
70
|
+
const uid = currentUid();
|
|
71
|
+
if (
|
|
72
|
+
!metadata.isFile() ||
|
|
73
|
+
metadata.isSymbolicLink() ||
|
|
74
|
+
metadata.nlink !== 1 ||
|
|
75
|
+
(process.platform !== "win32" && exactMode(metadata) !== 0o600) ||
|
|
76
|
+
(uid !== undefined && metadata.uid !== uid)
|
|
77
|
+
) {
|
|
78
|
+
throw new CliError(`${label} is not an owner-only regular file.`, {
|
|
79
|
+
exitCode: 4,
|
|
80
|
+
code: "host_trust_file_unsafe",
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function requireIdentity(identityPath, expectedFingerprint) {
|
|
86
|
+
const identity = resolve(identityPath);
|
|
87
|
+
await requirePrivateFile(identity, "The SSH identity");
|
|
88
|
+
const publicPath = `${identity}.pub`;
|
|
89
|
+
const publicMetadata = await lstat(publicPath);
|
|
90
|
+
const uid = currentUid();
|
|
91
|
+
if (
|
|
92
|
+
!publicMetadata.isFile() ||
|
|
93
|
+
publicMetadata.isSymbolicLink() ||
|
|
94
|
+
publicMetadata.nlink !== 1 ||
|
|
95
|
+
(uid !== undefined && publicMetadata.uid !== uid)
|
|
96
|
+
) {
|
|
97
|
+
throw new CliError("The SSH public identity is not a safe regular file.", {
|
|
98
|
+
exitCode: 4,
|
|
99
|
+
code: "host_trust_identity_unsafe",
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
const fingerprint = sshFingerprint(await readSshPublicKey(publicPath));
|
|
103
|
+
if (fingerprint !== expectedFingerprint) {
|
|
104
|
+
throw new CliError(
|
|
105
|
+
"The local SSH identity does not match the server owner key.",
|
|
106
|
+
{ exitCode: 4, code: "host_trust_identity_mismatch" },
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
return { identity, fingerprint };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function hostTrustPaths(stateDirectory, serverId, trustEpoch = "initial") {
|
|
113
|
+
if (!SERVER_ID.test(serverId)) {
|
|
114
|
+
throw new CliError("The server ID is invalid.", { exitCode: 2 });
|
|
115
|
+
}
|
|
116
|
+
if (!TRUST_EPOCH.test(trustEpoch)) {
|
|
117
|
+
throw new CliError("The SSH host-trust epoch is invalid.", {
|
|
118
|
+
exitCode: 4,
|
|
119
|
+
code: "host_trust_epoch_invalid",
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
const stateRoot = resolve(stateDirectory);
|
|
123
|
+
const sshDirectory = join(stateRoot, "ssh");
|
|
124
|
+
const knownHostsDirectory = join(sshDirectory, "known-hosts");
|
|
125
|
+
const serverDirectory = join(knownHostsDirectory, serverId);
|
|
126
|
+
return {
|
|
127
|
+
stateRoot,
|
|
128
|
+
sshDirectory,
|
|
129
|
+
knownHostsDirectory,
|
|
130
|
+
serverDirectory,
|
|
131
|
+
knownHostsFile: join(serverDirectory, `${trustEpoch}.known_hosts`),
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function ensureTrustDirectories(paths) {
|
|
136
|
+
await requirePrivateDirectory(paths.stateRoot);
|
|
137
|
+
await ensurePrivateDirectory(paths.sshDirectory);
|
|
138
|
+
await ensurePrivateDirectory(paths.knownHostsDirectory);
|
|
139
|
+
await ensurePrivateDirectory(paths.serverDirectory);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function decodeEd25519(material) {
|
|
143
|
+
if (!/^[A-Za-z0-9+/]+={0,2}$/.test(material)) return undefined;
|
|
144
|
+
const decoded = Buffer.from(material, "base64");
|
|
145
|
+
if (decoded.toString("base64") !== material) return undefined;
|
|
146
|
+
if (decoded.length !== 51 || decoded.readUInt32BE(0) !== 11) return undefined;
|
|
147
|
+
if (decoded.subarray(4, 15).toString("utf8") !== "ssh-ed25519") {
|
|
148
|
+
return undefined;
|
|
149
|
+
}
|
|
150
|
+
if (decoded.readUInt32BE(15) !== 32) return undefined;
|
|
151
|
+
return decoded;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function validateKnownHosts(content, publicIp, sshPort = 22) {
|
|
155
|
+
if (isIP(publicIp) !== 4) {
|
|
156
|
+
throw new CliError("The server returned an invalid public IP.", {
|
|
157
|
+
exitCode: 3,
|
|
158
|
+
code: "host_trust_ip_invalid",
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
if (!Number.isInteger(sshPort) || sshPort < 1 || sshPort > 65_535) {
|
|
162
|
+
throw new CliError("The SSH port is invalid.", { exitCode: 2 });
|
|
163
|
+
}
|
|
164
|
+
const expectedHost =
|
|
165
|
+
sshPort === 22 ? publicIp : `[${publicIp}]:${sshPort}`;
|
|
166
|
+
const lines = String(content).split(/\r?\n/).filter(Boolean);
|
|
167
|
+
const match = /^([^ ]+) (ssh-ed25519) ([A-Za-z0-9+/]+={0,2})$/.exec(
|
|
168
|
+
lines[0] || "",
|
|
169
|
+
);
|
|
170
|
+
const decoded = match && decodeEd25519(match[3]);
|
|
171
|
+
if (lines.length !== 1 || !match || match[1] !== expectedHost || !decoded) {
|
|
172
|
+
throw new CliError("The observed SSH host key is not a canonical Ed25519 pin.", {
|
|
173
|
+
exitCode: 4,
|
|
174
|
+
code: "host_trust_pin_invalid",
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
return {
|
|
178
|
+
content: `${match[1]} ${match[2]} ${match[3]}\n`,
|
|
179
|
+
algorithm: match[2],
|
|
180
|
+
fingerprint: `SHA256:${createHash("sha256")
|
|
181
|
+
.update(decoded)
|
|
182
|
+
.digest("base64")
|
|
183
|
+
.replace(/=+$/, "")}`,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function strictSshOptions(identityPath, knownHostsFile) {
|
|
188
|
+
if (!isAbsolute(knownHostsFile)) {
|
|
189
|
+
throw new CliError("The managed SSH host pin path is invalid.", {
|
|
190
|
+
exitCode: 4,
|
|
191
|
+
code: "host_trust_path_invalid",
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
return [
|
|
195
|
+
"-F",
|
|
196
|
+
"/dev/null",
|
|
197
|
+
"-i",
|
|
198
|
+
resolve(identityPath),
|
|
199
|
+
"-o",
|
|
200
|
+
"IdentitiesOnly=yes",
|
|
201
|
+
"-o",
|
|
202
|
+
"IdentityAgent=none",
|
|
203
|
+
"-o",
|
|
204
|
+
"BatchMode=yes",
|
|
205
|
+
"-o",
|
|
206
|
+
"ConnectTimeout=10",
|
|
207
|
+
"-o",
|
|
208
|
+
"ConnectionAttempts=1",
|
|
209
|
+
"-o",
|
|
210
|
+
"ServerAliveInterval=5",
|
|
211
|
+
"-o",
|
|
212
|
+
"ServerAliveCountMax=1",
|
|
213
|
+
"-o",
|
|
214
|
+
"PubkeyAuthentication=yes",
|
|
215
|
+
"-o",
|
|
216
|
+
"PreferredAuthentications=publickey",
|
|
217
|
+
"-o",
|
|
218
|
+
"PasswordAuthentication=no",
|
|
219
|
+
"-o",
|
|
220
|
+
"KbdInteractiveAuthentication=no",
|
|
221
|
+
"-o",
|
|
222
|
+
"ChallengeResponseAuthentication=no",
|
|
223
|
+
"-o",
|
|
224
|
+
"GSSAPIAuthentication=no",
|
|
225
|
+
"-o",
|
|
226
|
+
"StrictHostKeyChecking=yes",
|
|
227
|
+
"-o",
|
|
228
|
+
`UserKnownHostsFile=${knownHostsFile}`,
|
|
229
|
+
"-o",
|
|
230
|
+
"GlobalKnownHostsFile=/dev/null",
|
|
231
|
+
"-o",
|
|
232
|
+
"HashKnownHosts=no",
|
|
233
|
+
"-o",
|
|
234
|
+
"UpdateHostKeys=no",
|
|
235
|
+
"-o",
|
|
236
|
+
"VerifyHostKeyDNS=no",
|
|
237
|
+
"-o",
|
|
238
|
+
"HostKeyAlgorithms=ssh-ed25519",
|
|
239
|
+
"-o",
|
|
240
|
+
"ForwardAgent=no",
|
|
241
|
+
"-o",
|
|
242
|
+
"ForwardX11=no",
|
|
243
|
+
"-o",
|
|
244
|
+
"ClearAllForwardings=yes",
|
|
245
|
+
"-o",
|
|
246
|
+
"PermitLocalCommand=no",
|
|
247
|
+
"-o",
|
|
248
|
+
"ProxyCommand=none",
|
|
249
|
+
"-o",
|
|
250
|
+
"RequestTTY=no",
|
|
251
|
+
];
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function firstUseSshOptions(identityPath, candidatePath) {
|
|
255
|
+
const args = strictSshOptions(identityPath, candidatePath);
|
|
256
|
+
const index = args.indexOf("StrictHostKeyChecking=yes");
|
|
257
|
+
args[index] = "StrictHostKeyChecking=accept-new";
|
|
258
|
+
return args;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
async function spawnSsh(
|
|
262
|
+
args,
|
|
263
|
+
spawnImpl,
|
|
264
|
+
errorMessage,
|
|
265
|
+
diagnosticDirectory,
|
|
266
|
+
publicIp,
|
|
267
|
+
sshPort,
|
|
268
|
+
) {
|
|
269
|
+
const diagnosticPath = join(
|
|
270
|
+
diagnosticDirectory,
|
|
271
|
+
`.ssh-auth.${process.pid}.${randomUUID()}.log`,
|
|
272
|
+
);
|
|
273
|
+
const diagnosticHandle = await open(diagnosticPath, "wx", 0o600);
|
|
274
|
+
await diagnosticHandle.close();
|
|
275
|
+
let result;
|
|
276
|
+
try {
|
|
277
|
+
try {
|
|
278
|
+
result = await new Promise((resolvePromise, reject) => {
|
|
279
|
+
const child = spawnImpl("ssh", ["-v", "-E", diagnosticPath, ...args], {
|
|
280
|
+
shell: false,
|
|
281
|
+
stdio: ["ignore", "ignore", "ignore"],
|
|
282
|
+
});
|
|
283
|
+
child.once("error", reject);
|
|
284
|
+
child.once("close", (status) => resolvePromise({ status }));
|
|
285
|
+
});
|
|
286
|
+
} catch (error) {
|
|
287
|
+
if (error?.code === "ENOENT") {
|
|
288
|
+
throw new CliError("OpenSSH is required to establish VPS host trust.", {
|
|
289
|
+
exitCode: 2,
|
|
290
|
+
code: "host_trust_ssh_unavailable",
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
throw error;
|
|
294
|
+
}
|
|
295
|
+
if (result.status !== 0) {
|
|
296
|
+
throw new CliError(errorMessage, {
|
|
297
|
+
exitCode: 4,
|
|
298
|
+
code: "host_trust_authentication_failed",
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
await requirePrivateFile(diagnosticPath, "The private OpenSSH diagnostic");
|
|
302
|
+
const diagnostic = await readFile(diagnosticPath, "utf8");
|
|
303
|
+
const authenticationResults = diagnostic
|
|
304
|
+
.split(/\r?\n/)
|
|
305
|
+
.filter((line) => /^Authenticated to .* using "[^"]+"\.$/.test(line));
|
|
306
|
+
const expectedResult = `Authenticated to ${publicIp} ([${publicIp}]:${sshPort}) using "publickey".`;
|
|
307
|
+
if (
|
|
308
|
+
authenticationResults.length !== 1 ||
|
|
309
|
+
authenticationResults[0] !== expectedResult
|
|
310
|
+
) {
|
|
311
|
+
throw new CliError(
|
|
312
|
+
"SSH did not prove authentication with the exact owner public key. No runtime bootstrap was requested.",
|
|
313
|
+
{ exitCode: 4, code: "host_trust_authentication_method_invalid" },
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
} finally {
|
|
317
|
+
await rm(diagnosticPath, { force: true });
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
async function strictProbe({
|
|
322
|
+
identity,
|
|
323
|
+
publicIp,
|
|
324
|
+
sshPort,
|
|
325
|
+
sshUser,
|
|
326
|
+
knownHostsFile,
|
|
327
|
+
spawnImpl,
|
|
328
|
+
}) {
|
|
329
|
+
await spawnSsh(
|
|
330
|
+
[
|
|
331
|
+
...strictSshOptions(identity, knownHostsFile),
|
|
332
|
+
...(sshPort === 22 ? [] : ["-p", String(sshPort)]),
|
|
333
|
+
`${sshUser}@${publicIp}`,
|
|
334
|
+
"true",
|
|
335
|
+
],
|
|
336
|
+
spawnImpl,
|
|
337
|
+
"The VPS host key or owner SSH authentication did not match the managed pin.",
|
|
338
|
+
dirname(knownHostsFile),
|
|
339
|
+
publicIp,
|
|
340
|
+
sshPort,
|
|
341
|
+
);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
async function publishCandidate(
|
|
345
|
+
candidatePath,
|
|
346
|
+
knownHostsFile,
|
|
347
|
+
expectedContent,
|
|
348
|
+
publicIp,
|
|
349
|
+
sshPort,
|
|
350
|
+
) {
|
|
351
|
+
await syncPath(candidatePath);
|
|
352
|
+
try {
|
|
353
|
+
await link(candidatePath, knownHostsFile);
|
|
354
|
+
await unlink(candidatePath);
|
|
355
|
+
} catch (error) {
|
|
356
|
+
if (error?.code !== "EEXIST") throw error;
|
|
357
|
+
const existing = validateKnownHosts(
|
|
358
|
+
await readFile(knownHostsFile, "utf8"),
|
|
359
|
+
publicIp,
|
|
360
|
+
sshPort,
|
|
361
|
+
);
|
|
362
|
+
if (existing.content !== expectedContent) {
|
|
363
|
+
throw new CliError("The VPS host key changed during first-use pinning.", {
|
|
364
|
+
exitCode: 4,
|
|
365
|
+
code: "host_trust_race_mismatch",
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
await unlink(candidatePath);
|
|
369
|
+
}
|
|
370
|
+
await requirePrivateFile(knownHostsFile, "The managed SSH host pin");
|
|
371
|
+
await syncPath(knownHostsFile);
|
|
372
|
+
await syncPath(resolve(knownHostsFile, ".."));
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
export async function establishHostTrust({
|
|
376
|
+
stateDirectory,
|
|
377
|
+
serverId,
|
|
378
|
+
trustEpoch = "initial",
|
|
379
|
+
server,
|
|
380
|
+
identityPath,
|
|
381
|
+
sshUser,
|
|
382
|
+
sshPort = 22,
|
|
383
|
+
reinspectServer,
|
|
384
|
+
spawnImpl = nodeSpawn,
|
|
385
|
+
}) {
|
|
386
|
+
if (!/^[a-z_][a-z0-9_-]{0,31}$/.test(sshUser)) {
|
|
387
|
+
throw new CliError("--ssh-user is invalid.", { exitCode: 2 });
|
|
388
|
+
}
|
|
389
|
+
if (!server || isIP(server.publicIp) !== 4) {
|
|
390
|
+
throw new CliError("The server returned an invalid public IP.", {
|
|
391
|
+
exitCode: 3,
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
if (!Number.isInteger(sshPort) || sshPort < 1 || sshPort > 65_535) {
|
|
395
|
+
throw new CliError("The SSH port is invalid.", { exitCode: 2 });
|
|
396
|
+
}
|
|
397
|
+
const activeCancellation =
|
|
398
|
+
["cancellation_pending", "cancelled"].includes(server.state) &&
|
|
399
|
+
Number.isFinite(Date.parse(server.termEndsAt || "")) &&
|
|
400
|
+
Date.parse(server.termEndsAt) > Date.now();
|
|
401
|
+
if (
|
|
402
|
+
server.serverId !== serverId ||
|
|
403
|
+
(server.state !== "ready" && !activeCancellation)
|
|
404
|
+
) {
|
|
405
|
+
throw new CliError(
|
|
406
|
+
"The VPS must be ready and bound to this server before SSH host trust is established.",
|
|
407
|
+
{ exitCode: 5, code: "host_trust_server_unavailable" },
|
|
408
|
+
);
|
|
409
|
+
}
|
|
410
|
+
if (typeof server.sshFingerprint !== "string") {
|
|
411
|
+
throw new CliError("The server did not return its owner-key fingerprint.", {
|
|
412
|
+
exitCode: 3,
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
const { identity } = await requireIdentity(
|
|
416
|
+
identityPath,
|
|
417
|
+
server.sshFingerprint,
|
|
418
|
+
);
|
|
419
|
+
const paths = hostTrustPaths(stateDirectory, serverId, trustEpoch);
|
|
420
|
+
await ensureTrustDirectories(paths);
|
|
421
|
+
|
|
422
|
+
try {
|
|
423
|
+
await requirePrivateFile(paths.knownHostsFile, "The managed SSH host pin");
|
|
424
|
+
const pin = validateKnownHosts(
|
|
425
|
+
await readFile(paths.knownHostsFile, "utf8"),
|
|
426
|
+
server.publicIp,
|
|
427
|
+
sshPort,
|
|
428
|
+
);
|
|
429
|
+
await strictProbe({
|
|
430
|
+
identity,
|
|
431
|
+
publicIp: server.publicIp,
|
|
432
|
+
sshPort,
|
|
433
|
+
sshUser,
|
|
434
|
+
knownHostsFile: paths.knownHostsFile,
|
|
435
|
+
spawnImpl,
|
|
436
|
+
});
|
|
437
|
+
return {
|
|
438
|
+
state: "matched",
|
|
439
|
+
algorithm: pin.algorithm,
|
|
440
|
+
fingerprint: pin.fingerprint,
|
|
441
|
+
knownHostsFile: paths.knownHostsFile,
|
|
442
|
+
publicIp: server.publicIp,
|
|
443
|
+
trustEpoch,
|
|
444
|
+
};
|
|
445
|
+
} catch (error) {
|
|
446
|
+
if (error?.code !== "ENOENT") throw error;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
if (typeof reinspectServer !== "function") {
|
|
450
|
+
throw new CliError(
|
|
451
|
+
"A fresh server inspection is required before first-use SSH trust can be published.",
|
|
452
|
+
{ exitCode: 4, code: "host_trust_reinspection_required" },
|
|
453
|
+
);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
const candidatePath = join(
|
|
457
|
+
paths.serverDirectory,
|
|
458
|
+
`.${trustEpoch}.${process.pid}.${randomUUID()}.candidate`,
|
|
459
|
+
);
|
|
460
|
+
const handle = await open(candidatePath, "wx", 0o600);
|
|
461
|
+
await handle.close();
|
|
462
|
+
try {
|
|
463
|
+
await spawnSsh(
|
|
464
|
+
[
|
|
465
|
+
...firstUseSshOptions(identity, candidatePath),
|
|
466
|
+
...(sshPort === 22 ? [] : ["-p", String(sshPort)]),
|
|
467
|
+
`${sshUser}@${server.publicIp}`,
|
|
468
|
+
"true",
|
|
469
|
+
],
|
|
470
|
+
spawnImpl,
|
|
471
|
+
"Could not authenticate the first SSH connection to the exact VPS. No runtime bootstrap was requested.",
|
|
472
|
+
paths.serverDirectory,
|
|
473
|
+
server.publicIp,
|
|
474
|
+
sshPort,
|
|
475
|
+
);
|
|
476
|
+
await requirePrivateFile(candidatePath, "The observed SSH host-key candidate");
|
|
477
|
+
const pin = validateKnownHosts(
|
|
478
|
+
await readFile(candidatePath, "utf8"),
|
|
479
|
+
server.publicIp,
|
|
480
|
+
sshPort,
|
|
481
|
+
);
|
|
482
|
+
const refreshedServer = await reinspectServer();
|
|
483
|
+
const refreshedCancellation =
|
|
484
|
+
["cancellation_pending", "cancelled"].includes(refreshedServer?.state) &&
|
|
485
|
+
Number.isFinite(Date.parse(refreshedServer?.termEndsAt || "")) &&
|
|
486
|
+
Date.parse(refreshedServer.termEndsAt) > Date.now();
|
|
487
|
+
if (
|
|
488
|
+
refreshedServer?.serverId !== serverId ||
|
|
489
|
+
(refreshedServer.state !== "ready" && !refreshedCancellation) ||
|
|
490
|
+
refreshedServer.publicIp !== server.publicIp ||
|
|
491
|
+
refreshedServer.sshFingerprint !== server.sshFingerprint
|
|
492
|
+
) {
|
|
493
|
+
throw new CliError(
|
|
494
|
+
"The server identity changed while first-use SSH trust was being established. No host pin or runtime bootstrap was created.",
|
|
495
|
+
{ exitCode: 4, code: "host_trust_server_changed" },
|
|
496
|
+
);
|
|
497
|
+
}
|
|
498
|
+
await publishCandidate(
|
|
499
|
+
candidatePath,
|
|
500
|
+
paths.knownHostsFile,
|
|
501
|
+
pin.content,
|
|
502
|
+
server.publicIp,
|
|
503
|
+
sshPort,
|
|
504
|
+
);
|
|
505
|
+
await strictProbe({
|
|
506
|
+
identity,
|
|
507
|
+
publicIp: server.publicIp,
|
|
508
|
+
sshPort,
|
|
509
|
+
sshUser,
|
|
510
|
+
knownHostsFile: paths.knownHostsFile,
|
|
511
|
+
spawnImpl,
|
|
512
|
+
});
|
|
513
|
+
return {
|
|
514
|
+
state: "trusted_first_use",
|
|
515
|
+
algorithm: pin.algorithm,
|
|
516
|
+
fingerprint: pin.fingerprint,
|
|
517
|
+
knownHostsFile: paths.knownHostsFile,
|
|
518
|
+
publicIp: server.publicIp,
|
|
519
|
+
trustEpoch,
|
|
520
|
+
};
|
|
521
|
+
} finally {
|
|
522
|
+
await rm(candidatePath, { force: true });
|
|
523
|
+
}
|
|
524
|
+
}
|
package/src/installer.js
CHANGED
|
@@ -10,6 +10,7 @@ import { tmpdir } from "node:os";
|
|
|
10
10
|
import { join, resolve } from "node:path";
|
|
11
11
|
|
|
12
12
|
import { CliError } from "./errors.js";
|
|
13
|
+
import { strictSshOptions } from "./host-trust.js";
|
|
13
14
|
|
|
14
15
|
const MAX_ARTIFACT_BYTES = 256 * 1024 * 1024;
|
|
15
16
|
const BASE_REQUIRED_FILES = [
|
|
@@ -323,7 +324,7 @@ async function spawnChecked(
|
|
|
323
324
|
return result;
|
|
324
325
|
}
|
|
325
326
|
|
|
326
|
-
function sshBase(identity, sshUser, host) {
|
|
327
|
+
function sshBase(identity, sshUser, host, knownHostsFile) {
|
|
327
328
|
if (!/^[a-z_][a-z0-9_-]{0,31}$/.test(sshUser)) {
|
|
328
329
|
throw new CliError("--ssh-user is invalid.", { exitCode: 2 });
|
|
329
330
|
}
|
|
@@ -333,16 +334,7 @@ function sshBase(identity, sshUser, host) {
|
|
|
333
334
|
});
|
|
334
335
|
}
|
|
335
336
|
return [
|
|
336
|
-
|
|
337
|
-
resolve(identity),
|
|
338
|
-
"-o",
|
|
339
|
-
"IdentitiesOnly=yes",
|
|
340
|
-
"-o",
|
|
341
|
-
"StrictHostKeyChecking=yes",
|
|
342
|
-
"-o",
|
|
343
|
-
"ForwardAgent=no",
|
|
344
|
-
"-o",
|
|
345
|
-
"ClearAllForwardings=yes",
|
|
337
|
+
...strictSshOptions(resolve(identity), knownHostsFile),
|
|
346
338
|
`${sshUser}@${host}`,
|
|
347
339
|
];
|
|
348
340
|
}
|
|
@@ -353,6 +345,8 @@ export async function installRuntime({
|
|
|
353
345
|
token,
|
|
354
346
|
identity,
|
|
355
347
|
sshUser,
|
|
348
|
+
knownHostsFile,
|
|
349
|
+
trustedPublicIp,
|
|
356
350
|
bootstrap,
|
|
357
351
|
nestedPrivateProcfs = "preserve",
|
|
358
352
|
fetchImpl = globalThis.fetch,
|
|
@@ -367,6 +361,12 @@ export async function installRuntime({
|
|
|
367
361
|
exitCode: 5,
|
|
368
362
|
});
|
|
369
363
|
}
|
|
364
|
+
if (server.publicIp !== trustedPublicIp) {
|
|
365
|
+
throw new CliError(
|
|
366
|
+
"The server public IP changed after SSH host-key trust was established.",
|
|
367
|
+
{ exitCode: 4, code: "host_trust_ip_changed" },
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
370
|
const metadata = validateArtifact(bootstrap?.artifact);
|
|
371
371
|
if (!new Set(["preserve", "enable", "disable"]).has(nestedPrivateProcfs)) {
|
|
372
372
|
throw new CliError("The nested private procfs action is invalid.", {
|
|
@@ -400,7 +400,7 @@ export async function installRuntime({
|
|
|
400
400
|
const remoteID = randomUUID().replaceAll("-", "");
|
|
401
401
|
const remoteArchive = `/tmp/warpmetal-runtime-${remoteID}.tar.gz`;
|
|
402
402
|
const remoteBundle = `/tmp/warpmetal-runtime-${remoteID}`;
|
|
403
|
-
const ssh = sshBase(identity, sshUser, server.publicIp);
|
|
403
|
+
const ssh = sshBase(identity, sshUser, server.publicIp, knownHostsFile);
|
|
404
404
|
let remoteTouched = false;
|
|
405
405
|
let operationFailed = false;
|
|
406
406
|
try {
|
package/src/state.js
CHANGED
|
@@ -5,7 +5,7 @@ import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
|
5
5
|
|
|
6
6
|
import { CliError } from "./errors.js";
|
|
7
7
|
|
|
8
|
-
const STATE_VERSION =
|
|
8
|
+
const STATE_VERSION = 4;
|
|
9
9
|
const WALLET_PAYMENT_STATE_FIELDS = [
|
|
10
10
|
"walletPaymentAttemptId",
|
|
11
11
|
"walletBuyerPaymentIdentifier",
|
|
@@ -160,6 +160,19 @@ function migrateState(value) {
|
|
|
160
160
|
renewals: {},
|
|
161
161
|
};
|
|
162
162
|
}
|
|
163
|
+
if (
|
|
164
|
+
value?.version === 3 &&
|
|
165
|
+
value.orders &&
|
|
166
|
+
value.servers &&
|
|
167
|
+
value.operations &&
|
|
168
|
+
value.runtimes &&
|
|
169
|
+
value.sandboxes &&
|
|
170
|
+
value.accessGrants &&
|
|
171
|
+
value.identities &&
|
|
172
|
+
value.renewals
|
|
173
|
+
) {
|
|
174
|
+
return { ...value, version: STATE_VERSION };
|
|
175
|
+
}
|
|
163
176
|
return value;
|
|
164
177
|
}
|
|
165
178
|
|
|
@@ -597,6 +610,46 @@ export class StateStore {
|
|
|
597
610
|
...metadata,
|
|
598
611
|
createdAt: new Date().toISOString(),
|
|
599
612
|
};
|
|
613
|
+
if (kind === "reload") {
|
|
614
|
+
const server = state.servers[serverId] || { serverId };
|
|
615
|
+
server.pendingHostTrustReloadOperationId = operationId;
|
|
616
|
+
state.servers[serverId] = server;
|
|
617
|
+
}
|
|
618
|
+
});
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
async hostTrustEpoch(serverId) {
|
|
622
|
+
return (await this.server(serverId))?.hostTrustEpoch || "initial";
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
async applyReloadHostTrust(serverId, operation) {
|
|
626
|
+
const operationId = operation?.id;
|
|
627
|
+
if (
|
|
628
|
+
typeof operationId !== "string" ||
|
|
629
|
+
!["succeeded", "failed", "manual_review"].includes(operation?.state)
|
|
630
|
+
) {
|
|
631
|
+
return;
|
|
632
|
+
}
|
|
633
|
+
await this.update((state) => {
|
|
634
|
+
const saved = state.operations[operationId];
|
|
635
|
+
const server = state.servers[serverId];
|
|
636
|
+
if (
|
|
637
|
+
!saved ||
|
|
638
|
+
saved.serverId !== serverId ||
|
|
639
|
+
saved.kind !== "reload" ||
|
|
640
|
+
!server ||
|
|
641
|
+
server.pendingHostTrustReloadOperationId !== operationId
|
|
642
|
+
) {
|
|
643
|
+
return;
|
|
644
|
+
}
|
|
645
|
+
if (
|
|
646
|
+
operation.state === "succeeded" &&
|
|
647
|
+
operation.result?.reloadImpact?.ownerKnownHostsNeedRefresh === true
|
|
648
|
+
) {
|
|
649
|
+
server.hostTrustEpoch = `reload-${operationId}`;
|
|
650
|
+
server.hostTrustReloadOperationId = operationId;
|
|
651
|
+
}
|
|
652
|
+
delete server.pendingHostTrustReloadOperationId;
|
|
600
653
|
});
|
|
601
654
|
}
|
|
602
655
|
|