Initial vmc CLI
This commit is contained in:
2
.env.example
Normal file
2
.env.example
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
# Put your token in ~/.env or in vm-cloud/.env
|
||||||
|
HCLOUD_TOKEN=WXxVT5fyqkUZ7t6Xs20uIWPr7wckgHEc3ExoHRM9diJmmLptC7elJ5SrbsI3SJqj
|
||||||
17
.gitignore
vendored
Normal file
17
.gitignore
vendored
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
# secrets
|
||||||
|
.env*
|
||||||
|
!.env.example
|
||||||
|
*.key
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# deps/build
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
|
||||||
|
# generated evidence
|
||||||
|
outputs/
|
||||||
|
|
||||||
|
# misc
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
__MACOSX/
|
||||||
14
README.md
Normal file
14
README.md
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
# vm-cloud
|
||||||
|
|
||||||
|
Hetzner CLI + MCP tooling for VM ops and research notes.
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
- npm install
|
||||||
|
- ./bin/vmc servers list
|
||||||
|
- ./bin/vmc snapshot servers
|
||||||
|
- ./bin/vmc research new "Title"
|
||||||
|
|
||||||
|
## Env
|
||||||
|
|
||||||
|
Set HCLOUD_TOKEN in ~/.env or ./.env.
|
||||||
4
bin/vmc
Executable file
4
bin/vmc
Executable file
@@ -0,0 +1,4 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
exec npx -y tsx src/cli.ts "$@"
|
||||||
248
docs/AKASH_INTEGRATION.md
Normal file
248
docs/AKASH_INTEGRATION.md
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
# Akash Network Integration
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
VaultMesh Cloud CLI integration with [Akash Network](https://akash.network) - a decentralized compute marketplace built on Cosmos blockchain.
|
||||||
|
|
||||||
|
## Why Akash?
|
||||||
|
|
||||||
|
| Feature | Akash | Traditional Cloud |
|
||||||
|
|---------|-------|-------------------|
|
||||||
|
| **Cost** | 60-85% cheaper | Baseline |
|
||||||
|
| **Model** | Reverse auction marketplace | Fixed pricing |
|
||||||
|
| **Billing** | AKT tokens per-block | Fiat monthly/hourly |
|
||||||
|
| **Lock-in** | None - switch providers anytime | Vendor lock-in |
|
||||||
|
| **Censorship** | Resistant (blockchain) | Subject to provider |
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────┐
|
||||||
|
│ vmc CLI │
|
||||||
|
├─────────────────────┬───────────────────────────────────┤
|
||||||
|
│ vmc servers ... │ vmc akash ... │
|
||||||
|
│ (Hetzner) │ (Akash Network) │
|
||||||
|
├─────────────────────┼───────────────────────────────────┤
|
||||||
|
│ src/lib/hcloud.ts │ src/lib/akash/client.ts │
|
||||||
|
│ │ src/lib/akash/wallet.ts │
|
||||||
|
│ │ src/lib/akash/sdl.ts │
|
||||||
|
├─────────────────────┴───────────────────────────────────┤
|
||||||
|
│ Receipt System (src/lib/receipt.ts) │
|
||||||
|
│ Lock System (src/lib/lock.ts) │
|
||||||
|
└─────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Akash Concepts
|
||||||
|
|
||||||
|
### SDL (Stack Definition Language)
|
||||||
|
|
||||||
|
YAML-based deployment specification (like docker-compose):
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
version: "2.0"
|
||||||
|
|
||||||
|
services:
|
||||||
|
web:
|
||||||
|
image: nginx:latest
|
||||||
|
expose:
|
||||||
|
- port: 80
|
||||||
|
as: 80
|
||||||
|
to:
|
||||||
|
- global: true
|
||||||
|
|
||||||
|
profiles:
|
||||||
|
compute:
|
||||||
|
web:
|
||||||
|
resources:
|
||||||
|
cpu:
|
||||||
|
units: 1
|
||||||
|
memory:
|
||||||
|
size: 512Mi
|
||||||
|
storage:
|
||||||
|
- size: 10Gi
|
||||||
|
|
||||||
|
placement:
|
||||||
|
akash:
|
||||||
|
pricing:
|
||||||
|
web: 50uakt # micro-AKT per block (~6 sec)
|
||||||
|
|
||||||
|
deployment:
|
||||||
|
web:
|
||||||
|
akash:
|
||||||
|
profile: web
|
||||||
|
count: 1
|
||||||
|
```
|
||||||
|
|
||||||
|
### Deployment Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
1. CREATE DEPLOYMENT
|
||||||
|
└─> SDL manifest → Blockchain transaction
|
||||||
|
└─> Returns: DSEQ (deployment sequence number)
|
||||||
|
|
||||||
|
2. RECEIVE BIDS
|
||||||
|
└─> Providers bid on your deployment
|
||||||
|
└─> ~15 minutes window
|
||||||
|
|
||||||
|
3. ACCEPT BID → CREATE LEASE
|
||||||
|
└─> Select provider
|
||||||
|
└─> Escrow AKT for payment
|
||||||
|
|
||||||
|
4. SEND MANIFEST
|
||||||
|
└─> Upload deployment spec to provider
|
||||||
|
└─> Provider starts containers
|
||||||
|
|
||||||
|
5. MONITOR / UPDATE / CLOSE
|
||||||
|
└─> Check status, update resources, or terminate
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key Identifiers
|
||||||
|
|
||||||
|
| ID | Description |
|
||||||
|
|----|-------------|
|
||||||
|
| `DSEQ` | Deployment sequence number |
|
||||||
|
| `GSEQ` | Group sequence (usually 1) |
|
||||||
|
| `OSEQ` | Order sequence (usually 1) |
|
||||||
|
| `owner` | Wallet address (akash1...) |
|
||||||
|
| `provider` | Provider address (akash1...) |
|
||||||
|
|
||||||
|
## CLI Commands
|
||||||
|
|
||||||
|
### Read-Only (Phase 1)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# List all deployments for configured wallet
|
||||||
|
vmc akash list
|
||||||
|
|
||||||
|
# Get deployment details
|
||||||
|
vmc akash status <dseq>
|
||||||
|
|
||||||
|
# List bids for a deployment
|
||||||
|
vmc akash bids <dseq>
|
||||||
|
|
||||||
|
# Check wallet balance
|
||||||
|
vmc akash balance
|
||||||
|
```
|
||||||
|
|
||||||
|
### Mutations (Phase 2)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Deploy from SDL file
|
||||||
|
vmc akash deploy --sdl app.yaml --yes --reason "deploy web app"
|
||||||
|
|
||||||
|
# Close deployment (refunds remaining escrow)
|
||||||
|
vmc akash close <dseq> --yes --reason "scaling down"
|
||||||
|
|
||||||
|
# Update deployment resources
|
||||||
|
vmc akash update <dseq> --sdl app.yaml --yes --reason "increase memory"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Required for queries
|
||||||
|
AKASH_WALLET_ADDRESS=akash1... # Your wallet address
|
||||||
|
|
||||||
|
# Required for mutations
|
||||||
|
AKASH_MNEMONIC="word1 word2 ... word12" # 12-word seed phrase
|
||||||
|
|
||||||
|
# Optional
|
||||||
|
AKASH_NETWORK=testnet # testnet or mainnet
|
||||||
|
AKASH_REST_ENDPOINT=https://api.akashnet.net
|
||||||
|
```
|
||||||
|
|
||||||
|
### Testnet vs Mainnet
|
||||||
|
|
||||||
|
| Network | Endpoint | Tokens |
|
||||||
|
|---------|----------|--------|
|
||||||
|
| Testnet | `https://api.sandbox.akash.network` | Free test AKT |
|
||||||
|
| Mainnet | `https://api.akashnet.net` | Real AKT |
|
||||||
|
|
||||||
|
Get testnet tokens: [Akash Faucet](https://faucet.sandbox.akash.network)
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
src/lib/akash/
|
||||||
|
├── types.ts # TypeScript interfaces
|
||||||
|
├── client.ts # REST API client
|
||||||
|
├── wallet.ts # Mnemonic/signing (Phase 2)
|
||||||
|
└── sdl.ts # SDL parsing (Phase 2)
|
||||||
|
|
||||||
|
src/commands/akash/
|
||||||
|
├── list.ts # List deployments
|
||||||
|
├── status.ts # Deployment details
|
||||||
|
├── bids.ts # List bids
|
||||||
|
├── balance.ts # Wallet balance
|
||||||
|
├── deploy.ts # Create deployment (Phase 2)
|
||||||
|
└── close.ts # Close deployment (Phase 2)
|
||||||
|
|
||||||
|
templates/akash/
|
||||||
|
├── web.yaml # Basic web service
|
||||||
|
├── gpu.yaml # GPU workload
|
||||||
|
└── postgres.yaml # Database with storage
|
||||||
|
```
|
||||||
|
|
||||||
|
## Receipt Structure
|
||||||
|
|
||||||
|
Akash mutations produce VaultMesh-grade receipts:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"timestamp": "2025-12-26T18:30:00.000Z",
|
||||||
|
"platform": "akash",
|
||||||
|
"network": "testnet",
|
||||||
|
"action": "deploy",
|
||||||
|
"target": {
|
||||||
|
"dseq": "12345",
|
||||||
|
"owner": "akash1abc...",
|
||||||
|
"provider": "akash1xyz..."
|
||||||
|
},
|
||||||
|
"request": {
|
||||||
|
"sdl_hash": "sha256:abc123...",
|
||||||
|
"resources": {
|
||||||
|
"cpu": 1,
|
||||||
|
"memory": "512Mi",
|
||||||
|
"storage": "10Gi"
|
||||||
|
},
|
||||||
|
"max_price": "50uakt"
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"tx_hash": "ABCD1234...",
|
||||||
|
"lease_price": {
|
||||||
|
"amount": "45",
|
||||||
|
"denom": "uakt"
|
||||||
|
},
|
||||||
|
"status": "active"
|
||||||
|
},
|
||||||
|
"reason": "deploy web app",
|
||||||
|
"sha256": "..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"@akashnetwork/akash-api": "latest",
|
||||||
|
"@cosmjs/stargate": "^0.32.0",
|
||||||
|
"@cosmjs/proto-signing": "^0.32.0"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Resources
|
||||||
|
|
||||||
|
- [Akash Network Docs](https://akash.network/docs/)
|
||||||
|
- [SDL Reference](https://docs.akash.network/readme/stack-definition-language)
|
||||||
|
- [Awesome Akash (326+ examples)](https://github.com/akash-network/awesome-akash)
|
||||||
|
- [Akash Console (Web UI)](https://console.akash.network)
|
||||||
|
- [API Endpoints](https://akash.network/docs/akash-nodes/api-endpoints/)
|
||||||
|
|
||||||
|
## Implementation Status
|
||||||
|
|
||||||
|
- [ ] Phase 1: Read-only commands (list, status, bids, balance)
|
||||||
|
- [ ] Phase 2: Wallet signing + mutations (deploy, close, update)
|
||||||
|
- [ ] Phase 3: SDL templates and validation
|
||||||
|
- [ ] Phase 4: Multi-cloud abstraction layer
|
||||||
31
docs/research/2025-12-26-hetzner-baseline-2025-12-26.md
Normal file
31
docs/research/2025-12-26-hetzner-baseline-2025-12-26.md
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
# Hetzner Baseline 2025-12-26
|
||||||
|
|
||||||
|
Date: 2025-12-26
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
- Purpose:
|
||||||
|
- Scope:
|
||||||
|
- Risks:
|
||||||
|
|
||||||
|
## Evidence
|
||||||
|
|
||||||
|
<!-- snapshots go here -->
|
||||||
|
|
||||||
|
## Findings
|
||||||
|
|
||||||
|
-
|
||||||
|
|
||||||
|
## Next actions
|
||||||
|
|
||||||
|
-
|
||||||
|
|
||||||
|
|
||||||
|
- Source: `outputs/hetzner/servers-20251226-1814.json`
|
||||||
|
- Generated: 2025-12-26T18:14:12.297Z
|
||||||
|
- SHA256: `6da41678f221f56bb57bbe160b971599b75fcfd41675cf1311cc199b6c641347`
|
||||||
|
|
||||||
|
| Server | IP | Status | Type | Location | Created |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| vm-op-gb-a | 46.224.119.129 | running | ccx23 | Nuremberg | 2025-12-26 15:20 |
|
||||||
|
| vm-de-op-b | 46.224.179.24 | running | ccx23 | Nuremberg | 2025-12-26 15:38 |
|
||||||
1758
package-lock.json
generated
Normal file
1758
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
28
package.json
Normal file
28
package.json
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"name": "vm-cloud",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"description": "Hetzner ops + research documentation CLI",
|
||||||
|
"type": "module",
|
||||||
|
"bin": {
|
||||||
|
"vmc": "bin/vmc"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"dev": "tsx src/cli.ts",
|
||||||
|
"build": "tsc",
|
||||||
|
"start": "node dist/cli.js",
|
||||||
|
"mcp": "tsx src/index.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@noble/ed25519": "^2.1.0",
|
||||||
|
"@modelcontextprotocol/sdk": "^1.0.0",
|
||||||
|
"@noble/hashes": "^1.4.0",
|
||||||
|
"commander": "^12.0.0",
|
||||||
|
"dotenv": "^16.0.0",
|
||||||
|
"json-canonicalize": "^1.0.6"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^20.0.0",
|
||||||
|
"typescript": "^5.0.0",
|
||||||
|
"tsx": "^4.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
17
scripts/hash-receipt.ts
Normal file
17
scripts/hash-receipt.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { hashBlake3Hex, hashSha256Hex } from "../src/lib/hash.js";
|
||||||
|
import fs from "fs";
|
||||||
|
|
||||||
|
const body = JSON.parse(fs.readFileSync("outputs/receipts/test-receipt.json", "utf8"));
|
||||||
|
const blake3 = hashBlake3Hex(body);
|
||||||
|
const sha256 = hashSha256Hex(body);
|
||||||
|
|
||||||
|
const envelope = {
|
||||||
|
...body,
|
||||||
|
hash_alg: "blake3+sha256",
|
||||||
|
blake3,
|
||||||
|
sha256
|
||||||
|
};
|
||||||
|
|
||||||
|
fs.writeFileSync("outputs/receipts/test-receipt.json", JSON.stringify(envelope, null, 2));
|
||||||
|
console.log("blake3:", blake3);
|
||||||
|
console.log("sha256:", sha256);
|
||||||
246
src/cli.ts
Normal file
246
src/cli.ts
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
import { Command } from "commander";
|
||||||
|
import { requireHcloudToken } from "./lib/env.js";
|
||||||
|
import { HcloudClient } from "./lib/hcloud.js";
|
||||||
|
import { serversList } from "./commands/servers.list.js";
|
||||||
|
import { serversAction } from "./commands/servers.action.js";
|
||||||
|
import { serversLabels } from "./commands/servers.labels.js";
|
||||||
|
import { applyPlan } from "./commands/apply.js";
|
||||||
|
import { keygen } from "./commands/keygen.js";
|
||||||
|
import { signReceipt } from "./commands/sign.receipt.js";
|
||||||
|
import { verifyReceipt } from "./commands/verify.receipt.js";
|
||||||
|
import { snapshotServers } from "./commands/snapshot.js";
|
||||||
|
import { researchNew } from "./commands/research.new.js";
|
||||||
|
import { researchAppend } from "./commands/research.append.js";
|
||||||
|
|
||||||
|
const program = new Command();
|
||||||
|
program
|
||||||
|
.name("vmc")
|
||||||
|
.description("vm-cloud: Hetzner ops + research documentation")
|
||||||
|
.version("0.0.1");
|
||||||
|
|
||||||
|
const servers = program.command("servers").description("Server operations");
|
||||||
|
|
||||||
|
servers
|
||||||
|
.command("list")
|
||||||
|
.description("List Hetzner servers")
|
||||||
|
.action(async () => {
|
||||||
|
const client = new HcloudClient(requireHcloudToken());
|
||||||
|
await serversList(client);
|
||||||
|
});
|
||||||
|
|
||||||
|
servers
|
||||||
|
.command("reboot")
|
||||||
|
.description("Reboot a server by name or id")
|
||||||
|
.argument("<nameOrId>", "Server name or id")
|
||||||
|
.option("--yes", "Confirm the action without prompting")
|
||||||
|
.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 (
|
||||||
|
nameOrId: string,
|
||||||
|
opts: {
|
||||||
|
yes?: boolean;
|
||||||
|
reason?: string;
|
||||||
|
force?: boolean;
|
||||||
|
allowPartial?: boolean;
|
||||||
|
dryRun?: boolean;
|
||||||
|
sign?: boolean;
|
||||||
|
requireSig?: boolean;
|
||||||
|
}
|
||||||
|
) => {
|
||||||
|
const client = new HcloudClient(requireHcloudToken());
|
||||||
|
await serversAction(client, nameOrId, "reboot", opts);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
servers
|
||||||
|
.command("poweroff")
|
||||||
|
.description("Power off a server by name or id")
|
||||||
|
.argument("<nameOrId>", "Server name or id")
|
||||||
|
.option("--yes", "Confirm the action without prompting")
|
||||||
|
.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 (
|
||||||
|
nameOrId: string,
|
||||||
|
opts: {
|
||||||
|
yes?: boolean;
|
||||||
|
reason?: string;
|
||||||
|
force?: boolean;
|
||||||
|
allowPartial?: boolean;
|
||||||
|
dryRun?: boolean;
|
||||||
|
sign?: boolean;
|
||||||
|
requireSig?: boolean;
|
||||||
|
}
|
||||||
|
) => {
|
||||||
|
const client = new HcloudClient(requireHcloudToken());
|
||||||
|
await serversAction(client, nameOrId, "poweroff", opts);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
servers
|
||||||
|
.command("poweron")
|
||||||
|
.description("Power on a server by name or id")
|
||||||
|
.argument("<nameOrId>", "Server name or id")
|
||||||
|
.option("--yes", "Confirm the action without prompting")
|
||||||
|
.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 (
|
||||||
|
nameOrId: string,
|
||||||
|
opts: {
|
||||||
|
yes?: boolean;
|
||||||
|
reason?: string;
|
||||||
|
force?: boolean;
|
||||||
|
allowPartial?: boolean;
|
||||||
|
dryRun?: boolean;
|
||||||
|
sign?: boolean;
|
||||||
|
requireSig?: boolean;
|
||||||
|
}
|
||||||
|
) => {
|
||||||
|
const client = new HcloudClient(requireHcloudToken());
|
||||||
|
await serversAction(client, nameOrId, "poweron", opts);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
servers
|
||||||
|
.command("labels")
|
||||||
|
.description("Set labels on a server by name or id")
|
||||||
|
.argument("<nameOrId>", "Server name or id")
|
||||||
|
.argument("<labels...>", "Label pairs in key=value form")
|
||||||
|
.option("--yes", "Confirm the action without prompting")
|
||||||
|
.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 (
|
||||||
|
nameOrId: string,
|
||||||
|
labels: string[],
|
||||||
|
opts: {
|
||||||
|
yes?: boolean;
|
||||||
|
reason?: string;
|
||||||
|
force?: boolean;
|
||||||
|
allowPartial?: boolean;
|
||||||
|
dryRun?: boolean;
|
||||||
|
sign?: boolean;
|
||||||
|
requireSig?: boolean;
|
||||||
|
}
|
||||||
|
) => {
|
||||||
|
const client = new HcloudClient(requireHcloudToken());
|
||||||
|
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
|
||||||
|
.command("apply")
|
||||||
|
.description("Apply a plan file and perform the mutation")
|
||||||
|
.requiredOption("--plan <path>", "Plan file path (JSON)")
|
||||||
|
.option("--yes", "Confirm the action without prompting")
|
||||||
|
.option("--reason <text>", "Reason for the action (required with --yes)")
|
||||||
|
.option("--force", "Break an active server lock")
|
||||||
|
.option("--allow-partial", "Allow partial match when using --yes")
|
||||||
|
.option("--sign", "Sign the receipt after mutation")
|
||||||
|
.option("--require-sig", "Fail if signing fails or key is missing")
|
||||||
|
.action(
|
||||||
|
async (
|
||||||
|
opts: {
|
||||||
|
plan: string;
|
||||||
|
yes?: boolean;
|
||||||
|
reason?: string;
|
||||||
|
force?: boolean;
|
||||||
|
allowPartial?: boolean;
|
||||||
|
sign?: boolean;
|
||||||
|
requireSig?: boolean;
|
||||||
|
}
|
||||||
|
) => {
|
||||||
|
const client = new HcloudClient(requireHcloudToken());
|
||||||
|
await applyPlan(client, opts.plan, opts);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
program
|
||||||
|
.command("snapshot")
|
||||||
|
.description("Write snapshots to outputs/")
|
||||||
|
.command("servers")
|
||||||
|
.description("Snapshot Hetzner servers JSON")
|
||||||
|
.action(async () => {
|
||||||
|
const client = new HcloudClient(requireHcloudToken());
|
||||||
|
await snapshotServers(client);
|
||||||
|
});
|
||||||
|
|
||||||
|
const research = program
|
||||||
|
.command("research")
|
||||||
|
.description("Research documentation helpers");
|
||||||
|
|
||||||
|
research
|
||||||
|
.command("new")
|
||||||
|
.argument("<title>", "Title for the note")
|
||||||
|
.action((title: string) => {
|
||||||
|
researchNew(title);
|
||||||
|
});
|
||||||
|
|
||||||
|
research
|
||||||
|
.command("append")
|
||||||
|
.requiredOption("--from <path>", "Snapshot path (JSON)")
|
||||||
|
.option("--note <path>", "Specific note path (optional)")
|
||||||
|
.action((opts: { from: string; note?: string }) => {
|
||||||
|
researchAppend(opts.from, opts.note);
|
||||||
|
});
|
||||||
|
|
||||||
|
const verify = program.command("verify").description("Verification tools");
|
||||||
|
|
||||||
|
verify
|
||||||
|
.command("receipt")
|
||||||
|
.description("Verify a receipt file and optional plan/head linkage")
|
||||||
|
.argument("<path>", "Receipt JSON path")
|
||||||
|
.option("--head", "If receipt matches HEAD.file, validate HEAD.blake3 too")
|
||||||
|
.option("--plan", "If receipt references a plan file, verify its hashes too")
|
||||||
|
.option("--sig", "Verify the Ed25519 signature on the receipt")
|
||||||
|
.action(
|
||||||
|
async (p: string, opts: { head?: boolean; plan?: boolean; sig?: boolean }) => {
|
||||||
|
await verifyReceipt(p, opts);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
program.parseAsync(process.argv).catch((err) => {
|
||||||
|
console.error(err?.message ?? err);
|
||||||
|
const exitCode =
|
||||||
|
typeof (err as { exitCode?: number })?.exitCode === "number"
|
||||||
|
? (err as { exitCode?: number }).exitCode
|
||||||
|
: 1;
|
||||||
|
process.exit(exitCode);
|
||||||
|
});
|
||||||
190
src/commands/apply.ts
Normal file
190
src/commands/apply.ts
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { HcloudClient } from "../lib/hcloud.js";
|
||||||
|
import { requireConfirmation } from "../lib/confirm.js";
|
||||||
|
import { readPrivateKey } from "../lib/keys.js";
|
||||||
|
import { acquireServerLock } from "../lib/lock.js";
|
||||||
|
import { hashBlake3Hex, hashSha256Hex } from "../lib/hash.js";
|
||||||
|
import {
|
||||||
|
readPlan,
|
||||||
|
resolvePlanPath,
|
||||||
|
type PlanAction,
|
||||||
|
type PlanMatch,
|
||||||
|
type ServerPlan
|
||||||
|
} from "../lib/plan.js";
|
||||||
|
import { writeReceipt } from "../lib/receipt.js";
|
||||||
|
import { signReceiptFile } from "../lib/signature.js";
|
||||||
|
|
||||||
|
const ACTIONS: PlanAction[] = ["poweron", "poweroff", "reboot", "labels"];
|
||||||
|
const MATCHES: PlanMatch[] = ["id", "exact", "partial"];
|
||||||
|
|
||||||
|
function planError(message: string): never {
|
||||||
|
const err = new Error(message);
|
||||||
|
(err as { exitCode?: number }).exitCode = 4;
|
||||||
|
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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function applyPlan(
|
||||||
|
client: HcloudClient,
|
||||||
|
planPath: string,
|
||||||
|
opts: {
|
||||||
|
yes?: boolean;
|
||||||
|
reason?: string;
|
||||||
|
force?: boolean;
|
||||||
|
allowPartial?: boolean;
|
||||||
|
sign?: boolean;
|
||||||
|
requireSig?: boolean;
|
||||||
|
} = {}
|
||||||
|
) {
|
||||||
|
const absPlan = resolvePlanPath(planPath);
|
||||||
|
if (!fs.existsSync(absPlan)) {
|
||||||
|
throw new Error(`Plan not found: ${absPlan}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const plan = readPlan(absPlan);
|
||||||
|
const plan_sha256 = hashSha256Hex(plan);
|
||||||
|
const plan_blake3 = hashBlake3Hex(plan);
|
||||||
|
validatePlan(plan);
|
||||||
|
|
||||||
|
if (plan.match === "partial" && opts.yes && !opts.allowPartial) {
|
||||||
|
planError("Plan was created from a partial match. Use --allow-partial.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const reasonRaw = opts.reason?.trim();
|
||||||
|
if (opts.yes && !reasonRaw) {
|
||||||
|
const err = new Error("Reason is required when using --yes.");
|
||||||
|
(err as { exitCode?: number }).exitCode = 2;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
const expectedPath =
|
||||||
|
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 server = servers.find((s) => s.id === plan.server.id);
|
||||||
|
if (!server) {
|
||||||
|
planError(`Server id ${plan.server.id} not found.`);
|
||||||
|
}
|
||||||
|
if (server.name !== plan.server.name) {
|
||||||
|
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 plan_file = path.relative(process.cwd(), absPlan) || absPlan;
|
||||||
|
try {
|
||||||
|
const reason = reasonRaw || plan.reason || "unspecified";
|
||||||
|
await requireConfirmation({
|
||||||
|
action: plan.action,
|
||||||
|
server: { id: server.id, name: server.name, ip: server.public_net?.ipv4?.ip },
|
||||||
|
reason,
|
||||||
|
yes: opts.yes,
|
||||||
|
match: plan.match
|
||||||
|
});
|
||||||
|
|
||||||
|
let response;
|
||||||
|
if (plan.action === "labels") {
|
||||||
|
const body = plan.request.body as { labels: Record<string, string> };
|
||||||
|
response = await client.updateServerLabels(server.id, body.labels);
|
||||||
|
} else {
|
||||||
|
response = await client.powerAction(server.id, plan.action);
|
||||||
|
}
|
||||||
|
|
||||||
|
const receipt = writeReceipt({
|
||||||
|
action: plan.action,
|
||||||
|
reason,
|
||||||
|
server: { id: server.id, name: server.name, ip: server.public_net?.ipv4?.ip },
|
||||||
|
request: {
|
||||||
|
method: plan.request.method,
|
||||||
|
path: plan.request.path,
|
||||||
|
body: plan.request.body
|
||||||
|
},
|
||||||
|
response: {
|
||||||
|
status: response.status,
|
||||||
|
ok: response.ok,
|
||||||
|
data: response.data,
|
||||||
|
raw: response.raw
|
||||||
|
},
|
||||||
|
plan: { file: plan_file, sha256: plan_sha256, blake3: plan_blake3 },
|
||||||
|
lock: { file: lock.file, started_at: lock.info.started_at, force: opts.force }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(
|
||||||
|
`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(
|
||||||
|
`OK ${plan.action} ${server.name} (id=${server.id}) receipt=${receipt.file} blake3=${receipt.blake3} sha256=${receipt.sha256}`
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
} finally {
|
||||||
|
lock.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
8
src/commands/keygen.ts
Normal file
8
src/commands/keygen.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { generateKeypair } from "../lib/keys.js";
|
||||||
|
|
||||||
|
export async function keygen(opts: { force?: boolean } = {}) {
|
||||||
|
const { privateKeyPath, publicKeyPath, publicKey } = await generateKeypair(opts);
|
||||||
|
console.log(`Private key: ${privateKeyPath}`);
|
||||||
|
console.log(`Public key: ${publicKeyPath}`);
|
||||||
|
console.log(`Public key (hex): ${publicKey}`);
|
||||||
|
}
|
||||||
83
src/commands/research.append.ts
Normal file
83
src/commands/research.append.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import crypto from "node:crypto";
|
||||||
|
import { appendToResearchNote } from "../lib/report.js";
|
||||||
|
import type { HetznerServer } from "../lib/hcloud.js";
|
||||||
|
|
||||||
|
type Snapshot = {
|
||||||
|
generated_at?: string;
|
||||||
|
servers?: HetznerServer[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function sha256File(p: string) {
|
||||||
|
const buf = fs.readFileSync(p);
|
||||||
|
return crypto.createHash("sha256").update(buf).digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
function latestResearchNote(dir: string) {
|
||||||
|
if (!fs.existsSync(dir)) throw new Error(`Missing research dir: ${dir}`);
|
||||||
|
const files = fs
|
||||||
|
.readdirSync(dir)
|
||||||
|
.filter((f) => f.endsWith(".md"))
|
||||||
|
.sort();
|
||||||
|
if (files.length === 0) {
|
||||||
|
throw new Error(`No research notes found in: ${dir}`);
|
||||||
|
}
|
||||||
|
return path.join(dir, files[files.length - 1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderServersTable(servers: HetznerServer[]) {
|
||||||
|
const rows = servers.map((s) => ({
|
||||||
|
name: s.name ?? "—",
|
||||||
|
ip: s.public_net?.ipv4?.ip ?? "—",
|
||||||
|
status: s.status ?? "—",
|
||||||
|
type: s.server_type?.name ?? "—",
|
||||||
|
location: s.datacenter?.location?.city ?? "—",
|
||||||
|
created: (s.created ?? "—").slice(0, 16).replace("T", " ")
|
||||||
|
}));
|
||||||
|
|
||||||
|
const header = `| Server | IP | Status | Type | Location | Created |
|
||||||
|
|---|---|---|---|---|---|`;
|
||||||
|
|
||||||
|
const body = rows
|
||||||
|
.map(
|
||||||
|
(r) =>
|
||||||
|
`| ${r.name} | ${r.ip} | ${r.status} | ${r.type} | ${r.location} | ${r.created} |`
|
||||||
|
)
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
return rows.length ? `${header}\n${body}` : `${header}\n| — | — | — | — | — | — |`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function researchAppend(fromPath: string, notePath?: string) {
|
||||||
|
const absFrom = path.isAbsolute(fromPath)
|
||||||
|
? fromPath
|
||||||
|
: path.join(process.cwd(), fromPath);
|
||||||
|
if (!fs.existsSync(absFrom)) {
|
||||||
|
throw new Error(`Snapshot not found: ${absFrom}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const researchDir = path.join(process.cwd(), "docs", "research");
|
||||||
|
const targetNote = notePath
|
||||||
|
? path.isAbsolute(notePath)
|
||||||
|
? notePath
|
||||||
|
: path.join(process.cwd(), notePath)
|
||||||
|
: latestResearchNote(researchDir);
|
||||||
|
|
||||||
|
const sha = sha256File(absFrom);
|
||||||
|
const snap = JSON.parse(fs.readFileSync(absFrom, "utf8")) as Snapshot;
|
||||||
|
|
||||||
|
const servers = snap.servers ?? [];
|
||||||
|
const table = renderServersTable(servers);
|
||||||
|
const relFrom = path.relative(process.cwd(), absFrom) || absFrom;
|
||||||
|
|
||||||
|
const md = `- Source: \`${relFrom}\`
|
||||||
|
- Generated: ${snap.generated_at ?? "—"}
|
||||||
|
- SHA256: \`${sha}\`
|
||||||
|
|
||||||
|
${table}`;
|
||||||
|
|
||||||
|
appendToResearchNote(targetNote, md);
|
||||||
|
console.log(`Appended to: ${targetNote}`);
|
||||||
|
console.log(`SHA256: ${sha}`);
|
||||||
|
}
|
||||||
9
src/commands/research.new.ts
Normal file
9
src/commands/research.new.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
import { createResearchNote } from "../lib/report.js";
|
||||||
|
|
||||||
|
export function researchNew(title: string) {
|
||||||
|
const dir = path.join(process.cwd(), "docs", "research");
|
||||||
|
const file = createResearchNote(dir, title);
|
||||||
|
console.log(`Research note: ${file}`);
|
||||||
|
return file;
|
||||||
|
}
|
||||||
150
src/commands/servers.action.ts
Normal file
150
src/commands/servers.action.ts
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
import { HcloudClient } from "../lib/hcloud.js";
|
||||||
|
import { requireConfirmation } from "../lib/confirm.js";
|
||||||
|
import { readPrivateKey } from "../lib/keys.js";
|
||||||
|
import { acquireServerLock } from "../lib/lock.js";
|
||||||
|
import { writePlan } from "../lib/plan.js";
|
||||||
|
import { writeReceipt } from "../lib/receipt.js";
|
||||||
|
import { signReceiptFile } from "../lib/signature.js";
|
||||||
|
import { resolveServer } from "../lib/resolve.js";
|
||||||
|
|
||||||
|
export async function serversAction(
|
||||||
|
client: HcloudClient,
|
||||||
|
input: string,
|
||||||
|
action: "poweron" | "poweroff" | "reboot",
|
||||||
|
opts: {
|
||||||
|
yes?: boolean;
|
||||||
|
reason?: string;
|
||||||
|
force?: boolean;
|
||||||
|
allowPartial?: boolean;
|
||||||
|
dryRun?: boolean;
|
||||||
|
sign?: boolean;
|
||||||
|
requireSig?: boolean;
|
||||||
|
} = {}
|
||||||
|
) {
|
||||||
|
const { server, match } = await resolveServer(client, input);
|
||||||
|
if (match === "partial") {
|
||||||
|
console.warn(
|
||||||
|
`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();
|
||||||
|
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}`;
|
||||||
|
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 = {
|
||||||
|
plan_version: "1",
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
action,
|
||||||
|
server: {
|
||||||
|
id: server.id,
|
||||||
|
name: server.name,
|
||||||
|
ip: server.public_net?.ipv4?.ip
|
||||||
|
},
|
||||||
|
match,
|
||||||
|
request: { method: "POST", path },
|
||||||
|
reason: reasonRaw,
|
||||||
|
applied: false
|
||||||
|
} as const;
|
||||||
|
const { file, sha256, blake3 } = writePlan(plan);
|
||||||
|
console.log(`Resolved: ${server.name} (id=${server.id}) match=${match}`);
|
||||||
|
console.log(`Request: POST ${path}`);
|
||||||
|
console.log(`Plan: ${file}`);
|
||||||
|
console.log(`BLAKE3: ${blake3}`);
|
||||||
|
console.log(`SHA256: ${sha256}`);
|
||||||
|
return plan;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 });
|
||||||
|
try {
|
||||||
|
const reason = reasonRaw || "unspecified";
|
||||||
|
await requireConfirmation({
|
||||||
|
action,
|
||||||
|
server: {
|
||||||
|
id: server.id,
|
||||||
|
name: server.name,
|
||||||
|
ip: server.public_net?.ipv4?.ip
|
||||||
|
},
|
||||||
|
reason,
|
||||||
|
yes: opts.yes,
|
||||||
|
match
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await client.powerAction(server.id, action);
|
||||||
|
const receipt = writeReceipt({
|
||||||
|
action,
|
||||||
|
reason,
|
||||||
|
server: {
|
||||||
|
id: server.id,
|
||||||
|
name: server.name,
|
||||||
|
ip: server.public_net?.ipv4?.ip
|
||||||
|
},
|
||||||
|
request: { method: "POST", path },
|
||||||
|
response: {
|
||||||
|
status: response.status,
|
||||||
|
ok: response.ok,
|
||||||
|
data: response.data,
|
||||||
|
raw: response.raw
|
||||||
|
},
|
||||||
|
lock: { file: lock.file, started_at: lock.info.started_at, force: opts.force }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(
|
||||||
|
`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(
|
||||||
|
`OK ${action} ${server.name} (id=${server.id}) receipt=${receipt.file} blake3=${receipt.blake3} sha256=${receipt.sha256}`
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
} finally {
|
||||||
|
lock.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
175
src/commands/servers.labels.ts
Normal file
175
src/commands/servers.labels.ts
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
import { HcloudClient } from "../lib/hcloud.js";
|
||||||
|
import { requireConfirmation } from "../lib/confirm.js";
|
||||||
|
import { readPrivateKey } from "../lib/keys.js";
|
||||||
|
import { acquireServerLock } from "../lib/lock.js";
|
||||||
|
import { writePlan } from "../lib/plan.js";
|
||||||
|
import { writeReceipt } from "../lib/receipt.js";
|
||||||
|
import { signReceiptFile } from "../lib/signature.js";
|
||||||
|
import { resolveServer } from "../lib/resolve.js";
|
||||||
|
|
||||||
|
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> = {};
|
||||||
|
for (const raw of args) {
|
||||||
|
const idx = raw.indexOf("=");
|
||||||
|
if (idx <= 0 || idx === raw.length - 1) {
|
||||||
|
throw new Error(`Invalid label "${raw}". Use key=value`);
|
||||||
|
}
|
||||||
|
const key = raw.slice(0, idx).trim();
|
||||||
|
const value = raw.slice(idx + 1).trim();
|
||||||
|
if (!key || !value) {
|
||||||
|
throw new Error(`Invalid label "${raw}". Use key=value`);
|
||||||
|
}
|
||||||
|
labels[key] = value;
|
||||||
|
}
|
||||||
|
return labels;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function serversLabels(
|
||||||
|
client: HcloudClient,
|
||||||
|
input: string,
|
||||||
|
args: string[],
|
||||||
|
opts: {
|
||||||
|
yes?: boolean;
|
||||||
|
reason?: string;
|
||||||
|
force?: boolean;
|
||||||
|
allowPartial?: boolean;
|
||||||
|
dryRun?: boolean;
|
||||||
|
sign?: boolean;
|
||||||
|
requireSig?: boolean;
|
||||||
|
} = {}
|
||||||
|
) {
|
||||||
|
const { server, match } = await resolveServer(client, input);
|
||||||
|
if (match === "partial") {
|
||||||
|
console.warn(
|
||||||
|
`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();
|
||||||
|
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 next = { ...(server.labels ?? {}), ...labels };
|
||||||
|
const path = `/servers/${server.id}`;
|
||||||
|
|
||||||
|
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 = {
|
||||||
|
plan_version: "1",
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
action: "labels",
|
||||||
|
server: {
|
||||||
|
id: server.id,
|
||||||
|
name: server.name,
|
||||||
|
ip: server.public_net?.ipv4?.ip
|
||||||
|
},
|
||||||
|
match,
|
||||||
|
request: { method: "PUT", path, body: { labels: next } },
|
||||||
|
reason: reasonRaw,
|
||||||
|
applied: false
|
||||||
|
} as const;
|
||||||
|
const { file, sha256, blake3 } = writePlan(plan);
|
||||||
|
console.log(`Resolved: ${server.name} (id=${server.id}) match=${match}`);
|
||||||
|
console.log(`Request: PUT ${path}`);
|
||||||
|
console.log(JSON.stringify({ labels: next }, null, 2));
|
||||||
|
console.log(`Plan: ${file}`);
|
||||||
|
console.log(`BLAKE3: ${blake3}`);
|
||||||
|
console.log(`SHA256: ${sha256}`);
|
||||||
|
return plan;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 });
|
||||||
|
try {
|
||||||
|
const reason = reasonRaw || "unspecified";
|
||||||
|
await requireConfirmation({
|
||||||
|
action: "labels",
|
||||||
|
server: {
|
||||||
|
id: server.id,
|
||||||
|
name: server.name,
|
||||||
|
ip: server.public_net?.ipv4?.ip
|
||||||
|
},
|
||||||
|
reason,
|
||||||
|
yes: opts.yes,
|
||||||
|
match
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await client.updateServerLabels(server.id, next);
|
||||||
|
const receipt = writeReceipt({
|
||||||
|
action: "labels",
|
||||||
|
reason,
|
||||||
|
server: {
|
||||||
|
id: server.id,
|
||||||
|
name: server.name,
|
||||||
|
ip: server.public_net?.ipv4?.ip
|
||||||
|
},
|
||||||
|
request: { method: "PUT", path, body: { labels: next } },
|
||||||
|
response: {
|
||||||
|
status: response.status,
|
||||||
|
ok: response.ok,
|
||||||
|
data: response.data,
|
||||||
|
raw: response.raw
|
||||||
|
},
|
||||||
|
lock: { file: lock.file, started_at: lock.info.started_at, force: opts.force }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(
|
||||||
|
`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(
|
||||||
|
`OK labels ${server.name} (id=${server.id}) receipt=${receipt.file} blake3=${receipt.blake3} sha256=${receipt.sha256}`
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
} finally {
|
||||||
|
lock.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
44
src/commands/servers.list.ts
Normal file
44
src/commands/servers.list.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { HcloudClient } from "../lib/hcloud.js";
|
||||||
|
|
||||||
|
function pad(s: string, n: number) {
|
||||||
|
return s.length >= n ? s.slice(0, n) : s + " ".repeat(n - s.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function serversList(client: HcloudClient) {
|
||||||
|
const servers = await client.listServers();
|
||||||
|
|
||||||
|
const rows = servers.map((s) => ({
|
||||||
|
name: s.name,
|
||||||
|
ip: s.public_net?.ipv4?.ip ?? "—",
|
||||||
|
status: s.status,
|
||||||
|
type: s.server_type?.name ?? "—",
|
||||||
|
location: s.datacenter?.location?.city ?? "—",
|
||||||
|
created: s.created?.slice(0, 16).replace("T", " ") ?? "—"
|
||||||
|
}));
|
||||||
|
|
||||||
|
const header = [
|
||||||
|
pad("Server", 18),
|
||||||
|
pad("IP", 16),
|
||||||
|
pad("Status", 10),
|
||||||
|
pad("Type", 12),
|
||||||
|
pad("Location", 14),
|
||||||
|
"Created"
|
||||||
|
].join(" ");
|
||||||
|
|
||||||
|
console.log(header);
|
||||||
|
console.log("-".repeat(header.length));
|
||||||
|
for (const r of rows) {
|
||||||
|
console.log(
|
||||||
|
[
|
||||||
|
pad(r.name, 18),
|
||||||
|
pad(String(r.ip), 16),
|
||||||
|
pad(r.status, 10),
|
||||||
|
pad(r.type, 12),
|
||||||
|
pad(r.location, 14),
|
||||||
|
r.created
|
||||||
|
].join(" ")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
12
src/commands/sign.receipt.ts
Normal file
12
src/commands/sign.receipt.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { signReceiptFile } from "../lib/signature.js";
|
||||||
|
|
||||||
|
export async function signReceipt(
|
||||||
|
receiptPath: string,
|
||||||
|
opts: { force?: boolean } = {}
|
||||||
|
) {
|
||||||
|
const signed = await signReceiptFile(receiptPath, opts);
|
||||||
|
console.log("OK receipt signed");
|
||||||
|
console.log(`file: ${signed.file}`);
|
||||||
|
console.log(`signer_pub: ${signed.signer_pub}`);
|
||||||
|
console.log(`signer_kid: ${signed.signer_kid}`);
|
||||||
|
}
|
||||||
17
src/commands/snapshot.ts
Normal file
17
src/commands/snapshot.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
import { HcloudClient } from "../lib/hcloud.js";
|
||||||
|
import { nowStamp, writeJsonSnapshot } from "../lib/report.js";
|
||||||
|
|
||||||
|
export async function snapshotServers(client: HcloudClient) {
|
||||||
|
const servers = await client.listServers();
|
||||||
|
const { stamp } = nowStamp();
|
||||||
|
const outDir = path.join(process.cwd(), "outputs", "hetzner");
|
||||||
|
const name = `servers-${stamp}.json`;
|
||||||
|
const snap = writeJsonSnapshot(outDir, name, {
|
||||||
|
generated_at: new Date().toISOString(),
|
||||||
|
servers
|
||||||
|
});
|
||||||
|
console.log(`Saved: ${snap.file}`);
|
||||||
|
console.log(`SHA256: ${snap.sha}`);
|
||||||
|
return snap;
|
||||||
|
}
|
||||||
172
src/commands/verify.receipt.ts
Normal file
172
src/commands/verify.receipt.ts
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { hashBlake3Hex, hashSha256Hex } from "../lib/hash.js";
|
||||||
|
import { readHead } from "../lib/ledger.js";
|
||||||
|
import { keyIdFromPublicHex, 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) {
|
||||||
|
if (!fs.existsSync(p)) {
|
||||||
|
const err = new Error(`${label} not found: ${p}`);
|
||||||
|
(err as { exitCode?: number }).exitCode = 2;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripHashes(env: ReceiptEnvelope): ReceiptBody {
|
||||||
|
const {
|
||||||
|
hash_alg: _hash_alg,
|
||||||
|
blake3: _blake3,
|
||||||
|
sha256: _sha256,
|
||||||
|
sig_alg: _sig_alg,
|
||||||
|
signer_pub: _signer_pub,
|
||||||
|
signer_kid: _signer_kid,
|
||||||
|
signed_at: _signed_at,
|
||||||
|
signature: _signature,
|
||||||
|
...body
|
||||||
|
} = env;
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyReceipt(
|
||||||
|
receiptPath: string,
|
||||||
|
opts: { head?: boolean; plan?: boolean; sig?: boolean } = {}
|
||||||
|
) {
|
||||||
|
const abs = path.resolve(receiptPath);
|
||||||
|
mustExist(abs, "Receipt");
|
||||||
|
|
||||||
|
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.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[] = [];
|
||||||
|
if (blake3 !== env.blake3) {
|
||||||
|
failures.push(`BLAKE3 mismatch (expected ${env.blake3}, got ${blake3})`);
|
||||||
|
}
|
||||||
|
if (sha256 !== env.sha256) {
|
||||||
|
failures.push(`SHA256 mismatch (expected ${env.sha256}, got ${sha256})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (opts.plan && env.plan_file) {
|
||||||
|
const planAbs = path.isAbsolute(env.plan_file)
|
||||||
|
? env.plan_file
|
||||||
|
: path.join(process.cwd(), env.plan_file);
|
||||||
|
try {
|
||||||
|
mustExist(planAbs, "Plan file referenced by receipt");
|
||||||
|
const planRaw = fs.readFileSync(planAbs, "utf8");
|
||||||
|
const planObj = JSON.parse(planRaw) as unknown;
|
||||||
|
const planSha = hashSha256Hex(planObj);
|
||||||
|
const planB3 = hashBlake3Hex(planObj);
|
||||||
|
if (!env.plan_sha256 || !env.plan_blake3) {
|
||||||
|
failures.push("Receipt is missing plan hash fields");
|
||||||
|
} else {
|
||||||
|
if (env.plan_sha256 !== planSha) {
|
||||||
|
failures.push(
|
||||||
|
`Plan SHA256 mismatch (expected ${env.plan_sha256}, got ${planSha})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (env.plan_blake3 !== planB3) {
|
||||||
|
failures.push(
|
||||||
|
`Plan BLAKE3 mismatch (expected ${env.plan_blake3}, got ${planB3})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
failures.push(msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (opts.head) {
|
||||||
|
const head = readHead();
|
||||||
|
if (!head) {
|
||||||
|
failures.push("HEAD.json missing");
|
||||||
|
} else {
|
||||||
|
const rel = path.relative(process.cwd(), abs) || abs;
|
||||||
|
if (head.file === rel && head.blake3 !== env.blake3) {
|
||||||
|
failures.push(
|
||||||
|
`HEAD blake3 mismatch (HEAD=${head.blake3}, receipt=${env.blake3})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (opts.sig) {
|
||||||
|
if (!env.signature || !env.signer_pub || env.sig_alg !== "ed25519") {
|
||||||
|
const err = new Error("Receipt signature fields missing or invalid");
|
||||||
|
(err as { exitCode?: number }).exitCode = 2;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
const msg = new TextEncoder().encode(env.blake3);
|
||||||
|
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})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (failures.length) {
|
||||||
|
const err = new Error("Receipt verification failed:\n- " + failures.join("\n- "));
|
||||||
|
(err as { exitCode?: number }).exitCode = 5;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("OK receipt verified");
|
||||||
|
console.log(`file: ${abs}`);
|
||||||
|
console.log(`blake3: ${env.blake3}`);
|
||||||
|
console.log(`sha256: ${env.sha256}`);
|
||||||
|
if (env.prev_blake3) console.log(`prev_blake3: ${env.prev_blake3}`);
|
||||||
|
if (env.plan_file) console.log(`plan: ${env.plan_file}`);
|
||||||
|
}
|
||||||
6
src/index.ts
Normal file
6
src/index.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { startMcpServer } from "./mcp/server.js";
|
||||||
|
|
||||||
|
startMcpServer().catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
38
src/lib/confirm.ts
Normal file
38
src/lib/confirm.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import readline from "node:readline/promises";
|
||||||
|
|
||||||
|
export async function requireConfirmation(opts: {
|
||||||
|
action: string;
|
||||||
|
server: { id: number; name: string; ip?: string | null };
|
||||||
|
reason?: string;
|
||||||
|
yes?: boolean;
|
||||||
|
match?: "id" | "exact" | "partial";
|
||||||
|
}) {
|
||||||
|
if (opts.yes) return;
|
||||||
|
|
||||||
|
if (!process.stdin.isTTY) {
|
||||||
|
const err = new Error("Confirmation required. Re-run with --yes.");
|
||||||
|
(err as { exitCode?: number }).exitCode = 2;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reason = opts.reason?.trim() || "unspecified";
|
||||||
|
const ip = opts.server.ip ? `, ip=${opts.server.ip}` : "";
|
||||||
|
const matchNote = opts.match === "partial" ? " [partial match]" : "";
|
||||||
|
const prompt = `Confirm ${opts.action}${matchNote} on ${opts.server.name} (id=${opts.server.id}${ip}) reason=\"${reason}\"? [y/N] `;
|
||||||
|
|
||||||
|
const rl = readline.createInterface({
|
||||||
|
input: process.stdin,
|
||||||
|
output: process.stdout
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const answer = await rl.question(prompt);
|
||||||
|
if (!/^y(es)?$/i.test(answer.trim())) {
|
||||||
|
const err = new Error("Confirmation declined.");
|
||||||
|
(err as { exitCode?: number }).exitCode = 2;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
rl.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
23
src/lib/env.ts
Normal file
23
src/lib/env.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import os from "node:os";
|
||||||
|
import dotenv from "dotenv";
|
||||||
|
|
||||||
|
export function loadEnv() {
|
||||||
|
const homeEnv = path.join(os.homedir(), ".env");
|
||||||
|
const localEnv = path.join(process.cwd(), ".env");
|
||||||
|
|
||||||
|
if (fs.existsSync(homeEnv)) dotenv.config({ path: homeEnv });
|
||||||
|
if (fs.existsSync(localEnv)) dotenv.config({ path: localEnv });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requireHcloudToken(): string {
|
||||||
|
loadEnv();
|
||||||
|
const token = process.env.HCLOUD_TOKEN?.trim();
|
||||||
|
if (!token) {
|
||||||
|
throw new Error(
|
||||||
|
"HCLOUD_TOKEN missing. Put it in ~/.env or ./vm-cloud/.env as: HCLOUD_TOKEN=xxxxx"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return token;
|
||||||
|
}
|
||||||
17
src/lib/hash.ts
Normal file
17
src/lib/hash.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { blake3 } from "@noble/hashes/blake3";
|
||||||
|
import { sha256 } from "@noble/hashes/sha256";
|
||||||
|
import { bytesToHex } from "@noble/hashes/utils";
|
||||||
|
import { canonicalize } from "json-canonicalize";
|
||||||
|
|
||||||
|
export function canonicalBytes(obj: unknown): Uint8Array {
|
||||||
|
const json = canonicalize(obj);
|
||||||
|
return new TextEncoder().encode(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hashBlake3Hex(obj: unknown): string {
|
||||||
|
return bytesToHex(blake3(canonicalBytes(obj)));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hashSha256Hex(obj: unknown): string {
|
||||||
|
return bytesToHex(sha256(canonicalBytes(obj)));
|
||||||
|
}
|
||||||
75
src/lib/hcloud.ts
Normal file
75
src/lib/hcloud.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
export interface HetznerServer {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
status: string;
|
||||||
|
created: string;
|
||||||
|
public_net: { ipv4: { ip: string | null } };
|
||||||
|
server_type: { name: string; cores: number; memory: number; disk?: number };
|
||||||
|
datacenter: { name: string; location: { city: string; country?: string } };
|
||||||
|
labels?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
type HcloudListServersResponse = {
|
||||||
|
servers: HetznerServer[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type HcloudResponse<T = unknown> = {
|
||||||
|
ok: boolean;
|
||||||
|
status: number;
|
||||||
|
data: T | null;
|
||||||
|
raw: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class HcloudClient {
|
||||||
|
private base = "https://api.hetzner.cloud/v1";
|
||||||
|
constructor(private token: string) {}
|
||||||
|
|
||||||
|
private async requestWithMeta<T>(
|
||||||
|
method: string,
|
||||||
|
path: string,
|
||||||
|
body?: unknown
|
||||||
|
): Promise<HcloudResponse<T>> {
|
||||||
|
const res = await fetch(`${this.base}${path}`, {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${this.token}`,
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
body: body ? JSON.stringify(body) : undefined
|
||||||
|
});
|
||||||
|
|
||||||
|
const raw = await res.text().catch(() => "");
|
||||||
|
let data: T | null = null;
|
||||||
|
if (raw) {
|
||||||
|
try {
|
||||||
|
data = JSON.parse(raw) as T;
|
||||||
|
} catch {
|
||||||
|
data = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: res.ok, status: res.status, data, raw };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async req<T>(method: string, path: string, body?: unknown): Promise<T> {
|
||||||
|
const res = await this.requestWithMeta<T>(method, path, body);
|
||||||
|
if (!res.ok) {
|
||||||
|
const detail = res.raw || "unknown error";
|
||||||
|
throw new Error(`Hetzner API ${res.status} error: ${detail}`);
|
||||||
|
}
|
||||||
|
return res.data as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
async listServers(): Promise<HetznerServer[]> {
|
||||||
|
const data = await this.req<HcloudListServersResponse>("GET", "/servers");
|
||||||
|
return data.servers ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async powerAction(serverId: number, action: "poweron" | "poweroff" | "reboot") {
|
||||||
|
return this.requestWithMeta("POST", `/servers/${serverId}/actions/${action}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateServerLabels(serverId: number, labels: Record<string, string>) {
|
||||||
|
return this.requestWithMeta("PUT", `/servers/${serverId}`, { labels });
|
||||||
|
}
|
||||||
|
}
|
||||||
90
src/lib/keys.ts
Normal file
90
src/lib/keys.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { getPublicKey, sign, verify, utils, etc } from "@noble/ed25519";
|
||||||
|
import { blake3 } from "@noble/hashes/blake3";
|
||||||
|
import { sha512 } from "@noble/hashes/sha512";
|
||||||
|
import { bytesToHex, hexToBytes } from "@noble/hashes/utils";
|
||||||
|
|
||||||
|
const KEY_DIR = path.join(os.homedir(), ".config", "vm-cloud", "keys");
|
||||||
|
const PRIV_PATH = path.join(KEY_DIR, "operator_ed25519.key");
|
||||||
|
const PUB_PATH = path.join(KEY_DIR, "operator_ed25519.pub");
|
||||||
|
|
||||||
|
if (!etc.sha512Sync) {
|
||||||
|
etc.sha512Sync = (...messages) => sha512(etc.concatBytes(...messages));
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureKeyDir() {
|
||||||
|
fs.mkdirSync(KEY_DIR, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function keyPaths() {
|
||||||
|
return { dir: KEY_DIR, privateKey: PRIV_PATH, publicKey: PUB_PATH };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readPrivateKey(): Uint8Array {
|
||||||
|
if (!fs.existsSync(PRIV_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 {
|
||||||
|
if (!fs.existsSync(PUB_PATH)) {
|
||||||
|
const err = new Error(`Public key not found: ${PUB_PATH}`);
|
||||||
|
(err as { exitCode?: number }).exitCode = 2;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
const hex = fs.readFileSync(PUB_PATH, "utf8").trim();
|
||||||
|
return hexToBytes(hex);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateKeypair(opts: { force?: boolean } = {}) {
|
||||||
|
ensureKeyDir();
|
||||||
|
if (!opts.force && (fs.existsSync(PRIV_PATH) || fs.existsSync(PUB_PATH))) {
|
||||||
|
const err = new Error(
|
||||||
|
`Key already exists. Use --force to overwrite: ${PRIV_PATH}`
|
||||||
|
);
|
||||||
|
(err as { exitCode?: number }).exitCode = 2;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
const priv = utils.randomPrivateKey();
|
||||||
|
const pub = await getPublicKey(priv);
|
||||||
|
|
||||||
|
fs.writeFileSync(PRIV_PATH, bytesToHex(priv), { mode: 0o600 });
|
||||||
|
fs.writeFileSync(PUB_PATH, bytesToHex(pub), { mode: 0o644 });
|
||||||
|
|
||||||
|
return {
|
||||||
|
privateKeyPath: PRIV_PATH,
|
||||||
|
publicKeyPath: PUB_PATH,
|
||||||
|
publicKey: bytesToHex(pub)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function keyIdFromPublicHex(publicKeyHex: string) {
|
||||||
|
const pub = hexToBytes(publicKeyHex);
|
||||||
|
return bytesToHex(blake3(pub));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function signMessage(message: Uint8Array) {
|
||||||
|
const priv = readPrivateKey();
|
||||||
|
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(
|
||||||
|
message: Uint8Array,
|
||||||
|
signatureHex: string,
|
||||||
|
publicKeyHex: string
|
||||||
|
) {
|
||||||
|
const signature = hexToBytes(signatureHex);
|
||||||
|
const publicKey = hexToBytes(publicKeyHex);
|
||||||
|
return verify(signature, message, publicKey);
|
||||||
|
}
|
||||||
39
src/lib/ledger.ts
Normal file
39
src/lib/ledger.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { ensureDir } from "./report.js";
|
||||||
|
|
||||||
|
function receiptsDir() {
|
||||||
|
return path.join(process.cwd(), "outputs", "receipts");
|
||||||
|
}
|
||||||
|
|
||||||
|
function headPath() {
|
||||||
|
return path.join(receiptsDir(), "HEAD.json");
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ReceiptHead = { blake3: string; file: string; created_at: string };
|
||||||
|
|
||||||
|
export function readHead(): ReceiptHead | null {
|
||||||
|
try {
|
||||||
|
const raw = fs.readFileSync(headPath(), "utf8");
|
||||||
|
const data = JSON.parse(raw) as ReceiptHead;
|
||||||
|
if (!data?.blake3 || !data?.file || !data?.created_at) return null;
|
||||||
|
return data;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readPrevReceiptHash(): string | null {
|
||||||
|
try {
|
||||||
|
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 });
|
||||||
|
}
|
||||||
102
src/lib/lock.ts
Normal file
102
src/lib/lock.ts
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
export type LockInfo = {
|
||||||
|
pid: number;
|
||||||
|
user: string;
|
||||||
|
hostname: string;
|
||||||
|
started_at: string;
|
||||||
|
argv: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function homeCacheDir() {
|
||||||
|
return path.join(os.homedir(), ".cache", "vm-cloud", "locks");
|
||||||
|
}
|
||||||
|
|
||||||
|
function lockPath(serverId: number) {
|
||||||
|
return path.join(homeCacheDir(), `${serverId}.lock`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pidAlive(pid: number) {
|
||||||
|
try {
|
||||||
|
process.kill(pid, 0);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeLockExclusive(lockFile: string, info: LockInfo) {
|
||||||
|
const fd = fs.openSync(lockFile, "wx", 0o600);
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(fd, JSON.stringify(info, null, 2), "utf8");
|
||||||
|
} finally {
|
||||||
|
fs.closeSync(fd);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function acquireServerLock(
|
||||||
|
serverId: number,
|
||||||
|
opts: { force?: boolean } = {}
|
||||||
|
) {
|
||||||
|
const dir = homeCacheDir();
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
|
||||||
|
const lp = lockPath(serverId);
|
||||||
|
|
||||||
|
const info: LockInfo = {
|
||||||
|
pid: process.pid,
|
||||||
|
user: process.env.USER || "unknown",
|
||||||
|
hostname: os.hostname(),
|
||||||
|
started_at: new Date().toISOString(),
|
||||||
|
argv: process.argv.slice()
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let attempt = 0; attempt < 2; attempt++) {
|
||||||
|
try {
|
||||||
|
writeLockExclusive(lp, info);
|
||||||
|
return {
|
||||||
|
file: lp,
|
||||||
|
info,
|
||||||
|
release() {
|
||||||
|
try {
|
||||||
|
fs.unlinkSync(lp);
|
||||||
|
} catch {
|
||||||
|
// Ignore missing lock on release.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
const code = (err as NodeJS.ErrnoException)?.code;
|
||||||
|
if (code !== "EEXIST") throw err;
|
||||||
|
|
||||||
|
let existing: LockInfo | null = null;
|
||||||
|
try {
|
||||||
|
existing = JSON.parse(fs.readFileSync(lp, "utf8")) as LockInfo;
|
||||||
|
} catch {
|
||||||
|
existing = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const alive = existing?.pid ? pidAlive(existing.pid) : false;
|
||||||
|
if (alive && !opts.force) {
|
||||||
|
const lockErr = new Error(
|
||||||
|
`Server is locked by pid=${existing!.pid} user=${existing!.user} host=${existing!.hostname} since=${existing!.started_at}`
|
||||||
|
);
|
||||||
|
(lockErr as { exitCode?: number }).exitCode = 6;
|
||||||
|
(lockErr as { lockInfo?: LockInfo | null }).lockInfo = existing;
|
||||||
|
throw lockErr;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
fs.unlinkSync(lp);
|
||||||
|
} catch {
|
||||||
|
// Ignore removal errors so we can attempt to replace.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const err = new Error("Failed to acquire server lock");
|
||||||
|
(err as { exitCode?: number }).exitCode = 6;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
41
src/lib/plan.ts
Normal file
41
src/lib/plan.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { hashBlake3Hex, hashSha256Hex } from "./hash.js";
|
||||||
|
import { ensureDir, nowStamp } from "./report.js";
|
||||||
|
|
||||||
|
export type PlanAction = "poweron" | "poweroff" | "reboot" | "labels";
|
||||||
|
export type PlanMatch = "id" | "exact" | "partial";
|
||||||
|
|
||||||
|
export type ServerPlan = {
|
||||||
|
plan_version: "1";
|
||||||
|
created_at: string;
|
||||||
|
action: PlanAction;
|
||||||
|
server: { id: number; name: string; ip?: string | null };
|
||||||
|
match: PlanMatch;
|
||||||
|
request: { method: "POST" | "PUT"; path: string; body?: unknown };
|
||||||
|
reason?: string;
|
||||||
|
applied: false;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function resolvePlanPath(planPath: string) {
|
||||||
|
return path.isAbsolute(planPath) ? planPath : path.join(process.cwd(), planPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writePlan(plan: ServerPlan) {
|
||||||
|
const { stamp } = nowStamp();
|
||||||
|
const dir = path.join(process.cwd(), "outputs", "plans");
|
||||||
|
const name = `plan-${stamp}-${plan.action}-${plan.server.id}.json`;
|
||||||
|
ensureDir(dir);
|
||||||
|
const file = path.join(dir, name);
|
||||||
|
const json = JSON.stringify(plan, null, 2);
|
||||||
|
fs.writeFileSync(file, json, "utf8");
|
||||||
|
const sha256 = hashSha256Hex(plan);
|
||||||
|
const blake3 = hashBlake3Hex(plan);
|
||||||
|
return { file, sha256, blake3 };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readPlan(planPath: string): ServerPlan {
|
||||||
|
const abs = resolvePlanPath(planPath);
|
||||||
|
const raw = fs.readFileSync(abs, "utf8");
|
||||||
|
return JSON.parse(raw) as ServerPlan;
|
||||||
|
}
|
||||||
116
src/lib/receipt.ts
Normal file
116
src/lib/receipt.ts
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { hashBlake3Hex, hashSha256Hex } from "./hash.js";
|
||||||
|
import { readPrevReceiptHash, writeHead } from "./ledger.js";
|
||||||
|
import { ensureDir } from "./report.js";
|
||||||
|
|
||||||
|
type ReceiptRequest = {
|
||||||
|
method: string;
|
||||||
|
path: string;
|
||||||
|
body?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ReceiptResponse = {
|
||||||
|
status: number;
|
||||||
|
ok: boolean;
|
||||||
|
data?: unknown;
|
||||||
|
raw?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ReceiptInput = {
|
||||||
|
action: string;
|
||||||
|
reason: string;
|
||||||
|
server: { id: number; name: string; ip?: string | null };
|
||||||
|
request: ReceiptRequest;
|
||||||
|
response: ReceiptResponse;
|
||||||
|
lock?: { file: string; started_at: string; force?: boolean };
|
||||||
|
plan?: { file: string; sha256: string; blake3: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
type ReceiptBody = {
|
||||||
|
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: ReceiptRequest;
|
||||||
|
response: ReceiptResponse;
|
||||||
|
prev_blake3: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ReceiptEnvelope = ReceiptBody & {
|
||||||
|
hash_alg: "blake3+sha256";
|
||||||
|
blake3: string;
|
||||||
|
sha256: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function fileStamp(d = new Date()) {
|
||||||
|
const yyyy = d.getFullYear();
|
||||||
|
const mm = String(d.getMonth() + 1).padStart(2, "0");
|
||||||
|
const dd = String(d.getDate()).padStart(2, "0");
|
||||||
|
const hh = String(d.getHours()).padStart(2, "0");
|
||||||
|
const mi = String(d.getMinutes()).padStart(2, "0");
|
||||||
|
const ss = String(d.getSeconds()).padStart(2, "0");
|
||||||
|
return `${yyyy}${mm}${dd}-${hh}${mi}${ss}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeReceipt(input: ReceiptInput) {
|
||||||
|
const dir = path.join(process.cwd(), "outputs", "receipts");
|
||||||
|
ensureDir(dir);
|
||||||
|
|
||||||
|
const file = path.join(
|
||||||
|
dir,
|
||||||
|
`${fileStamp()}-${input.action}-${input.server.id}.json`
|
||||||
|
);
|
||||||
|
|
||||||
|
const created_at = new Date().toISOString();
|
||||||
|
const prev_blake3 = readPrevReceiptHash();
|
||||||
|
const body: ReceiptBody = {
|
||||||
|
receipt_version: "1",
|
||||||
|
created_at,
|
||||||
|
cwd: process.cwd(),
|
||||||
|
user: process.env.USER ?? process.env.LOGNAME ?? "unknown",
|
||||||
|
hostname: os.hostname(),
|
||||||
|
argv: process.argv,
|
||||||
|
reason: input.reason || "unspecified",
|
||||||
|
lock_file: input.lock?.file ?? null,
|
||||||
|
lock_started_at: input.lock?.started_at ?? null,
|
||||||
|
force: Boolean(input.lock?.force),
|
||||||
|
plan_file: input.plan?.file ?? null,
|
||||||
|
plan_sha256: input.plan?.sha256 ?? null,
|
||||||
|
plan_blake3: input.plan?.blake3 ?? null,
|
||||||
|
target: {
|
||||||
|
id: input.server.id,
|
||||||
|
name: input.server.name,
|
||||||
|
ip: input.server.ip ?? null
|
||||||
|
},
|
||||||
|
request: input.request,
|
||||||
|
response: input.response,
|
||||||
|
prev_blake3
|
||||||
|
};
|
||||||
|
|
||||||
|
const blake3 = hashBlake3Hex(body);
|
||||||
|
const sha256 = hashSha256Hex(body);
|
||||||
|
const envelope: ReceiptEnvelope = {
|
||||||
|
...body,
|
||||||
|
hash_alg: "blake3+sha256",
|
||||||
|
blake3,
|
||||||
|
sha256
|
||||||
|
};
|
||||||
|
|
||||||
|
fs.writeFileSync(file, JSON.stringify(envelope, null, 2), "utf8");
|
||||||
|
const relFile = path.relative(process.cwd(), file) || file;
|
||||||
|
writeHead({ blake3, file: relFile, created_at });
|
||||||
|
|
||||||
|
return { file, sha256, blake3 };
|
||||||
|
}
|
||||||
68
src/lib/report.ts
Normal file
68
src/lib/report.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import crypto from "node:crypto";
|
||||||
|
|
||||||
|
export function ensureDir(p: string) {
|
||||||
|
fs.mkdirSync(p, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function nowStamp() {
|
||||||
|
const d = new Date();
|
||||||
|
const yyyy = d.getFullYear();
|
||||||
|
const mm = String(d.getMonth() + 1).padStart(2, "0");
|
||||||
|
const dd = String(d.getDate()).padStart(2, "0");
|
||||||
|
const hh = String(d.getHours()).padStart(2, "0");
|
||||||
|
const mi = String(d.getMinutes()).padStart(2, "0");
|
||||||
|
return { date: `${yyyy}-${mm}-${dd}`, stamp: `${yyyy}${mm}${dd}-${hh}${mi}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function slugify(s: string) {
|
||||||
|
return s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeJsonSnapshot(dir: string, name: string, data: unknown) {
|
||||||
|
ensureDir(dir);
|
||||||
|
const json = JSON.stringify(data, null, 2);
|
||||||
|
const file = path.join(dir, name);
|
||||||
|
fs.writeFileSync(file, json, "utf8");
|
||||||
|
const sha = crypto.createHash("sha256").update(json).digest("hex");
|
||||||
|
return { file, sha };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createResearchNote(dir: string, title: string) {
|
||||||
|
ensureDir(dir);
|
||||||
|
const { date } = nowStamp();
|
||||||
|
const slug = slugify(title);
|
||||||
|
const file = path.join(dir, `${date}-${slug}.md`);
|
||||||
|
if (fs.existsSync(file)) return file;
|
||||||
|
|
||||||
|
const body = `# ${title}
|
||||||
|
|
||||||
|
Date: ${date}
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
- Purpose:
|
||||||
|
- Scope:
|
||||||
|
- Risks:
|
||||||
|
|
||||||
|
## Evidence
|
||||||
|
|
||||||
|
<!-- snapshots go here -->
|
||||||
|
|
||||||
|
## Findings
|
||||||
|
|
||||||
|
-
|
||||||
|
|
||||||
|
## Next actions
|
||||||
|
|
||||||
|
-
|
||||||
|
`;
|
||||||
|
fs.writeFileSync(file, body, "utf8");
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function appendToResearchNote(notePath: string, markdown: string) {
|
||||||
|
const sep = "\n\n";
|
||||||
|
fs.appendFileSync(notePath, sep + markdown, "utf8");
|
||||||
|
}
|
||||||
44
src/lib/resolve.ts
Normal file
44
src/lib/resolve.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { HcloudClient, HetznerServer } from "./hcloud.js";
|
||||||
|
|
||||||
|
function isNumericId(input: string) {
|
||||||
|
return /^[0-9]+$/.test(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ServerMatch = {
|
||||||
|
server: HetznerServer;
|
||||||
|
match: "id" | "exact" | "partial";
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function resolveServer(
|
||||||
|
client: HcloudClient,
|
||||||
|
input: string
|
||||||
|
): Promise<ServerMatch> {
|
||||||
|
const needle = input.trim();
|
||||||
|
if (!needle) {
|
||||||
|
throw new Error("Server name or id is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
const servers = await client.listServers();
|
||||||
|
|
||||||
|
if (isNumericId(needle)) {
|
||||||
|
const id = Number(needle);
|
||||||
|
const byId = servers.find((s) => s.id === id);
|
||||||
|
if (byId) return { server: byId, match: "id" };
|
||||||
|
throw new Error(`No server found with id ${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const exact = servers.find((s) => s.name === needle);
|
||||||
|
if (exact) return { server: exact, match: "exact" };
|
||||||
|
|
||||||
|
const lower = needle.toLowerCase();
|
||||||
|
const partial = servers.filter((s) => s.name.toLowerCase().includes(lower));
|
||||||
|
if (partial.length === 1) return { server: partial[0], match: "partial" };
|
||||||
|
if (partial.length > 1) {
|
||||||
|
const names = partial.map((s) => s.name).join(", ");
|
||||||
|
const err = new Error(`Ambiguous name "${needle}". Matches: ${names}`);
|
||||||
|
(err as { exitCode?: number }).exitCode = 3;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`No server found with name "${needle}"`);
|
||||||
|
}
|
||||||
63
src/lib/signature.ts
Normal file
63
src/lib/signature.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
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 };
|
||||||
|
}
|
||||||
1
src/lib/wireguard.ts
Normal file
1
src/lib/wireguard.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
// Placeholder for WireGuard mesh utilities.
|
||||||
5
src/mcp/server.ts
Normal file
5
src/mcp/server.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export async function startMcpServer() {
|
||||||
|
// Placeholder: wire up @modelcontextprotocol/sdk here in Step 4.
|
||||||
|
// Keeping it minimal so you can ship CLI first.
|
||||||
|
console.log("MCP server stub (not yet implemented). Use: npm run dev");
|
||||||
|
}
|
||||||
1
src/mcp/tools.ts
Normal file
1
src/mcp/tools.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
// Placeholder for MCP tool definitions.
|
||||||
13
tsconfig.json
Normal file
13
tsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user