// The MinIO admin client — minio's own code, living in the module (novox/hq ADR 0044). Ported out // of the shared hal sdk, where a change to MinIO's surface rebuilt everything; here it rebuilds only // minio. This module's tools, its provisioner and its events entrypoint import it; nothing outside // minio does. // // It speaks two planes with node built-ins only (never the `minio` npm package): // - the S3 data plane over `fetch`, signed with AWS Signature V4 (node:crypto) — bucket and object // operations, and presigned URLs; // - the admin plane through the `mc` CLI (node:child_process) — scoped service accounts, whose // creation the MinIO admin REST API guards behind an encrypted payload `fetch` cannot form. // This mirrors hal's MinIOClient/MinIOAdmin split, folded into one client the module builds from env. import { createHash, createHmac } from "node:crypto"; import { execFile } from "node:child_process"; import { readFileSync, writeFileSync, unlinkSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { promisify } from "node:util"; const execFileAsync = promisify(execFile); export interface MinioBucket { name: string; creationDate?: Date; } export interface MinioObject { name: string; size: number; lastModified: Date; etag?: string; } export interface MinioObjectStat { size: number; lastModified: Date; etag?: string; contentType?: string; } export interface MinioBucketInfo { name: string; exists: boolean; region: string; /** Sampled from the first page of a listing (up to 1000 keys) — a summary, not an audit. */ sampledObjects: number; sampledBytes: number; } /** A scoped credential a consumer receives: an access key/secret pair confined to one bucket. */ export interface AccessKey { accessKey: string; secretKey: string; } interface MinioOptions { endpoint: string; rootUser: string; rootPassword: string; region: string; mcBin: string; mcConfigDir: string; } export class MinioClient { readonly baseUrl: string; readonly region: string; private readonly rootUser: string; private readonly rootPassword: string; private readonly mcBin: string; private readonly mcConfigDir: string; private aliasReady = false; constructor(opts: MinioOptions) { this.baseUrl = opts.endpoint.replace(/\/$/, ""); this.region = opts.region; this.rootUser = opts.rootUser; this.rootPassword = opts.rootPassword; this.mcBin = opts.mcBin; this.mcConfigDir = opts.mcConfigDir; } /** * Build from the module's resolved environment. The endpoint and root identity come from * MESH_MINIO_*; the password may be given inline or as a mounted secret file (the manifest mounts * root.secret), so the provisioner container needs nothing written by hand. Throws when * unconfigured — an object store the module cannot reach is not a usable client. */ static fromEnv(env: NodeJS.ProcessEnv = process.env): MinioClient { const endpoint = env.MESH_MINIO_ENDPOINT; const rootUser = env.MESH_MINIO_ROOT_USER; const passwordFile = env.MESH_MINIO_ROOT_PASSWORD_FILE; const rootPassword = env.MESH_MINIO_ROOT_PASSWORD ?? (passwordFile ? readFileSync(passwordFile, "utf8").trim() : undefined); if (!endpoint || !rootUser || !rootPassword) { throw new Error("minio is not configured — set MESH_MINIO_ENDPOINT, MESH_MINIO_ROOT_USER and MESH_MINIO_ROOT_PASSWORD"); } return new MinioClient({ endpoint, rootUser, rootPassword, region: env.MESH_MINIO_REGION ?? "us-east-1", mcBin: env.MESH_MINIO_MC_BIN ?? "mc", mcConfigDir: env.MESH_MINIO_MC_CONFIG ?? join(tmpdir(), ".mc-mesh"), }); } // --- S3 data plane (signed fetch) --------------------------------------- async listBuckets(): Promise { const { status, text } = await this.request("GET", "/"); if (status !== 200) throw new Error(`minio listBuckets: ${status} ${text}`); const out: MinioBucket[] = []; const re = /\s*([^<]+)<\/Name>\s*([^<]*)<\/CreationDate>/g; let m: RegExpExecArray | null; while ((m = re.exec(text)) !== null) { out.push({ name: m[1], creationDate: m[2] ? new Date(m[2]) : undefined }); } return out; } async bucketExists(bucket: string): Promise { const { status } = await this.request("HEAD", `/${bucket}`); if (status === 200) return true; if (status === 404) return false; throw new Error(`minio bucketExists ${bucket}: ${status}`); } async createBucket(bucket: string): Promise { const { status, text } = await this.request("PUT", `/${bucket}`); // 200 created; 409 BucketAlreadyOwnedByYou — idempotent, a re-provision must not fail. if (status !== 200 && status !== 409) throw new Error(`minio createBucket ${bucket}: ${status} ${text}`); } async removeBucket(bucket: string): Promise { const { status, text } = await this.request("DELETE", `/${bucket}`); // 204 removed; 404 already gone — removal is idempotent too. if (status !== 204 && status !== 404) throw new Error(`minio removeBucket ${bucket}: ${status} ${text}`); } async listObjects(bucket: string, prefix = "", recursive = false, maxKeys = 100): Promise { const query: Record = { "list-type": "2", "max-keys": String(maxKeys) }; if (prefix) query.prefix = prefix; if (!recursive) query.delimiter = "/"; const { status, text } = await this.request("GET", `/${bucket}`, query); if (status !== 200) throw new Error(`minio listObjects ${bucket}: ${status} ${text}`); const out: MinioObject[] = []; for (const block of text.split("").slice(1)) { const key = tag(block, "Key"); if (!key) continue; out.push({ name: key, size: Number(tag(block, "Size") ?? "0"), lastModified: new Date(tag(block, "LastModified") ?? 0), etag: tag(block, "ETag")?.replace(/"|"/g, ""), }); } return out; } async statObject(bucket: string, object: string): Promise { const { status, headers } = await this.request("HEAD", `/${bucket}/${object}`); if (status !== 200) throw new Error(`minio statObject ${bucket}/${object}: ${status}`); const lm = headers.get("last-modified"); return { size: Number(headers.get("content-length") ?? "0"), lastModified: lm ? new Date(lm) : new Date(0), etag: headers.get("etag")?.replace(/"/g, "") ?? undefined, contentType: headers.get("content-type") ?? undefined, }; } async bucketInfo(bucket: string): Promise { const exists = await this.bucketExists(bucket); if (!exists) return { name: bucket, exists: false, region: this.region, sampledObjects: 0, sampledBytes: 0 }; const objects = await this.listObjects(bucket, "", true, 1000); return { name: bucket, exists: true, region: this.region, sampledObjects: objects.length, sampledBytes: objects.reduce((n, o) => n + o.size, 0), }; } /** A time-limited URL for GET (download) or PUT (upload) of one object — query-string SigV4. */ presignedUrl(method: "GET" | "PUT", bucket: string, object: string, expires = 86400): string { const { amzDate, dateStamp } = this.stamp(); const scope = `${dateStamp}/${this.region}/s3/aws4_request`; const host = new URL(this.baseUrl).host; const params: Record = { "X-Amz-Algorithm": "AWS4-HMAC-SHA256", "X-Amz-Credential": `${this.rootUser}/${scope}`, "X-Amz-Date": amzDate, "X-Amz-Expires": String(expires), "X-Amz-SignedHeaders": "host", }; const path = uriEncode(`/${bucket}/${object}`, false); const canonicalQuery = encodeQuery(params); const canonicalRequest = [method, path, canonicalQuery, `host:${host}\n`, "host", "UNSIGNED-PAYLOAD"].join("\n"); const stringToSign = ["AWS4-HMAC-SHA256", amzDate, scope, sha256hex(canonicalRequest)].join("\n"); const signature = hmac(this.signingKey(dateStamp), stringToSign).toString("hex"); return `${this.baseUrl}${path}?${canonicalQuery}&X-Amz-Signature=${signature}`; } // --- admin plane (mc CLI) ------------------------------------------------ /** * Create a service account scoped to one bucket, under a given access key and secret key, and * return the pair. The secret key is the mesh's — the mesh mints one password per consumer and * hands a copy to both ends (novox/hq ADR 0053), so minio sets that as the secret rather than * generating one the consumer could never learn. The MinIO admin REST API encrypts this request * with a key derived (Argon2) from the root secret, which node built-ins cannot reproduce — so, as * hal did, the module drives the `mc` CLI, which the runtime image bundles. */ async createAccessKey(bucket: string, accessKey: string, secretKey: string): Promise { await this.ensureAlias(); const policyPath = join(this.mcConfigDir, `policy-${accessKey}.json`); writeFileSync(policyPath, bucketPolicy(bucket), { mode: 0o600 }); try { await this.mc( "admin", "user", "svcacct", "add", "mesh", this.rootUser, "--access-key", accessKey, "--secret-key", secretKey, "--policy", policyPath, ); } finally { try { unlinkSync(policyPath); } catch { /* best effort */ } } return { accessKey, secretKey }; } async removeAccessKey(accessKey: string): Promise { await this.ensureAlias(); await this.mc("admin", "user", "svcacct", "rm", "mesh", accessKey); } private async ensureAlias(): Promise { if (this.aliasReady) return; await this.mc("alias", "set", "mesh", this.baseUrl, this.rootUser, this.rootPassword); this.aliasReady = true; } private async mc(...args: string[]): Promise { const { stdout } = await execFileAsync(this.mcBin, ["--config-dir", this.mcConfigDir, ...args], { timeout: 30_000 }); return stdout.trim(); } // --- SigV4 request plumbing --------------------------------------------- private async request( method: string, path: string, query: Record = {}, ): Promise<{ status: number; headers: Headers; text: string }> { const { amzDate, dateStamp } = this.stamp(); const host = new URL(this.baseUrl).host; const payloadHash = sha256hex(""); // no request bodies are sent on this client const encodedPath = uriEncode(path, false); const canonicalQuery = encodeQuery(query); const signedHeaders = "host;x-amz-content-sha256;x-amz-date"; const canonicalHeaders = `host:${host}\nx-amz-content-sha256:${payloadHash}\nx-amz-date:${amzDate}\n`; const canonicalRequest = [method, encodedPath, canonicalQuery, canonicalHeaders, signedHeaders, payloadHash].join("\n"); const scope = `${dateStamp}/${this.region}/s3/aws4_request`; const stringToSign = ["AWS4-HMAC-SHA256", amzDate, scope, sha256hex(canonicalRequest)].join("\n"); const signature = hmac(this.signingKey(dateStamp), stringToSign).toString("hex"); const authorization = `AWS4-HMAC-SHA256 Credential=${this.rootUser}/${scope}, SignedHeaders=${signedHeaders}, Signature=${signature}`; const url = `${this.baseUrl}${encodedPath}${canonicalQuery ? `?${canonicalQuery}` : ""}`; const res = await fetch(url, { method, // `host` is set by fetch from the URL; the wire header matches what we signed. headers: { Authorization: authorization, "x-amz-content-sha256": payloadHash, "x-amz-date": amzDate }, }); const text = method === "HEAD" ? "" : await res.text(); return { status: res.status, headers: res.headers, text }; } private signingKey(dateStamp: string): Buffer { const kDate = hmac(`AWS4${this.rootPassword}`, dateStamp); const kRegion = hmac(kDate, this.region); const kService = hmac(kRegion, "s3"); return hmac(kService, "aws4_request"); } private stamp(): { amzDate: string; dateStamp: string } { const amzDate = new Date().toISOString().replace(/[:-]|\.\d{3}/g, ""); return { amzDate, dateStamp: amzDate.slice(0, 8) }; } } // --- module-scoped helpers ------------------------------------------------- /** A deterministic 20-char access key id from a consumer name, so removal needs no stored state: * the provisioner recomputes the same id at teardown that it minted at creation. */ export function accessKeyFor(consumer: string): string { const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; const digest = createHash("sha256").update(consumer).digest(); let out = ""; for (let i = 0; i < 20; i++) out += chars[digest[i] % chars.length]; return out; } /** A DNS-safe bucket name derived from a consumer — the removable identity of its storage. */ export function bucketFor(consumer: string): string { const name = consumer.toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 63); return name.length >= 3 ? name : `mesh-${name}`; } function bucketPolicy(bucket: string): string { return JSON.stringify({ Version: "2012-10-17", Statement: [{ Effect: "Allow", Action: ["s3:*"], Resource: [`arn:aws:s3:::${bucket}`, `arn:aws:s3:::${bucket}/*`], }], }); } function tag(xml: string, name: string): string | undefined { const m = new RegExp(`<${name}>([^<]*)`).exec(xml); return m ? m[1] : undefined; } function hmac(key: Buffer | string, data: string): Buffer { return createHmac("sha256", key).update(data, "utf8").digest(); } function sha256hex(data: string): string { return createHash("sha256").update(data, "utf8").digest("hex"); } /** AWS canonical query: each key/value URI-encoded (slash included), sorted by encoded key. */ function encodeQuery(params: Record): string { return Object.keys(params) .map((k) => [uriEncode(k, true), uriEncode(params[k], true)] as const) .sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0)) .map(([k, v]) => `${k}=${v}`) .join("&"); } /** RFC 3986 URI encoding, byte-correct via UTF-8. Path callers keep "/" literal; query callers don't. */ function uriEncode(str: string, encodeSlash: boolean): string { let out = ""; for (const byte of Buffer.from(str, "utf8")) { const c = String.fromCharCode(byte); if (/[A-Za-z0-9_.~-]/.test(c)) out += c; else if (c === "/" && !encodeSlash) out += c; else out += "%" + byte.toString(16).toUpperCase().padStart(2, "0"); } return out; }