upgrade to v0.2.0 vaultmesh artifact
This commit is contained in:
13
.gitignore
vendored
13
.gitignore
vendored
@@ -1,17 +1,16 @@
|
|||||||
# secrets
|
# secrets
|
||||||
.env*
|
.env
|
||||||
|
.env.*
|
||||||
!.env.example
|
!.env.example
|
||||||
*.key
|
|
||||||
*.pem
|
|
||||||
|
|
||||||
# deps/build
|
# dependencies / build output
|
||||||
node_modules/
|
node_modules/
|
||||||
dist/
|
dist/
|
||||||
|
|
||||||
# generated evidence
|
# runtime artifacts
|
||||||
outputs/
|
outputs/
|
||||||
|
|
||||||
# misc
|
|
||||||
*.log
|
*.log
|
||||||
|
|
||||||
|
# OS / editor
|
||||||
.DS_Store
|
.DS_Store
|
||||||
__MACOSX/
|
__MACOSX/
|
||||||
|
|||||||
15
CHANGELOG.md
Normal file
15
CHANGELOG.md
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
## 0.2.0 (VaultMesh-grade artifact)
|
||||||
|
|
||||||
|
- Safety boundary: `--dry-run` plans + `vmc apply` execution
|
||||||
|
- Per-server lockfiles with `--force`
|
||||||
|
- Receipts:
|
||||||
|
- canonical hashing (BLAKE3 + SHA256)
|
||||||
|
- `prev_blake3` chaining and `HEAD.json`
|
||||||
|
- optional operator signatures (Ed25519) without altering canonical hashes
|
||||||
|
- Verification:
|
||||||
|
- `vmc verify receipt ...`
|
||||||
|
- `vmc verify chain ...`
|
||||||
|
- Merkle receipts:
|
||||||
|
- `vmc merkle receipts` outputs a root commitment file
|
||||||
87
README.md
87
README.md
@@ -1,14 +1,87 @@
|
|||||||
# vm-cloud
|
# vm-cloud
|
||||||
|
|
||||||
Hetzner CLI + MCP tooling for VM ops and research notes.
|
Hetzner Cloud operator CLI (`vmc`) with audit-ready receipts, plan/apply safety, and a tamper-evident local ledger.
|
||||||
|
|
||||||
## Quick start
|
## Quick start
|
||||||
|
|
||||||
- npm install
|
1) Install deps
|
||||||
- ./bin/vmc servers list
|
|
||||||
- ./bin/vmc snapshot servers
|
|
||||||
- ./bin/vmc research new "Title"
|
|
||||||
|
|
||||||
## Env
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
Set HCLOUD_TOKEN in ~/.env or ./.env.
|
2) Configure Hetzner token
|
||||||
|
|
||||||
|
Create `~/.env` (recommended) or a project `.env` with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
HCLOUD_TOKEN=xxx
|
||||||
|
```
|
||||||
|
|
||||||
|
3) Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# dev (runs TS directly)
|
||||||
|
npm run dev -- --help
|
||||||
|
|
||||||
|
# or
|
||||||
|
./bin/vmc --help
|
||||||
|
```
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
### Read-only
|
||||||
|
|
||||||
|
```bash
|
||||||
|
vmc servers list
|
||||||
|
vmc snapshot servers
|
||||||
|
vmc research new "Hetzner Baseline YYYY-MM-DD"
|
||||||
|
vmc research append --from outputs/hetzner/servers-*.json
|
||||||
|
```
|
||||||
|
|
||||||
|
### Mutations (safe)
|
||||||
|
|
||||||
|
All mutations:
|
||||||
|
- resolve server by id/name (exact first, partial only when unambiguous)
|
||||||
|
- require confirmation (or `--yes`)
|
||||||
|
- write a receipt to `outputs/receipts/`
|
||||||
|
- acquire a per-server lock (`~/.cache/vm-cloud/locks/<id>.lock`) to prevent concurrent ops
|
||||||
|
|
||||||
|
#### Plan (dry-run)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
vmc servers labels <name|id> env=prod owner=ops --dry-run
|
||||||
|
# => outputs/plans/plan-*.json with SHA256+BLAKE3
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Apply
|
||||||
|
|
||||||
|
```bash
|
||||||
|
vmc apply --plan outputs/plans/plan-*.json --yes --reason "change ticket / intent"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ledger + verification
|
||||||
|
|
||||||
|
```bash
|
||||||
|
vmc verify receipt outputs/receipts/<file>.json --head --plan --sig
|
||||||
|
vmc verify chain --head --sig
|
||||||
|
```
|
||||||
|
|
||||||
|
### Signing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
vmc keygen
|
||||||
|
vmc sign receipt outputs/receipts/<file>.json
|
||||||
|
```
|
||||||
|
|
||||||
|
### Merkle receipts
|
||||||
|
|
||||||
|
```bash
|
||||||
|
vmc merkle receipts
|
||||||
|
# => outputs/ledger/merkle-*.json (root over receipt blake3 chain)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Safety notes
|
||||||
|
|
||||||
|
- Never commit `.env` or `outputs/` or `node_modules/` (see `.gitignore`).
|
||||||
|
- Rotate any leaked tokens immediately.
|
||||||
|
|||||||
71
UPGRADE_PATH.md
Normal file
71
UPGRADE_PATH.md
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
# vm-cloud Upgrade Path (VaultMesh)
|
||||||
|
|
||||||
|
This repo is designed to evolve into a verifiable operator subsystem: **actions → receipts → ledger → merkle → anchors**.
|
||||||
|
|
||||||
|
## Current state (this artifact)
|
||||||
|
|
||||||
|
- Per-server lock files (`~/.cache/vm-cloud/locks/<serverId>.lock`) with `--force`
|
||||||
|
- Receipts written to `outputs/receipts/` with:
|
||||||
|
- canonical hashing (`blake3 + sha256`)
|
||||||
|
- `prev_blake3` chaining
|
||||||
|
- `HEAD.json` updated on every receipt
|
||||||
|
- Plan/apply boundary:
|
||||||
|
- `--dry-run` emits a plan to `outputs/plans/`
|
||||||
|
- `vmc apply --plan ...` executes and links the plan hashes into the receipt
|
||||||
|
- Operator signing (Ed25519):
|
||||||
|
- `vmc keygen`
|
||||||
|
- `vmc sign receipt <receipt>`
|
||||||
|
- `vmc verify receipt <receipt> --sig`
|
||||||
|
- `vmc verify chain --sig`
|
||||||
|
- Merkle commitment:
|
||||||
|
- `vmc merkle receipts` writes `outputs/ledger/merkle-*.json`
|
||||||
|
|
||||||
|
## Next steps (recommended)
|
||||||
|
|
||||||
|
### 1) CI gate: “ledger must verify”
|
||||||
|
|
||||||
|
In CI, after tests:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
node dist/cli.js verify chain --head
|
||||||
|
```
|
||||||
|
|
||||||
|
Optionally require signatures:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node dist/cli.js verify chain --sig --head
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2) Anchoring the Merkle root
|
||||||
|
|
||||||
|
The merkle root file is your commitment point. Anchor it using your preferred substrate:
|
||||||
|
|
||||||
|
- **btc-anchor**: commit root into an OP_RETURN / inscription scheme (policy-dependent)
|
||||||
|
- **eth-anchor**: store root in a contract/event log
|
||||||
|
- **rfc3161-anchor**: timestamp the root using an RFC3161 TSA
|
||||||
|
|
||||||
|
The anchoring step should store:
|
||||||
|
- `root`
|
||||||
|
- `created_at`
|
||||||
|
- `leaf_count`
|
||||||
|
- `head_blake3`
|
||||||
|
- a signature over the above (operator key)
|
||||||
|
|
||||||
|
### 3) Multi-operator keys + rotation
|
||||||
|
|
||||||
|
- Add a `key_id` (KID) and embed it in receipts
|
||||||
|
- Allow multiple trusted public keys for verification
|
||||||
|
- Add `vmc keys list` and `vmc keys trust <pub>` for distributed ops
|
||||||
|
|
||||||
|
### 4) MCP plan/apply flow
|
||||||
|
|
||||||
|
Expose “plan” and “apply” as MCP tools so an agent can:
|
||||||
|
- generate a plan (dry-run)
|
||||||
|
- have a human approve/sign the plan
|
||||||
|
- apply it and produce a linked receipt
|
||||||
|
|
||||||
|
### 5) Drift + policy checks
|
||||||
|
|
||||||
|
- Add `vmc policy check` that evaluates server labels, naming, and network posture
|
||||||
|
- Make policy checks emit signed attestations into the same ledger
|
||||||
10
bin/vmc
10
bin/vmc
@@ -1,4 +1,10 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
cd "$(dirname "$0")/.."
|
|
||||||
exec npx -y tsx src/cli.ts "$@"
|
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
|
||||||
|
if [[ -f "$ROOT/dist/cli.js" ]]; then
|
||||||
|
exec node "$ROOT/dist/cli.js" "$@"
|
||||||
|
else
|
||||||
|
exec npx -y tsx "$ROOT/src/cli.ts" "$@"
|
||||||
|
fi
|
||||||
|
|||||||
32
package-lock.json
generated
32
package-lock.json
generated
@@ -9,11 +9,8 @@
|
|||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.0.0",
|
"@modelcontextprotocol/sdk": "^1.0.0",
|
||||||
"@noble/ed25519": "^2.1.0",
|
|
||||||
"@noble/hashes": "^1.4.0",
|
|
||||||
"commander": "^12.0.0",
|
"commander": "^12.0.0",
|
||||||
"dotenv": "^16.0.0",
|
"dotenv": "^16.0.0"
|
||||||
"json-canonicalize": "^1.0.6"
|
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"vmc": "bin/vmc"
|
"vmc": "bin/vmc"
|
||||||
@@ -517,27 +514,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@noble/ed25519": {
|
|
||||||
"version": "2.3.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@noble/ed25519/-/ed25519-2.3.0.tgz",
|
|
||||||
"integrity": "sha512-M7dvXL2B92/M7dw9+gzuydL8qn/jiqNHaoR3Q+cb1q1GHV7uwE17WCyFMG+Y+TZb5izcaXk5TdJRrDUxHXL78A==",
|
|
||||||
"license": "MIT",
|
|
||||||
"funding": {
|
|
||||||
"url": "https://paulmillr.com/funding/"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@noble/hashes": {
|
|
||||||
"version": "1.8.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
|
|
||||||
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": "^14.21.3 || >=16"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://paulmillr.com/funding/"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "20.19.27",
|
"version": "20.19.27",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.27.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.27.tgz",
|
||||||
@@ -1219,12 +1195,6 @@
|
|||||||
"url": "https://github.com/sponsors/panva"
|
"url": "https://github.com/sponsors/panva"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/json-canonicalize": {
|
|
||||||
"version": "1.2.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/json-canonicalize/-/json-canonicalize-1.2.0.tgz",
|
|
||||||
"integrity": "sha512-TTdjBvqrqJKSADlEsY5rWbx8/1tOrVlTR/aSLU8N2VSInCTffP0p+byYB8Es+OmL4ZOeEftjUdvV+eJeSzJC/Q==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/json-schema-traverse": {
|
"node_modules/json-schema-traverse": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
||||||
|
|||||||
10
package.json
10
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "vm-cloud",
|
"name": "vm-cloud",
|
||||||
"version": "0.0.1",
|
"version": "0.2.0",
|
||||||
"description": "Hetzner ops + research documentation CLI",
|
"description": "Hetzner ops + research documentation CLI",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"bin": {
|
"bin": {
|
||||||
@@ -10,15 +10,15 @@
|
|||||||
"dev": "tsx src/cli.ts",
|
"dev": "tsx src/cli.ts",
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"start": "node dist/cli.js",
|
"start": "node dist/cli.js",
|
||||||
"mcp": "tsx src/index.ts"
|
"mcp": "tsx src/index.ts",
|
||||||
|
"verify": "tsx src/cli.ts verify chain --head"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@noble/ed25519": "^2.1.0",
|
|
||||||
"@modelcontextprotocol/sdk": "^1.0.0",
|
"@modelcontextprotocol/sdk": "^1.0.0",
|
||||||
"@noble/hashes": "^1.4.0",
|
|
||||||
"commander": "^12.0.0",
|
"commander": "^12.0.0",
|
||||||
"dotenv": "^16.0.0",
|
"dotenv": "^16.0.0",
|
||||||
"json-canonicalize": "^1.0.6"
|
"@noble/hashes": "^1.4.0",
|
||||||
|
"@noble/ed25519": "^2.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^20.0.0",
|
"@types/node": "^20.0.0",
|
||||||
|
|||||||
162
src/cli.ts
162
src/cli.ts
@@ -1,22 +1,28 @@
|
|||||||
import { Command } from "commander";
|
import { Command } from "commander";
|
||||||
import { requireHcloudToken } from "./lib/env.js";
|
import { requireHcloudToken } from "./lib/env.js";
|
||||||
import { HcloudClient } from "./lib/hcloud.js";
|
import { HcloudClient } from "./lib/hcloud.js";
|
||||||
|
|
||||||
import { serversList } from "./commands/servers.list.js";
|
import { serversList } from "./commands/servers.list.js";
|
||||||
import { serversAction } from "./commands/servers.action.js";
|
import { serversAction } from "./commands/servers.action.js";
|
||||||
import { serversLabels } from "./commands/servers.labels.js";
|
import { serversLabels } from "./commands/servers.labels.js";
|
||||||
import { applyPlan } from "./commands/apply.js";
|
import { applyPlan } from "./commands/apply.js";
|
||||||
|
|
||||||
|
import { snapshotServers } from "./commands/snapshot.js";
|
||||||
|
|
||||||
|
import { researchNew } from "./commands/research.new.js";
|
||||||
|
import { researchAppend } from "./commands/research.append.js";
|
||||||
|
|
||||||
import { keygen } from "./commands/keygen.js";
|
import { keygen } from "./commands/keygen.js";
|
||||||
import { signReceipt } from "./commands/sign.receipt.js";
|
import { signReceipt } from "./commands/sign.receipt.js";
|
||||||
import { verifyReceipt } from "./commands/verify.receipt.js";
|
import { verifyReceipt } from "./commands/verify.receipt.js";
|
||||||
import { snapshotServers } from "./commands/snapshot.js";
|
import { verifyChain } from "./commands/verify.chain.js";
|
||||||
import { researchNew } from "./commands/research.new.js";
|
import { merkleReceipts } from "./commands/merkle.receipts.js";
|
||||||
import { researchAppend } from "./commands/research.append.js";
|
|
||||||
|
|
||||||
const program = new Command();
|
const program = new Command();
|
||||||
program
|
program
|
||||||
.name("vmc")
|
.name("vmc")
|
||||||
.description("vm-cloud: Hetzner ops + research documentation")
|
.description("vm-cloud: Hetzner ops + research documentation")
|
||||||
.version("0.0.1");
|
.version("0.2.0");
|
||||||
|
|
||||||
const servers = program.command("servers").description("Server operations");
|
const servers = program.command("servers").description("Server operations");
|
||||||
|
|
||||||
@@ -28,18 +34,22 @@ servers
|
|||||||
await serversList(client);
|
await serversList(client);
|
||||||
});
|
});
|
||||||
|
|
||||||
servers
|
function addMutationFlags(cmd: Command) {
|
||||||
.command("reboot")
|
return cmd
|
||||||
.description("Reboot a server by name or id")
|
|
||||||
.argument("<nameOrId>", "Server name or id")
|
|
||||||
.option("--yes", "Confirm the action without prompting")
|
.option("--yes", "Confirm the action without prompting")
|
||||||
.option("--reason <text>", "Reason for the action (optional)")
|
.option("--reason <text>", "Reason for the action (required with --yes)")
|
||||||
.option("--force", "Break an active server lock")
|
.option("--force", "Break an active server lock")
|
||||||
.option("--allow-partial", "Allow partial match when using --yes")
|
.option("--allow-partial", "Allow partial match when using --yes")
|
||||||
.option("--dry-run", "Plan only; do not call Hetzner")
|
.option("--dry-run", "Plan only; do not call Hetzner")
|
||||||
.option("--sign", "Sign the receipt after mutation")
|
.option("--sign", "If an operator key exists, sign the resulting receipt");
|
||||||
.option("--require-sig", "Fail if signing fails or key is missing")
|
}
|
||||||
.action(
|
|
||||||
|
addMutationFlags(
|
||||||
|
servers
|
||||||
|
.command("reboot")
|
||||||
|
.description("Reboot a server by name or id")
|
||||||
|
.argument("<nameOrId>", "Server name or id")
|
||||||
|
).action(
|
||||||
async (
|
async (
|
||||||
nameOrId: string,
|
nameOrId: string,
|
||||||
opts: {
|
opts: {
|
||||||
@@ -49,26 +59,19 @@ servers
|
|||||||
allowPartial?: boolean;
|
allowPartial?: boolean;
|
||||||
dryRun?: boolean;
|
dryRun?: boolean;
|
||||||
sign?: boolean;
|
sign?: boolean;
|
||||||
requireSig?: boolean;
|
|
||||||
}
|
}
|
||||||
) => {
|
) => {
|
||||||
const client = new HcloudClient(requireHcloudToken());
|
const client = new HcloudClient(requireHcloudToken());
|
||||||
await serversAction(client, nameOrId, "reboot", opts);
|
await serversAction(client, nameOrId, "reboot", opts);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
servers
|
addMutationFlags(
|
||||||
|
servers
|
||||||
.command("poweroff")
|
.command("poweroff")
|
||||||
.description("Power off a server by name or id")
|
.description("Power off a server by name or id")
|
||||||
.argument("<nameOrId>", "Server name or id")
|
.argument("<nameOrId>", "Server name or id")
|
||||||
.option("--yes", "Confirm the action without prompting")
|
).action(
|
||||||
.option("--reason <text>", "Reason for the action (optional)")
|
|
||||||
.option("--force", "Break an active server lock")
|
|
||||||
.option("--allow-partial", "Allow partial match when using --yes")
|
|
||||||
.option("--dry-run", "Plan only; do not call Hetzner")
|
|
||||||
.option("--sign", "Sign the receipt after mutation")
|
|
||||||
.option("--require-sig", "Fail if signing fails or key is missing")
|
|
||||||
.action(
|
|
||||||
async (
|
async (
|
||||||
nameOrId: string,
|
nameOrId: string,
|
||||||
opts: {
|
opts: {
|
||||||
@@ -78,26 +81,19 @@ servers
|
|||||||
allowPartial?: boolean;
|
allowPartial?: boolean;
|
||||||
dryRun?: boolean;
|
dryRun?: boolean;
|
||||||
sign?: boolean;
|
sign?: boolean;
|
||||||
requireSig?: boolean;
|
|
||||||
}
|
}
|
||||||
) => {
|
) => {
|
||||||
const client = new HcloudClient(requireHcloudToken());
|
const client = new HcloudClient(requireHcloudToken());
|
||||||
await serversAction(client, nameOrId, "poweroff", opts);
|
await serversAction(client, nameOrId, "poweroff", opts);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
servers
|
addMutationFlags(
|
||||||
|
servers
|
||||||
.command("poweron")
|
.command("poweron")
|
||||||
.description("Power on a server by name or id")
|
.description("Power on a server by name or id")
|
||||||
.argument("<nameOrId>", "Server name or id")
|
.argument("<nameOrId>", "Server name or id")
|
||||||
.option("--yes", "Confirm the action without prompting")
|
).action(
|
||||||
.option("--reason <text>", "Reason for the action (optional)")
|
|
||||||
.option("--force", "Break an active server lock")
|
|
||||||
.option("--allow-partial", "Allow partial match when using --yes")
|
|
||||||
.option("--dry-run", "Plan only; do not call Hetzner")
|
|
||||||
.option("--sign", "Sign the receipt after mutation")
|
|
||||||
.option("--require-sig", "Fail if signing fails or key is missing")
|
|
||||||
.action(
|
|
||||||
async (
|
async (
|
||||||
nameOrId: string,
|
nameOrId: string,
|
||||||
opts: {
|
opts: {
|
||||||
@@ -107,27 +103,20 @@ servers
|
|||||||
allowPartial?: boolean;
|
allowPartial?: boolean;
|
||||||
dryRun?: boolean;
|
dryRun?: boolean;
|
||||||
sign?: boolean;
|
sign?: boolean;
|
||||||
requireSig?: boolean;
|
|
||||||
}
|
}
|
||||||
) => {
|
) => {
|
||||||
const client = new HcloudClient(requireHcloudToken());
|
const client = new HcloudClient(requireHcloudToken());
|
||||||
await serversAction(client, nameOrId, "poweron", opts);
|
await serversAction(client, nameOrId, "poweron", opts);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
servers
|
addMutationFlags(
|
||||||
|
servers
|
||||||
.command("labels")
|
.command("labels")
|
||||||
.description("Set labels on a server by name or id")
|
.description("Set labels on a server by name or id")
|
||||||
.argument("<nameOrId>", "Server name or id")
|
.argument("<nameOrId>", "Server name or id")
|
||||||
.argument("<labels...>", "Label pairs in key=value form")
|
.argument("<labels...>", "Label pairs in key=value form")
|
||||||
.option("--yes", "Confirm the action without prompting")
|
).action(
|
||||||
.option("--reason <text>", "Reason for the action (optional)")
|
|
||||||
.option("--force", "Break an active server lock")
|
|
||||||
.option("--allow-partial", "Allow partial match when using --yes")
|
|
||||||
.option("--dry-run", "Plan only; do not call Hetzner")
|
|
||||||
.option("--sign", "Sign the receipt after mutation")
|
|
||||||
.option("--require-sig", "Fail if signing fails or key is missing")
|
|
||||||
.action(
|
|
||||||
async (
|
async (
|
||||||
nameOrId: string,
|
nameOrId: string,
|
||||||
labels: string[],
|
labels: string[],
|
||||||
@@ -138,32 +127,12 @@ servers
|
|||||||
allowPartial?: boolean;
|
allowPartial?: boolean;
|
||||||
dryRun?: boolean;
|
dryRun?: boolean;
|
||||||
sign?: boolean;
|
sign?: boolean;
|
||||||
requireSig?: boolean;
|
|
||||||
}
|
}
|
||||||
) => {
|
) => {
|
||||||
const client = new HcloudClient(requireHcloudToken());
|
const client = new HcloudClient(requireHcloudToken());
|
||||||
await serversLabels(client, nameOrId, labels, opts);
|
await serversLabels(client, nameOrId, labels, opts);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
program
|
|
||||||
.command("keygen")
|
|
||||||
.description("Generate an Ed25519 operator keypair")
|
|
||||||
.option("--force", "Overwrite existing keypair")
|
|
||||||
.action(async (opts: { force?: boolean }) => {
|
|
||||||
await keygen(opts);
|
|
||||||
});
|
|
||||||
|
|
||||||
const sign = program.command("sign").description("Signing tools");
|
|
||||||
|
|
||||||
sign
|
|
||||||
.command("receipt")
|
|
||||||
.description("Sign a receipt with the operator key")
|
|
||||||
.argument("<path>", "Receipt JSON path")
|
|
||||||
.option("--force", "Overwrite existing signature fields")
|
|
||||||
.action(async (p: string, opts: { force?: boolean }) => {
|
|
||||||
await signReceipt(p, opts);
|
|
||||||
});
|
|
||||||
|
|
||||||
program
|
program
|
||||||
.command("apply")
|
.command("apply")
|
||||||
@@ -173,28 +142,24 @@ program
|
|||||||
.option("--reason <text>", "Reason for the action (required with --yes)")
|
.option("--reason <text>", "Reason for the action (required with --yes)")
|
||||||
.option("--force", "Break an active server lock")
|
.option("--force", "Break an active server lock")
|
||||||
.option("--allow-partial", "Allow partial match when using --yes")
|
.option("--allow-partial", "Allow partial match when using --yes")
|
||||||
.option("--sign", "Sign the receipt after mutation")
|
.option("--sign", "If an operator key exists, sign the resulting receipt")
|
||||||
.option("--require-sig", "Fail if signing fails or key is missing")
|
|
||||||
.action(
|
.action(
|
||||||
async (
|
async (opts: {
|
||||||
opts: {
|
|
||||||
plan: string;
|
plan: string;
|
||||||
yes?: boolean;
|
yes?: boolean;
|
||||||
reason?: string;
|
reason?: string;
|
||||||
force?: boolean;
|
force?: boolean;
|
||||||
allowPartial?: boolean;
|
allowPartial?: boolean;
|
||||||
sign?: boolean;
|
sign?: boolean;
|
||||||
requireSig?: boolean;
|
}) => {
|
||||||
}
|
|
||||||
) => {
|
|
||||||
const client = new HcloudClient(requireHcloudToken());
|
const client = new HcloudClient(requireHcloudToken());
|
||||||
await applyPlan(client, opts.plan, opts);
|
await applyPlan(client, opts.plan, opts);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
program
|
const snapshot = program.command("snapshot").description("Write snapshots to outputs/");
|
||||||
.command("snapshot")
|
|
||||||
.description("Write snapshots to outputs/")
|
snapshot
|
||||||
.command("servers")
|
.command("servers")
|
||||||
.description("Snapshot Hetzner servers JSON")
|
.description("Snapshot Hetzner servers JSON")
|
||||||
.action(async () => {
|
.action(async () => {
|
||||||
@@ -221,20 +186,55 @@ research
|
|||||||
researchAppend(opts.from, opts.note);
|
researchAppend(opts.from, opts.note);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
program
|
||||||
|
.command("keygen")
|
||||||
|
.description("Generate an Ed25519 operator keypair")
|
||||||
|
.option("--force", "Overwrite existing keypair")
|
||||||
|
.action(async (opts: { force?: boolean }) => {
|
||||||
|
await keygen(opts);
|
||||||
|
});
|
||||||
|
|
||||||
|
const sign = program.command("sign").description("Signing tools");
|
||||||
|
|
||||||
|
sign
|
||||||
|
.command("receipt")
|
||||||
|
.description("Sign a receipt with the operator key")
|
||||||
|
.argument("<path>", "Receipt JSON path")
|
||||||
|
.option("--force", "Overwrite existing signature fields")
|
||||||
|
.action(async (p: string, opts: { force?: boolean }) => {
|
||||||
|
await signReceipt(p, opts);
|
||||||
|
});
|
||||||
|
|
||||||
const verify = program.command("verify").description("Verification tools");
|
const verify = program.command("verify").description("Verification tools");
|
||||||
|
|
||||||
verify
|
verify
|
||||||
.command("receipt")
|
.command("receipt")
|
||||||
.description("Verify a receipt file and optional plan/head linkage")
|
.description("Verify a receipt file and optional plan/head linkage")
|
||||||
.argument("<path>", "Receipt JSON path")
|
.argument("<path>", "Receipt JSON path")
|
||||||
.option("--head", "If receipt matches HEAD.file, validate HEAD.blake3 too")
|
.option("--head", "Assert this receipt is the current HEAD and validate HEAD.blake3")
|
||||||
.option("--plan", "If receipt references a plan file, verify its hashes too")
|
.option("--plan", "If receipt references a plan file, verify its hashes too")
|
||||||
.option("--sig", "Verify the Ed25519 signature on the receipt")
|
.option("--sig", "Verify the Ed25519 signature on the receipt")
|
||||||
.action(
|
.action(async (p: string, opts: { head?: boolean; plan?: boolean; sig?: boolean }) => {
|
||||||
async (p: string, opts: { head?: boolean; plan?: boolean; sig?: boolean }) => {
|
|
||||||
await verifyReceipt(p, opts);
|
await verifyReceipt(p, opts);
|
||||||
}
|
});
|
||||||
);
|
|
||||||
|
verify
|
||||||
|
.command("chain")
|
||||||
|
.description("Verify the receipt chain via prev_blake3 pointers (from HEAD back)")
|
||||||
|
.option("--head", "Ensure HEAD.file exists")
|
||||||
|
.option("--sig", "Require and verify Ed25519 signatures on every receipt")
|
||||||
|
.action(async (opts: { head?: boolean; sig?: boolean }) => {
|
||||||
|
await verifyChain(opts);
|
||||||
|
});
|
||||||
|
|
||||||
|
const merkle = program.command("merkle").description("Merkle tools");
|
||||||
|
|
||||||
|
merkle
|
||||||
|
.command("receipts")
|
||||||
|
.description("Compute a Merkle root over the receipt chain (genesis->HEAD)")
|
||||||
|
.action(() => {
|
||||||
|
merkleReceipts();
|
||||||
|
});
|
||||||
|
|
||||||
program.parseAsync(process.argv).catch((err) => {
|
program.parseAsync(process.argv).catch((err) => {
|
||||||
console.error(err?.message ?? err);
|
console.error(err?.message ?? err);
|
||||||
|
|||||||
@@ -2,49 +2,17 @@ import fs from "node:fs";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { HcloudClient } from "../lib/hcloud.js";
|
import { HcloudClient } from "../lib/hcloud.js";
|
||||||
import { requireConfirmation } from "../lib/confirm.js";
|
import { requireConfirmation } from "../lib/confirm.js";
|
||||||
import { readPrivateKey } from "../lib/keys.js";
|
|
||||||
import { acquireServerLock } from "../lib/lock.js";
|
import { acquireServerLock } from "../lib/lock.js";
|
||||||
import { hashBlake3Hex, hashSha256Hex } from "../lib/hash.js";
|
import { hashBlake3Hex, hashSha256Hex } from "../lib/hash.js";
|
||||||
import {
|
import { readPlan, resolvePlanPath, validatePlan, type ServerPlan } from "../lib/plan.js";
|
||||||
readPlan,
|
|
||||||
resolvePlanPath,
|
|
||||||
type PlanAction,
|
|
||||||
type PlanMatch,
|
|
||||||
type ServerPlan
|
|
||||||
} from "../lib/plan.js";
|
|
||||||
import { writeReceipt } from "../lib/receipt.js";
|
import { writeReceipt } from "../lib/receipt.js";
|
||||||
import { signReceiptFile } from "../lib/signature.js";
|
import { maybeAutoSignReceipt } from "../lib/signing.js";
|
||||||
|
|
||||||
const ACTIONS: PlanAction[] = ["poweron", "poweroff", "reboot", "labels"];
|
function mustExist(p: string, label: string) {
|
||||||
const MATCHES: PlanMatch[] = ["id", "exact", "partial"];
|
if (!fs.existsSync(p)) {
|
||||||
|
const err = new Error(`${label} not found: ${p}`);
|
||||||
function planError(message: string): never {
|
(err as { exitCode?: number }).exitCode = 2;
|
||||||
const err = new Error(message);
|
|
||||||
(err as { exitCode?: number }).exitCode = 4;
|
|
||||||
throw err;
|
throw err;
|
||||||
}
|
|
||||||
|
|
||||||
function validatePlan(plan: ServerPlan) {
|
|
||||||
if (plan.plan_version !== "1") {
|
|
||||||
planError(`Unsupported plan_version: ${String(plan.plan_version)}`);
|
|
||||||
}
|
|
||||||
if (!plan.created_at || Number.isNaN(Date.parse(plan.created_at))) {
|
|
||||||
planError("Plan created_at is missing or invalid.");
|
|
||||||
}
|
|
||||||
if (!ACTIONS.includes(plan.action)) {
|
|
||||||
planError(`Unsupported action: ${String(plan.action)}`);
|
|
||||||
}
|
|
||||||
if (!plan.server || typeof plan.server.id !== "number" || !plan.server.name) {
|
|
||||||
planError("Plan server id/name is missing or invalid.");
|
|
||||||
}
|
|
||||||
if (!MATCHES.includes(plan.match)) {
|
|
||||||
planError(`Unsupported match type: ${String(plan.match)}`);
|
|
||||||
}
|
|
||||||
if (!plan.request?.method || !plan.request?.path) {
|
|
||||||
planError("Plan request method/path is missing.");
|
|
||||||
}
|
|
||||||
if (plan.applied !== false) {
|
|
||||||
planError("Plan must have applied=false.");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,84 +25,66 @@ export async function applyPlan(
|
|||||||
force?: boolean;
|
force?: boolean;
|
||||||
allowPartial?: boolean;
|
allowPartial?: boolean;
|
||||||
sign?: boolean;
|
sign?: boolean;
|
||||||
requireSig?: boolean;
|
|
||||||
} = {}
|
} = {}
|
||||||
) {
|
) {
|
||||||
const absPlan = resolvePlanPath(planPath);
|
const absPlan = resolvePlanPath(planPath);
|
||||||
if (!fs.existsSync(absPlan)) {
|
mustExist(absPlan, "Plan");
|
||||||
throw new Error(`Plan not found: ${absPlan}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const plan = readPlan(absPlan);
|
const plan = readPlan(absPlan);
|
||||||
const plan_sha256 = hashSha256Hex(plan);
|
|
||||||
const plan_blake3 = hashBlake3Hex(plan);
|
|
||||||
validatePlan(plan);
|
validatePlan(plan);
|
||||||
|
|
||||||
|
const plan_sha256 = hashSha256Hex(plan);
|
||||||
|
const plan_blake3 = hashBlake3Hex(plan);
|
||||||
|
const plan_file = path.relative(process.cwd(), absPlan) || absPlan;
|
||||||
|
|
||||||
if (plan.match === "partial" && opts.yes && !opts.allowPartial) {
|
if (plan.match === "partial" && opts.yes && !opts.allowPartial) {
|
||||||
planError("Plan was created from a partial match. Use --allow-partial.");
|
const err = new Error(
|
||||||
|
`Plan match is partial. Use --allow-partial to proceed.`
|
||||||
|
);
|
||||||
|
(err as { exitCode?: number }).exitCode = 4;
|
||||||
|
throw err;
|
||||||
}
|
}
|
||||||
|
|
||||||
const reasonRaw = opts.reason?.trim();
|
const reasonRaw = opts.reason?.trim();
|
||||||
if (opts.yes && !reasonRaw) {
|
if (opts.yes && !reasonRaw && !plan.reason) {
|
||||||
const err = new Error("Reason is required when using --yes.");
|
const err = new Error("Reason is required when using --yes (or set plan.reason).");
|
||||||
(err as { exitCode?: number }).exitCode = 2;
|
(err as { exitCode?: number }).exitCode = 2;
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
|
||||||
const expectedPath =
|
// Ensure target still exists.
|
||||||
plan.action === "labels"
|
|
||||||
? `/servers/${plan.server.id}`
|
|
||||||
: `/servers/${plan.server.id}/actions/${plan.action}`;
|
|
||||||
const expectedMethod = plan.action === "labels" ? "PUT" : "POST";
|
|
||||||
if (plan.request.path !== expectedPath || plan.request.method !== expectedMethod) {
|
|
||||||
planError("Plan request does not match the expected action path/method.");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (plan.action === "labels") {
|
|
||||||
const body = plan.request.body as { labels?: Record<string, string> } | undefined;
|
|
||||||
if (!body?.labels || typeof body.labels !== "object") {
|
|
||||||
planError("Plan labels body is missing or invalid.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const servers = await client.listServers();
|
const servers = await client.listServers();
|
||||||
const server = servers.find((s) => s.id === plan.server.id);
|
const server = servers.find((s) => s.id === plan.server.id);
|
||||||
if (!server) {
|
if (!server) {
|
||||||
planError(`Server id ${plan.server.id} not found.`);
|
const err = new Error(`No server found with id ${plan.server.id}`);
|
||||||
}
|
(err as { exitCode?: number }).exitCode = 3;
|
||||||
if (server.name !== plan.server.name) {
|
throw err;
|
||||||
planError(
|
|
||||||
`Server name mismatch for id ${plan.server.id}: expected ${plan.server.name}, got ${server.name}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (opts.requireSig) {
|
|
||||||
try {
|
|
||||||
readPrivateKey();
|
|
||||||
} catch (err) {
|
|
||||||
const msg = err instanceof Error ? err.message : String(err);
|
|
||||||
const requireErr = new Error(`Signing key required: ${msg}`);
|
|
||||||
(requireErr as { exitCode?: number }).exitCode = 7;
|
|
||||||
throw requireErr;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const lock = acquireServerLock(server.id, { force: opts.force });
|
const lock = acquireServerLock(server.id, { force: opts.force });
|
||||||
const plan_file = path.relative(process.cwd(), absPlan) || absPlan;
|
|
||||||
try {
|
try {
|
||||||
const reason = reasonRaw || plan.reason || "unspecified";
|
const reason = reasonRaw || plan.reason || "unspecified";
|
||||||
|
|
||||||
await requireConfirmation({
|
await requireConfirmation({
|
||||||
action: plan.action,
|
action: plan.action,
|
||||||
server: { id: server.id, name: server.name, ip: server.public_net?.ipv4?.ip },
|
server: {
|
||||||
|
id: server.id,
|
||||||
|
name: server.name,
|
||||||
|
ip: server.public_net?.ipv4?.ip
|
||||||
|
},
|
||||||
reason,
|
reason,
|
||||||
yes: opts.yes,
|
yes: opts.yes,
|
||||||
match: plan.match
|
match: plan.match
|
||||||
});
|
});
|
||||||
|
|
||||||
let response;
|
let response:
|
||||||
|
| Awaited<ReturnType<HcloudClient["powerAction"]>>
|
||||||
|
| Awaited<ReturnType<HcloudClient["updateServerLabels"]>>;
|
||||||
|
|
||||||
if (plan.action === "labels") {
|
if (plan.action === "labels") {
|
||||||
const body = plan.request.body as { labels: Record<string, string> };
|
const body = (plan.request.body ?? {}) as { labels?: Record<string, string> };
|
||||||
response = await client.updateServerLabels(server.id, body.labels);
|
const labels = body.labels ?? {};
|
||||||
|
response = await client.updateServerLabels(server.id, labels);
|
||||||
} else {
|
} else {
|
||||||
response = await client.powerAction(server.id, plan.action);
|
response = await client.powerAction(server.id, plan.action);
|
||||||
}
|
}
|
||||||
@@ -142,12 +92,12 @@ export async function applyPlan(
|
|||||||
const receipt = writeReceipt({
|
const receipt = writeReceipt({
|
||||||
action: plan.action,
|
action: plan.action,
|
||||||
reason,
|
reason,
|
||||||
server: { id: server.id, name: server.name, ip: server.public_net?.ipv4?.ip },
|
server: {
|
||||||
request: {
|
id: server.id,
|
||||||
method: plan.request.method,
|
name: server.name,
|
||||||
path: plan.request.path,
|
ip: server.public_net?.ipv4?.ip
|
||||||
body: plan.request.body
|
|
||||||
},
|
},
|
||||||
|
request: plan.request,
|
||||||
response: {
|
response: {
|
||||||
status: response.status,
|
status: response.status,
|
||||||
ok: response.ok,
|
ok: response.ok,
|
||||||
@@ -164,20 +114,8 @@ export async function applyPlan(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const mustSign = Boolean(opts.sign || opts.requireSig);
|
if (opts.sign) {
|
||||||
if (mustSign) {
|
await maybeAutoSignReceipt(receipt.file);
|
||||||
try {
|
|
||||||
const signed = await signReceiptFile(receipt.file);
|
|
||||||
console.log(`Signed: ${signed.file} signer_kid=${signed.signer_kid}`);
|
|
||||||
} catch (err) {
|
|
||||||
const msg = err instanceof Error ? err.message : String(err);
|
|
||||||
if (opts.requireSig) {
|
|
||||||
const signErr = new Error(`Receipt signing failed: ${msg}`);
|
|
||||||
(signErr as { exitCode?: number }).exitCode = 7;
|
|
||||||
throw signErr;
|
|
||||||
}
|
|
||||||
console.warn(`WARN: receipt signing failed: ${msg}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { generateKeypair } from "../lib/keys.js";
|
import { generateKeypair, keyPaths } from "../lib/keys.js";
|
||||||
|
|
||||||
export async function keygen(opts: { force?: boolean } = {}) {
|
export async function keygen(opts: { force?: boolean } = {}) {
|
||||||
const { privateKeyPath, publicKeyPath, publicKey } = await generateKeypair(opts);
|
const paths = keyPaths();
|
||||||
console.log(`Private key: ${privateKeyPath}`);
|
const res = await generateKeypair({ force: opts.force });
|
||||||
console.log(`Public key: ${publicKeyPath}`);
|
console.log(`Key directory: ${paths.dir}`);
|
||||||
console.log(`Public key (hex): ${publicKey}`);
|
console.log(`Public key: ${res.pub_path}`);
|
||||||
|
console.log(`Public hex: ${res.pub_hex}`);
|
||||||
|
console.log(`Private key: ${res.priv_path} (keep secret)`);
|
||||||
}
|
}
|
||||||
|
|||||||
65
src/commands/merkle.receipts.ts
Normal file
65
src/commands/merkle.receipts.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { nowStamp, ensureDir } from "../lib/report.js";
|
||||||
|
import { readHead, receiptsDir } from "../lib/ledger.js";
|
||||||
|
import { merkleRootBlake3 } from "../lib/merkle.js";
|
||||||
|
|
||||||
|
type ReceiptEnv = { blake3?: string; prev_blake3?: string | null };
|
||||||
|
|
||||||
|
export function merkleReceipts() {
|
||||||
|
const head = readHead();
|
||||||
|
if (!head) {
|
||||||
|
const err = new Error("HEAD.json not found or invalid");
|
||||||
|
(err as { exitCode?: number }).exitCode = 2;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dir = receiptsDir();
|
||||||
|
const files = fs
|
||||||
|
.readdirSync(dir)
|
||||||
|
.filter((f) => f.endsWith(".json") && f !== "HEAD.json")
|
||||||
|
.map((f) => path.join(dir, f));
|
||||||
|
|
||||||
|
const map = new Map<string, ReceiptEnv>();
|
||||||
|
for (const f of files) {
|
||||||
|
try {
|
||||||
|
const raw = fs.readFileSync(f, "utf8");
|
||||||
|
const env = JSON.parse(raw) as ReceiptEnv;
|
||||||
|
if (env.blake3) map.set(env.blake3, env);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Walk prev pointers from head to genesis.
|
||||||
|
const chain: string[] = [];
|
||||||
|
let cur: string | null = head.blake3;
|
||||||
|
while (cur) {
|
||||||
|
const env = map.get(cur);
|
||||||
|
if (!env) break;
|
||||||
|
chain.push(cur);
|
||||||
|
cur = env.prev_blake3 ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const leaves = chain.slice().reverse(); // genesis -> head
|
||||||
|
const root = merkleRootBlake3(leaves);
|
||||||
|
|
||||||
|
const { stamp } = nowStamp();
|
||||||
|
const outDir = path.join(process.cwd(), "outputs", "ledger");
|
||||||
|
ensureDir(outDir);
|
||||||
|
const outFile = path.join(outDir, `merkle-${stamp}.json`);
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
alg: "blake3",
|
||||||
|
leaf_count: leaves.length,
|
||||||
|
head_blake3: head.blake3,
|
||||||
|
root,
|
||||||
|
leaves
|
||||||
|
};
|
||||||
|
|
||||||
|
fs.writeFileSync(outFile, JSON.stringify(payload, null, 2), "utf8");
|
||||||
|
console.log(`Merkle root: ${root}`);
|
||||||
|
console.log(`Leaves: ${leaves.length}`);
|
||||||
|
console.log(`Wrote: ${outFile}`);
|
||||||
|
}
|
||||||
@@ -1,11 +1,10 @@
|
|||||||
import { HcloudClient } from "../lib/hcloud.js";
|
import { HcloudClient } from "../lib/hcloud.js";
|
||||||
import { requireConfirmation } from "../lib/confirm.js";
|
import { requireConfirmation } from "../lib/confirm.js";
|
||||||
import { readPrivateKey } from "../lib/keys.js";
|
|
||||||
import { acquireServerLock } from "../lib/lock.js";
|
import { acquireServerLock } from "../lib/lock.js";
|
||||||
import { writePlan } from "../lib/plan.js";
|
import { writePlan } from "../lib/plan.js";
|
||||||
import { writeReceipt } from "../lib/receipt.js";
|
import { writeReceipt } from "../lib/receipt.js";
|
||||||
import { signReceiptFile } from "../lib/signature.js";
|
|
||||||
import { resolveServer } from "../lib/resolve.js";
|
import { resolveServer } from "../lib/resolve.js";
|
||||||
|
import { maybeAutoSignReceipt } from "../lib/signing.js";
|
||||||
|
|
||||||
export async function serversAction(
|
export async function serversAction(
|
||||||
client: HcloudClient,
|
client: HcloudClient,
|
||||||
@@ -18,38 +17,21 @@ export async function serversAction(
|
|||||||
allowPartial?: boolean;
|
allowPartial?: boolean;
|
||||||
dryRun?: boolean;
|
dryRun?: boolean;
|
||||||
sign?: boolean;
|
sign?: boolean;
|
||||||
requireSig?: boolean;
|
|
||||||
} = {}
|
} = {}
|
||||||
) {
|
) {
|
||||||
const { server, match } = await resolveServer(client, input);
|
const { server, match } = await resolveServer(client, input);
|
||||||
|
|
||||||
if (match === "partial") {
|
if (match === "partial") {
|
||||||
console.warn(
|
console.warn(
|
||||||
`Partial match: "${input}" resolved to ${server.name} (${server.id})`
|
`Partial match: "${input}" resolved to ${server.name} (${server.id})`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (match === "partial" && opts.yes && !opts.allowPartial) {
|
|
||||||
const err = new Error(
|
|
||||||
`Partial match for "${input}". Use --allow-partial to proceed.`
|
|
||||||
);
|
|
||||||
(err as { exitCode?: number }).exitCode = 4;
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
|
|
||||||
const reasonRaw = opts.reason?.trim();
|
const reasonRaw = opts.reason?.trim();
|
||||||
if (!opts.dryRun && opts.yes && !reasonRaw) {
|
|
||||||
const err = new Error("Reason is required when using --yes.");
|
|
||||||
(err as { exitCode?: number }).exitCode = 2;
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
|
|
||||||
const path = `/servers/${server.id}/actions/${action}`;
|
// Plan-only mode (no locks, no confirmation, no API calls)
|
||||||
|
const reqPath = `/servers/${server.id}/actions/${action}`;
|
||||||
if (opts.dryRun) {
|
if (opts.dryRun) {
|
||||||
if (opts.sign || opts.requireSig) {
|
|
||||||
const err = new Error("Cannot sign receipts during --dry-run.");
|
|
||||||
(err as { exitCode?: number }).exitCode = 2;
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
const plan = {
|
const plan = {
|
||||||
plan_version: "1",
|
plan_version: "1",
|
||||||
created_at: new Date().toISOString(),
|
created_at: new Date().toISOString(),
|
||||||
@@ -60,40 +42,41 @@ export async function serversAction(
|
|||||||
ip: server.public_net?.ipv4?.ip
|
ip: server.public_net?.ipv4?.ip
|
||||||
},
|
},
|
||||||
match,
|
match,
|
||||||
request: { method: "POST", path },
|
request: { method: "POST", path: reqPath },
|
||||||
reason: reasonRaw,
|
reason: reasonRaw,
|
||||||
applied: false
|
applied: false
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const { file, sha256, blake3 } = writePlan(plan);
|
const { file, sha256, blake3 } = writePlan(plan);
|
||||||
console.log(`Resolved: ${server.name} (id=${server.id}) match=${match}`);
|
console.log(`Resolved: ${server.name} (id=${server.id}) match=${match}`);
|
||||||
console.log(`Request: POST ${path}`);
|
console.log(`Request: POST ${reqPath}`);
|
||||||
console.log(`Plan: ${file}`);
|
console.log(`Plan: ${file}`);
|
||||||
console.log(`BLAKE3: ${blake3}`);
|
console.log(`BLAKE3: ${blake3}`);
|
||||||
console.log(`SHA256: ${sha256}`);
|
console.log(`SHA256: ${sha256}`);
|
||||||
return plan;
|
return plan;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (opts.requireSig) {
|
if (match === "partial" && opts.yes && !opts.allowPartial) {
|
||||||
try {
|
const err = new Error(
|
||||||
readPrivateKey();
|
`Partial match for "${input}". Use --allow-partial to proceed.`
|
||||||
} catch (err) {
|
);
|
||||||
const msg = err instanceof Error ? err.message : String(err);
|
(err as { exitCode?: number }).exitCode = 4;
|
||||||
const requireErr = new Error(`Signing key required: ${msg}`);
|
throw err;
|
||||||
(requireErr as { exitCode?: number }).exitCode = 7;
|
|
||||||
throw requireErr;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (opts.yes && !reasonRaw) {
|
||||||
|
const err = new Error("Reason is required when using --yes.");
|
||||||
|
(err as { exitCode?: number }).exitCode = 2;
|
||||||
|
throw err;
|
||||||
}
|
}
|
||||||
|
|
||||||
const lock = acquireServerLock(server.id, { force: opts.force });
|
const lock = acquireServerLock(server.id, { force: opts.force });
|
||||||
try {
|
try {
|
||||||
const reason = reasonRaw || "unspecified";
|
const reason = reasonRaw || "unspecified";
|
||||||
|
|
||||||
await requireConfirmation({
|
await requireConfirmation({
|
||||||
action,
|
action,
|
||||||
server: {
|
server: { id: server.id, name: server.name, ip: server.public_net?.ipv4?.ip },
|
||||||
id: server.id,
|
|
||||||
name: server.name,
|
|
||||||
ip: server.public_net?.ipv4?.ip
|
|
||||||
},
|
|
||||||
reason,
|
reason,
|
||||||
yes: opts.yes,
|
yes: opts.yes,
|
||||||
match
|
match
|
||||||
@@ -103,12 +86,8 @@ export async function serversAction(
|
|||||||
const receipt = writeReceipt({
|
const receipt = writeReceipt({
|
||||||
action,
|
action,
|
||||||
reason,
|
reason,
|
||||||
server: {
|
server: { id: server.id, name: server.name, ip: server.public_net?.ipv4?.ip },
|
||||||
id: server.id,
|
request: { method: "POST", path: reqPath },
|
||||||
name: server.name,
|
|
||||||
ip: server.public_net?.ipv4?.ip
|
|
||||||
},
|
|
||||||
request: { method: "POST", path },
|
|
||||||
response: {
|
response: {
|
||||||
status: response.status,
|
status: response.status,
|
||||||
ok: response.ok,
|
ok: response.ok,
|
||||||
@@ -118,28 +97,16 @@ export async function serversAction(
|
|||||||
lock: { file: lock.file, started_at: lock.info.started_at, force: opts.force }
|
lock: { file: lock.file, started_at: lock.info.started_at, force: opts.force }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (opts.sign) {
|
||||||
|
await maybeAutoSignReceipt(receipt.file);
|
||||||
|
}
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Hetzner API error ${response.status}. Receipt: ${receipt.file}`
|
`Hetzner API error ${response.status}. Receipt: ${receipt.file}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const mustSign = Boolean(opts.sign || opts.requireSig);
|
|
||||||
if (mustSign) {
|
|
||||||
try {
|
|
||||||
const signed = await signReceiptFile(receipt.file);
|
|
||||||
console.log(`Signed: ${signed.file} signer_kid=${signed.signer_kid}`);
|
|
||||||
} catch (err) {
|
|
||||||
const msg = err instanceof Error ? err.message : String(err);
|
|
||||||
if (opts.requireSig) {
|
|
||||||
const signErr = new Error(`Receipt signing failed: ${msg}`);
|
|
||||||
(signErr as { exitCode?: number }).exitCode = 7;
|
|
||||||
throw signErr;
|
|
||||||
}
|
|
||||||
console.warn(`WARN: receipt signing failed: ${msg}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
`OK ${action} ${server.name} (id=${server.id}) receipt=${receipt.file} blake3=${receipt.blake3} sha256=${receipt.sha256}`
|
`OK ${action} ${server.name} (id=${server.id}) receipt=${receipt.file} blake3=${receipt.blake3} sha256=${receipt.sha256}`
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,27 +1,26 @@
|
|||||||
import { HcloudClient } from "../lib/hcloud.js";
|
import { HcloudClient } from "../lib/hcloud.js";
|
||||||
import { requireConfirmation } from "../lib/confirm.js";
|
import { requireConfirmation } from "../lib/confirm.js";
|
||||||
import { readPrivateKey } from "../lib/keys.js";
|
|
||||||
import { acquireServerLock } from "../lib/lock.js";
|
import { acquireServerLock } from "../lib/lock.js";
|
||||||
import { writePlan } from "../lib/plan.js";
|
import { writePlan } from "../lib/plan.js";
|
||||||
import { writeReceipt } from "../lib/receipt.js";
|
import { writeReceipt } from "../lib/receipt.js";
|
||||||
import { signReceiptFile } from "../lib/signature.js";
|
|
||||||
import { resolveServer } from "../lib/resolve.js";
|
import { resolveServer } from "../lib/resolve.js";
|
||||||
|
import { maybeAutoSignReceipt } from "../lib/signing.js";
|
||||||
|
|
||||||
function parseLabels(args: string[]) {
|
function parseLabels(args: string[]) {
|
||||||
if (!args.length) {
|
|
||||||
throw new Error("At least one label in key=value form is required");
|
|
||||||
}
|
|
||||||
|
|
||||||
const labels: Record<string, string> = {};
|
const labels: Record<string, string> = {};
|
||||||
for (const raw of args) {
|
for (const a of args) {
|
||||||
const idx = raw.indexOf("=");
|
const idx = a.indexOf("=");
|
||||||
if (idx <= 0 || idx === raw.length - 1) {
|
if (idx <= 0 || idx === a.length - 1) {
|
||||||
throw new Error(`Invalid label "${raw}". Use key=value`);
|
const err = new Error(`Invalid label "${a}". Use key=value.`);
|
||||||
|
(err as { exitCode?: number }).exitCode = 2;
|
||||||
|
throw err;
|
||||||
}
|
}
|
||||||
const key = raw.slice(0, idx).trim();
|
const key = a.slice(0, idx).trim();
|
||||||
const value = raw.slice(idx + 1).trim();
|
const value = a.slice(idx + 1).trim();
|
||||||
if (!key || !value) {
|
if (!key || !value) {
|
||||||
throw new Error(`Invalid label "${raw}". Use key=value`);
|
const err = new Error(`Invalid label "${a}". Use key=value.`);
|
||||||
|
(err as { exitCode?: number }).exitCode = 2;
|
||||||
|
throw err;
|
||||||
}
|
}
|
||||||
labels[key] = value;
|
labels[key] = value;
|
||||||
}
|
}
|
||||||
@@ -39,41 +38,23 @@ export async function serversLabels(
|
|||||||
allowPartial?: boolean;
|
allowPartial?: boolean;
|
||||||
dryRun?: boolean;
|
dryRun?: boolean;
|
||||||
sign?: boolean;
|
sign?: boolean;
|
||||||
requireSig?: boolean;
|
|
||||||
} = {}
|
} = {}
|
||||||
) {
|
) {
|
||||||
const { server, match } = await resolveServer(client, input);
|
const { server, match } = await resolveServer(client, input);
|
||||||
|
|
||||||
if (match === "partial") {
|
if (match === "partial") {
|
||||||
console.warn(
|
console.warn(
|
||||||
`Partial match: "${input}" resolved to ${server.name} (${server.id})`
|
`Partial match: "${input}" resolved to ${server.name} (${server.id})`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (match === "partial" && opts.yes && !opts.allowPartial) {
|
|
||||||
const err = new Error(
|
|
||||||
`Partial match for "${input}". Use --allow-partial to proceed.`
|
|
||||||
);
|
|
||||||
(err as { exitCode?: number }).exitCode = 4;
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
|
|
||||||
const reasonRaw = opts.reason?.trim();
|
const reasonRaw = opts.reason?.trim();
|
||||||
if (!opts.dryRun && opts.yes && !reasonRaw) {
|
|
||||||
const err = new Error("Reason is required when using --yes.");
|
|
||||||
(err as { exitCode?: number }).exitCode = 2;
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
|
|
||||||
const labels = parseLabels(args);
|
const labels = parseLabels(args);
|
||||||
const next = { ...(server.labels ?? {}), ...labels };
|
const next = { ...(server.labels ?? {}), ...labels };
|
||||||
const path = `/servers/${server.id}`;
|
const reqPath = `/servers/${server.id}`;
|
||||||
|
|
||||||
|
// Plan-only mode
|
||||||
if (opts.dryRun) {
|
if (opts.dryRun) {
|
||||||
if (opts.sign || opts.requireSig) {
|
|
||||||
const err = new Error("Cannot sign receipts during --dry-run.");
|
|
||||||
(err as { exitCode?: number }).exitCode = 2;
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
const plan = {
|
const plan = {
|
||||||
plan_version: "1",
|
plan_version: "1",
|
||||||
created_at: new Date().toISOString(),
|
created_at: new Date().toISOString(),
|
||||||
@@ -84,13 +65,14 @@ export async function serversLabels(
|
|||||||
ip: server.public_net?.ipv4?.ip
|
ip: server.public_net?.ipv4?.ip
|
||||||
},
|
},
|
||||||
match,
|
match,
|
||||||
request: { method: "PUT", path, body: { labels: next } },
|
request: { method: "PUT", path: reqPath, body: { labels: next } },
|
||||||
reason: reasonRaw,
|
reason: reasonRaw,
|
||||||
applied: false
|
applied: false
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const { file, sha256, blake3 } = writePlan(plan);
|
const { file, sha256, blake3 } = writePlan(plan);
|
||||||
console.log(`Resolved: ${server.name} (id=${server.id}) match=${match}`);
|
console.log(`Resolved: ${server.name} (id=${server.id}) match=${match}`);
|
||||||
console.log(`Request: PUT ${path}`);
|
console.log(`Request: PUT ${reqPath}`);
|
||||||
console.log(JSON.stringify({ labels: next }, null, 2));
|
console.log(JSON.stringify({ labels: next }, null, 2));
|
||||||
console.log(`Plan: ${file}`);
|
console.log(`Plan: ${file}`);
|
||||||
console.log(`BLAKE3: ${blake3}`);
|
console.log(`BLAKE3: ${blake3}`);
|
||||||
@@ -98,27 +80,27 @@ export async function serversLabels(
|
|||||||
return plan;
|
return plan;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (opts.requireSig) {
|
if (match === "partial" && opts.yes && !opts.allowPartial) {
|
||||||
try {
|
const err = new Error(
|
||||||
readPrivateKey();
|
`Partial match for "${input}". Use --allow-partial to proceed.`
|
||||||
} catch (err) {
|
);
|
||||||
const msg = err instanceof Error ? err.message : String(err);
|
(err as { exitCode?: number }).exitCode = 4;
|
||||||
const requireErr = new Error(`Signing key required: ${msg}`);
|
throw err;
|
||||||
(requireErr as { exitCode?: number }).exitCode = 7;
|
|
||||||
throw requireErr;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (opts.yes && !reasonRaw) {
|
||||||
|
const err = new Error("Reason is required when using --yes.");
|
||||||
|
(err as { exitCode?: number }).exitCode = 2;
|
||||||
|
throw err;
|
||||||
}
|
}
|
||||||
|
|
||||||
const lock = acquireServerLock(server.id, { force: opts.force });
|
const lock = acquireServerLock(server.id, { force: opts.force });
|
||||||
try {
|
try {
|
||||||
const reason = reasonRaw || "unspecified";
|
const reason = reasonRaw || "unspecified";
|
||||||
|
|
||||||
await requireConfirmation({
|
await requireConfirmation({
|
||||||
action: "labels",
|
action: "labels",
|
||||||
server: {
|
server: { id: server.id, name: server.name, ip: server.public_net?.ipv4?.ip },
|
||||||
id: server.id,
|
|
||||||
name: server.name,
|
|
||||||
ip: server.public_net?.ipv4?.ip
|
|
||||||
},
|
|
||||||
reason,
|
reason,
|
||||||
yes: opts.yes,
|
yes: opts.yes,
|
||||||
match
|
match
|
||||||
@@ -128,12 +110,8 @@ export async function serversLabels(
|
|||||||
const receipt = writeReceipt({
|
const receipt = writeReceipt({
|
||||||
action: "labels",
|
action: "labels",
|
||||||
reason,
|
reason,
|
||||||
server: {
|
server: { id: server.id, name: server.name, ip: server.public_net?.ipv4?.ip },
|
||||||
id: server.id,
|
request: { method: "PUT", path: reqPath, body: { labels: next } },
|
||||||
name: server.name,
|
|
||||||
ip: server.public_net?.ipv4?.ip
|
|
||||||
},
|
|
||||||
request: { method: "PUT", path, body: { labels: next } },
|
|
||||||
response: {
|
response: {
|
||||||
status: response.status,
|
status: response.status,
|
||||||
ok: response.ok,
|
ok: response.ok,
|
||||||
@@ -143,28 +121,16 @@ export async function serversLabels(
|
|||||||
lock: { file: lock.file, started_at: lock.info.started_at, force: opts.force }
|
lock: { file: lock.file, started_at: lock.info.started_at, force: opts.force }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (opts.sign) {
|
||||||
|
await maybeAutoSignReceipt(receipt.file);
|
||||||
|
}
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Hetzner API error ${response.status}. Receipt: ${receipt.file}`
|
`Hetzner API error ${response.status}. Receipt: ${receipt.file}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const mustSign = Boolean(opts.sign || opts.requireSig);
|
|
||||||
if (mustSign) {
|
|
||||||
try {
|
|
||||||
const signed = await signReceiptFile(receipt.file);
|
|
||||||
console.log(`Signed: ${signed.file} signer_kid=${signed.signer_kid}`);
|
|
||||||
} catch (err) {
|
|
||||||
const msg = err instanceof Error ? err.message : String(err);
|
|
||||||
if (opts.requireSig) {
|
|
||||||
const signErr = new Error(`Receipt signing failed: ${msg}`);
|
|
||||||
(signErr as { exitCode?: number }).exitCode = 7;
|
|
||||||
throw signErr;
|
|
||||||
}
|
|
||||||
console.warn(`WARN: receipt signing failed: ${msg}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
`OK labels ${server.name} (id=${server.id}) receipt=${receipt.file} blake3=${receipt.blake3} sha256=${receipt.sha256}`
|
`OK labels ${server.name} (id=${server.id}) receipt=${receipt.file} blake3=${receipt.blake3} sha256=${receipt.sha256}`
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,12 +1,8 @@
|
|||||||
import { signReceiptFile } from "../lib/signature.js";
|
import { signReceiptFile } from "../lib/signing.js";
|
||||||
|
|
||||||
export async function signReceipt(
|
export async function signReceipt(path: string, opts: { force?: boolean } = {}) {
|
||||||
receiptPath: string,
|
const res = await signReceiptFile(path, { force: opts.force });
|
||||||
opts: { force?: boolean } = {}
|
console.log(`Signed: ${path}`);
|
||||||
) {
|
console.log(`signer_pub: ${res.signer_pub}`);
|
||||||
const signed = await signReceiptFile(receiptPath, opts);
|
console.log(`signature: ${res.signature}`);
|
||||||
console.log("OK receipt signed");
|
|
||||||
console.log(`file: ${signed.file}`);
|
|
||||||
console.log(`signer_pub: ${signed.signer_pub}`);
|
|
||||||
console.log(`signer_kid: ${signed.signer_kid}`);
|
|
||||||
}
|
}
|
||||||
|
|||||||
111
src/commands/verify.chain.ts
Normal file
111
src/commands/verify.chain.ts
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { hashBlake3Hex, hashSha256Hex } from "../lib/hash.js";
|
||||||
|
import { readHead, receiptsDir } from "../lib/ledger.js";
|
||||||
|
import { verifyMessage } from "../lib/keys.js";
|
||||||
|
|
||||||
|
type ReceiptEnvelope = Record<string, unknown> & {
|
||||||
|
blake3?: string;
|
||||||
|
sha256?: string;
|
||||||
|
prev_blake3?: string | null;
|
||||||
|
|
||||||
|
sig_alg?: string;
|
||||||
|
signer_pub?: string;
|
||||||
|
signature?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function stripForHash(env: ReceiptEnvelope) {
|
||||||
|
const {
|
||||||
|
hash_alg: _hash_alg,
|
||||||
|
blake3: _blake3,
|
||||||
|
sha256: _sha256,
|
||||||
|
sig_alg: _sig_alg,
|
||||||
|
signer_pub: _signer_pub,
|
||||||
|
signature: _signature,
|
||||||
|
signed_at: _signed_at,
|
||||||
|
...body
|
||||||
|
} = env as Record<string, unknown> as any;
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyChain(opts: { sig?: boolean; head?: boolean } = {}) {
|
||||||
|
const head = readHead();
|
||||||
|
if (!head) {
|
||||||
|
const err = new Error("HEAD.json not found or invalid");
|
||||||
|
(err as { exitCode?: number }).exitCode = 2;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dir = receiptsDir();
|
||||||
|
if (!fs.existsSync(dir)) {
|
||||||
|
const err = new Error(`Receipts dir not found: ${dir}`);
|
||||||
|
(err as { exitCode?: number }).exitCode = 2;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = fs
|
||||||
|
.readdirSync(dir)
|
||||||
|
.filter((f) => f.endsWith(".json") && f !== "HEAD.json")
|
||||||
|
.map((f) => path.join(dir, f));
|
||||||
|
|
||||||
|
const map = new Map<string, { file: string; env: ReceiptEnvelope }>();
|
||||||
|
for (const f of files) {
|
||||||
|
try {
|
||||||
|
const raw = fs.readFileSync(f, "utf8");
|
||||||
|
const env = JSON.parse(raw) as ReceiptEnvelope;
|
||||||
|
const b3 = String(env.blake3 ?? "");
|
||||||
|
if (b3) map.set(b3, { file: f, env });
|
||||||
|
} catch {
|
||||||
|
// ignore unreadable receipts
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const failures: string[] = [];
|
||||||
|
let cur: string | null = head.blake3;
|
||||||
|
let count = 0;
|
||||||
|
|
||||||
|
while (cur) {
|
||||||
|
const item = map.get(cur);
|
||||||
|
if (!item) {
|
||||||
|
failures.push(`missing receipt for blake3=${cur}`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { env, file } = item;
|
||||||
|
const body = stripForHash(env);
|
||||||
|
const b3_calc = hashBlake3Hex(body);
|
||||||
|
const sha_calc = hashSha256Hex(body);
|
||||||
|
|
||||||
|
if (env.blake3 !== b3_calc) failures.push(`blake3 mismatch: ${path.basename(file)}`);
|
||||||
|
if (env.sha256 !== sha_calc) failures.push(`sha256 mismatch: ${path.basename(file)}`);
|
||||||
|
|
||||||
|
if (opts.sig) {
|
||||||
|
if (env.sig_alg !== "ed25519" || !env.signature || !env.signer_pub) {
|
||||||
|
failures.push(`missing signature fields: ${path.basename(file)}`);
|
||||||
|
} else {
|
||||||
|
const msg = new TextEncoder().encode(String(env.blake3 ?? ""));
|
||||||
|
const ok = await verifyMessage(msg, String(env.signature), String(env.signer_pub));
|
||||||
|
if (!ok) failures.push(`bad signature: ${path.basename(file)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
count += 1;
|
||||||
|
cur = env.prev_blake3 ? String(env.prev_blake3) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (opts.head) {
|
||||||
|
// HEAD.file should exist and match the head hash.
|
||||||
|
const headFileAbs = path.resolve(head.file);
|
||||||
|
if (!fs.existsSync(headFileAbs)) {
|
||||||
|
failures.push(`HEAD.file missing: ${head.file}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (failures.length) {
|
||||||
|
const err = new Error("Ledger chain verification failed:\n- " + failures.join("\n- "));
|
||||||
|
(err as { exitCode?: number }).exitCode = 5;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`OK chain receipts=${count} head=${head.blake3}`);
|
||||||
|
}
|
||||||
@@ -2,37 +2,7 @@ import fs from "node:fs";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { hashBlake3Hex, hashSha256Hex } from "../lib/hash.js";
|
import { hashBlake3Hex, hashSha256Hex } from "../lib/hash.js";
|
||||||
import { readHead } from "../lib/ledger.js";
|
import { readHead } from "../lib/ledger.js";
|
||||||
import { keyIdFromPublicHex, verifyMessage } from "../lib/keys.js";
|
import { verifyMessage } from "../lib/keys.js";
|
||||||
|
|
||||||
type ReceiptEnvelope = {
|
|
||||||
receipt_version: "1";
|
|
||||||
created_at: string;
|
|
||||||
cwd: string;
|
|
||||||
user: string;
|
|
||||||
hostname: string;
|
|
||||||
argv: string[];
|
|
||||||
reason: string;
|
|
||||||
lock_file: string | null;
|
|
||||||
lock_started_at: string | null;
|
|
||||||
force: boolean;
|
|
||||||
plan_file: string | null;
|
|
||||||
plan_sha256: string | null;
|
|
||||||
plan_blake3: string | null;
|
|
||||||
target: { id: number; name: string; ip?: string | null };
|
|
||||||
request: unknown;
|
|
||||||
response: unknown;
|
|
||||||
prev_blake3: string | null;
|
|
||||||
hash_alg: "blake3+sha256";
|
|
||||||
blake3: string;
|
|
||||||
sha256: string;
|
|
||||||
sig_alg?: "ed25519";
|
|
||||||
signer_pub?: string;
|
|
||||||
signer_kid?: string;
|
|
||||||
signed_at?: string;
|
|
||||||
signature?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ReceiptBody = Omit<ReceiptEnvelope, "hash_alg" | "blake3" | "sha256">;
|
|
||||||
|
|
||||||
function mustExist(p: string, label: string) {
|
function mustExist(p: string, label: string) {
|
||||||
if (!fs.existsSync(p)) {
|
if (!fs.existsSync(p)) {
|
||||||
@@ -42,16 +12,31 @@ function mustExist(p: string, label: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function stripHashes(env: ReceiptEnvelope): ReceiptBody {
|
type ReceiptEnvelope = Record<string, unknown> & {
|
||||||
|
hash_alg?: string;
|
||||||
|
blake3?: string;
|
||||||
|
sha256?: string;
|
||||||
|
prev_blake3?: string | null;
|
||||||
|
|
||||||
|
plan_file?: string | null;
|
||||||
|
plan_sha256?: string | null;
|
||||||
|
plan_blake3?: string | null;
|
||||||
|
|
||||||
|
sig_alg?: string;
|
||||||
|
signer_pub?: string;
|
||||||
|
signature?: string;
|
||||||
|
signed_at?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function stripForHash(env: ReceiptEnvelope) {
|
||||||
const {
|
const {
|
||||||
hash_alg: _hash_alg,
|
hash_alg: _hash_alg,
|
||||||
blake3: _blake3,
|
blake3: _blake3,
|
||||||
sha256: _sha256,
|
sha256: _sha256,
|
||||||
sig_alg: _sig_alg,
|
sig_alg: _sig_alg,
|
||||||
signer_pub: _signer_pub,
|
signer_pub: _signer_pub,
|
||||||
signer_kid: _signer_kid,
|
|
||||||
signed_at: _signed_at,
|
|
||||||
signature: _signature,
|
signature: _signature,
|
||||||
|
signed_at: _signed_at,
|
||||||
...body
|
...body
|
||||||
} = env;
|
} = env;
|
||||||
return body;
|
return body;
|
||||||
@@ -65,95 +50,69 @@ export async function verifyReceipt(
|
|||||||
mustExist(abs, "Receipt");
|
mustExist(abs, "Receipt");
|
||||||
|
|
||||||
const raw = fs.readFileSync(abs, "utf8");
|
const raw = fs.readFileSync(abs, "utf8");
|
||||||
let env: ReceiptEnvelope;
|
const env = JSON.parse(raw) as ReceiptEnvelope;
|
||||||
try {
|
|
||||||
env = JSON.parse(raw) as ReceiptEnvelope;
|
|
||||||
} catch {
|
|
||||||
const err = new Error("Receipt is not valid JSON");
|
|
||||||
(err as { exitCode?: number }).exitCode = 2;
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (env.hash_alg !== "blake3+sha256" || !env.blake3 || !env.sha256) {
|
|
||||||
const err = new Error(
|
|
||||||
"Receipt missing required hash fields (hash_alg/blake3/sha256)"
|
|
||||||
);
|
|
||||||
(err as { exitCode?: number }).exitCode = 2;
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = stripHashes(env);
|
|
||||||
const blake3 = hashBlake3Hex(body);
|
|
||||||
const sha256 = hashSha256Hex(body);
|
|
||||||
|
|
||||||
const failures: string[] = [];
|
const failures: string[] = [];
|
||||||
if (blake3 !== env.blake3) {
|
|
||||||
failures.push(`BLAKE3 mismatch (expected ${env.blake3}, got ${blake3})`);
|
if (env.hash_alg !== "blake3+sha256") {
|
||||||
}
|
failures.push(`hash_alg mismatch (got ${String(env.hash_alg)})`);
|
||||||
if (sha256 !== env.sha256) {
|
|
||||||
failures.push(`SHA256 mismatch (expected ${env.sha256}, got ${sha256})`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (opts.plan && env.plan_file) {
|
const body = stripForHash(env);
|
||||||
const planAbs = path.isAbsolute(env.plan_file)
|
const blake3_calc = hashBlake3Hex(body);
|
||||||
? env.plan_file
|
const sha256_calc = hashSha256Hex(body);
|
||||||
: path.join(process.cwd(), env.plan_file);
|
|
||||||
try {
|
if (env.blake3 !== blake3_calc) failures.push("blake3 mismatch");
|
||||||
mustExist(planAbs, "Plan file referenced by receipt");
|
if (env.sha256 !== sha256_calc) failures.push("sha256 mismatch");
|
||||||
const planRaw = fs.readFileSync(planAbs, "utf8");
|
|
||||||
const planObj = JSON.parse(planRaw) as unknown;
|
if (opts.plan) {
|
||||||
const planSha = hashSha256Hex(planObj);
|
const planFile = env.plan_file ? String(env.plan_file) : "";
|
||||||
const planB3 = hashBlake3Hex(planObj);
|
const planSha = env.plan_sha256 ? String(env.plan_sha256) : "";
|
||||||
if (!env.plan_sha256 || !env.plan_blake3) {
|
const planBlake = env.plan_blake3 ? String(env.plan_blake3) : "";
|
||||||
failures.push("Receipt is missing plan hash fields");
|
|
||||||
|
if (!planFile) {
|
||||||
|
failures.push("receipt has no plan_file");
|
||||||
} else {
|
} else {
|
||||||
if (env.plan_sha256 !== planSha) {
|
const planAbs = path.resolve(planFile);
|
||||||
failures.push(
|
if (!fs.existsSync(planAbs)) {
|
||||||
`Plan SHA256 mismatch (expected ${env.plan_sha256}, got ${planSha})`
|
failures.push(`plan file not found: ${planFile}`);
|
||||||
);
|
} else {
|
||||||
}
|
try {
|
||||||
if (env.plan_blake3 !== planB3) {
|
const planRaw = fs.readFileSync(planAbs, "utf8");
|
||||||
failures.push(
|
const plan = JSON.parse(planRaw) as unknown;
|
||||||
`Plan BLAKE3 mismatch (expected ${env.plan_blake3}, got ${planB3})`
|
const sha = hashSha256Hex(plan);
|
||||||
);
|
const b3 = hashBlake3Hex(plan);
|
||||||
|
if (planSha && sha !== planSha) failures.push("plan_sha256 mismatch");
|
||||||
|
if (planBlake && b3 !== planBlake) failures.push("plan_blake3 mismatch");
|
||||||
|
} catch (e) {
|
||||||
|
failures.push(`failed to read/parse plan: ${(e as Error).message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
|
||||||
const msg = err instanceof Error ? err.message : String(err);
|
|
||||||
failures.push(msg);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (opts.head) {
|
if (opts.head) {
|
||||||
const head = readHead();
|
const head = readHead();
|
||||||
if (!head) {
|
if (!head) {
|
||||||
failures.push("HEAD.json missing");
|
failures.push("HEAD.json not found or invalid");
|
||||||
} else {
|
} else {
|
||||||
const rel = path.relative(process.cwd(), abs) || abs;
|
const rel = path.relative(process.cwd(), abs) || abs;
|
||||||
if (head.file === rel && head.blake3 !== env.blake3) {
|
if (head.file !== rel) {
|
||||||
failures.push(
|
failures.push(`receipt is not HEAD.file (HEAD.file=${head.file})`);
|
||||||
`HEAD blake3 mismatch (HEAD=${head.blake3}, receipt=${env.blake3})`
|
}
|
||||||
);
|
if (head.blake3 !== env.blake3) {
|
||||||
|
failures.push("HEAD.blake3 mismatch");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (opts.sig) {
|
if (opts.sig) {
|
||||||
if (!env.signature || !env.signer_pub || env.sig_alg !== "ed25519") {
|
if (env.sig_alg !== "ed25519" || !env.signature || !env.signer_pub) {
|
||||||
const err = new Error("Receipt signature fields missing or invalid");
|
failures.push("signature fields missing (need sig_alg, signer_pub, signature)");
|
||||||
(err as { exitCode?: number }).exitCode = 2;
|
} else {
|
||||||
throw err;
|
const msg = new TextEncoder().encode(String(env.blake3 ?? ""));
|
||||||
}
|
const ok = await verifyMessage(msg, String(env.signature), String(env.signer_pub));
|
||||||
const msg = new TextEncoder().encode(env.blake3);
|
if (!ok) failures.push("signature verification failed");
|
||||||
const ok = await verifyMessage(msg, env.signature, env.signer_pub);
|
|
||||||
if (!ok) failures.push("Signature verification failed");
|
|
||||||
if (env.signer_kid) {
|
|
||||||
const kid = keyIdFromPublicHex(env.signer_pub);
|
|
||||||
if (env.signer_kid !== kid) {
|
|
||||||
failures.push(
|
|
||||||
`Signer key id mismatch (expected ${env.signer_kid}, got ${kid})`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,10 +122,8 @@ export async function verifyReceipt(
|
|||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("OK receipt verified");
|
console.log(`OK receipt ${receiptPath}`);
|
||||||
console.log(`file: ${abs}`);
|
console.log(`blake3=${env.blake3}`);
|
||||||
console.log(`blake3: ${env.blake3}`);
|
console.log(`sha256=${env.sha256}`);
|
||||||
console.log(`sha256: ${env.sha256}`);
|
if (opts.sig && env.signature) console.log(`sig=ok signer_pub=${env.signer_pub}`);
|
||||||
if (env.prev_blake3) console.log(`prev_blake3: ${env.prev_blake3}`);
|
|
||||||
if (env.plan_file) console.log(`plan: ${env.plan_file}`);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,59 @@
|
|||||||
|
import crypto from "node:crypto";
|
||||||
import { blake3 } from "@noble/hashes/blake3";
|
import { blake3 } from "@noble/hashes/blake3";
|
||||||
import { sha256 } from "@noble/hashes/sha256";
|
import { bytesToHex, utf8ToBytes } from "@noble/hashes/utils";
|
||||||
import { bytesToHex } from "@noble/hashes/utils";
|
|
||||||
import { canonicalize } from "json-canonicalize";
|
|
||||||
|
|
||||||
export function canonicalBytes(obj: unknown): Uint8Array {
|
type Json =
|
||||||
const json = canonicalize(obj);
|
| null
|
||||||
return new TextEncoder().encode(json);
|
| boolean
|
||||||
|
| number
|
||||||
|
| string
|
||||||
|
| Json[]
|
||||||
|
| { [k: string]: Json };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deterministic JSON normalization: recursively sorts object keys and removes
|
||||||
|
* `undefined` so hashing is stable across runs.
|
||||||
|
*
|
||||||
|
* Note: this is *not* a full RFC 8785 implementation, but it is stable for the
|
||||||
|
* structures we emit (plain JSON objects).
|
||||||
|
*/
|
||||||
|
export function canonicalize(value: unknown): Json {
|
||||||
|
if (value === null) return null;
|
||||||
|
|
||||||
|
const t = typeof value;
|
||||||
|
if (t === "string" || t === "number" || t === "boolean") return value as Json;
|
||||||
|
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.map((v) => canonicalize(v)) as Json;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (t === "object") {
|
||||||
|
const obj = value as Record<string, unknown>;
|
||||||
|
const out: Record<string, Json> = {};
|
||||||
|
for (const k of Object.keys(obj).sort()) {
|
||||||
|
const v = obj[k];
|
||||||
|
if (v === undefined) continue;
|
||||||
|
out[k] = canonicalize(v);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// functions / symbols / bigint should never appear in our receipts/plans
|
||||||
|
return String(value) as unknown as Json;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function hashBlake3Hex(obj: unknown): string {
|
export function canonicalJson(value: unknown): string {
|
||||||
return bytesToHex(blake3(canonicalBytes(obj)));
|
return JSON.stringify(canonicalize(value));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function hashSha256Hex(obj: unknown): string {
|
export function hashSha256Hex(value: unknown): string {
|
||||||
return bytesToHex(sha256(canonicalBytes(obj)));
|
return crypto
|
||||||
|
.createHash("sha256")
|
||||||
|
.update(canonicalJson(value), "utf8")
|
||||||
|
.digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hashBlake3Hex(value: unknown): string {
|
||||||
|
const bytes = utf8ToBytes(canonicalJson(value));
|
||||||
|
return bytesToHex(blake3(bytes));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import fs from "node:fs";
|
|||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { getPublicKey, sign, verify, utils, etc } from "@noble/ed25519";
|
import { getPublicKey, sign, verify, utils, etc } from "@noble/ed25519";
|
||||||
import { blake3 } from "@noble/hashes/blake3";
|
|
||||||
import { sha512 } from "@noble/hashes/sha512";
|
import { sha512 } from "@noble/hashes/sha512";
|
||||||
import { bytesToHex, hexToBytes } from "@noble/hashes/utils";
|
import { bytesToHex, hexToBytes } from "@noble/hashes/utils";
|
||||||
|
|
||||||
@@ -10,6 +9,7 @@ const KEY_DIR = path.join(os.homedir(), ".config", "vm-cloud", "keys");
|
|||||||
const PRIV_PATH = path.join(KEY_DIR, "operator_ed25519.key");
|
const PRIV_PATH = path.join(KEY_DIR, "operator_ed25519.key");
|
||||||
const PUB_PATH = path.join(KEY_DIR, "operator_ed25519.pub");
|
const PUB_PATH = path.join(KEY_DIR, "operator_ed25519.pub");
|
||||||
|
|
||||||
|
// noble/ed25519 requires a SHA-512 implementation to be wired.
|
||||||
if (!etc.sha512Sync) {
|
if (!etc.sha512Sync) {
|
||||||
etc.sha512Sync = (...messages) => sha512(etc.concatBytes(...messages));
|
etc.sha512Sync = (...messages) => sha512(etc.concatBytes(...messages));
|
||||||
}
|
}
|
||||||
@@ -19,34 +19,26 @@ function ensureKeyDir() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function keyPaths() {
|
export function keyPaths() {
|
||||||
return { dir: KEY_DIR, privateKey: PRIV_PATH, publicKey: PUB_PATH };
|
return { dir: KEY_DIR, priv: PRIV_PATH, pub: PUB_PATH };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function readPrivateKey(): Uint8Array {
|
export function hasKeypair(): boolean {
|
||||||
if (!fs.existsSync(PRIV_PATH)) {
|
return fs.existsSync(PRIV_PATH) && fs.existsSync(PUB_PATH);
|
||||||
const err = new Error(`Private key not found: ${PRIV_PATH}`);
|
|
||||||
(err as { exitCode?: number }).exitCode = 2;
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
const hex = fs.readFileSync(PRIV_PATH, "utf8").trim();
|
|
||||||
return hexToBytes(hex);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function readPublicKey(): Uint8Array {
|
export function readPublicKeyHex(): string {
|
||||||
if (!fs.existsSync(PUB_PATH)) {
|
return fs.readFileSync(PUB_PATH, "utf8").trim();
|
||||||
const err = new Error(`Public key not found: ${PUB_PATH}`);
|
}
|
||||||
(err as { exitCode?: number }).exitCode = 2;
|
|
||||||
throw err;
|
export function readPrivateKeyHex(): string {
|
||||||
}
|
return fs.readFileSync(PRIV_PATH, "utf8").trim();
|
||||||
const hex = fs.readFileSync(PUB_PATH, "utf8").trim();
|
|
||||||
return hexToBytes(hex);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateKeypair(opts: { force?: boolean } = {}) {
|
export async function generateKeypair(opts: { force?: boolean } = {}) {
|
||||||
ensureKeyDir();
|
ensureKeyDir();
|
||||||
if (!opts.force && (fs.existsSync(PRIV_PATH) || fs.existsSync(PUB_PATH))) {
|
if (!opts.force && (fs.existsSync(PRIV_PATH) || fs.existsSync(PUB_PATH))) {
|
||||||
const err = new Error(
|
const err = new Error(
|
||||||
`Key already exists. Use --force to overwrite: ${PRIV_PATH}`
|
`Keypair already exists. Use --force to overwrite: ${PUB_PATH}`
|
||||||
);
|
);
|
||||||
(err as { exitCode?: number }).exitCode = 2;
|
(err as { exitCode?: number }).exitCode = 2;
|
||||||
throw err;
|
throw err;
|
||||||
@@ -58,33 +50,24 @@ export async function generateKeypair(opts: { force?: boolean } = {}) {
|
|||||||
fs.writeFileSync(PRIV_PATH, bytesToHex(priv), { mode: 0o600 });
|
fs.writeFileSync(PRIV_PATH, bytesToHex(priv), { mode: 0o600 });
|
||||||
fs.writeFileSync(PUB_PATH, bytesToHex(pub), { mode: 0o644 });
|
fs.writeFileSync(PUB_PATH, bytesToHex(pub), { mode: 0o644 });
|
||||||
|
|
||||||
return {
|
return { priv_path: PRIV_PATH, pub_path: PUB_PATH, pub_hex: bytesToHex(pub) };
|
||||||
privateKeyPath: PRIV_PATH,
|
|
||||||
publicKeyPath: PUB_PATH,
|
|
||||||
publicKey: bytesToHex(pub)
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function keyIdFromPublicHex(publicKeyHex: string) {
|
export async function signMessage(
|
||||||
const pub = hexToBytes(publicKeyHex);
|
message: Uint8Array,
|
||||||
return bytesToHex(blake3(pub));
|
privHex?: string
|
||||||
}
|
): Promise<string> {
|
||||||
|
const priv = hexToBytes(privHex ?? readPrivateKeyHex());
|
||||||
export async function signMessage(message: Uint8Array) {
|
const sig = await sign(message, priv);
|
||||||
const priv = readPrivateKey();
|
return bytesToHex(sig);
|
||||||
const pub = await getPublicKey(priv);
|
|
||||||
const signature = await sign(message, priv);
|
|
||||||
const publicKey = bytesToHex(pub);
|
|
||||||
const signerKid = keyIdFromPublicHex(publicKey);
|
|
||||||
return { signature: bytesToHex(signature), publicKey, signerKid };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function verifyMessage(
|
export async function verifyMessage(
|
||||||
message: Uint8Array,
|
message: Uint8Array,
|
||||||
signatureHex: string,
|
sigHex: string,
|
||||||
publicKeyHex: string
|
pubHex?: string
|
||||||
) {
|
): Promise<boolean> {
|
||||||
const signature = hexToBytes(signatureHex);
|
const sig = hexToBytes(sigHex);
|
||||||
const publicKey = hexToBytes(publicKeyHex);
|
const pub = hexToBytes(pubHex ?? readPublicKeyHex());
|
||||||
return verify(signature, message, publicKey);
|
return verify(sig, message, pub);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import fs from "node:fs";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { ensureDir } from "./report.js";
|
import { ensureDir } from "./report.js";
|
||||||
|
|
||||||
function receiptsDir() {
|
export function receiptsDir() {
|
||||||
return path.join(process.cwd(), "outputs", "receipts");
|
return path.join(process.cwd(), "outputs", "receipts");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -12,28 +12,22 @@ function headPath() {
|
|||||||
|
|
||||||
export type ReceiptHead = { blake3: string; file: string; created_at: string };
|
export type ReceiptHead = { blake3: string; file: string; created_at: string };
|
||||||
|
|
||||||
|
export function writeHead(head: ReceiptHead) {
|
||||||
|
ensureDir(receiptsDir());
|
||||||
|
fs.writeFileSync(headPath(), JSON.stringify(head, null, 2), "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
export function readHead(): ReceiptHead | null {
|
export function readHead(): ReceiptHead | null {
|
||||||
try {
|
try {
|
||||||
const raw = fs.readFileSync(headPath(), "utf8");
|
const raw = fs.readFileSync(headPath(), "utf8");
|
||||||
const data = JSON.parse(raw) as ReceiptHead;
|
const data = JSON.parse(raw) as Partial<ReceiptHead>;
|
||||||
if (!data?.blake3 || !data?.file || !data?.created_at) return null;
|
if (!data?.blake3 || !data?.file || !data?.created_at) return null;
|
||||||
return data;
|
return data as ReceiptHead;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function readPrevReceiptHash(): string | null {
|
export function readPrevReceiptHash(): string | null {
|
||||||
try {
|
return readHead()?.blake3 ?? null;
|
||||||
const raw = fs.readFileSync(headPath(), "utf8");
|
|
||||||
const data = JSON.parse(raw) as { blake3?: string };
|
|
||||||
return typeof data.blake3 === "string" ? data.blake3 : null;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function writeHead(next: { blake3: string; file: string; created_at: string }) {
|
|
||||||
ensureDir(receiptsDir());
|
|
||||||
fs.writeFileSync(headPath(), JSON.stringify(next, null, 2), { mode: 0o600 });
|
|
||||||
}
|
}
|
||||||
|
|||||||
24
src/lib/merkle.ts
Normal file
24
src/lib/merkle.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { blake3 } from "@noble/hashes/blake3";
|
||||||
|
import { bytesToHex, concatBytes, hexToBytes } from "@noble/hashes/utils";
|
||||||
|
|
||||||
|
export function merkleRootBlake3(leavesHex: string[]): string {
|
||||||
|
if (!leavesHex.length) {
|
||||||
|
const err = new Error("No leaves provided for merkle root");
|
||||||
|
(err as { exitCode?: number }).exitCode = 2;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
let level = leavesHex.map((h) => hexToBytes(h));
|
||||||
|
|
||||||
|
while (level.length > 1) {
|
||||||
|
const next: Uint8Array[] = [];
|
||||||
|
for (let i = 0; i < level.length; i += 2) {
|
||||||
|
const left = level[i];
|
||||||
|
const right = i + 1 < level.length ? level[i + 1] : left; // duplicate last
|
||||||
|
next.push(blake3(concatBytes(left, right)));
|
||||||
|
}
|
||||||
|
level = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
return bytesToHex(level[0]);
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { hashBlake3Hex, hashSha256Hex } from "./hash.js";
|
|
||||||
import { ensureDir, nowStamp } from "./report.js";
|
import { ensureDir, nowStamp } from "./report.js";
|
||||||
|
import { hashBlake3Hex, hashSha256Hex } from "./hash.js";
|
||||||
|
|
||||||
export type PlanAction = "poweron" | "poweroff" | "reboot" | "labels";
|
export type PlanAction = "poweron" | "poweroff" | "reboot" | "labels";
|
||||||
export type PlanMatch = "id" | "exact" | "partial";
|
export type PlanMatch = "id" | "exact" | "partial";
|
||||||
@@ -17,25 +17,41 @@ export type ServerPlan = {
|
|||||||
applied: false;
|
applied: false;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function resolvePlanPath(planPath: string) {
|
export function writePlan(plan: ServerPlan): {
|
||||||
return path.isAbsolute(planPath) ? planPath : path.join(process.cwd(), planPath);
|
file: string;
|
||||||
}
|
sha256: string;
|
||||||
|
blake3: string;
|
||||||
export function writePlan(plan: ServerPlan) {
|
} {
|
||||||
const { stamp } = nowStamp();
|
const { stamp } = nowStamp();
|
||||||
const dir = path.join(process.cwd(), "outputs", "plans");
|
const dir = path.join(process.cwd(), "outputs", "plans");
|
||||||
const name = `plan-${stamp}-${plan.action}-${plan.server.id}.json`;
|
|
||||||
ensureDir(dir);
|
ensureDir(dir);
|
||||||
|
const name = `plan-${stamp}-${plan.action}-${plan.server.id}.json`;
|
||||||
const file = path.join(dir, name);
|
const file = path.join(dir, name);
|
||||||
const json = JSON.stringify(plan, null, 2);
|
fs.writeFileSync(file, JSON.stringify(plan, null, 2), "utf8");
|
||||||
fs.writeFileSync(file, json, "utf8");
|
|
||||||
const sha256 = hashSha256Hex(plan);
|
const sha256 = hashSha256Hex(plan);
|
||||||
const blake3 = hashBlake3Hex(plan);
|
const blake3 = hashBlake3Hex(plan);
|
||||||
return { file, sha256, blake3 };
|
return { file, sha256, blake3 };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function readPlan(planPath: string): ServerPlan {
|
export function readPlan(planPath: string): ServerPlan {
|
||||||
const abs = resolvePlanPath(planPath);
|
const raw = fs.readFileSync(planPath, "utf8");
|
||||||
const raw = fs.readFileSync(abs, "utf8");
|
const data = JSON.parse(raw) as unknown;
|
||||||
return JSON.parse(raw) as ServerPlan;
|
validatePlan(data);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validatePlan(plan: unknown): asserts plan is ServerPlan {
|
||||||
|
const p = plan as Partial<ServerPlan>;
|
||||||
|
if (!p || typeof p !== "object") throw new Error("Invalid plan (not an object)");
|
||||||
|
if (p.plan_version !== "1") throw new Error("Invalid plan_version");
|
||||||
|
if (!p.created_at) throw new Error("Invalid created_at");
|
||||||
|
if (!p.action) throw new Error("Invalid action");
|
||||||
|
if (!p.server?.id || !p.server?.name) throw new Error("Invalid server");
|
||||||
|
if (!p.match) throw new Error("Invalid match");
|
||||||
|
if (!p.request?.method || !p.request?.path) throw new Error("Invalid request");
|
||||||
|
if (p.applied !== false) throw new Error("Plan.applied must be false");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolvePlanPath(p: string) {
|
||||||
|
return path.resolve(p);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ type ReceiptRequest = {
|
|||||||
type ReceiptResponse = {
|
type ReceiptResponse = {
|
||||||
status: number;
|
status: number;
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
data?: unknown;
|
data: unknown;
|
||||||
raw?: string;
|
raw: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ReceiptInput = {
|
type ReceiptInput = {
|
||||||
@@ -36,22 +36,34 @@ type ReceiptBody = {
|
|||||||
hostname: string;
|
hostname: string;
|
||||||
argv: string[];
|
argv: string[];
|
||||||
reason: string;
|
reason: string;
|
||||||
|
|
||||||
lock_file: string | null;
|
lock_file: string | null;
|
||||||
lock_started_at: string | null;
|
lock_started_at: string | null;
|
||||||
force: boolean;
|
force: boolean;
|
||||||
|
|
||||||
plan_file: string | null;
|
plan_file: string | null;
|
||||||
plan_sha256: string | null;
|
plan_sha256: string | null;
|
||||||
plan_blake3: string | null;
|
plan_blake3: string | null;
|
||||||
|
|
||||||
target: { id: number; name: string; ip?: string | null };
|
target: { id: number; name: string; ip?: string | null };
|
||||||
|
|
||||||
request: ReceiptRequest;
|
request: ReceiptRequest;
|
||||||
response: ReceiptResponse;
|
response: ReceiptResponse;
|
||||||
|
|
||||||
prev_blake3: string | null;
|
prev_blake3: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ReceiptEnvelope = ReceiptBody & {
|
export type ReceiptEnvelope = ReceiptBody & {
|
||||||
hash_alg: "blake3+sha256";
|
hash_alg: "blake3+sha256";
|
||||||
blake3: string;
|
blake3: string;
|
||||||
sha256: string;
|
sha256: string;
|
||||||
|
|
||||||
|
// Optional signing metadata. These fields are NOT included in the canonical
|
||||||
|
// receipt hash (verification strips them before hashing).
|
||||||
|
sig_alg?: "ed25519";
|
||||||
|
signer_pub?: string;
|
||||||
|
signature?: string;
|
||||||
|
signed_at?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
function fileStamp(d = new Date()) {
|
function fileStamp(d = new Date()) {
|
||||||
@@ -64,51 +76,59 @@ function fileStamp(d = new Date()) {
|
|||||||
return `${yyyy}${mm}${dd}-${hh}${mi}${ss}`;
|
return `${yyyy}${mm}${dd}-${hh}${mi}${ss}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function writeReceipt(input: ReceiptInput) {
|
export function writeReceipt(input: ReceiptInput): {
|
||||||
|
file: string;
|
||||||
|
sha256: string;
|
||||||
|
blake3: string;
|
||||||
|
} {
|
||||||
const dir = path.join(process.cwd(), "outputs", "receipts");
|
const dir = path.join(process.cwd(), "outputs", "receipts");
|
||||||
ensureDir(dir);
|
ensureDir(dir);
|
||||||
|
|
||||||
const file = path.join(
|
|
||||||
dir,
|
|
||||||
`${fileStamp()}-${input.action}-${input.server.id}.json`
|
|
||||||
);
|
|
||||||
|
|
||||||
const created_at = new Date().toISOString();
|
const created_at = new Date().toISOString();
|
||||||
const prev_blake3 = readPrevReceiptHash();
|
const prev_blake3 = readPrevReceiptHash();
|
||||||
|
|
||||||
const body: ReceiptBody = {
|
const body: ReceiptBody = {
|
||||||
receipt_version: "1",
|
receipt_version: "1",
|
||||||
created_at,
|
created_at,
|
||||||
cwd: process.cwd(),
|
cwd: process.cwd(),
|
||||||
user: process.env.USER ?? process.env.LOGNAME ?? "unknown",
|
user: process.env.USER ?? process.env.LOGNAME ?? "unknown",
|
||||||
hostname: os.hostname(),
|
hostname: os.hostname(),
|
||||||
argv: process.argv,
|
argv: process.argv.slice(),
|
||||||
reason: input.reason || "unspecified",
|
reason: input.reason || "unspecified",
|
||||||
|
|
||||||
lock_file: input.lock?.file ?? null,
|
lock_file: input.lock?.file ?? null,
|
||||||
lock_started_at: input.lock?.started_at ?? null,
|
lock_started_at: input.lock?.started_at ?? null,
|
||||||
force: Boolean(input.lock?.force),
|
force: Boolean(input.lock?.force),
|
||||||
|
|
||||||
plan_file: input.plan?.file ?? null,
|
plan_file: input.plan?.file ?? null,
|
||||||
plan_sha256: input.plan?.sha256 ?? null,
|
plan_sha256: input.plan?.sha256 ?? null,
|
||||||
plan_blake3: input.plan?.blake3 ?? null,
|
plan_blake3: input.plan?.blake3 ?? null,
|
||||||
|
|
||||||
target: {
|
target: {
|
||||||
id: input.server.id,
|
id: input.server.id,
|
||||||
name: input.server.name,
|
name: input.server.name,
|
||||||
ip: input.server.ip ?? null
|
ip: input.server.ip ?? null
|
||||||
},
|
},
|
||||||
|
|
||||||
request: input.request,
|
request: input.request,
|
||||||
response: input.response,
|
response: input.response,
|
||||||
|
|
||||||
prev_blake3
|
prev_blake3
|
||||||
};
|
};
|
||||||
|
|
||||||
const blake3 = hashBlake3Hex(body);
|
const blake3 = hashBlake3Hex(body);
|
||||||
const sha256 = hashSha256Hex(body);
|
const sha256 = hashSha256Hex(body);
|
||||||
const envelope: ReceiptEnvelope = {
|
|
||||||
|
const env: ReceiptEnvelope = {
|
||||||
...body,
|
...body,
|
||||||
hash_alg: "blake3+sha256",
|
hash_alg: "blake3+sha256",
|
||||||
blake3,
|
blake3,
|
||||||
sha256
|
sha256
|
||||||
};
|
};
|
||||||
|
|
||||||
fs.writeFileSync(file, JSON.stringify(envelope, null, 2), "utf8");
|
const file = path.join(dir, `${fileStamp()}-${input.action}-${input.server.id}.json`);
|
||||||
|
fs.writeFileSync(file, JSON.stringify(env, null, 2), "utf8");
|
||||||
|
|
||||||
const relFile = path.relative(process.cwd(), file) || file;
|
const relFile = path.relative(process.cwd(), file) || file;
|
||||||
writeHead({ blake3, file: relFile, created_at });
|
writeHead({ blake3, file: relFile, created_at });
|
||||||
|
|
||||||
|
|||||||
@@ -1,63 +0,0 @@
|
|||||||
import fs from "node:fs";
|
|
||||||
import path from "node:path";
|
|
||||||
import { signMessage } from "./keys.js";
|
|
||||||
|
|
||||||
type ReceiptEnvelope = {
|
|
||||||
blake3: string;
|
|
||||||
sig_alg?: string;
|
|
||||||
signer_pub?: string;
|
|
||||||
signer_kid?: string;
|
|
||||||
signed_at?: string;
|
|
||||||
signature?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function signReceiptFile(
|
|
||||||
receiptPath: string,
|
|
||||||
opts: { force?: boolean } = {}
|
|
||||||
) {
|
|
||||||
const abs = path.resolve(receiptPath);
|
|
||||||
if (!fs.existsSync(abs)) {
|
|
||||||
const err = new Error(`Receipt not found: ${abs}`);
|
|
||||||
(err as { exitCode?: number }).exitCode = 2;
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
|
|
||||||
const raw = fs.readFileSync(abs, "utf8");
|
|
||||||
let env: ReceiptEnvelope;
|
|
||||||
try {
|
|
||||||
env = JSON.parse(raw) as ReceiptEnvelope;
|
|
||||||
} catch {
|
|
||||||
const err = new Error("Receipt is not valid JSON");
|
|
||||||
(err as { exitCode?: number }).exitCode = 2;
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!env.blake3) {
|
|
||||||
const err = new Error("Receipt missing blake3 field");
|
|
||||||
(err as { exitCode?: number }).exitCode = 2;
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((env.signature || env.signer_pub) && !opts.force) {
|
|
||||||
const err = new Error("Receipt already signed. Use --force to overwrite.");
|
|
||||||
(err as { exitCode?: number }).exitCode = 2;
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
|
|
||||||
const msg = new TextEncoder().encode(env.blake3);
|
|
||||||
const { signature, publicKey, signerKid } = await signMessage(msg);
|
|
||||||
const signedAt = new Date().toISOString();
|
|
||||||
|
|
||||||
const next = {
|
|
||||||
...env,
|
|
||||||
sig_alg: "ed25519",
|
|
||||||
signer_pub: publicKey,
|
|
||||||
signer_kid: signerKid,
|
|
||||||
signed_at: signedAt,
|
|
||||||
signature
|
|
||||||
};
|
|
||||||
|
|
||||||
fs.writeFileSync(abs, JSON.stringify(next, null, 2), "utf8");
|
|
||||||
|
|
||||||
return { file: abs, signer_pub: publicKey, signer_kid: signerKid, signed_at: signedAt, signature };
|
|
||||||
}
|
|
||||||
58
src/lib/signing.ts
Normal file
58
src/lib/signing.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { hasKeypair, readPublicKeyHex, signMessage } from "./keys.js";
|
||||||
|
|
||||||
|
export async function signReceiptFile(
|
||||||
|
receiptPath: string,
|
||||||
|
opts: { force?: boolean } = {}
|
||||||
|
) {
|
||||||
|
const abs = path.resolve(receiptPath);
|
||||||
|
const raw = fs.readFileSync(abs, "utf8");
|
||||||
|
const env = JSON.parse(raw) as Record<string, unknown>;
|
||||||
|
|
||||||
|
const blake3 = String(env["blake3"] ?? "");
|
||||||
|
if (!blake3) {
|
||||||
|
const err = new Error("Receipt missing blake3 field");
|
||||||
|
(err as { exitCode?: number }).exitCode = 2;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasSig =
|
||||||
|
Boolean(env["signature"]) || Boolean(env["signer_pub"]) || Boolean(env["sig_alg"]);
|
||||||
|
if (hasSig && !opts.force) {
|
||||||
|
const err = new Error("Receipt already has signature fields (use --force to overwrite)");
|
||||||
|
(err as { exitCode?: number }).exitCode = 2;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasKeypair()) {
|
||||||
|
const err = new Error("No operator keypair found. Run: vmc keygen");
|
||||||
|
(err as { exitCode?: number }).exitCode = 2;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
const msg = new TextEncoder().encode(blake3);
|
||||||
|
const signature = await signMessage(msg);
|
||||||
|
const signer_pub = readPublicKeyHex();
|
||||||
|
|
||||||
|
env["sig_alg"] = "ed25519";
|
||||||
|
env["signer_pub"] = signer_pub;
|
||||||
|
env["signature"] = signature;
|
||||||
|
env["signed_at"] = new Date().toISOString();
|
||||||
|
|
||||||
|
fs.writeFileSync(abs, JSON.stringify(env, null, 2), "utf8");
|
||||||
|
return { signature, signer_pub };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Best-effort auto-sign: if keys exist and the receipt is unsigned, sign it.
|
||||||
|
* Never throws (returns null on failure).
|
||||||
|
*/
|
||||||
|
export async function maybeAutoSignReceipt(receiptPath: string) {
|
||||||
|
if (!hasKeypair()) return null;
|
||||||
|
try {
|
||||||
|
return await signReceiptFile(receiptPath, { force: false });
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user