Initial vmc CLI

This commit is contained in:
Vault Sovereign
2025-12-26 19:35:03 +00:00
commit a075fcf95f
37 changed files with 3967 additions and 0 deletions

38
src/lib/confirm.ts Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1 @@
// Placeholder for WireGuard mesh utilities.